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`
|
//! Shape OpenFUT Core's semantic owned inventory into the FIFA 17 `/club`
|
||||||
//! response envelope `{"itemData":[ <player item>, … ]}`.
|
//! response envelope `{"itemData":[ <player item>, … ]}`.
|
||||||
//!
|
//!
|
||||||
//! The per-item field set replicates `fifa17-recon/tools/fut_store.py::_item`
|
//! This module owns only the **`/club` envelope**; the per-item shape lives in
|
||||||
//! (the proven-safe player item), reversed onto Core's semantic values.
|
//! the shared [`crate::fut::item`] primitive so `/club` and squad projection
|
||||||
//!
|
//! emit byte-identical items. Items whose real FIFA asset id is unknown are
|
||||||
//! ## The asset-id boundary (load-bearing, evidence-grounded)
|
//! **dropped and counted** here (a collection may omit an unrenderable card);
|
||||||
//!
|
//! squad projection, which cannot omit a starter, refuses instead.
|
||||||
//! 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 serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::fut::entities::ReverseEntityResolver;
|
use crate::fut::entities::ReverseEntityResolver;
|
||||||
|
use crate::fut::item::shape_item;
|
||||||
/// One owned item in game-independent terms, as read from Core's `/collection`.
|
// Re-exported so existing `club_response::{…}` callers keep working; the types
|
||||||
#[derive(Debug, Clone)]
|
// are now defined once in `fut::item`.
|
||||||
pub struct CoreOwnedItem {
|
pub use crate::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats};
|
||||||
/// 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
|
/// Shape the whole `/club` response. Items without a resolvable real asset id
|
||||||
/// are dropped (counted in `ShapeStats`), never emitted with a fabricated 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.
|
//! socket — a Rust UTAS host wires it to Core later.
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod club_response;
|
pub mod club_response;
|
||||||
|
pub mod item;
|
||||||
pub mod entities;
|
pub mod entities;
|
||||||
pub mod owned_query;
|
pub mod owned_query;
|
||||||
pub mod squad;
|
pub mod squad;
|
||||||
|
|||||||
Reference in New Issue
Block a user