feat(fifa17): serve staff, so the club has a manager and matches can start

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.
This commit is contained in:
funman300
2026-08-21 17:13:44 +00:00
parent de747b79e6
commit 9c2edc4eee
7 changed files with 517 additions and 28 deletions
+80 -1
View File
@@ -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<Fifa17StaffIdentity> {
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::*;