fix(fifa17): project active club items through squad.actives
FIFA 17 makes a club item resident ONLY through squad.actives. The squad
parser's arm for atom 11 computes the address of the i-th element of the
client's five-element club-item array and hands it to the item
deserializer as the out-handle:
cmp edi,0x5 ; at most five entries are read
mov rax,QWORD PTR [r13+0x108] ; the club-item array
lea rcx,[rax+rcx*8] ; &array[edi]
call 0x18013fe00 ; item deserializer, writing that slot
That deserializer inserts the record into the client's resident item map
- keyed by wire instance id, gated only on the id being non-zero - and
binds the slot handle to it. So each element must be a full item object
like squad.manager[].itemData; an id reference alone installs nothing,
because the manager installer looks its id up in that same map and does
nothing on a miss.
We emitted actives: [] as an 'observed constant', which was circular: it
came from our own captures and the Python oracle seeded it. The client
then read the array-end token immediately, parsed nothing, and left all
five slots null, so every lookup resolved to the static not-found
sentinel whose item pointer is NULL. That is why the pre-match kit
selector had no kits, and it is also why /club?type=kit could never fix
it: no /club response feeds that array.
Core already owns the designations via /club/active-items, so the host
reuses get_active_kits() and the adapter shapes each entry with the same
shape_club_item primitive /club?type=kit uses, keeping one wire dialect.
A Core transport error yields no actives and is reported rather than
silently empty. userInfo.actives already mirrors the squad's.
Verified against the client's own fcc_kitcards table: 6300006 is team
21's home card (category 2) and 6400003 the away card (category 3).
This commit is contained in:
@@ -35,11 +35,14 @@ use std::collections::HashMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::club_response::ActiveKitAssignments;
|
||||
use crate::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item::{
|
||||
shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver, STAFF_CONTRACT,
|
||||
shape_club_item, shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver,
|
||||
STAFF_CONTRACT,
|
||||
};
|
||||
use crate::fut::item_state;
|
||||
use crate::fut::squad::FIFA17_SQUAD_SLOTS;
|
||||
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
|
||||
|
||||
@@ -235,15 +238,75 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
Ok(SquadProjection::Projected(squad))
|
||||
}
|
||||
|
||||
/// The active club items for `squad.actives`, in slot order.
|
||||
///
|
||||
/// ## Why this exists, and why it is NOT `[]`
|
||||
///
|
||||
/// `actives` is the ONLY carrier that makes a club item resident in FIFA 17.
|
||||
/// The squad parser's arm for atom 11 (`actives`) computes the address of the
|
||||
/// i-th element of the client's five-element club-item array and hands it to the
|
||||
/// item deserializer as the out-handle:
|
||||
///
|
||||
/// ```text
|
||||
/// cmp edi,0x5 ; at most five entries are read
|
||||
/// jge <skip>
|
||||
/// mov rax,QWORD PTR [r13+0x108] ; the club-item array
|
||||
/// lea rcx,[rax+rcx*8] ; &array[edi] (edi * 24)
|
||||
/// call 0x18013fe00 ; the item deserializer, writing that slot
|
||||
/// ```
|
||||
///
|
||||
/// That deserializer inserts the record into the client's resident item map
|
||||
/// (keyed by wire instance id, and its only gate is a non-zero id) and binds the
|
||||
/// slot handle to it. So each element must be a FULL item object, exactly like
|
||||
/// `squad.manager[].itemData` — an id reference alone installs nothing, because
|
||||
/// the manager installer looks its id up in that same map and does nothing when
|
||||
/// it misses.
|
||||
///
|
||||
/// An empty array makes the client read the array-end token immediately and
|
||||
/// parse nothing, which leaves all five slots null. Every later consumer then
|
||||
/// resolves to the client's static not-found sentinel, whose item pointer is
|
||||
/// NULL — which is exactly why the pre-match kit selector had no kits.
|
||||
///
|
||||
/// Elements are shaped by the shared [`shape_club_item`], the same primitive
|
||||
/// `/club?type=kit` uses, so the two routes cannot drift. Ordering is positional
|
||||
/// on the wire but not semantic: both client consumers (the activate path and
|
||||
/// the store lookup) search the five slots by content — itemState, or
|
||||
/// cardtype/cardsubtypeid — never by index.
|
||||
///
|
||||
/// A designated kit whose owned row or FIFA kit identity cannot be resolved is
|
||||
/// omitted rather than emitted with a fabricated id, matching `/club`.
|
||||
pub fn squad_actives<I: ItemIdentityResolver + ?Sized>(
|
||||
owned: &HashMap<String, CoreOwnedItem>,
|
||||
ident: &I,
|
||||
active_kits: ActiveKitAssignments<'_>,
|
||||
) -> Value {
|
||||
let mut out = Vec::new();
|
||||
for (owned_card_id, state) in [
|
||||
(active_kits.home, item_state::ACTIVE_HOME_KIT),
|
||||
(active_kits.away, item_state::ACTIVE_AWAY_KIT),
|
||||
] {
|
||||
let Some(owned_card_id) = owned_card_id else {
|
||||
continue;
|
||||
};
|
||||
let Some(item) = owned.get(owned_card_id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(id) = ident.resolve_kit(item) {
|
||||
out.push(shape_club_item(id, state));
|
||||
}
|
||||
}
|
||||
Value::Array(out)
|
||||
}
|
||||
|
||||
/// Wrap a projected squad object into the `userMassInfo.squad` shape, injecting
|
||||
/// the session-envelope fields the projector does not own (`personaId`, plus the
|
||||
/// observed constants `changed: 0`, `actives: []`).
|
||||
pub fn user_mass_info_squad(projected: Value, persona_id: i64) -> Value {
|
||||
/// the session-envelope fields the projector does not own: `personaId`, the
|
||||
/// observed constant `changed: 0`, and `actives` from [`squad_actives`].
|
||||
pub fn user_mass_info_squad(projected: Value, persona_id: i64, actives: Value) -> Value {
|
||||
let mut obj = projected;
|
||||
if let Value::Object(map) = &mut obj {
|
||||
map.insert("personaId".into(), json!(persona_id));
|
||||
map.insert("changed".into(), json!(0));
|
||||
map.insert("actives".into(), json!([]));
|
||||
map.insert("actives".into(), actives);
|
||||
}
|
||||
obj
|
||||
}
|
||||
|
||||
@@ -24,16 +24,18 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use openfut_adapter_fifa17::fut::club_response::ActiveKitAssignments;
|
||||
use openfut_adapter_fifa17::fut::item::{
|
||||
CoreOwnedItem, Fifa17Identity, Fifa17StaffIdentity, ItemIdentityResolver, STAFF_CONTRACT,
|
||||
CoreOwnedItem, Fifa17Identity, Fifa17KitIdentity, 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::{
|
||||
project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
||||
project_squad, squad_actives, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
||||
SquadProjection, SquadProjectionInput,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const PUT_BASELINE: &str = include_str!("../fixtures/utas/squad_put_f442.json");
|
||||
const PUT_SWAP: &str = include_str!("../fixtures/utas/squad_put_swap_f442.json");
|
||||
@@ -503,11 +505,17 @@ fn one_projector_serves_every_endpoint_no_divergence() {
|
||||
&ident,
|
||||
);
|
||||
|
||||
// userMassInfo.squad = the projected object + session envelope.
|
||||
let ummi = user_mass_info_squad(projected.clone(), 33068179);
|
||||
// userMassInfo.squad = the projected object + session envelope. `actives` is
|
||||
// supplied by the caller now, so the envelope must carry it through verbatim
|
||||
// rather than hardcoding an empty array.
|
||||
let actives = json!([{ "id": 100004874, "itemState": "activeHomeKit" }]);
|
||||
let ummi = user_mass_info_squad(projected.clone(), 33068179, actives.clone());
|
||||
assert_eq!(ummi["personaId"], 33068179);
|
||||
assert_eq!(ummi["changed"], 0);
|
||||
assert!(ummi["actives"].is_array());
|
||||
assert_eq!(
|
||||
ummi["actives"], actives,
|
||||
"the envelope must pass actives through, not replace it"
|
||||
);
|
||||
assert_eq!(ummi["players"], projected["players"], "same projected body");
|
||||
assert_eq!(ummi["formation"], projected["formation"]);
|
||||
|
||||
@@ -530,3 +538,153 @@ fn one_projector_serves_every_endpoint_no_divergence() {
|
||||
// The summary carries only those six keys — no divergent squad shape.
|
||||
assert_eq!(entry.as_object().unwrap().len(), 6);
|
||||
}
|
||||
|
||||
// ---- squad.actives: the only carrier that makes a club item resident --------
|
||||
|
||||
/// A resolver that can answer `resolve_kit`, which the default trait method
|
||||
/// cannot (it returns `None` for player-only resolvers).
|
||||
struct KitIdentity(HashMap<String, Fifa17KitIdentity>);
|
||||
impl ItemIdentityResolver for KitIdentity {
|
||||
fn resolve(&self, _it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_kit(&self, it: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
|
||||
self.0.get(&it.owned_card_id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
fn kit_owned(owned_card_id: &str) -> CoreOwnedItem {
|
||||
CoreOwnedItem {
|
||||
owned_card_id: owned_card_id.to_string(),
|
||||
card_id: format!("def-{owned_card_id}"),
|
||||
rating: 0,
|
||||
position: String::new(),
|
||||
nation: String::new(),
|
||||
league: String::new(),
|
||||
club: String::new(),
|
||||
attributes: [0; 6],
|
||||
contract_matches: None,
|
||||
source_rating: None,
|
||||
core_content_kind: Some("kit".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn home_away_fixture() -> (HashMap<String, CoreOwnedItem>, KitIdentity) {
|
||||
let owned = HashMap::from([
|
||||
("oc-home".to_string(), kit_owned("oc-home")),
|
||||
("oc-away".to_string(), kit_owned("oc-away")),
|
||||
]);
|
||||
// Real `fcc_kitcards` rows for team 21: 6300006 is the home card (category 2,
|
||||
// assetid 14) and 6400003 the away card (category 3, assetid 15).
|
||||
let ident = KitIdentity(HashMap::from([
|
||||
(
|
||||
"oc-home".to_string(),
|
||||
Fifa17KitIdentity {
|
||||
item_id: 100004874,
|
||||
asset_id: 14,
|
||||
resource_id: 6300006,
|
||||
card_asset_id: 35,
|
||||
subtype: 9,
|
||||
team_id: 21,
|
||||
category: 2,
|
||||
year: 0,
|
||||
},
|
||||
),
|
||||
(
|
||||
"oc-away".to_string(),
|
||||
Fifa17KitIdentity {
|
||||
item_id: 100004873,
|
||||
asset_id: 15,
|
||||
resource_id: 6400003,
|
||||
card_asset_id: 35,
|
||||
subtype: 9,
|
||||
team_id: 21,
|
||||
category: 3,
|
||||
year: 0,
|
||||
},
|
||||
),
|
||||
]));
|
||||
(owned, ident)
|
||||
}
|
||||
|
||||
/// The client's squad parser reads at most five `actives` entries and parses each
|
||||
/// one straight into a slot of its five-element club-item array, so each element
|
||||
/// must be a full item object carrying a non-zero `id` — an id reference alone
|
||||
/// installs nothing.
|
||||
#[test]
|
||||
fn squad_actives_emits_full_items_for_the_designated_kits() {
|
||||
let (owned, ident) = home_away_fixture();
|
||||
let actives = squad_actives(
|
||||
&owned,
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("oc-home"),
|
||||
away: Some("oc-away"),
|
||||
},
|
||||
);
|
||||
let arr = actives.as_array().expect("actives is an array");
|
||||
assert_eq!(arr.len(), 2, "one entry per designated kit");
|
||||
|
||||
assert_eq!(arr[0]["id"], 100004874);
|
||||
assert_eq!(arr[0]["itemState"], "activeHomeKit");
|
||||
assert_eq!(arr[0]["resourceId"], 6300006);
|
||||
assert_eq!(arr[1]["id"], 100004873);
|
||||
assert_eq!(arr[1]["itemState"], "activeAwayKit");
|
||||
assert_eq!(arr[1]["resourceId"], 6400003);
|
||||
|
||||
for entry in arr {
|
||||
assert_eq!(entry["itemType"], "kit");
|
||||
assert_eq!(
|
||||
entry["cardsubtypeid"], 9,
|
||||
"cardsubtypeid 9 derives cardtype 7"
|
||||
);
|
||||
assert_eq!(entry["teamid"], 21, "the clone path keys kit art on teamid");
|
||||
assert_ne!(entry["id"], 0, "a zero id is never made resident");
|
||||
}
|
||||
}
|
||||
|
||||
/// Undesignated slots contribute nothing, and an unresolvable designation is
|
||||
/// omitted rather than emitted with a fabricated id — the same policy `/club`
|
||||
/// applies when a card has no FIFA identity.
|
||||
#[test]
|
||||
fn squad_actives_omits_absent_and_unresolvable_designations() {
|
||||
let (owned, ident) = home_away_fixture();
|
||||
|
||||
let home_only = squad_actives(
|
||||
&owned,
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("oc-home"),
|
||||
away: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(home_only.as_array().unwrap().len(), 1);
|
||||
assert_eq!(home_only[0]["itemState"], "activeHomeKit");
|
||||
|
||||
// Designated but not present in the owned collection.
|
||||
let dangling = squad_actives(
|
||||
&owned,
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("oc-missing"),
|
||||
away: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(dangling.as_array().unwrap().len(), 0);
|
||||
|
||||
// Present and designated, but with no resolvable FIFA kit identity.
|
||||
let unresolvable = squad_actives(
|
||||
&HashMap::from([("oc-x".to_string(), kit_owned("oc-x"))]),
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("oc-x"),
|
||||
away: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(unresolvable.as_array().unwrap().len(), 0);
|
||||
|
||||
// Nothing designated at all is an empty array, which is what left every
|
||||
// club-item slot null before this projector existed.
|
||||
let none = squad_actives(&owned, &ident, ActiveKitAssignments::default());
|
||||
assert_eq!(none.as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ use openfut_adapter_fifa17::fut::squad_ext::{
|
||||
build_squad_write, Fifa17SquadExtensionV1, SquadBuildError, EXT_NAMESPACE, EXT_SCHEMA_VERSION,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::squad_projection::{
|
||||
project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
||||
project_squad, squad_actives, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
||||
SquadProjection, SquadProjectionInput,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::store_catalog::{
|
||||
@@ -2663,8 +2663,10 @@ pub struct SquadLog {
|
||||
|
||||
/// The active squad projected from Core, with the freshness policy applied.
|
||||
enum HostProjection {
|
||||
/// Fresh: the projected FIFA squad object (before any endpoint envelope).
|
||||
Squad(Value),
|
||||
/// Fresh: the projected FIFA squad object (before any endpoint envelope),
|
||||
/// plus the active club items for its `actives` array. They travel together
|
||||
/// because both are derived from the SAME bounded Core fetch.
|
||||
Squad { squad: Value, actives: Value },
|
||||
/// Stored extension is stale vs the canonical squad — NEVER applied.
|
||||
Stale,
|
||||
/// No extension stored — nothing fabricated.
|
||||
@@ -2747,8 +2749,28 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection {
|
||||
owned: &owned_by_id,
|
||||
manager,
|
||||
};
|
||||
// The active club designations Core already owns (`/club/active-items`), shaped
|
||||
// into the `actives` array the client needs to make a club item resident. A
|
||||
// transport error here is non-fatal and yields no actives: the squad still
|
||||
// renders, exactly as it did before this array was populated. It is reported,
|
||||
// because silently empty actives is precisely the failure that left the
|
||||
// pre-match kit selector blank.
|
||||
let actives = match deps.core.get_active_kits() {
|
||||
Ok(assignments) => squad_actives(
|
||||
&owned_by_id,
|
||||
deps.resolver,
|
||||
ActiveKitAssignments {
|
||||
home: assignments.home_owned_card_id.as_deref(),
|
||||
away: assignments.away_owned_card_id.as_deref(),
|
||||
},
|
||||
),
|
||||
Err(error) => {
|
||||
eprintln!("utas-host WARN active club items unavailable, squad.actives empty: {error}");
|
||||
json!([])
|
||||
}
|
||||
};
|
||||
match project_squad(&input, deps.resolver, deps.entities) {
|
||||
Ok(SquadProjection::Projected(v)) => HostProjection::Squad(v),
|
||||
Ok(SquadProjection::Projected(squad)) => HostProjection::Squad { squad, actives },
|
||||
Ok(SquadProjection::Stale) => HostProjection::Stale,
|
||||
Ok(SquadProjection::Missing) => HostProjection::Missing,
|
||||
Err(e) => HostProjection::Error(e.to_string()),
|
||||
@@ -2917,8 +2939,8 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
|
||||
/// to an empty list, NEVER served from Python and NEVER projected from stale ext.
|
||||
pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
|
||||
match project_active_squad(deps) {
|
||||
HostProjection::Squad(v) => (
|
||||
json_response(&squad_list(&v)),
|
||||
HostProjection::Squad { squad, .. } => (
|
||||
json_response(&squad_list(&squad)),
|
||||
SquadLog {
|
||||
outcome: "ok",
|
||||
detail: String::new(),
|
||||
@@ -2955,8 +2977,8 @@ pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
|
||||
/// (never 401/403, never a Python fallback that could mask split authority).
|
||||
pub fn handle_squad_active(deps: &SquadDeps<'_>, persona_id: i64) -> (WireResponse, SquadLog) {
|
||||
match project_active_squad(deps) {
|
||||
HostProjection::Squad(v) => (
|
||||
json_response(&user_mass_info_squad(v, persona_id)),
|
||||
HostProjection::Squad { squad, actives } => (
|
||||
json_response(&user_mass_info_squad(squad, persona_id, actives)),
|
||||
SquadLog {
|
||||
outcome: "ok",
|
||||
detail: String::new(),
|
||||
@@ -3081,8 +3103,8 @@ pub fn handle_user_mass_info(
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let (squad_val, log) = match project_active_squad(deps) {
|
||||
HostProjection::Squad(v) => (
|
||||
user_mass_info_squad(v, persona),
|
||||
HostProjection::Squad { squad, actives } => (
|
||||
user_mass_info_squad(squad, persona, actives),
|
||||
SquadLog {
|
||||
outcome: "ok",
|
||||
detail: String::new(),
|
||||
@@ -4556,7 +4578,9 @@ impl Server {
|
||||
};
|
||||
let deps = self.squad_deps();
|
||||
let (squad, squad_outcome) = match project_active_squad(&deps) {
|
||||
HostProjection::Squad(v) => (user_mass_info_squad(v, self.persona_id), "ok"),
|
||||
HostProjection::Squad { squad, actives } => {
|
||||
(user_mass_info_squad(squad, self.persona_id, actives), "ok")
|
||||
}
|
||||
HostProjection::Stale => (empty_squad_overlay(self.persona_id), "stale_integrity"),
|
||||
HostProjection::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"),
|
||||
HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),
|
||||
|
||||
Reference in New Issue
Block a user