0e200758f0
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).
691 lines
25 KiB
Rust
691 lines
25 KiB
Rust
//! Adapter-level squad read-after-write / round-trip fidelity, driven entirely by
|
|
//! committed sanitized capture evidence.
|
|
//!
|
|
//! Pipeline exercised end to end, with NO database, socket, or Core:
|
|
//!
|
|
//! ```text
|
|
//! captured PUT ─parse─▶ Fifa17SquadPut
|
|
//! ─build─▶ ProposedSquad (canonical) + Fifa17SquadExtensionV1
|
|
//! (simulate committed canonical state: the ProposedSquad IS what Core stored)
|
|
//! ─project─▶ FIFA 17 squad wire object
|
|
//! ```
|
|
//!
|
|
//! Fidelity is asserted by ownership class:
|
|
//! CANONICAL player instance per index, formation, captain, bench split, and
|
|
//! the ownership-backed manager assignment (migration 0023)
|
|
//! EXTENSION custom, kicktakers, kit numbers (by player), squadType
|
|
//! SHADOW chemistry/rating/starRating (client-reported, round-tripped as-is)
|
|
//! DERIVED correct FIFA 17 item identity (wire id + resourceId)
|
|
//!
|
|
//! We assert *semantic wire fidelity*, not byte equality: the read oracle was
|
|
//! produced by the pre-migration Python backend, whose display-only shadow fields
|
|
//! (`untradeable`, `discardValue`) and server-*recomputed* chemistry are not the
|
|
//! adapter's to reproduce.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use openfut_adapter_fifa17::fut::club_response::ActiveKitAssignments;
|
|
use openfut_adapter_fifa17::fut::item::{
|
|
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_actives, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
|
SquadProjection, SquadProjectionInput,
|
|
};
|
|
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");
|
|
const PUT_F433: &str = include_str!("../fixtures/utas/squad_put_f433.json");
|
|
const READ_ORACLE: &str = include_str!("../fixtures/utas/squad_read_usermassinfo.json");
|
|
|
|
// ---- host-role stand-ins (identity resolution, entity resolver) -------------
|
|
|
|
/// Wire→owned reverse map. In production the host builds this from the identity
|
|
/// store; here every occupied wire id maps to a stable `oc-<wire>`.
|
|
struct OcResolver;
|
|
impl SquadWireResolver for OcResolver {
|
|
fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
|
|
Some(format!("oc-{wire}"))
|
|
}
|
|
}
|
|
|
|
/// owned_card_id → FIFA identity, so two copies of one definition stay distinct.
|
|
/// `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
|
|
/// projector round-trip asserts item *identity* (wire id + asset), not entity ids.
|
|
struct NoEntities;
|
|
impl openfut_adapter_fifa17::fut::entities::ReverseEntityResolver for NoEntities {
|
|
fn league_id(&self, _: &str) -> Option<u32> {
|
|
None
|
|
}
|
|
fn team_id(&self, _: &str) -> Option<u32> {
|
|
None
|
|
}
|
|
fn nation_id(&self, _: &str) -> Option<u32> {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Build the owned-item map + identity table from the persisted read oracle, so
|
|
/// every wire id used by the captures resolves to its real FIFA asset id/rating.
|
|
/// Keyed by `oc-<wire>` to match `OcResolver`.
|
|
fn oracle_tables() -> (HashMap<String, CoreOwnedItem>, TableIdentity) {
|
|
let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap();
|
|
let mut owned = HashMap::new();
|
|
let mut ident = HashMap::new();
|
|
for p in oracle["players"].as_array().unwrap() {
|
|
let it = &p["itemData"];
|
|
let wire = it["id"].as_i64().unwrap();
|
|
if wire == 0 {
|
|
continue; // empty slot
|
|
}
|
|
let oc = format!("oc-{wire}");
|
|
let asset = it["resourceId"].as_u64().unwrap() as u32;
|
|
let attrs: Vec<u8> = it["attributeList"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|a| a["value"].as_u64().unwrap() as u8)
|
|
.collect();
|
|
owned.insert(
|
|
oc.clone(),
|
|
CoreOwnedItem {
|
|
owned_card_id: oc.clone(),
|
|
card_id: format!("def-{asset}"),
|
|
rating: it["rating"].as_u64().unwrap() as u8,
|
|
position: it["preferredPosition"].as_str().unwrap().to_string(),
|
|
nation: String::new(),
|
|
league: String::new(),
|
|
club: String::new(),
|
|
attributes: [attrs[0], attrs[1], attrs[2], attrs[3], attrs[4], attrs[5]],
|
|
// The captured wire carries the club's real per-instance
|
|
// contract count, so the round trip proves the PERSISTED number
|
|
// reaches the wire rather than a constant.
|
|
contract_matches: it["contract"].as_i64(),
|
|
source_rating: None,
|
|
core_content_kind: None,
|
|
},
|
|
);
|
|
ident.insert(
|
|
oc,
|
|
Fifa17Identity {
|
|
item_id: wire as u32,
|
|
asset_id: asset,
|
|
resource_id: asset,
|
|
rareflag: 1,
|
|
},
|
|
);
|
|
}
|
|
(owned, TableIdentity(ident, HashMap::new()))
|
|
}
|
|
|
|
/// The full pipeline: parse a captured PUT, build the canonical + extension, then
|
|
/// project — treating the just-built canonical squad as Core's committed state.
|
|
fn project_put(
|
|
put: &Fifa17SquadPut,
|
|
owned: &HashMap<String, CoreOwnedItem>,
|
|
ident: &TableIdentity,
|
|
) -> Value {
|
|
let SquadWriteBuild {
|
|
canonical,
|
|
extension,
|
|
} = build_squad_write(put, &OcResolver).expect("build must succeed for a full valid squad");
|
|
let slots: Vec<ProjectionSlot> = canonical
|
|
.slots
|
|
.iter()
|
|
.map(|s| ProjectionSlot {
|
|
owned_card_id: s.owned_card_id.clone(),
|
|
index: s.index,
|
|
is_captain: s.is_captain,
|
|
is_on_bench: s.is_on_bench,
|
|
})
|
|
.collect();
|
|
let input = SquadProjectionInput {
|
|
fifa_squad_id: canonical.squad_id,
|
|
name: canonical.name.clone().unwrap_or_default(),
|
|
formation: canonical.formation.clone().unwrap(),
|
|
slots,
|
|
ext: SquadExtInput::Fresh(extension),
|
|
owned,
|
|
// This stand-in supplies no owned manager; the manager is projected from
|
|
// the ownership-backed assignment, exercised in persisted_read below.
|
|
manager: None,
|
|
};
|
|
match project_squad(&input, ident, &NoEntities).unwrap() {
|
|
SquadProjection::Projected(v) => v,
|
|
other => panic!("expected Projected, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Map FIFA-array index → (wire item id, kit number) for the occupied slots of a
|
|
/// projected or captured squad object.
|
|
fn occupied(v: &Value) -> HashMap<i64, (i64, i64)> {
|
|
v["players"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|p| p["itemData"]["id"].as_i64().unwrap() != 0)
|
|
.map(|p| {
|
|
(
|
|
p["index"].as_i64().unwrap(),
|
|
(
|
|
p["itemData"]["id"].as_i64().unwrap(),
|
|
p["kitNumber"].as_i64().unwrap(),
|
|
),
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn baseline_projects_the_known_squad_round_trip() {
|
|
let (owned, ident) = oracle_tables();
|
|
let put = parse_squad_put(PUT_BASELINE.as_bytes()).unwrap();
|
|
let projected = project_put(&put, &owned, &ident);
|
|
|
|
// Fixed 23-slot array; 11 occupied at 0..=10.
|
|
assert_eq!(projected["players"].as_array().unwrap().len(), 23);
|
|
let put_v: Value = serde_json::from_str(PUT_BASELINE).unwrap();
|
|
assert_eq!(
|
|
occupied(&projected),
|
|
occupied(&put_v),
|
|
"wire id + kit per index round-trip"
|
|
);
|
|
|
|
// CANONICAL: formation verbatim, captain follows the semantic player.
|
|
assert_eq!(projected["formation"], "f442");
|
|
assert_eq!(
|
|
projected["captain"], 100000001,
|
|
"captain is the player's WIRE id"
|
|
);
|
|
// EXTENSION: custom byte-identical, squadType preserved. The manager is now
|
|
// an ownership-backed assignment (not projected from the PUT/ext); with none
|
|
// supplied to this stand-in it projects empty.
|
|
assert_eq!(projected["custom"], put_v["custom"]);
|
|
assert!(projected["manager"].as_array().unwrap().is_empty());
|
|
assert_eq!(projected["squadType"], "REGULAR_SQUAD");
|
|
// SHADOW: client-reported values carried as-is (baseline chemistry 52).
|
|
assert_eq!(projected["chemistry"], 52);
|
|
assert_eq!(projected["rating"], 90);
|
|
assert_eq!(projected["starRating"], 90);
|
|
}
|
|
|
|
#[test]
|
|
fn swap_moves_two_players_with_their_kits_and_round_trips() {
|
|
// The swap PUT is baseline with two players rotated between slots. Projecting
|
|
// build(swap) must reproduce the swap wire exactly, and the affected players'
|
|
// kit numbers must have travelled with them (kit follows the player).
|
|
let (owned, ident) = oracle_tables();
|
|
let projected = project_put(
|
|
&parse_squad_put(PUT_SWAP.as_bytes()).unwrap(),
|
|
&owned,
|
|
&ident,
|
|
);
|
|
let swap_v: Value = serde_json::from_str(PUT_SWAP).unwrap();
|
|
let base_v: Value = serde_json::from_str(PUT_BASELINE).unwrap();
|
|
|
|
// CANONICAL: the projected occupancy per index matches the swap PUT exactly.
|
|
assert_eq!(
|
|
occupied(&projected),
|
|
occupied(&swap_v),
|
|
"player+kit per index round-trip"
|
|
);
|
|
|
|
// The swap is real: at least two indices carry a different player than baseline.
|
|
let (proj_occ, base_occ) = (occupied(&projected), occupied(&base_v));
|
|
let moved: Vec<i64> = proj_occ
|
|
.iter()
|
|
.filter(|(idx, pair)| base_occ.get(idx).map(|b| b.0) != Some(pair.0))
|
|
.map(|(idx, _)| *idx)
|
|
.collect();
|
|
assert!(
|
|
moved.len() >= 2,
|
|
"a swap changes at least two slots, got {moved:?}"
|
|
);
|
|
|
|
// kit follows the PLAYER: for every player, its kit in baseline == its kit
|
|
// in the swap projection, regardless of which slot it now occupies.
|
|
let kit_by_player = |occ: &HashMap<i64, (i64, i64)>| -> HashMap<i64, i64> {
|
|
occ.values().map(|(id, kit)| (*id, *kit)).collect()
|
|
};
|
|
assert_eq!(
|
|
kit_by_player(&proj_occ),
|
|
kit_by_player(&base_occ),
|
|
"each player kept its kit number through the swap"
|
|
);
|
|
|
|
assert_eq!(
|
|
projected["captain"], 100000001,
|
|
"captain follows the semantic player"
|
|
);
|
|
assert_eq!(
|
|
projected["custom"], swap_v["custom"],
|
|
"opaque custom unchanged by the swap"
|
|
);
|
|
// SHADOW: the client-reported chemistry from THIS PUT (58) is round-tripped
|
|
// as-is — never reconciled to a server recompute.
|
|
assert_eq!(projected["chemistry"], 58);
|
|
}
|
|
|
|
#[test]
|
|
fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
|
// Simulate Core's committed state for the persisted (post-relaunch) squad by
|
|
// reconstructing the canonical slots + FIFA extension straight from the read
|
|
// evidence, then project and require the read back — the strongest fidelity
|
|
// check across all four ownership classes.
|
|
use openfut_adapter_fifa17::fut::squad::ClientReportedSquadEval;
|
|
use openfut_adapter_fifa17::fut::squad_ext::{Fifa17SquadExtensionV1, KicktakerRef};
|
|
use std::collections::BTreeMap;
|
|
|
|
let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap();
|
|
let (owned, mut ident) = oracle_tables();
|
|
let captain = oracle["captain"].as_i64().unwrap();
|
|
|
|
let mut slots = Vec::new();
|
|
let mut kit_numbers = BTreeMap::new();
|
|
for p in oracle["players"].as_array().unwrap() {
|
|
let wire = p["itemData"]["id"].as_i64().unwrap();
|
|
if wire == 0 {
|
|
continue;
|
|
}
|
|
let index = p["index"].as_i64().unwrap();
|
|
let oc = format!("oc-{wire}");
|
|
kit_numbers.insert(oc.clone(), p["kitNumber"].as_i64().unwrap());
|
|
slots.push(ProjectionSlot {
|
|
owned_card_id: oc,
|
|
index,
|
|
is_captain: wire == captain,
|
|
is_on_bench: index >= 11,
|
|
});
|
|
}
|
|
// 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.1.insert(
|
|
mgr_oc.clone(),
|
|
Fifa17StaffIdentity {
|
|
item_id: mgr_wire as u32,
|
|
resource_id: 1_000_509,
|
|
subtype: 4,
|
|
nation: 45,
|
|
league_id: 53,
|
|
team_id: 241,
|
|
},
|
|
);
|
|
let manager_item = CoreOwnedItem {
|
|
owned_card_id: mgr_oc,
|
|
card_id: "def-manager".to_string(),
|
|
rating: 0,
|
|
position: String::new(),
|
|
nation: String::new(),
|
|
league: String::new(),
|
|
club: String::new(),
|
|
attributes: [0; 6],
|
|
// The captured `manager` ref is the bare `{id, dream}` form, so the wire
|
|
// carries no staff contract to mirror: this instance is untracked and
|
|
// must fall back to the pack-fresh default.
|
|
contract_matches: None,
|
|
// Core's authored staff `value`; the squad projection never reads it (the
|
|
// client re-rates a manager from its own table), so the round trip is
|
|
// unaffected either way.
|
|
source_rating: Some(88),
|
|
core_content_kind: Some("manager".to_string()),
|
|
};
|
|
let kicktakers: Vec<KicktakerRef> =
|
|
serde_json::from_value(oracle["kicktakers"].clone()).unwrap();
|
|
let ext = Fifa17SquadExtensionV1 {
|
|
custom: oracle["custom"].as_str().map(str::to_string),
|
|
squad_type: oracle["squadType"].as_str().map(str::to_string),
|
|
kit_numbers,
|
|
kicktakers,
|
|
client_reported: ClientReportedSquadEval {
|
|
chemistry: oracle["chemistry"].as_i64(),
|
|
rating: oracle["rating"].as_i64(),
|
|
star_rating: oracle["starRating"].as_i64(),
|
|
},
|
|
};
|
|
let input = SquadProjectionInput {
|
|
fifa_squad_id: oracle["id"].as_i64().unwrap(),
|
|
name: oracle["squadName"].as_str().unwrap().to_string(),
|
|
formation: oracle["formation"].as_str().unwrap().to_string(),
|
|
slots,
|
|
ext: SquadExtInput::Fresh(ext),
|
|
owned: &owned,
|
|
manager: Some(manager_item),
|
|
};
|
|
let SquadProjection::Projected(projected) = project_squad(&input, &ident, &NoEntities).unwrap()
|
|
else {
|
|
panic!("expected Projected");
|
|
};
|
|
|
|
// CANONICAL + DERIVED: identity and placement per slot match the read.
|
|
assert_eq!(
|
|
occupied(&projected),
|
|
occupied(&oracle),
|
|
"player+kit per index"
|
|
);
|
|
assert_eq!(projected["captain"], oracle["captain"]);
|
|
assert_eq!(projected["formation"], oracle["formation"]);
|
|
for (pp, op) in projected["players"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.zip(oracle["players"].as_array().unwrap())
|
|
{
|
|
assert_eq!(
|
|
pp["itemData"]["id"], op["itemData"]["id"],
|
|
"wire id per slot"
|
|
);
|
|
assert_eq!(
|
|
pp["itemData"]["resourceId"], op["itemData"]["resourceId"],
|
|
"asset id per slot"
|
|
);
|
|
assert_eq!(pp["itemData"]["rating"], op["itemData"]["rating"]);
|
|
assert_eq!(
|
|
pp["itemData"]["preferredPosition"],
|
|
op["itemData"]["preferredPosition"]
|
|
);
|
|
}
|
|
// EXTENSION + SHADOW: sourced from the read, so they round-trip identically.
|
|
assert_eq!(projected["custom"], oracle["custom"]);
|
|
// 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,
|
|
"this manager instance is untracked, so the pack-fresh fallback shows"
|
|
);
|
|
assert_eq!(projected["kicktakers"], oracle["kicktakers"]);
|
|
assert_eq!(projected["squadType"], oracle["squadType"]);
|
|
assert_eq!(projected["chemistry"], oracle["chemistry"]);
|
|
assert_eq!(projected["rating"], oracle["rating"]);
|
|
assert_eq!(projected["starRating"], oracle["starRating"]);
|
|
}
|
|
|
|
#[test]
|
|
fn formation_change_reindexes_without_deriving_slots_and_kit_follows_player() {
|
|
let (owned, ident) = oracle_tables();
|
|
let swap = project_put(
|
|
&parse_squad_put(PUT_SWAP.as_bytes()).unwrap(),
|
|
&owned,
|
|
&ident,
|
|
);
|
|
let f433 = project_put(
|
|
&parse_squad_put(PUT_F433.as_bytes()).unwrap(),
|
|
&owned,
|
|
&ident,
|
|
);
|
|
|
|
assert_eq!(f433["formation"], "f433");
|
|
assert_eq!(swap["formation"], "f442");
|
|
|
|
// Same 11 starters (same set of wire ids), reindexed.
|
|
let set = |v: &Value| {
|
|
let mut ids: Vec<i64> = occupied(v).values().map(|(id, _)| *id).collect();
|
|
ids.sort();
|
|
ids
|
|
};
|
|
assert_eq!(
|
|
set(&swap),
|
|
set(&f433),
|
|
"same 11 players survive the formation change"
|
|
);
|
|
|
|
// The captain (wire 100000001) moved from index 8 (f442) to index 10 (f433) —
|
|
// proof indices are round-tripped, not derived from the formation.
|
|
let idx_of = |v: &Value, wire: i64| -> i64 {
|
|
occupied(v)
|
|
.into_iter()
|
|
.find(|(_, (id, _))| *id == wire)
|
|
.unwrap()
|
|
.0
|
|
};
|
|
assert_eq!(idx_of(&swap, 100000001), 8);
|
|
assert_eq!(idx_of(&f433, 100000001), 10);
|
|
|
|
// kit follows the PLAYER, not the slot: captain keeps kit 8 across the reindex.
|
|
let kit_of = |v: &Value, wire: i64| -> i64 {
|
|
occupied(v)
|
|
.into_iter()
|
|
.find(|(_, (id, _))| *id == wire)
|
|
.unwrap()
|
|
.1
|
|
.1
|
|
};
|
|
assert_eq!(kit_of(&swap, 100000001), 8);
|
|
assert_eq!(
|
|
kit_of(&f433, 100000001),
|
|
8,
|
|
"kit stayed with the player despite the reindex"
|
|
);
|
|
assert_eq!(f433["captain"], 100000001, "captain still the same player");
|
|
}
|
|
|
|
#[test]
|
|
fn one_projector_serves_every_endpoint_no_divergence() {
|
|
let (owned, ident) = oracle_tables();
|
|
let projected = project_put(
|
|
&parse_squad_put(PUT_SWAP.as_bytes()).unwrap(),
|
|
&owned,
|
|
&ident,
|
|
);
|
|
|
|
// 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_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"]);
|
|
|
|
// squad/list = a summary SUBSET of the SAME object, not a second projection.
|
|
let list = squad_list(&projected);
|
|
let entry = &list["squad"][0];
|
|
for k in [
|
|
"id",
|
|
"squadName",
|
|
"formation",
|
|
"squadType",
|
|
"rating",
|
|
"chemistry",
|
|
] {
|
|
assert_eq!(
|
|
entry[k], projected[k],
|
|
"summary field {k} derived from the one projection"
|
|
);
|
|
}
|
|
// 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);
|
|
}
|