fix(fifa17): carry the manager's item in the squad, not just its id

The operator picked a manager in the FUT hub, then found no manager on the
pre-match squad. The save was NOT the problem: the host logged three
`route=squad-replace status=200 outcome=ok detail=[]` with no unresolved ref,
Core wrote the `squad_managers` row, and every read projected the assignment
back. The client simply had nothing to draw.

`squad.manager[]` was emitted as a bare `[{id, dream}]`. That looked
retail-faithful, and the previous commit defended it on the grounds that no
capture had ever shown otherwise. Re-reading the captures with a populated
manager in hand shows why that was the wrong conclusion: every retail capture
carrying the bare form has `id: 0` — an EMPTY manager. None of them ever
demonstrated that a POPULATED ref renders without its item, because none of them
had one. `plan-2026-08-05-families.md` says as much outright: "FUN_18013d1f0 was
never read for a staff member".

The squad object is self-contained everywhere else: `players[].itemData` carries
the whole card rather than an id resolved out of band. The manager is the same
kind of slot in the same object, and the one implementation that ever drove a
working manager — the Python oracle's squad — emits `id` BESIDE `itemData`.
The element shapes differ and both are now pinned by tests: a player slot is
`{index, itemData, kitNumber}`, the manager is `{id, itemData, dream}`.

So the manager is projected through `resolve_staff` and its item embedded with
`shape_staff_item`, the same 11-key record `/club` serves. An assignment with no
resolvable staff identity still yields `[]` rather than a fabricated ref.

`STAFF_CONTRACT` moves next to `shape_staff_item` in `fut::item` (re-exported
from `club_response`) so `/club` and the squad cannot disagree about the
contract the client checks before kickoff.

