feat(adapter): FIFA17 squad extension v1 + full-replacement PUT builder

Add fut::squad_ext::Fifa17SquadExtensionV1 — the versioned, adapter-owned
payload Core stores opaquely alongside the canonical squad. Carries the
FIFA-only wire state that is not Core-canonical:
  - custom[]        opaque 33-int string, round-tripped verbatim
  - squad_type      observed FIFA token
  - kit_numbers     keyed by owned_card_id (kit follows the PLAYER, proven
                    by the swap/formation captures), never by slot/definition
  - manager         opaque item ref (not a squad player; not shaped)
  - kicktakers      opaque role refs; relationship to captain UNKNOWN, so
                    preserved verbatim and never normalized to the captain
  - client_reported chemistry/rating/starRating shadow, never authoritative
from_payload enforces the payload schema version first (distinct from Core's
DB schema); an unknown version is rejected, never coerced.

build_squad_write turns a parsed PUT + host wire->owned resolver into a
canonical ProposedSquad + extension, refusing on unresolved ids or a
duplicate owned item. Identity resolution is explicitly NOT authorization.

Refactor the 550a59d parser scaffold: ProposedSquad is now pure canonical
(FIFA-only + shadow fields moved to the extension); the canonical formation
is the FIFA wire token verbatim (drop the lossy f442->"4-4-2" map that
could not even represent f433) so formation and index round-trip exactly
with no derivation. Bench split is the fixed 23-slot array convention.
This commit is contained in:
funman300
2026-08-12 02:19:22 +00:00
parent b50e0359f7
commit 80a8bc4520
3 changed files with 401 additions and 50 deletions
+45 -50
View File
@@ -29,7 +29,7 @@
//! * FIFA's chemistry/rating algorithm — client-reported values are carried in
//! [`ClientReportedSquadEval`] and never reconciled with Core's own evaluation.
use serde::Deserialize;
use serde::{Deserialize, Serialize};
/// A `{ "id": <wire item id>, "dream": bool }` reference (player, manager, …).
#[derive(Debug, Clone, Deserialize)]
@@ -105,7 +105,7 @@ pub trait SquadWireResolver {
/// Client-reported squad evaluation. Kept DISTINCT from Core's authoritative
/// evaluation and never reconciled — FIFA's chemistry/rating algorithm is UNKNOWN.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientReportedSquadEval {
pub chemistry: Option<i64>,
pub rating: Option<i64>,
@@ -124,25 +124,27 @@ pub struct ProposedSlot {
pub is_on_bench: bool,
}
/// A full-squad replacement in game-independent terms, ready for the host to map
/// onto Core's `SquadReplacement`/`SaveSquadRequest`. No `openfut-core` dependency.
/// A full-squad replacement in **canonical** (game-independent) terms, ready for
/// the host to map onto Core's `SquadReplacement`/`SaveSquadRequest`. Carries no
/// FIFA-only state (that is [`crate::fut::squad_ext::Fifa17SquadExtensionV1`])
/// and no `openfut-core` dependency. No FIFA wire integer survives into a slot —
/// every player is a Core `owned_card_id`.
#[derive(Debug, Clone)]
pub struct ProposedSquad {
/// The FIFA wire squad id the PUT targeted (`0` = the active squad). Routing
/// only — it selects which Core squad to replace; it is never a Core field.
pub squad_id: i64,
pub name: Option<String>,
/// The raw FIFA formation token (e.g. `"f442"`) …
pub formation_wire: Option<String>,
/// … and its Core mapping if known (`None` = UNKNOWN formation, not guessed).
pub formation_core: Option<&'static str>,
/// The FIFA formation token exactly as sent (e.g. `"f442"`, `"f433"`). Core
/// stores it verbatim as its opaque formation token and never interprets it,
/// so it round-trips exactly — never mapped to a second representation and
/// never used to derive slot layout.
pub formation: Option<String>,
pub slots: Vec<ProposedSlot>,
pub client_eval: ClientReportedSquadEval,
/// Occupied wire item ids the resolver could not map. A caller MUST refuse the
/// replacement if this is non-empty — a save must never silently drop an
/// owned player it failed to identify.
pub unresolved_wire_ids: Vec<i64>,
/// Opaque FIFA-only client state (the 33-int `custom` array) preserved
/// verbatim; must round-trip unchanged once GET reconstruction is designed.
pub custom_opaque: Option<String>,
}
/// Parse errors — explicit, never a silent empty squad.
@@ -160,28 +162,30 @@ impl std::fmt::Display for SquadError {
}
impl std::error::Error for SquadError {}
/// FIFA 17 f442 places its 11 starters at `players` indices `0..=10`; higher
/// indices are bench/reserve. Proven ONLY for f442 (the one captured squad).
/// The FIFA 17 squad wire is a fixed 23-slot array: indices `0..=10` are the
/// pitch (the 11 starters), `11..=22` are bench/reserves. This layout is a
/// property of the array, not of the formation — the captured f442 and f433
/// saves both place their 11 starters at `0..=10`. Bench membership is therefore
/// derived from the index alone, NEVER from the formation token.
pub const FIFA17_STARTER_SLOTS: i64 = 11;
/// Length of the fixed FIFA 17 squad slot array (evidence: every captured save
/// and read carries exactly 23 slots).
pub const FIFA17_SQUAD_SLOTS: i64 = 23;
/// Parse a squad-save body into the typed wire form. Structural only.
pub fn parse_squad_put(body: &[u8]) -> Result<Fifa17SquadPut, SquadError> {
serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string()))
}
/// Map a FIFA formation token to Core's formation string. `None` = UNKNOWN
/// formation — never guessed (only f442 is captured/proven).
pub fn map_formation(wire: &str) -> Option<&'static str> {
match wire {
"f442" => Some("4-4-2"),
_ => None,
}
}
/// Resolve a parsed save into a game-independent [`ProposedSquad`]: drop empty
/// (`id == 0`) slots, reverse-map each occupied slot's wire id, flag the captain,
/// derive the bench split (f442), and carry client-reported evaluation + opaque
/// `custom`. Unresolvable occupied ids are reported, never guessed or dropped.
/// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty
/// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core
/// `owned_card_id`, flag the captain, and derive the bench split from the fixed
/// 23-slot array. FIFA-only state (`custom`, manager, kicktakers, kit numbers,
/// squadType) and client-reported evaluation are NOT canonical — they are built
/// separately into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The
/// formation token is carried verbatim (never mapped). Unresolvable occupied ids
/// are reported, never guessed or dropped.
pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> ProposedSquad {
let captain = put.captain.unwrap_or(0);
let mut slots = Vec::new();
@@ -204,16 +208,9 @@ pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> Pr
ProposedSquad {
squad_id: put.id,
name: put.squad_name.clone(),
formation_wire: put.formation.clone(),
formation_core: put.formation.as_deref().and_then(map_formation),
formation: put.formation.clone(),
slots,
client_eval: ClientReportedSquadEval {
chemistry: put.chemistry,
rating: put.rating,
star_rating: put.star_rating,
},
unresolved_wire_ids: unresolved,
custom_opaque: put.custom.clone(),
}
}
@@ -273,18 +270,10 @@ mod tests {
sq.unresolved_wire_ids.is_empty(),
"all occupied ids resolved"
);
assert_eq!(sq.formation_core, Some("4-4-2"));
assert_eq!(
sq.custom_opaque, put.custom,
"opaque custom preserved verbatim"
);
assert_eq!(
sq.client_eval,
ClientReportedSquadEval {
chemistry: Some(52),
rating: Some(90),
star_rating: Some(90)
}
sq.formation.as_deref(),
Some("f442"),
"formation token carried verbatim, never mapped"
);
// Every occupied f442 slot is a starter (indices 0..=10).
assert!(sq.slots.iter().all(|s| !s.is_on_bench));
@@ -329,10 +318,16 @@ mod tests {
}
#[test]
fn unknown_formation_is_never_guessed() {
assert_eq!(map_formation("f442"), Some("4-4-2"));
assert_eq!(map_formation("f433"), None);
assert_eq!(map_formation(""), None);
fn formation_token_round_trips_verbatim() {
// Canonical formation is the FIFA token as-sent; f433 is preserved as
// readily as f442 (the old lossy f442->"4-4-2" map is gone).
for tok in ["f442", "f433"] {
let body = format!(
r#"{{"id":0,"formation":"{tok}","captain":0,"players":[{{"index":0,"itemData":{{"id":100000003}},"kitNumber":1}}]}}"#
);
let sq = to_proposed(&parse_squad_put(body.as_bytes()).unwrap(), &full_resolver());
assert_eq!(sq.formation.as_deref(), Some(tok));
}
}
#[test]