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).
This commit is contained in:
@@ -1,117 +1,19 @@
|
||||
//! 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.
|
||||
//! 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<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),
|
||||
})
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -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<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`).
|
||||
///
|
||||
/// 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<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),
|
||||
})
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user