Verified on the restored club: userMassInfo, /squad/0 and /squad/active all
carry the manager with resourceId 1000509, contract 7 and the nation/league/team
the client cannot supply itself. Adapter 217 lib + 25 integration, host 114 lib +
36 host_test and every economy suite green.
This commit is contained in:
funman300
2026-08-21 17:30:14 +00:00
parent 9c2edc4eee
commit 12ad04c9d4
4 changed files with 174 additions and 85 deletions
@@ -11,7 +11,7 @@ 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, shape_staff_item};
use crate::fut::item::{shape_item, shape_kit_item, shape_staff_item, STAFF_CONTRACT};
// Re-exported so existing `club_response::{…}` callers keep working; the types
// are now defined once in `fut::item`.
pub use crate::fut::item::{
@@ -19,14 +19,6 @@ pub use crate::fut::item::{
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> {
+8
View File
@@ -223,6 +223,14 @@ pub fn shape_kit_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
})
}
/// 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;
/// Build one FIFA 17 staff item (manager or coach).
///
/// The key set is deliberately minimal and is taken field-by-field from the
@@ -36,7 +36,9 @@ use std::collections::HashMap;
use serde_json::{json, Value};
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
use crate::fut::item::{
shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver, STAFF_CONTRACT,
};
use crate::fut::squad::FIFA17_SQUAD_SLOTS;
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
@@ -176,15 +178,33 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
}
}
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref.
// Emitted as the STATIC_REVERSED `[{id, dream}]` shape (the same shape the
// client sends on save). An owned manager with no resolvable FIFA identity
// is omitted (non-fatal, like /club dropping an unrenderable card) rather
// than emitted with a fabricated id. NOTE: a richer manager itemData
// (contract/leagueId/nation) is INFERRED-only from the offline-season
// diagnosis and deliberately NOT invented here pending wire reversal.
let manager = match input.manager.as_ref().and_then(|m| ident.resolve(m)) {
Some(id) => json!([{ "id": id.item_id, "dream": false }]),
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref
// AND carrying its item, as `[{id, itemData, dream}]`.
//
// The bare `[{id, dream}]` form is NOT sufficient, which cost a real
// debugging round: the operator picked a manager in the hub, the save
// persisted (Core `squad_managers` row written, `outcome=ok`, no unresolved
// ref), and the pre-match squad still showed no manager. Every retail
// capture that shows the bare form has `id: 0` — an EMPTY manager — so none
// of them ever demonstrated that a POPULATED ref resolves without its item.
//
// The squad response is self-contained for players: `players[].itemData`
// carries the whole card rather than an id the client resolves out of band.
// The manager is the same kind of slot in the same object, and the one
// implementation that ever drove a working manager (the Python oracle's
// squad) emits `id` BESIDE `itemData` exactly like this. Note the element
// shape differs from a player slot: `{index, itemData, kitNumber}` there,
// `{id, itemData, dream}` here.
//
// An owned manager with no resolvable FIFA staff identity is omitted
// (non-fatal, like /club dropping an unrenderable card) rather than emitted
// with a fabricated id.
let manager = match input.manager.as_ref().and_then(|m| ident.resolve_staff(m)) {
Some(id) => json!([{
"id": id.item_id,
"itemData": shape_staff_item(id, STAFF_CONTRACT),
"dream": false,
}]),
None => json!([]),
};
let squad = json!({
@@ -235,14 +255,22 @@ pub fn squad_list(projected: &Value) -> Value {
mod tests {
use super::*;
use crate::fut::entities::Fifa17Entities;
use crate::fut::item::Fifa17Identity;
use crate::fut::item::{Fifa17Identity, Fifa17StaffIdentity};
// A resolver that mints a distinct wire id per owned item and a fixed asset.
struct TableIdentity(HashMap<String, Fifa17Identity>);
// `staff` is separate because a manager resolves through the STAFF identity,
// which carries the chemistry fields a player identity has no room for.
struct TableIdentity(
HashMap<String, Fifa17Identity>,
HashMap<String, Fifa17StaffIdentity>,
);
impl ItemIdentityResolver for TableIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.0.get(&it.owned_card_id).copied()
}
fn resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
self.1.get(&it.owned_card_id).copied()
}
}
fn ent() -> Fifa17Entities {
@@ -298,15 +326,18 @@ mod tests {
fn fresh_projects_full_23_slot_array_with_captain_wire_id() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]));
let ident = TableIdentity(
HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]),
HashMap::new(),
);
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
@@ -334,7 +365,7 @@ mod tests {
let owned = HashMap::new();
// A stale extension IS carried (host may log it) but must not be applied.
let input = one_slot_input(&owned, SquadExtInput::Stale(fresh_ext()));
let ident = TableIdentity(HashMap::new());
let ident = TableIdentity(HashMap::new(), HashMap::new());
assert_eq!(
project_squad(&input, &ident, &ent()).unwrap(),
SquadProjection::Stale
@@ -345,7 +376,7 @@ mod tests {
fn missing_is_explicit_never_fabricated() {
let owned = HashMap::new();
let input = one_slot_input(&owned, SquadExtInput::Missing);
let ident = TableIdentity(HashMap::new());
let ident = TableIdentity(HashMap::new(), HashMap::new());
assert_eq!(
project_squad(&input, &ident, &ent()).unwrap(),
SquadProjection::Missing
@@ -356,7 +387,7 @@ mod tests {
fn occupied_starter_without_asset_identity_is_refused_not_faked() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(HashMap::new()); // resolves nothing
let ident = TableIdentity(HashMap::new(), HashMap::new()); // resolves nothing
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
assert_eq!(
project_squad(&input, &ident, &ent()),
@@ -371,26 +402,29 @@ mod tests {
let mut owned = HashMap::new();
owned.insert("oc-a".to_string(), owned_item("oc-a", "fifa17_101490"));
owned.insert("oc-b".to_string(), owned_item("oc-b", "fifa17_101490"));
let ident = TableIdentity(HashMap::from([
(
"oc-a".to_string(),
Fifa17Identity {
item_id: 100000030,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
),
(
"oc-b".to_string(),
Fifa17Identity {
item_id: 100000031,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
),
]));
let ident = TableIdentity(
HashMap::from([
(
"oc-a".to_string(),
Fifa17Identity {
item_id: 100000030,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
),
(
"oc-b".to_string(),
Fifa17Identity {
item_id: 100000031,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
),
]),
HashMap::new(),
);
let mut kit = std::collections::BTreeMap::new();
kit.insert("oc-a".to_string(), 7);
kit.insert("oc-b".to_string(), 19);
@@ -445,8 +479,8 @@ mod tests {
fn manager_projected_from_ownership_as_wire_ref() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(HashMap::from([
(
let ident = TableIdentity(
HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
@@ -454,26 +488,47 @@ mod tests {
resource_id: 20801,
rareflag: 1,
},
),
(
)]),
HashMap::from([(
"oc-mgr".to_string(),
Fifa17Identity {
Fifa17StaffIdentity {
item_id: 100000427,
asset_id: 5001,
resource_id: 5001,
rareflag: 1,
resource_id: 1_000_509,
subtype: 4,
nation: 45,
league_id: 53,
team_id: 241,
},
),
]));
)]),
);
let mut input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
input.manager = Some(owned_item("oc-mgr", "fifa17_mgr"));
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
};
// The item must ride ALONG with the ref: a bare `{id, dream}` left the
// pre-match squad with no manager even though the assignment had been
// saved, because nothing in the response described the card.
assert_eq!(
v["manager"],
json!([{ "id": 100000427, "dream": false }]),
"manager is the ownership-backed wire ref, resolved from the owned item"
json!([{
"id": 100000427,
"itemData": {
"id": 100000427,
"resourceId": 1_000_509,
"cardsubtypeid": 4,
"itemType": "staff",
"nation": 45,
"leagueId": 53,
"teamid": 241,
"contract": STAFF_CONTRACT,
"itemState": "free",
"owners": 1,
"untradeable": false,
},
"dream": false,
}]),
"manager is the ownership-backed wire ref WITH its item"
);
}
@@ -481,15 +536,18 @@ mod tests {
fn absent_manager_projects_empty_array_never_fabricated() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]));
let ident = TableIdentity(
HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]),
HashMap::new(),
);
// one_slot_input leaves manager: None.
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
@@ -24,7 +24,9 @@
use std::collections::HashMap;
use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver};
use openfut_adapter_fifa17::fut::item::{
CoreOwnedItem, Fifa17Identity, Fifa17StaffIdentity, ItemIdentityResolver, STAFF_CONTRACT,
};
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, Fifa17SquadPut, SquadWireResolver};
use openfut_adapter_fifa17::fut::squad_ext::{build_squad_write, SquadWriteBuild};
use openfut_adapter_fifa17::fut::squad_projection::{
@@ -50,11 +52,19 @@ impl SquadWireResolver for OcResolver {
}
/// owned_card_id → FIFA identity, so two copies of one definition stay distinct.
struct TableIdentity(HashMap<String, Fifa17Identity>);
/// `staff` is a second table because a manager resolves through the STAFF
/// identity, which carries the chemistry fields a player identity cannot hold.
struct TableIdentity(
HashMap<String, Fifa17Identity>,
HashMap<String, Fifa17StaffIdentity>,
);
impl ItemIdentityResolver for TableIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.0.get(&it.owned_card_id).copied()
}
fn resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
self.1.get(&it.owned_card_id).copied()
}
}
/// Neutral entity resolver — badge/flag ids are covered by `fut::item` tests; the
@@ -116,7 +126,7 @@ fn oracle_tables() -> (HashMap<String, CoreOwnedItem>, TableIdentity) {
},
);
}
(owned, TableIdentity(ident))
(owned, TableIdentity(ident, HashMap::new()))
}
/// The full pipeline: parse a captured PUT, build the canonical + extension, then
@@ -298,17 +308,22 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
is_on_bench: index >= 11,
});
}
// The manager is now ownership-backed: register its owned instance + identity
// and pass it as the assignment, not as an opaque extension field.
// The manager is ownership-backed: register its owned instance + STAFF
// identity and pass it as the assignment, not as an opaque extension field.
// A real managercards row is used (1000509 Luis Enrique, nation 45, LaLiga
// 53, Barcelona 241) so the projected item is a shape the client could
// actually merge.
let mgr_wire = oracle["manager"][0]["id"].as_i64().unwrap();
let mgr_oc = format!("oc-{mgr_wire}");
ident.0.insert(
ident.1.insert(
mgr_oc.clone(),
Fifa17Identity {
Fifa17StaffIdentity {
item_id: mgr_wire as u32,
asset_id: 5001,
resource_id: 5001,
rareflag: 1,
resource_id: 1_000_509,
subtype: 4,
nation: 45,
league_id: 53,
team_id: 241,
},
);
let manager_item = CoreOwnedItem {
@@ -378,7 +393,23 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
}
// EXTENSION + SHADOW: sourced from the read, so they round-trip identically.
assert_eq!(projected["custom"], oracle["custom"]);
assert_eq!(projected["manager"], oracle["manager"]);
// The manager REF round-trips; the item now rides with it. The capture this
// oracle came from carried a bare `{id, dream}`, but its manager was the
// dangling one every retail capture has, so it never showed that a populated
// ref renders on its own — and in practice it did not.
assert_eq!(
projected["manager"][0]["id"], oracle["manager"][0]["id"],
"the manager wire ref itself must still round-trip"
);
assert_eq!(
projected["manager"][0]["dream"],
oracle["manager"][0]["dream"]
);
let mgr_item = &projected["manager"][0]["itemData"];
assert_eq!(mgr_item["id"], oracle["manager"][0]["id"]);
assert_eq!(mgr_item["cardsubtypeid"], 4);
assert_eq!(mgr_item["resourceId"], 1_000_509);
assert_eq!(mgr_item["contract"], STAFF_CONTRACT);
assert_eq!(projected["kicktakers"], oracle["kicktakers"]);
assert_eq!(projected["squadType"], oracle["squadType"]);
assert_eq!(projected["chemistry"], oracle["chemistry"]);