feat(adapter): single FIFA17 squad projector + fixture round-trip tests

fut::squad_projection is the ONE projector for every squad read shape.
project_squad(canonical squad + Fresh extension + owned items) -> the FIFA
17 squad wire object; user_mass_info_squad and squad_list are envelope-only
wrappers over the same output (no per-endpoint domain model).

Design guarantees exercised by tests:
  - purity / no N+1: consumes a host-assembled input (read_squad_with_ext +
    one batch owned-cards fetch + in-memory card defs); no per-slot lookup
  - shared shaper: every occupied slot is shaped by fut::item::shape_item,
    so squad items and /club items cannot drift
  - Fresh -> full projection; Stale -> never applied (verdict surfaced);
    Missing -> explicit, never fabricated
  - captain projects as the resolved WIRE id (never resourceId); index and
    formation round-trip verbatim; kit follows the player; two owned copies
    of one definition stay distinct

Adds committed sanitized fixtures decoded from the squad session capture
(swap, f433, persisted userMassInfo.squad read, squad/list) and
tests/squad_projection.rs: baseline / swap / formation-change / persisted
read-after-write round-trips asserted by ownership class (canonical,
extension, shadow, derived identity), plus one-projector no-divergence.
This commit is contained in:
funman300
2026-08-12 02:28:05 +00:00
parent 80a8bc4520
commit e09344490f
7 changed files with 723 additions and 0 deletions
@@ -0,0 +1 @@
{"squad": [{"rating": 89, "chemistry": 59, "formation": "f442", "id": 0, "squadName": "OpenFUT", "squadType": "REGULAR_SQUAD"}]}
@@ -0,0 +1 @@
{"id": 0, "custom": "[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,50,50,0,50,40,65,0,65,50,50,1]", "squadName": "OpenFUT", "chemistry": 49, "starRating": 90, "rating": 90, "formation": "f433", "squadType": "REGULAR_SQUAD", "manager": [{"id": 100000427, "dream": false}], "players": [{"index": 0, "itemData": {"id": 100000003, "dream": false}, "kitNumber": 1}, {"index": 1, "itemData": {"id": 100000006, "dream": false}, "kitNumber": 4}, {"index": 2, "itemData": {"id": 100000005, "dream": false}, "kitNumber": 3}, {"index": 3, "itemData": {"id": 100000008, "dream": false}, "kitNumber": 6}, {"index": 4, "itemData": {"id": 100000007, "dream": false}, "kitNumber": 5}, {"index": 5, "itemData": {"id": 100000002, "dream": false}, "kitNumber": 9}, {"index": 6, "itemData": {"id": 100000004, "dream": false}, "kitNumber": 2}, {"index": 7, "itemData": {"id": 100000009, "dream": false}, "kitNumber": 7}, {"index": 8, "itemData": {"id": 100000010, "dream": false}, "kitNumber": 10}, {"index": 9, "itemData": {"id": 100000025, "dream": false}, "kitNumber": 11}, {"index": 10, "itemData": {"id": 100000001, "dream": false}, "kitNumber": 8}, {"index": 11, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 12, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 13, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 14, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 15, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 16, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 17, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 18, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 19, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 20, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 21, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 22, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}], "captain": 100000001, "kicktakers": [{"index": 0, "id": 100000001, "dream": false}, {"index": 1, "id": 100000001, "dream": false}, {"index": 2, "id": 100000001, "dream": false}, {"index": 3, "id": 100000001, "dream": false}, {"index": 4, "id": 100000001, "dream": false}]}
@@ -0,0 +1 @@
{"id": 0, "custom": "[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,50,50,0,50,40,65,0,65,50,50,1]", "squadName": "OpenFUT", "chemistry": 58, "starRating": 90, "rating": 90, "formation": "f442", "squadType": "REGULAR_SQUAD", "manager": [{"id": 100000427, "dream": false}], "players": [{"index": 0, "itemData": {"id": 100000003, "dream": false}, "kitNumber": 1}, {"index": 1, "itemData": {"id": 100000006, "dream": false}, "kitNumber": 4}, {"index": 2, "itemData": {"id": 100000005, "dream": false}, "kitNumber": 3}, {"index": 3, "itemData": {"id": 100000008, "dream": false}, "kitNumber": 6}, {"index": 4, "itemData": {"id": 100000007, "dream": false}, "kitNumber": 5}, {"index": 5, "itemData": {"id": 100000002, "dream": false}, "kitNumber": 9}, {"index": 6, "itemData": {"id": 100000004, "dream": false}, "kitNumber": 2}, {"index": 7, "itemData": {"id": 100000009, "dream": false}, "kitNumber": 7}, {"index": 8, "itemData": {"id": 100000001, "dream": false}, "kitNumber": 8}, {"index": 9, "itemData": {"id": 100000010, "dream": false}, "kitNumber": 10}, {"index": 10, "itemData": {"id": 100000025, "dream": false}, "kitNumber": 11}, {"index": 11, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 12, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 13, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 14, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 15, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 16, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 17, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 18, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 19, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 20, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 21, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 22, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}], "captain": 100000001, "kicktakers": [{"index": 0, "id": 100000001, "dream": false}, {"index": 1, "id": 100000001, "dream": false}, {"index": 2, "id": 100000001, "dream": false}, {"index": 3, "id": 100000001, "dream": false}, {"index": 4, "id": 100000001, "dream": false}]}
File diff suppressed because one or more lines are too long
+1
View File
@@ -11,3 +11,4 @@ pub mod entities;
pub mod owned_query;
pub mod squad;
pub mod squad_ext;
pub mod squad_projection;
@@ -0,0 +1,364 @@
//! The **single** FIFA 17 squad projector: canonical Core squad + Fresh FIFA
//! extension → the FIFA 17 squad wire object.
//!
//! One projector serves every squad read shape. `userMassInfo.squad` embeds the
//! full object; `GET /squad/list` is a summary *subset* of it; a future
//! `/squad/active` is the same object again. Endpoint wrappers ([`user_mass_info_squad`],
//! [`squad_list`]) only shape the outer envelope — there is deliberately no
//! second squad domain model per endpoint.
//!
//! ## Purity / no N+1
//!
//! The projector touches no database, socket, or Core API. It consumes a
//! [`SquadProjectionInput`] the host assembles from ONE bounded batch — Core's
//! `read_squad_with_ext` (canonical squad + players + extension freshness) plus a
//! single "all owned cards for this club" fetch joined against the in-memory card
//! definitions. There is no per-slot lookup here or above.
//!
//! ## Item identity is shared, never reconstructed
//!
//! Each occupied slot is shaped by the shared [`crate::fut::item::shape_item`],
//! the same primitive `/club` uses — the projector never rebuilds the card shape
//! itself, so squad items and `/club` items cannot drift, and two owned copies of
//! one definition stay distinct (each carries its own resolved wire id).
//!
//! ## Fresh / Stale / Missing
//!
//! Freshness comes from Core and is surfaced, never buried in a default:
//! * **Fresh** → project the full object.
//! * **Stale** → NEVER overlay the stale extension on the newer canonical squad;
//! return [`SquadProjection::Stale`] for the host to act on (e.g. fall back).
//! * **Missing** → return [`SquadProjection::Missing`]; the projector does NOT
//! fabricate a manager/custom/kicktakers/kit numbers just to emit a response.
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::squad::FIFA17_SQUAD_SLOTS;
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
/// Freshness of the FIFA 17 extension relative to the current canonical squad,
/// as reported by Core's `read_squad_with_ext`. This is a game-independent mirror
/// the host populates from Core's `SquadExtState`; the adapter never computes the
/// canonical fingerprint itself (that is Core's server-side job).
#[derive(Debug, Clone)]
pub enum SquadExtInput {
Fresh(Fifa17SquadExtensionV1),
/// The stored extension whose fingerprint no longer matches the canonical
/// squad. Carried so the host can log/inspect it, but the projector NEVER
/// applies it over the newer canonical squad (mirrors Core's
/// `SquadExtState::Stale { stored, .. }`).
Stale(Fifa17SquadExtensionV1),
Missing,
}
/// One canonical slot as read back from Core. `is_on_bench` is carried through
/// from Core, never re-derived from the formation.
#[derive(Debug, Clone)]
pub struct ProjectionSlot {
pub owned_card_id: String,
pub index: i64,
pub is_captain: bool,
pub is_on_bench: bool,
}
/// Everything the pure projector needs to render one full squad.
pub struct SquadProjectionInput<'a> {
/// FIFA wire squad id (`0` = active).
pub fifa_squad_id: i64,
pub name: String,
/// FIFA formation token, verbatim from the canonical squad (never mapped).
pub formation: String,
pub slots: Vec<ProjectionSlot>,
pub ext: SquadExtInput,
/// Every owned item a slot references, keyed by `owned_card_id`. Assembled by
/// the host in one batch — the projector only reads from it.
pub owned: &'a HashMap<String, CoreOwnedItem>,
}
/// Result of a projection, with the extension-freshness verdict surfaced.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SquadProjection {
/// A fully projected FIFA 17 squad object (the `userMassInfo.squad` shape,
/// minus session envelope fields the endpoint wrapper adds).
Projected(Value),
/// The stored extension is stale vs the canonical squad — not applied.
Stale,
/// No extension stored — nothing fabricated.
Missing,
}
/// Hard projection failures — a squad cannot be rendered faithfully. Never
/// silently degraded (a squad cannot drop a starter the way `/club` drops a card).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SquadProjectError {
/// A slot references an `owned_card_id` absent from the projection input.
MissingOwnedItem(String),
/// An occupied slot's owned item has no real FIFA asset identity — it cannot
/// be rendered and MUST NOT be faked.
NoFifaIdentity(String),
}
impl std::fmt::Display for SquadProjectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SquadProjectError::MissingOwnedItem(id) => {
write!(f, "projection input missing owned item {id}")
}
SquadProjectError::NoFifaIdentity(id) => {
write!(f, "owned item {id} has no real FIFA asset identity (cannot render)")
}
}
}
}
impl std::error::Error for SquadProjectError {}
/// Project a squad. On `Fresh`, returns the full FIFA squad object; on
/// `Stale`/`Missing`, returns that verdict without fabricating anything.
pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
input: &SquadProjectionInput<'_>,
ident: &I,
ent: &impl ReverseEntityResolver,
) -> Result<SquadProjection, SquadProjectError> {
let ext = match &input.ext {
SquadExtInput::Stale(_) => return Ok(SquadProjection::Stale),
SquadExtInput::Missing => return Ok(SquadProjection::Missing),
SquadExtInput::Fresh(ext) => ext,
};
// Index occupied slots by their FIFA array index for O(1) fill.
let by_index: HashMap<i64, &ProjectionSlot> =
input.slots.iter().map(|s| (s.index, s)).collect();
let mut players = Vec::with_capacity(FIFA17_SQUAD_SLOTS as usize);
let mut captain_wire: i64 = 0;
for index in 0..FIFA17_SQUAD_SLOTS {
match by_index.get(&index) {
Some(slot) => {
let item = input
.owned
.get(&slot.owned_card_id)
.ok_or_else(|| SquadProjectError::MissingOwnedItem(slot.owned_card_id.clone()))?;
let id = ident
.resolve(item)
.ok_or_else(|| SquadProjectError::NoFifaIdentity(slot.owned_card_id.clone()))?;
if slot.is_captain {
captain_wire = id.item_id as i64;
}
// kit follows the player: look it up by owned id, never by index.
let kit = ext.kit_numbers.get(&slot.owned_card_id).copied().unwrap_or(0);
players.push(json!({
"index": index,
"itemData": shape_item(item, id, ent),
"kitNumber": kit,
}));
}
None => players.push(json!({
"index": index,
"itemData": { "id": 0, "dream": false },
"kitNumber": 0,
})),
}
}
let squad = json!({
"id": input.fifa_squad_id,
"squadName": input.name,
"formation": input.formation,
"squadType": ext.squad_type,
"chemistry": ext.client_reported.chemistry,
"starRating": ext.client_reported.star_rating,
"rating": ext.client_reported.rating,
"captain": captain_wire,
"manager": ext.manager,
"custom": ext.custom,
"players": players,
"kicktakers": ext.kicktakers,
});
Ok(SquadProjection::Projected(squad))
}
/// 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 {
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!([]));
}
obj
}
/// The `GET /squad/list` summary response — a subset of the SAME projected
/// object, wrapped in `{"squad":[ … ]}`. Not a separate domain projection.
pub fn squad_list(projected: &Value) -> Value {
let summary = json!({
"id": projected.get("id").cloned().unwrap_or(Value::Null),
"squadName": projected.get("squadName").cloned().unwrap_or(Value::Null),
"formation": projected.get("formation").cloned().unwrap_or(Value::Null),
"squadType": projected.get("squadType").cloned().unwrap_or(Value::Null),
"rating": projected.get("rating").cloned().unwrap_or(Value::Null),
"chemistry": projected.get("chemistry").cloned().unwrap_or(Value::Null),
});
json!({ "squad": [summary] })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fut::entities::Fifa17Entities;
use crate::fut::item::Fifa17Identity;
// A resolver that mints a distinct wire id per owned item and a fixed asset.
struct TableIdentity(HashMap<String, Fifa17Identity>);
impl ItemIdentityResolver for TableIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.0.get(&it.owned_card_id).copied()
}
}
fn ent() -> Fifa17Entities {
Fifa17Entities::from_maps(HashMap::new(), HashMap::new(), HashMap::new())
}
fn owned_item(id: &str, card: &str) -> CoreOwnedItem {
CoreOwnedItem {
owned_card_id: id.into(),
card_id: card.into(),
rating: 84,
position: "ST".into(),
nation: "n".into(),
league: "l".into(),
club: "c".into(),
attributes: [80, 80, 80, 80, 40, 80],
}
}
fn one_slot_input<'a>(
owned: &'a HashMap<String, CoreOwnedItem>,
ext: SquadExtInput,
) -> SquadProjectionInput<'a> {
SquadProjectionInput {
fifa_squad_id: 0,
name: "OpenFUT".into(),
formation: "f442".into(),
slots: vec![ProjectionSlot {
owned_card_id: "oc1".into(),
index: 0,
is_captain: true,
is_on_bench: false,
}],
ext,
owned,
}
}
fn fresh_ext() -> Fifa17SquadExtensionV1 {
let mut kit = std::collections::BTreeMap::new();
kit.insert("oc1".to_string(), 9);
Fifa17SquadExtensionV1 {
custom: Some("[1,2,3]".into()),
squad_type: Some("REGULAR_SQUAD".into()),
kit_numbers: kit,
manager: vec![],
kicktakers: vec![],
client_reported: Default::default(),
}
}
#[test]
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 },
)]));
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
};
assert_eq!(v["players"].as_array().unwrap().len(), 23, "fixed 23-slot array");
assert_eq!(v["players"][0]["itemData"]["id"], 100000042, "wire id, not resourceId");
assert_eq!(v["players"][0]["itemData"]["resourceId"], 20801);
assert_eq!(v["players"][0]["kitNumber"], 9, "kit from ext by owned id");
assert_eq!(v["players"][1]["itemData"]["id"], 0, "empty slot");
assert_eq!(v["captain"], 100000042, "captain is the resolved WIRE id");
assert_ne!(v["captain"], 20801, "captain must NOT be the resourceId");
assert_eq!(v["custom"], "[1,2,3]");
assert_eq!(v["formation"], "f442");
}
#[test]
fn stale_is_never_applied() {
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());
assert_eq!(project_squad(&input, &ident, &ent()).unwrap(), SquadProjection::Stale);
}
#[test]
fn missing_is_explicit_never_fabricated() {
let owned = HashMap::new();
let input = one_slot_input(&owned, SquadExtInput::Missing);
let ident = TableIdentity(HashMap::new());
assert_eq!(project_squad(&input, &ident, &ent()).unwrap(), SquadProjection::Missing);
}
#[test]
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 input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
assert_eq!(
project_squad(&input, &ident, &ent()),
Err(SquadProjectError::NoFifaIdentity("oc1".into()))
);
}
#[test]
fn two_owned_copies_of_one_definition_project_as_distinct_players() {
// Same card definition -> same resourceId; two distinct owned instances
// in two slots with distinct kits must stay distinct on the wire.
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 }),
("oc-b".to_string(), Fifa17Identity { item_id: 100000031, asset_id: 101490 }),
]));
let mut kit = std::collections::BTreeMap::new();
kit.insert("oc-a".to_string(), 7);
kit.insert("oc-b".to_string(), 19);
let ext = Fifa17SquadExtensionV1 { kit_numbers: kit, ..fresh_ext() };
let owned_ref = &owned;
let input = SquadProjectionInput {
fifa_squad_id: 0,
name: "OpenFUT".into(),
formation: "f442".into(),
slots: vec![
ProjectionSlot { owned_card_id: "oc-a".into(), index: 0, is_captain: false, is_on_bench: false },
ProjectionSlot { owned_card_id: "oc-b".into(), index: 1, is_captain: false, is_on_bench: false },
],
ext: SquadExtInput::Fresh(ext),
owned: owned_ref,
};
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!();
};
let a = &v["players"][0]["itemData"];
let b = &v["players"][1]["itemData"];
assert_eq!(a["resourceId"], b["resourceId"], "same definition => same asset");
assert_ne!(a["id"], b["id"], "distinct owned copies keep distinct wire ids");
assert_eq!(v["players"][0]["kitNumber"], 7);
assert_eq!(v["players"][1]["kitNumber"], 19, "kit stays with the instance");
}
}
@@ -0,0 +1,354 @@
//! 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
//! EXTENSION custom, kicktakers, manager, 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::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver};
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,
SquadProjection, SquadProjectionInput,
};
use serde_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.
struct TableIdentity(HashMap<String, Fifa17Identity>);
impl ItemIdentityResolver for TableIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.0.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]],
},
);
ident.insert(oc, Fifa17Identity { item_id: wire as u32, asset_id: asset });
}
(owned, TableIdentity(ident))
}
/// 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<'a>(
put: &Fifa17SquadPut,
owned: &'a 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,
};
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, manager + squadType preserved.
assert_eq!(projected["custom"], put_v["custom"]);
assert_eq!(projected["manager"], put_v["manager"]);
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, WireItemRef};
use std::collections::BTreeMap;
let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap();
let (owned, 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,
});
}
let manager: Vec<WireItemRef> = serde_json::from_value(oracle["manager"].clone()).unwrap();
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,
manager,
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,
};
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"]);
assert_eq!(projected["manager"], oracle["manager"]);
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.
let ummi = user_mass_info_squad(projected.clone(), 33068179);
assert_eq!(ummi["personaId"], 33068179);
assert_eq!(ummi["changed"], 0);
assert!(ummi["actives"].is_array());
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);
}