feat(utas): FIFA17 UTAS migration host + /club adapter mappings

openfut-utas-host: the first live UTAS host. Serves GET /ut/game/<title>/club
from OpenFUT Core via the FIFA17 adapter and reverse-proxies every other UTAS
route verbatim to the Python oracle. Plaintext HTTP/1.1 keep-alive (no TLS);
route classification before execution; a Core error on /club degrades to an
empty page and never falls back to Python. CoreAccess is a host-owned boundary
(the adapter stays transport-agnostic).

openfut-adapter-fifa17::fut: owned_query (wire parse + FIFA id->name mapping,
unknown id = hard error), entities (id<->name from committed tables), and
club_response (FIFA _item shaping; drops items lacking a real FIFA asset id,
never fabricates one).

openfut-core submodule advanced to the reconciled trunk (6acae54 = 8c8a4116
multi-game + eab522a replace_squad/SquadRules + the /club semantic query).
11 host tests + adapter fut tests; 10/10 host mutations killed. rare=SP UNKNOWN.
Retail rendering of Core inventory still blocked on the Core-card->asset-id
identity decision (next phase).
This commit is contained in:
funman300
2026-08-11 21:40:15 +00:00
parent 04c5043aba
commit c0a3f68ded
14 changed files with 2456 additions and 1 deletions
@@ -0,0 +1,290 @@
//! Shape OpenFUT Core's semantic owned inventory into the FIFA 17 `/club`
//! response envelope `{"itemData":[ <player item>, … ]}`.
//!
//! The per-item field set replicates `fifa17-recon/tools/fut_store.py::_item`
//! (the proven-safe player item), reversed onto Core's semantic values.
//!
//! ## The asset-id boundary (load-bearing, evidence-grounded)
//!
//! FIFA renders a card by resolving `resourceId & 0xffffff` against the client's
//! OWN local players table (`fut_cards.py:11-21`, proven live): a real id renders
//! a real footballer, an **invented id renders a blank generic card**. OpenFUT
//! Core's catalogue is synthetic string ids (`card_pl_001`) with no FIFA asset
//! id, and no committed card→asset mapping exists. So an [`ItemIdentityResolver`]
//! is injected; when it cannot supply a **real** FIFA asset id for an item, that
//! item is **dropped and counted** — never emitted with a fabricated id. This
//! keeps the response freeze-safe and honest until the Core-card→asset identity
//! decision is made (the current blocker for retail rendering of Core inventory).
//!
//! Entity ids (`leagueId`/`teamid`/`nation`) come from the reverse resolver; an
//! unresolved name yields a neutral `0` (a valid int — non-fatal; it only means
//! "no badge/flag"), because those are not the identity the renderer keys on.
use serde_json::{json, Value};
use crate::fut::entities::ReverseEntityResolver;
/// One owned item in game-independent terms, as read from Core's `/collection`.
#[derive(Debug, Clone)]
pub struct CoreOwnedItem {
/// Core owned-instance id (string).
pub owned_card_id: String,
/// Core card-definition id (string), used for asset resolution.
pub card_id: String,
/// Effective overall rating.
pub rating: u8,
/// Effective position, e.g. "ST".
pub position: String,
pub nation: String,
pub league: String,
pub club: String,
/// [pace, shooting, passing, dribbling, defending, physical].
pub attributes: [u8; 6],
}
/// The FIFA-side numeric identity of an owned item. `asset_id` MUST be a real
/// FIFA player asset (low 24 bits the client resolves); `item_id` is the wire
/// instance id used for later item operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fifa17Identity {
pub item_id: u32,
pub asset_id: u32,
}
/// Supplies the FIFA numeric identity for a Core item. Returning `None` means
/// "no real FIFA asset id known" → the item is dropped (never faked).
pub trait ItemIdentityResolver {
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
}
/// Diagnostics from shaping (safe to log — counts only).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ShapeStats {
pub emitted: usize,
pub dropped_no_asset: usize,
}
/// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table;
/// non-fatal display field).
fn discard_value(rating: u8) -> i64 {
match rating {
r if r >= 85 => 1500,
r if r >= 80 => 900,
r if r >= 75 => 600,
r if r >= 65 => 300,
_ => 150,
}
}
/// Build one FIFA `_item` object (version byte 0x00 → `resourceId == assetId`).
fn shape_item(item: &CoreOwnedItem, id: Fifa17Identity, ent: &impl ReverseEntityResolver) -> Value {
let asset = id.asset_id;
let league_id = ent.league_id(&item.league).unwrap_or(0);
let team_id = ent.team_id(&item.club).unwrap_or(0);
let nation_id = ent.nation_id(&item.nation).unwrap_or(0);
let attribute_list: Vec<Value> = item
.attributes
.iter()
.enumerate()
.map(|(i, v)| json!({ "index": i, "value": v }))
.collect();
json!({
"id": id.item_id,
"resourceId": asset,
"assetId": asset,
"cardassetid": asset,
"definitionId": asset,
"cardsubtypeid": 0,
"itemType": "player",
"rareflag": 1,
"rating": item.rating,
"preferredPosition": item.position,
"nation": nation_id,
"teamid": team_id,
"leagueId": league_id,
"playStyle": 250,
"attributeList": attribute_list,
"itemState": "free",
"owners": 1,
"untradeable": true,
"contract": 7,
"fitness": 99,
"discardValue": discard_value(item.rating),
})
}
/// Shape the whole `/club` response. Items without a resolvable real asset id
/// are dropped (counted in `ShapeStats`), never emitted with a fabricated id.
pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
items: &[CoreOwnedItem],
ent: &impl ReverseEntityResolver,
ident: &I,
) -> (Value, ShapeStats) {
let mut out = Vec::with_capacity(items.len());
let mut stats = ShapeStats::default();
for item in items {
match ident.resolve(item) {
Some(id) => {
out.push(shape_item(item, id, ent));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
}
}
(json!({ "itemData": out }), stats)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fut::entities::Fifa17Entities;
use std::collections::HashMap;
fn entities() -> Fifa17Entities {
Fifa17Entities::from_maps(
HashMap::from([(13, "Premier League".to_string())]),
HashMap::from([(52, "Argentina".to_string())]),
HashMap::from([(5, "Chelsea".to_string())]),
)
}
fn item(
owned: &str,
card: &str,
rating: u8,
pos: &str,
nation: &str,
league: &str,
club: &str,
) -> CoreOwnedItem {
CoreOwnedItem {
owned_card_id: owned.into(),
card_id: card.into(),
rating,
position: pos.into(),
nation: nation.into(),
league: league.into(),
club: club.into(),
attributes: [90, 88, 70, 85, 40, 78],
}
}
/// Test resolver: card_id -> real asset id, item_id from a table. Stands in
/// for the (unresolved-in-production) Core-card→asset mapping.
struct MapIdentity(HashMap<String, Fifa17Identity>);
impl ItemIdentityResolver for MapIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.0.get(&it.card_id).copied()
}
}
#[test]
fn shapes_item_with_full_field_set_and_reverse_ids() {
let ent = entities();
let ident = MapIdentity(HashMap::from([(
"card_ch_1".to_string(),
Fifa17Identity {
item_id: 100000001,
asset_id: 20801,
},
)]));
let items = vec![item(
"oc1",
"card_ch_1",
86,
"CDM",
"Argentina",
"Premier League",
"Chelsea",
)];
let (body, stats) = shape_club_response(&items, &ent, &ident);
assert_eq!(stats.emitted, 1);
assert_eq!(stats.dropped_no_asset, 0);
let it = &body["itemData"][0];
assert_eq!(it["id"], 100000001);
assert_eq!(it["resourceId"], 20801);
assert_eq!(it["assetId"], 20801);
assert_eq!(
it["definitionId"], 20801,
"version byte 0 => resourceId==assetId==definitionId"
);
assert_eq!(it["rating"], 86);
assert_eq!(it["preferredPosition"], "CDM");
assert_eq!(it["leagueId"], 13);
assert_eq!(it["teamid"], 5);
assert_eq!(it["nation"], 52);
assert_eq!(it["itemType"], "player");
assert_eq!(it["rareflag"], 1);
assert_eq!(it["contract"], 7);
assert_eq!(it["fitness"], 99);
assert_eq!(it["attributeList"].as_array().unwrap().len(), 6);
assert_eq!(it["attributeList"][0], json!({"index":0,"value":90}));
}
#[test]
fn drops_items_without_a_real_asset_id_never_faking() {
let ent = entities();
// Empty identity map == the current synthetic-catalogue reality.
let ident = MapIdentity(HashMap::new());
let items = vec![item(
"oc1",
"card_pl_001",
84,
"ST",
"England",
"Premier League",
"Northgate United",
)];
let (body, stats) = shape_club_response(&items, &ent, &ident);
assert_eq!(stats.emitted, 0);
assert_eq!(stats.dropped_no_asset, 1);
assert_eq!(
body["itemData"].as_array().unwrap().len(),
0,
"no fabricated ids emitted"
);
}
#[test]
fn unresolved_entity_names_become_neutral_zero_not_dropped() {
let ent = entities();
let ident = MapIdentity(HashMap::from([(
"card_x".to_string(),
Fifa17Identity {
item_id: 100000002,
asset_id: 158023,
},
)]));
// Synthetic club "Northgate United" has no FIFA team id.
let items = vec![item(
"oc2",
"card_x",
84,
"ST",
"England",
"Premier League",
"Northgate United",
)];
let (body, _) = shape_club_response(&items, &ent, &ident);
let it = &body["itemData"][0];
assert_eq!(
it["teamid"], 0,
"unknown club -> neutral 0, item still emitted"
);
assert_eq!(it["leagueId"], 13);
assert_eq!(it["nation"], 0, "England not in the test nation map -> 0");
}
#[test]
fn envelope_is_itemdata_object() {
let ent = entities();
let ident = MapIdentity(HashMap::new());
let (body, _) = shape_club_response(&[], &ent, &ident);
assert!(body.get("itemData").unwrap().is_array());
assert_eq!(
body.as_object().unwrap().len(),
1,
"only itemData at top level"
);
}
}