Files
OpenFUT/openfut-adapter-fifa17/src/fut/squad_projection.rs
T
funman300 9026220533 feat(fifa17): manager contracts, from a Core-owned staff tier
ROOT CAUSE, one line. openfut-import-fifa17 emitted `"overall": 0` for every
non-player Core definition while `d.rating` already held EA's authoritative
`value` -- and the very next block wrote that same number correctly to the
adapter catalog. So the tier existed host-side but never reached Core:
Core overall 0 -> /collection effective_overall 0 -> CoreOwnedItem.rating 0 ->
tier_for_rating(0) = Bronze for a Gold (88) manager. That silent mis-grant is
exactly what the 409 was protecting against, so the refusal was correct.

The emitter now also writes `source_rating`, keeping `overall` at 0. Regenerating
the production pack changes exactly 18 entries and exactly one field each
(source_rating None -> value); same 1710 ids, same fingerprint 28c333f1e833338a.

WHY value IS the tier source, and why the thresholds are the player ladder:
LIVE_PROVEN, not inferred. The client re-rates staff from its own
managercards/*coachcards/physiocards by carddbid and applies discard_level's
65/75 ladder; coach_probe/discard_probe agree 4/4 (manager value 88 -> level 3,
coaches 66 -> level 2). The shipped coach tables corroborate: each family has
exactly 3 tiers x 2 rarities, and only 65/75 splits them 2/2/2.

Manager contracts stop refusing and now resolve the TARGET's tier from
Core-owned state. Still fail-closed everywhere it matters: a coach or physio is
`contract_target_not_a_manager` (only cardsubtypeid 4 is a manager), and a
manager Core carries no source_rating for is `manager_tier_unknown` rather than
a guessed tier. Core's own content_kind token is sent as target_kind, because
Core calls the squad manager `manager` while the catalog classifies it
`staff`+subtype 4.

NOT implemented, unchanged: STORED_MANAGER_BONUS and MATCH_CONTRACT_DECREMENT.
2026-08-22 20:08:26 +00:00

582 lines
22 KiB
Rust

//! 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::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item::{
shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver, STAFF_CONTRACT,
};
use crate::fut::squad::FIFA17_SQUAD_SLOTS;
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
/// 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>,
/// The owned instance assigned as this squad's **manager** (Core's
/// ownership-backed `squad_managers` assignment, migration 0023), or `None`.
/// Projected as the FIFA `manager` wire ref resolved from ownership — never a
/// dangling wire id, and never fabricated when absent.
pub manager: Option<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,
ident.discard_value(item),
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
),
"kitNumber": kit,
}));
}
None => players.push(json!({
"index": index,
"itemData": { "id": 0, "dream": false },
"kitNumber": 0,
})),
}
}
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref
// AND carrying its item, as `[{id, itemData, dream}]`.
//
// The bare `[{id, dream}]` form is NOT sufficient, which cost a real
// debugging round: the operator picked a manager in the hub, the save
// persisted (Core `squad_managers` row written, `outcome=ok`, no unresolved
// ref), and the pre-match squad still showed no manager. Every retail
// capture that shows the bare form has `id: 0` — an EMPTY manager — so none
// of them ever demonstrated that a POPULATED ref resolves without its item.
//
// The squad response is self-contained for players: `players[].itemData`
// carries the whole card rather than an id the client resolves out of band.
// The manager is the same kind of slot in the same object, and the one
// implementation that ever drove a working manager (the Python oracle's
// squad) emits `id` BESIDE `itemData` exactly like this. Note the element
// shape differs from a player slot: `{index, itemData, kitNumber}` there,
// `{id, itemData, dream}` here.
//
// An owned manager with no resolvable FIFA staff identity is omitted
// (non-fatal, like /club dropping an unrenderable card) rather than emitted
// with a fabricated id.
let manager = match input
.manager
.as_ref()
.and_then(|m| ident.resolve_staff(m).map(|id| (m, id)))
{
Some((mgr, id)) => json!([{
"id": id.item_id,
"itemData": shape_staff_item(id, mgr.contract_matches.unwrap_or(STAFF_CONTRACT)),
"dream": false,
}]),
None => json!([]),
};
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": 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, Fifa17StaffIdentity};
// A resolver that mints a distinct wire id per owned item and a fixed asset.
// `staff` is separate because a manager resolves through the STAFF identity,
// which carries the chemistry fields a player identity has no room for.
struct TableIdentity(
HashMap<String, Fifa17Identity>,
HashMap<String, Fifa17StaffIdentity>,
);
impl ItemIdentityResolver for TableIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.0.get(&it.owned_card_id).copied()
}
fn resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
self.1.get(&it.owned_card_id).copied()
}
}
fn ent() -> Fifa17Entities {
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],
contract_matches: None,
source_rating: None,
core_content_kind: None,
}
}
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,
manager: None,
}
}
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,
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,
resource_id: 20801,
rareflag: 1,
},
)]),
HashMap::new(),
);
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
};
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(), 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(), 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(), 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,
resource_id: 101490,
rareflag: 1,
},
),
(
"oc-b".to_string(),
Fifa17Identity {
item_id: 100000031,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
),
]),
HashMap::new(),
);
let mut kit = std::collections::BTreeMap::new();
kit.insert("oc-a".to_string(), 7);
kit.insert("oc-b".to_string(), 19);
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,
manager: None,
};
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"
);
}
#[test]
fn manager_projected_from_ownership_as_wire_ref() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(
HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]),
HashMap::from([(
"oc-mgr".to_string(),
Fifa17StaffIdentity {
item_id: 100000427,
resource_id: 1_000_509,
subtype: 4,
nation: 45,
league_id: 53,
team_id: 241,
},
)]),
);
let mut input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
// A manager mid-way through its contracts: the projection must report
// Core's persisted count, not the pack-fresh constant, or the pre-match
// screen contradicts the club screen.
let mut manager = owned_item("oc-mgr", "fifa17_mgr");
manager.contract_matches = Some(12);
input.manager = Some(manager);
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
};
// The item must ride ALONG with the ref: a bare `{id, dream}` left the
// pre-match squad with no manager even though the assignment had been
// saved, because nothing in the response described the card.
assert_eq!(
v["manager"],
json!([{
"id": 100000427,
"itemData": {
"id": 100000427,
"resourceId": 1_000_509,
"cardsubtypeid": 4,
"itemType": "staff",
"nation": 45,
"leagueId": 53,
"teamid": 241,
"contract": 12,
"itemState": "free",
"owners": 1,
"untradeable": false,
},
"dream": false,
}]),
"manager is the ownership-backed wire ref WITH its item"
);
}
#[test]
fn absent_manager_projects_empty_array_never_fabricated() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(
HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]),
HashMap::new(),
);
// one_slot_input leaves manager: None.
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
};
assert_eq!(
v["manager"],
json!([]),
"no manager assignment => empty array, nothing fabricated"
);
}
}