From b50e0359f72bdcc68644bcc11f094f0152fe26c0 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 12 Aug 2026 02:14:41 +0000 Subject: [PATCH] feat(adapter): extract shared FIFA17 FUT item-shaping primitive Move the per-item card shaper (CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats, shape_item) out of club_response into fut::item so /club and the upcoming squad projection emit byte-identical items from one source of truth. club_response keeps only the /club {itemData:[...]} envelope and re-exports the moved types for API stability. shape_item is now pub; no behavior change (all /club and oracle-parity tests unchanged and green). Adds item-shaper tests: full-field identity mapping and the duplicate owned-copy invariant (two instances of one definition keep distinct wire ids, share one asset id). --- .../src/fut/club_response.rs | 116 +---------- openfut-adapter-fifa17/src/fut/item.rs | 196 ++++++++++++++++++ openfut-adapter-fifa17/src/fut/mod.rs | 1 + 3 files changed, 206 insertions(+), 107 deletions(-) create mode 100644 openfut-adapter-fifa17/src/fut/item.rs diff --git a/openfut-adapter-fifa17/src/fut/club_response.rs b/openfut-adapter-fifa17/src/fut/club_response.rs index b2a5e03..6d39a46 100644 --- a/openfut-adapter-fifa17/src/fut/club_response.rs +++ b/openfut-adapter-fifa17/src/fut/club_response.rs @@ -1,117 +1,19 @@ //! Shape OpenFUT Core's semantic owned inventory into the FIFA 17 `/club` //! response envelope `{"itemData":[ , … ]}`. //! -//! 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. +//! This module owns only the **`/club` envelope**; the per-item shape lives in +//! the shared [`crate::fut::item`] primitive so `/club` and squad projection +//! emit byte-identical items. Items whose real FIFA asset id is unknown are +//! **dropped and counted** here (a collection may omit an unrenderable card); +//! squad projection, which cannot omit a starter, refuses instead. 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; -} - -/// 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 = 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), - }) -} +use crate::fut::item::shape_item; +// Re-exported so existing `club_response::{…}` callers keep working; the types +// are now defined once in `fut::item`. +pub use crate::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats}; /// Shape the whole `/club` response. Items without a resolvable real asset id /// are dropped (counted in `ShapeStats`), never emitted with a fabricated id. diff --git a/openfut-adapter-fifa17/src/fut/item.rs b/openfut-adapter-fifa17/src/fut/item.rs new file mode 100644 index 0000000..054b620 --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/item.rs @@ -0,0 +1,196 @@ +//! The shared FIFA 17 FUT **item-shaping primitive**. +//! +//! One function shapes one owned FUT item into the numeric card object the FIFA +//! 17 client renders, and **every** route that emits a player item goes through +//! it — `/club` (via [`crate::fut::club_response`]) and squad projection (via +//! [`crate::fut::squad_projection`]) alike. There is deliberately no second copy +//! of the field set: an item is shaped in exactly one place so the two routes +//! can never drift. +//! +//! ## The asset-id boundary (load-bearing, evidence-grounded) +//! +//! FIFA renders a card by resolving `resourceId & 0xffffff` against the client's +//! OWN local players table (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. So an +//! [`ItemIdentityResolver`] is injected; when it cannot supply a **real** FIFA +//! asset id for an item, the item carries no fabricated identity — the caller +//! decides what that means (`/club` drops and counts it; a squad refuses to +//! project a starter it cannot render, never faking one). +//! +//! Entity ids (`leagueId`/`teamid`/`nation`) come from a 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 inventory. +#[derive(Debug, Clone)] +pub struct CoreOwnedItem { + /// Core owned-instance id (string). The stable per-copy identity — two + /// copies of the same card definition have distinct `owned_card_id`s. + 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. Two owned copies of the same +/// definition share an `asset_id` but MUST have distinct `item_id`s. +#[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 caller must not fabricate one. +pub trait ItemIdentityResolver { + fn resolve(&self, item: &CoreOwnedItem) -> Option; +} + +/// 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`). +/// +/// This is the single source of truth for a player item's on-wire shape; the +/// `/club` envelope and squad projection both call it, so their items are +/// identical by construction. `id` is the owned instance's resolved FIFA +/// identity — pass the resolver's answer for *this* owned copy so two copies of +/// one definition stay distinct on the wire. +pub 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 = 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), + }) +} + +#[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) -> CoreOwnedItem { + CoreOwnedItem { + owned_card_id: owned.into(), + card_id: card.into(), + rating, + position: pos.into(), + nation: "Argentina".into(), + league: "Premier League".into(), + club: "Chelsea".into(), + attributes: [90, 88, 70, 85, 40, 78], + } + } + + #[test] + fn shapes_real_identity_and_reverse_entity_ids() { + let ent = entities(); + let it = shape_item( + &item("oc1", "card_ch_1", 86, "CDM"), + Fifa17Identity { item_id: 100000001, asset_id: 20801 }, + &ent, + ); + assert_eq!(it["id"], 100000001, "wire instance id"); + 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["attributeList"].as_array().unwrap().len(), 6); + assert_eq!(it["attributeList"][0], json!({"index":0,"value":90})); + } + + #[test] + fn two_owned_copies_of_one_definition_stay_distinct_on_the_wire() { + // fifa17_101490 has two owned instances: same definition/asset, two + // distinct owned ids and two distinct wire ids. Shaping each from its + // own identity must NEVER collapse them. + let ent = entities(); + let a = shape_item( + &item("oc-a", "fifa17_101490", 84, "ST"), + Fifa17Identity { item_id: 100000030, asset_id: 101490 }, + &ent, + ); + let b = shape_item( + &item("oc-b", "fifa17_101490", 84, "ST"), + Fifa17Identity { item_id: 100000031, asset_id: 101490 }, + &ent, + ); + assert_eq!(a["resourceId"], b["resourceId"], "same definition => same asset"); + assert_ne!(a["id"], b["id"], "distinct owned copies keep distinct wire ids"); + assert_eq!(a["id"], 100000030); + assert_eq!(b["id"], 100000031); + } +} diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index 30e5a11..993f346 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -6,6 +6,7 @@ //! socket — a Rust UTAS host wires it to Core later. pub mod catalog; pub mod club_response; +pub mod item; pub mod entities; pub mod owned_query; pub mod squad;