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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user