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:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user