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:
@@ -10,3 +10,4 @@ pub mod item;
|
||||
pub mod entities;
|
||||
pub mod owned_query;
|
||||
pub mod squad;
|
||||
pub mod squad_ext;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
//! FIFA 17 **Squad Extension v1** — the adapter-owned, versioned payload that
|
||||
//! carries the squad's FIFA-only wire state that OpenFUT Core does not model.
|
||||
//!
|
||||
//! Core stores this serialized payload **opaquely** (never interpreting it) next
|
||||
//! to the canonical squad, anchored by a server fingerprint. This module owns its
|
||||
//! schema and meaning; Core must never import this type.
|
||||
//!
|
||||
//! ## What lives here (and why it is not canonical)
|
||||
//!
|
||||
//! | field | ownership rationale |
|
||||
//! |-------|---------------------|
|
||||
//! | `custom` | opaque 33-int string; meaning UNKNOWN, round-tripped verbatim |
|
||||
//! | `squad_type` | an observed FIFA wire token; no matching generic Core concept |
|
||||
//! | `kit_numbers` | keyed by **`owned_card_id`** — evidence: kit follows the player |
|
||||
//! | `manager` | a FIFA manager item ref; not a squad player, semantics opaque |
|
||||
//! | `kicktakers` | role→item refs; relationship to captain UNKNOWN, kept opaque |
|
||||
//! | `client_reported` | chemistry/rating/starRating — client shadow, NOT authority |
|
||||
//!
|
||||
//! ## Versioning
|
||||
//!
|
||||
//! Two independent version numbers must not be confused:
|
||||
//! * [`EXT_SCHEMA_VERSION`] — the version of **this** payload schema. Stored in
|
||||
//! Core's `game_entity_ext.schema_version` and re-checked on read
|
||||
//! ([`Fifa17SquadExtensionV1::from_payload`] rejects any other version).
|
||||
//! * Core's own DB storage schema version — a Core concern, unrelated to this.
|
||||
//!
|
||||
//! [`EXT_NAMESPACE`] is the opaque scope key Core files the row under.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad, SquadEntityRef};
|
||||
|
||||
/// Opaque scope key Core files this extension under (`game_entity_ext.namespace`).
|
||||
pub const EXT_NAMESPACE: &str = "fifa17.squad";
|
||||
|
||||
/// The version of THIS payload schema (goes into `OpaqueExtensionWrite.schema_version`).
|
||||
/// Distinct from Core's DB storage schema version.
|
||||
pub const EXT_SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
/// A FIFA item reference `{ id, dream }` preserved verbatim from the wire. `id`
|
||||
/// is the FIFA wire item id (opaque to this extension — used for manager and
|
||||
/// kicktaker refs whose semantics are not modelled).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WireItemRef {
|
||||
pub id: i64,
|
||||
#[serde(default)]
|
||||
pub dream: bool,
|
||||
}
|
||||
|
||||
impl From<&SquadEntityRef> for WireItemRef {
|
||||
fn from(r: &SquadEntityRef) -> Self {
|
||||
WireItemRef { id: r.id, dream: r.dream }
|
||||
}
|
||||
}
|
||||
|
||||
/// A kicktaker slot preserved verbatim. `index` is the role slot (0..=4 observed);
|
||||
/// `item` is the referenced FIFA wire item. The role→player meaning and any
|
||||
/// relationship to the captain are UNKNOWN, so this is stored opaquely and never
|
||||
/// normalized to the captain or to a Core `owned_card_id`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KicktakerRef {
|
||||
pub index: i64,
|
||||
#[serde(flatten)]
|
||||
pub item: WireItemRef,
|
||||
}
|
||||
|
||||
/// FIFA 17 Squad Extension, version 1. Serialized to the opaque payload Core stores.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Fifa17SquadExtensionV1 {
|
||||
/// Opaque 33-int array as a JSON-encoded string, verbatim. Never decoded.
|
||||
#[serde(default)]
|
||||
pub custom: Option<String>,
|
||||
/// FIFA squad-type wire token (e.g. `"REGULAR_SQUAD"`).
|
||||
#[serde(default)]
|
||||
pub squad_type: Option<String>,
|
||||
/// kit number per player, keyed by Core `owned_card_id`. Keyed by the player
|
||||
/// instance — NEVER by slot/index or by card definition — because the wire
|
||||
/// proves the kit number follows the player across swaps and formation change.
|
||||
#[serde(default)]
|
||||
pub kit_numbers: BTreeMap<String, i64>,
|
||||
/// Manager item ref(s), opaque. Not a squad player; not shaped as an item.
|
||||
#[serde(default)]
|
||||
pub manager: Vec<WireItemRef>,
|
||||
/// Kicktaker role refs, opaque (see [`KicktakerRef`]).
|
||||
#[serde(default)]
|
||||
pub kicktakers: Vec<KicktakerRef>,
|
||||
/// Client-reported evaluation (shadow state). NEVER Core's authoritative
|
||||
/// evaluation and never reconciled with it.
|
||||
#[serde(default)]
|
||||
pub client_reported: ClientReportedSquadEval,
|
||||
}
|
||||
|
||||
/// Errors reading a stored extension payload.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ExtError {
|
||||
/// The stored `schema_version` is not one this adapter understands. Never
|
||||
/// silently coerced — the caller decides (e.g. treat as unreadable).
|
||||
UnsupportedSchemaVersion(i64),
|
||||
/// The payload bytes did not deserialize as this schema.
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExtError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ExtError::UnsupportedSchemaVersion(v) => {
|
||||
write!(f, "unsupported fifa17 squad extension schema version {v} (want {EXT_SCHEMA_VERSION})")
|
||||
}
|
||||
ExtError::Parse(e) => write!(f, "fifa17 squad extension parse error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ExtError {}
|
||||
|
||||
impl Fifa17SquadExtensionV1 {
|
||||
/// Build the extension from the parsed PUT plus the resolved canonical squad.
|
||||
/// `custom`/`squad_type`/manager/kicktakers/client eval come straight off the
|
||||
/// wire; kit numbers are re-keyed from slot index onto the resolved
|
||||
/// `owned_card_id` so they stay bound to the player, not the slot.
|
||||
pub fn from_put(put: &Fifa17SquadPut, canonical: &ProposedSquad) -> Self {
|
||||
let kit_numbers = canonical
|
||||
.slots
|
||||
.iter()
|
||||
.map(|s| (s.owned_card_id.clone(), s.kit_number))
|
||||
.collect();
|
||||
Fifa17SquadExtensionV1 {
|
||||
custom: put.custom.clone(),
|
||||
squad_type: put.squad_type.clone(),
|
||||
kit_numbers,
|
||||
manager: put.manager.iter().map(WireItemRef::from).collect(),
|
||||
kicktakers: put
|
||||
.kicktakers
|
||||
.iter()
|
||||
.map(|k| KicktakerRef {
|
||||
index: k.index,
|
||||
item: WireItemRef { id: k.id, dream: k.dream },
|
||||
})
|
||||
.collect(),
|
||||
client_reported: ClientReportedSquadEval {
|
||||
chemistry: put.chemistry,
|
||||
rating: put.rating,
|
||||
star_rating: put.star_rating,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to the opaque payload string Core stores.
|
||||
pub fn to_payload(&self) -> String {
|
||||
// Infallible for this type (no maps with non-string keys, no floats).
|
||||
serde_json::to_string(self).expect("fifa17 squad extension serializes")
|
||||
}
|
||||
|
||||
/// Parse a stored payload, enforcing the schema version FIRST. A version this
|
||||
/// adapter does not understand is rejected — never coerced or ignored.
|
||||
pub fn from_payload(schema_version: i64, payload: &str) -> Result<Self, ExtError> {
|
||||
if schema_version != EXT_SCHEMA_VERSION {
|
||||
return Err(ExtError::UnsupportedSchemaVersion(schema_version));
|
||||
}
|
||||
serde_json::from_str(payload).map_err(|e| ExtError::Parse(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors building a squad write from a PUT. A save is refused, never silently
|
||||
/// degraded, when it cannot be expressed faithfully as a canonical replacement.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum SquadBuildError {
|
||||
/// One or more occupied wire ids did not reverse-map to a Core owned item.
|
||||
/// Saving would silently drop an owned player — refused.
|
||||
UnresolvedWireIds(Vec<i64>),
|
||||
/// The same Core owned item appears in two slots. A full replacement cannot
|
||||
/// place one instance twice (two *copies* of a definition are distinct owned
|
||||
/// items and are fine — this is the same `owned_card_id` twice).
|
||||
DuplicateOwnedItem(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SquadBuildError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SquadBuildError::UnresolvedWireIds(ids) => {
|
||||
write!(f, "unresolved FIFA wire item ids (save refused): {ids:?}")
|
||||
}
|
||||
SquadBuildError::DuplicateOwnedItem(id) => {
|
||||
write!(f, "owned item {id} placed in two slots (save refused)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for SquadBuildError {}
|
||||
|
||||
/// The adapter's PUT-build output: a canonical replacement plus the FIFA-only
|
||||
/// extension. The host maps `canonical` onto Core's `SquadReplacement` and
|
||||
/// serializes `extension` into an `OpaqueExtensionWrite`
|
||||
/// (`namespace = EXT_NAMESPACE`, `schema_version = EXT_SCHEMA_VERSION`,
|
||||
/// `payload = extension.to_payload()`) so both commit in one Core transaction.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SquadWriteBuild {
|
||||
pub canonical: ProposedSquad,
|
||||
pub extension: Fifa17SquadExtensionV1,
|
||||
}
|
||||
|
||||
/// Build a full-replacement squad write from a parsed PUT and a host-supplied
|
||||
/// wire→owned resolver. Refuses (never degrades) on any unresolved occupied id
|
||||
/// or a duplicate owned item.
|
||||
///
|
||||
/// The resolver only maps identity; it is NOT authorization. The host still
|
||||
/// verifies every resolved `owned_card_id` belongs to the active FIFA 17
|
||||
/// profile/club before committing — a resolvable id is not proof of ownership.
|
||||
pub fn build_squad_write(
|
||||
put: &Fifa17SquadPut,
|
||||
resolver: &dyn crate::fut::squad::SquadWireResolver,
|
||||
) -> Result<SquadWriteBuild, SquadBuildError> {
|
||||
let canonical = crate::fut::squad::to_proposed(put, resolver);
|
||||
if !canonical.unresolved_wire_ids.is_empty() {
|
||||
return Err(SquadBuildError::UnresolvedWireIds(canonical.unresolved_wire_ids.clone()));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for slot in &canonical.slots {
|
||||
if !seen.insert(slot.owned_card_id.as_str()) {
|
||||
return Err(SquadBuildError::DuplicateOwnedItem(slot.owned_card_id.clone()));
|
||||
}
|
||||
}
|
||||
let extension = Fifa17SquadExtensionV1::from_put(put, &canonical);
|
||||
Ok(SquadWriteBuild { canonical, extension })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fut::squad::{parse_squad_put, SquadWireResolver};
|
||||
use std::collections::HashMap;
|
||||
|
||||
const PUT_F442: &str = include_str!("../../fixtures/utas/squad_put_f442.json");
|
||||
|
||||
struct MapResolver(HashMap<i64, String>);
|
||||
impl SquadWireResolver for MapResolver {
|
||||
fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
|
||||
self.0.get(&wire).cloned()
|
||||
}
|
||||
}
|
||||
fn full_resolver() -> MapResolver {
|
||||
let ids = [
|
||||
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
|
||||
100000001, 100000002, 100000025,
|
||||
];
|
||||
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
|
||||
}
|
||||
|
||||
fn built() -> SquadWriteBuild {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
build_squad_write(&put, &full_resolver()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trips_the_whole_extension() {
|
||||
let ext = built().extension;
|
||||
let payload = ext.to_payload();
|
||||
let back = Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, &payload).unwrap();
|
||||
assert_eq!(ext, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_is_preserved_byte_for_byte() {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
let ext = built().extension;
|
||||
assert_eq!(ext.custom, put.custom, "opaque custom carried verbatim");
|
||||
// survives a serialize/parse cycle unchanged.
|
||||
let back = Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, &ext.to_payload()).unwrap();
|
||||
assert_eq!(back.custom, put.custom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kit_number_is_keyed_by_owned_item_not_slot() {
|
||||
let ext = built().extension;
|
||||
// In the f442 fixture, owned oc-100000001 (captain) wears kit 8 at index 8;
|
||||
// oc-100000025 wears kit 11 at index 10. Keyed by owned id, not index.
|
||||
assert_eq!(ext.kit_numbers.get("oc-100000001"), Some(&8));
|
||||
assert_eq!(ext.kit_numbers.get("oc-100000025"), Some(&11));
|
||||
assert_eq!(ext.kit_numbers.len(), 11, "one per occupied slot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_reported_eval_is_carried_as_shadow() {
|
||||
let ext = built().extension;
|
||||
assert_eq!(
|
||||
ext.client_reported,
|
||||
ClientReportedSquadEval { chemistry: Some(52), rating: Some(90), star_rating: Some(90) }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_and_kicktakers_preserved_opaquely() {
|
||||
let ext = built().extension;
|
||||
assert_eq!(ext.manager, vec![WireItemRef { id: 100000427, dream: false }]);
|
||||
assert_eq!(ext.kicktakers.len(), 5);
|
||||
// All five reference the same wire id in this capture; carried verbatim,
|
||||
// NEVER normalized to the captain even though they coincide here.
|
||||
assert!(ext.kicktakers.iter().all(|k| k.item.id == 100000001));
|
||||
assert_eq!(ext.kicktakers[0].index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_schema_version_is_rejected_not_coerced() {
|
||||
let payload = built().extension.to_payload();
|
||||
assert_eq!(
|
||||
Fifa17SquadExtensionV1::from_payload(2, &payload),
|
||||
Err(ExtError::UnsupportedSchemaVersion(2))
|
||||
);
|
||||
assert_eq!(
|
||||
Fifa17SquadExtensionV1::from_payload(0, &payload),
|
||||
Err(ExtError::UnsupportedSchemaVersion(0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_payload_errors() {
|
||||
assert!(matches!(
|
||||
Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, "not json"),
|
||||
Err(ExtError::Parse(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_refuses_unresolved_wire_ids() {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
let mut ids = full_resolver().0;
|
||||
ids.remove(&100000025);
|
||||
let err = build_squad_write(&put, &MapResolver(ids)).unwrap_err();
|
||||
assert_eq!(err, SquadBuildError::UnresolvedWireIds(vec![100000025]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_refuses_duplicate_owned_item() {
|
||||
// Two occupied slots resolving to the SAME owned id (one instance twice).
|
||||
let body = br#"{"id":0,"formation":"f442","captain":100000003,"players":[
|
||||
{"index":0,"itemData":{"id":100000003},"kitNumber":1},
|
||||
{"index":1,"itemData":{"id":100000004},"kitNumber":2}]}"#;
|
||||
let put = parse_squad_put(body).unwrap();
|
||||
let mut m = HashMap::new();
|
||||
m.insert(100000003, "oc-dup".to_string());
|
||||
m.insert(100000004, "oc-dup".to_string()); // collide onto same owned id
|
||||
let err = build_squad_write(&put, &MapResolver(m)).unwrap_err();
|
||||
assert_eq!(err, SquadBuildError::DuplicateOwnedItem("oc-dup".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_replacement_carries_every_occupied_slot_no_diff() {
|
||||
let build = built();
|
||||
assert_eq!(build.canonical.slots.len(), 11, "whole squad, not a diff");
|
||||
assert_eq!(build.canonical.formation.as_deref(), Some("f442"));
|
||||
// No FIFA wire integer survives into the canonical slots.
|
||||
assert!(build.canonical.slots.iter().all(|s| s.owned_card_id.starts_with("oc-")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user