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:
funman300
2026-08-12 02:14:41 +00:00
parent 58a300c7f4
commit b50e0359f7
3 changed files with 206 additions and 107 deletions
+9 -107
View File
@@ -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.