From 9c2edc4eee5839c9d774daea23e35f4b7e99df4b Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 21 Aug 2026 17:13:44 +0000 Subject: [PATCH] feat(fifa17): serve staff, so the club has a manager and matches can start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIFA refuses to kick off with "your player or managers contracts have expired". The club had no manager, and could not have had one: `/club?type=manager` (the token the STAFF tab actually sends) was rejected by the host, and staff items were counted and dropped by the adapter instead of being shaped. The squad's manager reference is a red herring worth recording. It points at wire id 100000427, which resolves to resourceId 3000083 = a FITNESS COACH (cardsubtypeid 8), not a manager. The client's own club/stats agrees: staff:3, staffManager:0, staffGKCoach:1, staffFitnessCoach:2. This club has never owned a manager, so one is MINTED rather than restored. Wire shape is not guessed. `fifa17-recon/tools/fut_staff.py` is an instruction-level reversal of the item parser and the managercards merge that justifies every key by its record offset, and CARD_SYSTEM.md records it confirmed live on 2026-08-05 (ten managers rendered with correct flags, league names and "CONTRACT 7" on the card front). `shape_staff_item` emits exactly that key set and nothing else: * `nation` (rec+0xde) and `leagueId` (rec+0xe0) are MANAGER-ONLY slots the client's merge never writes, so the server is their only source — they are the flag, the league badge and both halves of manager chemistry. Coaches get neither, because the four coach tables have no nation/league/team column and emitting zeroes there would be invention. * `resourceId` is the RAW merge key: staff are read as a u32 with NO &0xffffff mask (players are the only masked family), so `version` must stay 0 or the lookup misses — silently, since the manager branch has no else-arm. * `preferredPosition`/`attributeList` are omitted because they SURVIVE the merge and are then read by the card view-model; `assetId`/`rating`/`rareflag` are omitted because the merge overwrites them from the client's own tables. A staff card is therefore never routed through `shape_item`. Managers stay inside `ContentKind::Staff`, discriminated by `cardsubtypeid == 4` — the client's own discriminator, and its own stats model counts a manager INSIDE the staff total with staffManager as a bucket within it. A parallel `ContentKind::Manager` would have been a second source of truth for a fact the subtype already carries, and would have silently under-counted club/stats. `squad.manager[]` stays `[{id, dream}]`. The only populated form anywhere is the oracle's DRAFT squad; no capture has ever shown itemData in a regular squad, and feeding that deserializer the wrong container type freezes the SAX reader. The contract reaches the client through the CardsDb record registered from the /club envelope, which is a find-or-insert and therefore accumulates. TWO SILENT BUGS FOUND ON THE WAY, both of which made a correct assignment look like no assignment at all: 1. `get_squad_manager` read `manager.owned_card_id`, but Core returns the assigned OWNED CARD, whose field is `id`. It therefore ALWAYS returned None — indistinguishable from "no manager". Now reads `id`, and a present-but- unreadable manager is an error rather than a silent absence. The projection also now warns when an assignment cannot be resolved to an owned instance, which is the documented "Core drops an owned card with no CardDefinition from /collection without erroring" trap. 2. `Route::WatchList` was produced by NO classifier arm, so its handler was unreachable and every `watchList` request fell through to Passthrough — the same defect class as `season/list`. Against a stack whose Python upstream is deliberately dead this 502'd. This was failing `sbc_survives_complete_core_and_host_restart` at HEAD before this change. The manager itself is seeded from the client's own tables, never invented: managercards 1000509 (assetid == carddbid), nation 45, manager[509] "Luis Enrique" teamid 241, leagueteamlinks 241 -> league 53. League 53 is also the dominant league in the restored squad (12 of 23), so the chemistry pairing is the correct one rather than an arbitrary pick. Verified live against the restored club: /club?type=manager and ?type=staff both return 4 items (the minted manager plus the 3 coaches the profile already owned and could never see), the manager carries contract 7 with nation/league/team, coaches correctly carry none of the three, squad.manager resolves to the same wire id, and no staff leaks into ?type=player. Adapter 219 tests, host 114 lib + 36 host_test + all economy suites green. --- openfut-adapter-fifa17/src/fut/catalog.rs | 23 ++- .../src/fut/club_response.rs | 117 ++++++++++++++- .../src/fut/content_taxonomy.rs | 6 + openfut-adapter-fifa17/src/fut/item.rs | 81 +++++++++- openfut-utas-host/src/lib.rs | 85 +++++++++-- openfut-utas-host/tests/host_test.rs | 139 ++++++++++++++++++ scripts/sold-staging-up.py | 94 +++++++++++- 7 files changed, 517 insertions(+), 28 deletions(-) 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)" )