diff --git a/openfut-adapter-fifa17/src/fut/catalog.rs b/openfut-adapter-fifa17/src/fut/catalog.rs index c43f80a..d396145 100644 --- a/openfut-adapter-fifa17/src/fut/catalog.rs +++ b/openfut-adapter-fifa17/src/fut/catalog.rs @@ -41,8 +41,19 @@ pub struct Fifa17CardIdentity { /// FIFA card-art class. Players default to `asset_id`; kit definitions carry /// the verified `fcc_kitcards.cardassetid` value (`35`). pub card_asset_id: u32, - /// Source team id for a club kit. Zero for content kinds that do not use it. + /// Source team id for a club kit, or a manager's real club. Zero for content + /// kinds that do not use it. pub team_id: i64, + /// Manager chemistry nation (`managercards.nation`), zero when unused. + /// + /// The client NEVER supplies this: the managercards merge (`FUN_1801356c0`) + /// leaves the manager-only record slot `rec+0xde` untouched, so the server is + /// its only source. See `fifa17-recon/tools/fut_staff.py`. + pub nation: i64, + /// Manager chemistry league, zero when unused. Derived upstream through + /// `manager.teamid` → `leagueteamlinks.leagueid`, because `managercards` has + /// no league column. Lands in the equally untouched slot `rec+0xe0`. + pub league_id: i64, } /// The FIFA 17 numeric namespace policy for owned-item wire ids. @@ -139,9 +150,15 @@ struct RawCard { /// Separate card-art id for non-player definitions; absent → `asset_id`. #[serde(default)] card_asset_id: Option, - /// Source team id for a kit definition; absent → `0`. + /// Source team id for a kit or manager definition; absent → `0`. #[serde(default)] team_id: Option, + /// Manager chemistry nation; absent → `0`. + #[serde(default)] + nation: Option, + /// Manager chemistry league; absent → `0`. + #[serde(default)] + league_id: Option, } fn default_rareflag() -> i64 { @@ -200,6 +217,8 @@ impl Fifa17CardCatalog { subtype: rc.subtype, card_asset_id: rc.card_asset_id.unwrap_or(rc.asset_id), team_id: rc.team_id.unwrap_or(0), + nation: rc.nation.unwrap_or(0), + league_id: rc.league_id.unwrap_or(0), }, ); } diff --git a/openfut-adapter-fifa17/src/fut/club_response.rs b/openfut-adapter-fifa17/src/fut/club_response.rs index 275a0b8..634315b 100644 --- a/openfut-adapter-fifa17/src/fut/club_response.rs +++ b/openfut-adapter-fifa17/src/fut/club_response.rs @@ -11,13 +11,22 @@ use serde_json::{json, Value}; use crate::fut::content_taxonomy::ContentKind; use crate::fut::entities::ReverseEntityResolver; -use crate::fut::item::{shape_item, shape_kit_item}; +use crate::fut::item::{shape_item, shape_kit_item, shape_staff_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, Fifa17KitIdentity, ItemIdentityResolver, ShapeStats, + CoreOwnedItem, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver, + ShapeStats, }; +/// Contracts remaining on an owned staff card. +/// +/// Staff consume contracts exactly as players do (`rec+0x8c`), and the client +/// refuses to start a match when the manager's has run out. Core does not model +/// staff contracts, so this mirrors the constant `shape_item` already emits for +/// players rather than inventing a second, different default. +pub const STAFF_CONTRACT: i64 = 7; + /// Active club-level kit roles, keyed by Core owned-instance id. #[derive(Debug, Clone, Copy, Default)] pub struct ActiveKitAssignments<'a> { @@ -67,7 +76,14 @@ pub fn shape_club_response_with_kits( } None => stats.dropped_no_asset += 1, }, - ContentKind::Consumable | ContentKind::Staff => { + ContentKind::Staff => match ident.resolve_staff(item) { + Some(id) => { + out.push(shape_staff_item(id, STAFF_CONTRACT)); + stats.emitted += 1; + } + None => stats.dropped_no_asset += 1, + }, + ContentKind::Consumable => { stats.excluded_non_player += 1; } } @@ -240,6 +256,7 @@ mod tests { ids: HashMap, kinds: HashMap, kits: HashMap, + staff: HashMap, } impl ItemIdentityResolver for KindMapIdentity { fn resolve(&self, it: &CoreOwnedItem) -> Option { @@ -248,6 +265,9 @@ mod tests { fn resolve_kit(&self, it: &CoreOwnedItem) -> Option { self.kits.get(&it.card_id).copied() } + fn resolve_staff(&self, it: &CoreOwnedItem) -> Option { + self.staff.get(&it.card_id).copied() + } fn kind_of(&self, it: &CoreOwnedItem) -> ContentKind { self.kinds .get(&it.card_id) @@ -257,7 +277,7 @@ mod tests { } #[test] - fn consumable_and_staff_are_excluded_from_club_players() { + fn consumables_are_excluded_but_staff_is_shaped() { let ent = entities(); let id = |item_id: u32, asset: u32| Fifa17Identity { item_id, @@ -269,9 +289,19 @@ mod tests { ids: HashMap::from([ ("card_player".to_string(), id(100000001, 20801)), ("card_consumable".to_string(), id(100000002, 5003012)), - ("card_staff".to_string(), id(100000003, 3000083)), ]), kits: HashMap::new(), + staff: HashMap::from([( + "card_staff".to_string(), + Fifa17StaffIdentity { + item_id: 100000003, + resource_id: 3000083, + subtype: 8, + nation: 0, + league_id: 0, + team_id: 0, + }, + )]), kinds: HashMap::from([ ("card_consumable".to_string(), ContentKind::Consumable), ("card_staff".to_string(), ContentKind::Staff), @@ -291,13 +321,83 @@ mod tests { item("oc3", "card_staff", 0, "", "", "", ""), ]; let (body, stats) = shape_club_response(&items, &ent, &ident); - assert_eq!(stats.emitted, 1, "only the player is emitted"); - assert_eq!(stats.excluded_non_player, 2, "consumable + staff excluded"); + assert_eq!( + stats.emitted, 2, + "the player and the staff card are emitted" + ); + assert_eq!( + stats.excluded_non_player, 1, + "only the consumable is excluded; staff has a wire envelope of its own" + ); assert_eq!(stats.dropped_no_asset, 0); let arr = body["itemData"].as_array().unwrap(); - assert_eq!(arr.len(), 1); + assert_eq!(arr.len(), 2); assert_eq!(arr[0]["id"], 100000001, "the player survives"); assert_eq!(arr[0]["itemType"], "player"); + let coach = &arr[1]; + assert_eq!(coach["id"], 100000003); + assert_eq!(coach["resourceId"], 3000083); + assert_eq!(coach["cardsubtypeid"], 8); + assert_eq!(coach["itemType"], "staff"); + assert_eq!(coach["contract"], STAFF_CONTRACT); + assert!( + coach.get("nation").is_none() + && coach.get("leagueId").is_none() + && coach.get("teamid").is_none(), + "a COACH has no nation/league/team column in the client's tables, so \ + those keys must be absent rather than invented as zeroes" + ); + assert!( + coach.get("attributeList").is_none() && coach.get("preferredPosition").is_none(), + "both survive the client's merge and are read by the card view-model" + ); + } + + #[test] + fn manager_carries_the_chemistry_fields_only_the_server_can_supply() { + let ent = entities(); + let ident = KindMapIdentity { + ids: HashMap::new(), + kits: HashMap::new(), + staff: HashMap::from([( + "card_manager".to_string(), + Fifa17StaffIdentity { + item_id: 100004871, + resource_id: 1000509, + subtype: 4, + nation: 45, + league_id: 53, + team_id: 241, + }, + )]), + kinds: HashMap::from([("card_manager".to_string(), ContentKind::Staff)]), + }; + let items = vec![item("oc-mgr", "card_manager", 0, "", "", "", "")]; + let (body, stats) = shape_club_response(&items, &ent, &ident); + assert_eq!(stats.emitted, 1); + let mgr = &body["itemData"][0]; + assert_eq!( + mgr["cardsubtypeid"], 4, + "subtype alone selects managercards" + ); + assert_eq!( + mgr["resourceId"], 1000509, + "the merge key is read RAW: it must equal the carddbid with no version byte" + ); + // rec+0xde / rec+0xe0 / rec+0x94 — the merge never writes these, so an + // omission here is an unrecoverable blank flag and zero chemistry. + assert_eq!(mgr["nation"], 45); + assert_eq!(mgr["leagueId"], 53); + assert_eq!(mgr["teamid"], 241); + assert_eq!(mgr["contract"], STAFF_CONTRACT); + assert_eq!(mgr["itemState"], "free"); + assert_eq!(mgr["owners"], 1); + let keys: Vec<&String> = mgr.as_object().unwrap().keys().collect(); + assert_eq!( + keys.len(), + 11, + "exactly the 11 justified keys, no more: {keys:?}" + ); } #[test] @@ -313,6 +413,7 @@ mod tests { }; let ident = KindMapIdentity { ids: HashMap::new(), + staff: HashMap::new(), kits: HashMap::from([ ("kit-home".into(), kit(100000010, 6300006, 21)), ("kit-away".into(), kit(100000011, 6400003, 21)), diff --git a/openfut-adapter-fifa17/src/fut/content_taxonomy.rs b/openfut-adapter-fifa17/src/fut/content_taxonomy.rs index 0a6b262..7ccd85c 100644 --- a/openfut-adapter-fifa17/src/fut/content_taxonomy.rs +++ b/openfut-adapter-fifa17/src/fut/content_taxonomy.rs @@ -83,6 +83,12 @@ pub fn consumable_family(subtype: i64) -> Option<(&'static str, &'static str)> { Some(pair) } +/// `cardsubtypeid` of a MANAGER staff card. This value alone selects the +/// `managercards` merge in the client (`FUN_1800d8330` → cardtype 2 → +/// `FUN_1801356c0`), and it is what distinguishes a manager from the four coach +/// families inside [`ContentKind::Staff`]. +pub const MANAGER_SUBTYPE: i64 = 4; + /// The staff role + honest display label for a staff `cardsubtypeid` (4..=8), or /// `None` for any other subtype (→ DEFER). Grounded in the `FUN_1800d8330` /// family selector. diff --git a/openfut-adapter-fifa17/src/fut/item.rs b/openfut-adapter-fifa17/src/fut/item.rs index 174b1d2..51228ef 100644 --- a/openfut-adapter-fifa17/src/fut/item.rs +++ b/openfut-adapter-fifa17/src/fut/item.rs @@ -24,7 +24,7 @@ use serde_json::{json, Value}; -use crate::fut::content_taxonomy::ContentKind; +use crate::fut::content_taxonomy::{ContentKind, MANAGER_SUBTYPE}; use crate::fut::entities::ReverseEntityResolver; /// One owned item in game-independent terms, as read from Core's inventory. @@ -78,6 +78,29 @@ pub struct Fifa17KitIdentity { pub team_id: i64, } +/// FIFA-side identity fields needed to render an owned staff card (manager or +/// coach). Unlike a player, a staff record carries NO attributes, rating, +/// position or rareflag: the client merges all of those from its own +/// `managercards`/`*coachcards` tables keyed on `resource_id`. +/// +/// `nation`/`league_id`/`team_id` are meaningful for a MANAGER only +/// (`subtype == MANAGER_SUBTYPE`) and are zero for the four coach families, +/// whose tables carry no nation/league/team column. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Fifa17StaffIdentity { + pub item_id: u32, + /// THE merge key, read RAW as a u32 by `FUN_1801356c0` with NO `& 0xffffff` + /// mask (players are the only family that is masked). It must equal the + /// table `carddbid` exactly — a non-zero version byte silently breaks the + /// lookup, and the manager branch has no else-arm to report the miss. + pub resource_id: u32, + /// `cardsubtypeid`. This ALONE selects which staff table the client merges. + pub subtype: i64, + pub nation: i64, + pub league_id: i64, + pub team_id: i64, +} + /// 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 { @@ -89,6 +112,13 @@ pub trait ItemIdentityResolver { None } + /// Resolve one owned staff definition (manager or coach). Default `None` + /// preserves existing resolvers; the catalog-backed FIFA17 resolver + /// overrides it. + fn resolve_staff(&self, _item: &CoreOwnedItem) -> Option { + None + } + /// Classify a Core item's definition as player/consumable/staff. Defaults to /// [`ContentKind::Player`] so existing resolvers keep their behaviour; a /// catalog-backed resolver overrides this to consult its `kind_of`, letting @@ -193,6 +223,55 @@ pub fn shape_kit_item(id: Fifa17KitIdentity, item_state: &str) -> Value { }) } +/// Build one FIFA 17 staff item (manager or coach). +/// +/// The key set is deliberately minimal and is taken field-by-field from the +/// instruction-level reversal in `fifa17-recon/tools/fut_staff.py`, where every +/// key is justified by its CardsDLL record offset: +/// +/// * `id` → `rec+0x08`, `resourceId` → `rec+0x18` (the RAW merge key), +/// `cardsubtypeid` → `rec+0x50` (alone selects which staff table is merged), +/// `contract` → `rec+0x8c`, `itemState` → `rec+0x5c`, `owners` → `rec+0x48`, +/// `untradeable` → `rec+0x49`. +/// * `nation` → `rec+0xde` and `leagueId` → `rec+0xe0` are MANAGER-ONLY record +/// slots the client's merge never writes, so the server is their only source; +/// they drive the manager's flag, league badge and both halves of manager +/// chemistry. `teamid` → `rec+0x94` is read by the card view-model. +/// +/// Everything else is omitted on purpose, because each is either overwritten by +/// the merge from the client's own table (`assetId`/`cardassetid` at `rec+0x20`, +/// `rating` at `rec+0xb4`, `rareflag` at `rec+0x58`), skipped by the parser +/// (`definitionId`), or — worse — SURVIVES the merge and is then read by the +/// view-model, which would hang a position label or an attribute row on a +/// manager (`preferredPosition` at `rec+0x146`, `attributeList` at `rec+0x98`). +/// A staff card must therefore never be routed through [`shape_item`]. +/// +/// The four coach families carry no nation/league/team columns in the client's +/// tables, so those three keys are emitted for a manager only rather than being +/// invented as zeroes for a coach. +pub fn shape_staff_item(id: Fifa17StaffIdentity, contract: i64) -> Value { + let mut item = json!({ + "id": id.item_id, + "resourceId": id.resource_id, + "cardsubtypeid": id.subtype, + // Inert on the wire (the parser reads atom 0x173 into a stack string and + // frees it), but it is what every staff family reports, and our own + // readers use it to tell a staff card from a footballer at a glance. + "itemType": "staff", + "contract": contract, + "itemState": "free", + "owners": 1, + "untradeable": false, + }); + if id.subtype == MANAGER_SUBTYPE { + let obj = item.as_object_mut().expect("json! built an object"); + obj.insert("nation".to_string(), json!(id.nation)); + obj.insert("leagueId".to_string(), json!(id.league_id)); + obj.insert("teamid".to_string(), json!(id.team_id)); + } + item +} + #[cfg(test)] mod tests { use super::*; diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 4f7b14c..b09d6be 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -52,7 +52,7 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH}; use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy}; use openfut_adapter_fifa17::fut::club_response::{ shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17Identity, - Fifa17KitIdentity, ItemIdentityResolver, + Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver, }; use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput, ContextField}; use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind; @@ -228,6 +228,12 @@ pub fn classify(method: &str, path: &str) -> Route { Some("hub") if get => Route::Hub, Some("store") => Route::StaticAck, Some("match/keepalive") => Route::StaticAck, + // `watchList` has had a Rust handler all along, but nothing ever produced + // this route, so `Route::WatchList` was unreachable and every request fell + // through to Passthrough — the same defect class as `season/list`. The + // handler answers GET with an empty list plus Core credits, and acks the + // mutating verbs, so all four methods are claimed here. + Some("watchList") => Route::WatchList, // `season…` is Rust-owned for the methods the client actually uses: the // GETs that drive the mode, and the PUT that stores season state. The // bare tail keeps the old empty body. Matching the PREFIX (not just @@ -875,10 +881,22 @@ impl CoreAccess for HttpCoreClient { return Err(CoreError::Status(status)); } let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?; - Ok(v.get("manager") - .and_then(|m| m.get("owned_card_id")) - .and_then(|x| x.as_str()) - .map(str::to_string)) + // Core returns the assigned OWNED CARD (or null), so the instance id is + // the card's `id` — the PUT above takes `owned_card_id` because there it + // is a reference, not the resource. Reading the wrong key here used to + // yield None, which is indistinguishable from "no manager assigned": the + // assignment simply never arrived and the squad projected without one. + // A present-but-unreadable manager is therefore an error, never a silent + // absence. + match v.get("manager") { + None | Some(Value::Null) => Ok(None), + Some(manager) => match manager.get("id").and_then(Value::as_str) { + Some(id) => Ok(Some(id.to_string())), + None => Err(CoreError::Parse(format!( + "/club/manager returned a manager with no string `id`: {manager}" + ))), + }, + } } fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> { @@ -1728,6 +1746,23 @@ impl ItemIdentityResolver for Fifa17IdentityResolver { }) } + fn resolve_staff(&self, item: &CoreOwnedItem) -> Option { + let ident = self.catalog.lookup(&item.card_id)?; + if ident.kind != ContentKind::Staff { + return None; + } + Some(Fifa17StaffIdentity { + item_id: self.wire_for(item)?, + // RAW, unmasked: the staff merge keys on this exactly, so it must be + // the table carddbid with no version byte. + resource_id: ident.resource_id, + subtype: ident.subtype, + nation: ident.nation, + league_id: ident.league_id, + team_id: ident.team_id, + }) + } + /// Delegate content classification to the catalog. Unknown definitions /// retain the backward-compatible Player default but fail identity resolution. fn kind_of(&self, item: &CoreOwnedItem) -> ContentKind { @@ -1831,6 +1866,13 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) let requested_kind = match raw.item_type.as_deref() { None | Some("player") => ContentKind::Player, Some("kit") => ContentKind::Kit, + // The STAFF tab is the only staff request ever observed on the wire, and + // it asked with `type=manager`. `type=staff` is accepted as the obvious + // sibling token rather than betting the tab never sends it: both mean the + // same owned set here, because managers and coaches are one content kind + // (the client's own club-stats model likewise counts a manager inside its + // `staff` total, with `staffManager` as a bucket within it). + Some("staff") | Some("manager") => ContentKind::Staff, Some(other) => { return ( json_response(&json!({ "itemData": [] })), @@ -2027,15 +2069,30 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection { is_on_bench: s.is_on_bench, }) .collect(); - // The ownership-backed manager assignment (Core `squad_managers`). A fetch - // error or absent endpoint yields no manager — non-fatal, a squad renders - // without one, never fabricated. - let manager = deps - .core - .get_squad_manager() - .ok() - .flatten() - .and_then(|id| owned_by_id.get(&id).cloned()); + // The ownership-backed manager assignment (Core `squad_managers`). An absent + // endpoint or a transport error yields no manager — non-fatal, a squad still + // renders without one and an older Core has no such route. But a manager that + // IS assigned and cannot be resolved is reported: the client refuses to start + // a match without a manager, so silence here costs an unexplained dead end. + let manager = match deps.core.get_squad_manager() { + Ok(Some(owned_card_id)) => { + let found = owned_by_id.get(&owned_card_id).cloned(); + if found.is_none() { + eprintln!( + "utas-host WARN squad manager {owned_card_id} is assigned in Core \ + but absent from the owned collection — its card definition is \ + most likely missing from the loaded content pack, so Core drops \ + it from /collection without erroring" + ); + } + found + } + Ok(None) => None, + Err(error) => { + eprintln!("utas-host WARN squad manager read unavailable: {error}"); + None + } + }; let input = SquadProjectionInput { fifa_squad_id: ACTIVE_SQUAD_WIRE_ID, name: read.name, diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index 86d0b78..f0a852f 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -1889,3 +1889,142 @@ fn club_stats_year_counts_tiers_from_core_no_python() { assert_eq!(g["staff"], 0); assert_eq!(rec.lock().len(), 0, "club/stats never reaches Python"); } + +/// A resolver over a catalog authored verbatim, so a test can express the +/// non-player fields (`kind`/`subtype`/`nation`/`league_id`/`team_id`) that the +/// `card_id -> asset_id` helper above cannot. +fn resolver_for_catalog(cards_json: &str) -> Arc { + let doc = format!("{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{cards_json}}}}}"); + let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap(); + let store = JsonIdentityStore::open(unique_store_path()).unwrap(); + Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store))) +} + +/// `?type=staff` and `?type=manager` must both serve the club's staff. +/// +/// Regression: every `?type=` other than player/kit short-circuited to an empty +/// page with `outcome:"unsupported_type"` and never reached Core, so an owned +/// manager was unreachable — and FIFA refuses to start a match without one. The +/// STAFF tab was observed asking with `type=manager`; `type=staff` is the +/// sibling token. +#[test] +fn staff_and_manager_queries_both_serve_the_clubs_staff() { + let core = Arc::new(FakeCore::new( + vec![ + item("oc-mgr", "fifa17_1000509", 0, "", "", "", ""), + item("oc-coach", "fifa17_3000083", 0, "", "", "", ""), + item( + "oc-p", + "card_player", + 86, + "CDM", + "Argentina", + "Premier League", + "Chelsea", + ), + ], + 3, + )); + let resolver = resolver_for_catalog( + "\"fifa17_1000509\":{\"asset_id\":1000509,\"kind\":\"staff\",\"subtype\":4,\ + \"nation\":45,\"league_id\":53,\"team_id\":241},\ + \"fifa17_3000083\":{\"asset_id\":3000083,\"kind\":\"staff\",\"subtype\":8},\ + \"card_player\":{\"asset_id\":20801}", + ); + let entities = entities(); + let hidden = std::collections::HashSet::new(); + let kits = CoreKitAssignments::default(); + + for token in ["staff", "manager"] { + let deps = ClubDeps { + core: core.as_ref(), + entities: &entities, + assets: resolver.as_ref(), + hidden: &hidden, + active_kits: &kits, + }; + let (resp, log) = handle_club(&format!("type={token}&start=0&count=50"), &deps); + assert_eq!(log.outcome, "ok", "type={token} must not be unsupported"); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + let items = body["itemData"].as_array().unwrap(); + assert_eq!(items.len(), 2, "type={token}: manager + coach, no player"); + let manager = items + .iter() + .find(|i| i["cardsubtypeid"] == 4) + .unwrap_or_else(|| panic!("type={token}: no manager in {items:?}")); + // The merge key is read RAW: a version byte here breaks the lookup and + // the manager branch has no else-arm to report the miss. + assert_eq!(manager["resourceId"], 1_000_509); + assert_eq!(manager["itemType"], "staff"); + // Slots the client's own merge never writes, so the server is the only + // possible source of the flag, the badge and manager chemistry. + assert_eq!(manager["nation"], 45); + assert_eq!(manager["leagueId"], 53); + assert_eq!(manager["teamid"], 241); + assert!( + manager["contract"].as_i64().unwrap_or(0) > 0, + "a manager out of contract is exactly what blocks kickoff" + ); + let coach = items.iter().find(|i| i["cardsubtypeid"] == 8).unwrap(); + assert!( + coach.get("nation").is_none() && coach.get("leagueId").is_none(), + "coach tables carry no nation/league column; inventing zeroes would be a lie" + ); + } +} + +/// A player query must never leak staff, and vice versa: a manager carries +/// nation/leagueId/teamid, which would pollute the by-league and by-team club +/// drill-downs if it appeared among the players. +#[test] +fn staff_never_leaks_into_the_player_query() { + let core = Arc::new(FakeCore::new( + vec![ + item("oc-mgr", "fifa17_1000509", 0, "", "", "", ""), + item( + "oc-p", + "card_player", + 86, + "CDM", + "Argentina", + "Premier League", + "Chelsea", + ), + ], + 2, + )); + let resolver = resolver_for_catalog( + "\"fifa17_1000509\":{\"asset_id\":1000509,\"kind\":\"staff\",\"subtype\":4,\ + \"nation\":45,\"league_id\":53,\"team_id\":241},\ + \"card_player\":{\"asset_id\":20801}", + ); + let entities = entities(); + let hidden = std::collections::HashSet::new(); + let kits = CoreKitAssignments::default(); + let deps = ClubDeps { + core: core.as_ref(), + entities: &entities, + assets: resolver.as_ref(), + hidden: &hidden, + active_kits: &kits, + }; + let (resp, _) = handle_club("type=player&start=0&count=50", &deps); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + let items = body["itemData"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["resourceId"], 20801, "only the footballer"); +} + +/// `watchList` had a Rust handler that nothing could reach: no classifier arm +/// produced `Route::WatchList`, so every request fell through to Passthrough and, +/// against a staging stack with a deliberately dead Python upstream, 502'd. +#[test] +fn watchlist_is_rust_owned_and_never_passed_through() { + for method in ["GET", "PUT", "POST", "DELETE"] { + assert_eq!( + classify(method, "/ut/game/fifa17/watchList"), + Route::WatchList, + "{method} watchList must be served by Rust, not proxied" + ); + } +} diff --git a/scripts/sold-staging-up.py b/scripts/sold-staging-up.py index 7986ea4..b90653e 100755 --- a/scripts/sold-staging-up.py +++ b/scripts/sold-staging-up.py @@ -184,6 +184,30 @@ STAGING_KITS = [ ("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003), ] +# The club manager. FIFA refuses to start a match without one ("your player or +# managers contracts have expired"), and NEITHER club owns a manager: the real +# import has 1992 players and exactly 3 staff items, all coaches (2 fitness, 1 GK), +# which the client's own club/stats confirms with staffManager:0. So a manager is +# minted here rather than restored. +# +# Every value below is read out of the client's OWN tables, never invented: +# managercards.carddbid 1000509 (assetid == carddbid on all 417 rows) +# managercards.nation 45 -> the flag and the nation half of chemistry +# manager[509].surname "Luis Enrique", teamid 241 +# leagueteamlinks[241].leagueid 53 -> the badge and the league half of chemistry +# League 53 is also the dominant league in the restored squad (12 of 23 players), +# so this manager is the chemistry-correct choice for it, not an arbitrary one. +MANAGER_SUBTYPE = 4 +STAGING_MANAGER = { + "owned_id": "owned-a-manager", + "card_id": "fifa17_1000509", + "resource_id": 1_000_509, + "nation": 45, + "league_id": 53, + "team_id": 241, + "label": "Luis Enrique", +} + READY_TIMEOUT_S = 60.0 @@ -499,13 +523,52 @@ def materialise(lay: Layout) -> None: "card_asset_id": 35, "team_id": KIT_TEAM_ID, } + + mgr = STAGING_MANAGER + if mgr["card_id"] not in existing: + definitions.append({ + "id": mgr["card_id"], + "name": mgr["label"], + "overall": 0, + "position": "", + "nation": "", + "league": "", + "club": "", + "pace": 0, + "shooting": 0, + "passing": 0, + "dribbling": 0, + "defending": 0, + "physical": 0, + "rarity": "bronze", + "image_path": None, + }) + # `version` MUST stay 0 and `asset_id` MUST be the raw carddbid: the client's + # managercards merge reads the wire resourceId as a u32 WITHOUT masking off a + # version byte (players are the only family that is masked), and the manager + # branch has no else-arm, so a wrong key fails silently with a blank card. + catalog["cards"][mgr["card_id"]] = { + "asset_id": mgr["resource_id"], + "version": 0, + "rareflag": 0, + "kind": "staff", + "subtype": MANAGER_SUBTYPE, + "team_id": mgr["team_id"], + "nation": mgr["nation"], + "league_id": mgr["league_id"], + } with open(safe_path(lay.cards), "w") as fh: json.dump(definitions, fh, indent=2) fh.write("\n") with open(safe_path(lay.catalog), "w") as fh: json.dump(catalog, fh, indent=2) fh.write("\n") - ok("added ownership-backed home/away kit fixtures from fcc_kitcards") + ok( + "added ownership-backed home/away kit fixtures from fcc_kitcards, and the " + f"manager {STAGING_MANAGER['label']} (carddbid " + f"{STAGING_MANAGER['resource_id']}, nation {STAGING_MANAGER['nation']}, " + f"league {STAGING_MANAGER['league_id']}) from managercards" + ) def install_real_club(lay: Layout) -> dict: @@ -566,6 +629,7 @@ def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None: [card for _, card in SELLER_SQUAD_CARDS] + [DISPOSABLE_CARD] + [card for _, _, card, _ in STAGING_KITS] + + [STAGING_MANAGER["card_id"]] ) what = f"all {len(wanted)} fixture seed card ids" else: @@ -576,8 +640,9 @@ def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None: finally: conn.close() wanted |= {card for _, _, card, _ in STAGING_KITS} + wanted.add(STAGING_MANAGER["card_id"]) what = (f"all {len(wanted)} distinct card ids owned by the real club " - "(plus the kit fixtures)") + "(plus the kit and manager fixtures)") missing_pack = sorted(wanted - pack_ids) missing_cat = sorted(wanted - catalog_ids) if missing_pack or missing_cat: @@ -725,6 +790,9 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None: (owned_id, seller_club, card_id, TS) for _slot, owned_id, card_id, _resource_id in STAGING_KITS ] + owned.append( + (STAGING_MANAGER["owned_id"], seller_club, STAGING_MANAGER["card_id"], TS) + ) if real_club is None: owned = ( [(item, SELLER_CLUB, card, TS) for item, card in SELLER_SQUAD_CARDS] @@ -760,6 +828,25 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None: for slot, owned_id, _card_id, _resource_id in STAGING_KITS ], ) + + # Assign the manager to whichever squad the club actually has: the + # fixture's own, or the real club's imported squad. Migration 0023 + # keys squad_managers by squad_id, so the assignment must name a real + # squad row rather than the club. + squad_row = conn.execute( + "SELECT id FROM squads WHERE club_id = ? ORDER BY updated_at DESC " + "LIMIT 1", (seller_club,) + ).fetchone() + if squad_row is None: + raise Fatal( + f"club {seller_club} has no squad, so the manager cannot be " + "assigned; FIFA refuses to start a match without one" + ) + conn.execute( + "INSERT OR REPLACE INTO squad_managers (squad_id, owned_card_id, " + "updated_at) VALUES (?, ?, ?)", + (squad_row[0], STAGING_MANAGER["owned_id"], TS), + ) finally: conn.close() if real_club is None: @@ -773,7 +860,8 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None: f"kept the real club untouched ({real_club['owned_cards']} items, " f"{real_club['coins']:,} coins, squad {real_club['squads'][0]['name']!r} " f"with {real_club['squad_players']} players); added " - f"{len(STAGING_KITS)} active kits and Buyer B ({BUYER_COINS} coins)" + f"{len(STAGING_KITS)} active kits, the manager " + f"{STAGING_MANAGER['label']} and Buyer B ({BUYER_COINS} coins)" )