feat(adapter): FIFA17 squad full-replacement wire parser + reverse-map scaffolding (unrouted)
This commit is contained in:
@@ -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":52,"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":100000010,"dream":false},"kitNumber":10},{"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":100000006,"dream":false},"kitNumber":4},{"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":100000002,"dream":false},"kitNumber":9},{"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}]}
|
||||||
@@ -8,3 +8,4 @@ pub mod catalog;
|
|||||||
pub mod club_response;
|
pub mod club_response;
|
||||||
pub mod entities;
|
pub mod entities;
|
||||||
pub mod owned_query;
|
pub mod owned_query;
|
||||||
|
pub mod squad;
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
//! FIFA 17 squad **full-replacement** wire (`PUT /ut/game/fifa17/squad/<id>`) →
|
||||||
|
//! a game-independent proposed replacement OpenFUT Core can apply.
|
||||||
|
//!
|
||||||
|
//! ## Scope (deliberately preparatory — nothing is routed yet)
|
||||||
|
//!
|
||||||
|
//! This module parses the captured squad-save wire and reverse-maps each slot's
|
||||||
|
//! FIFA wire item id to a Core owned-instance id, producing a [`ProposedSquad`].
|
||||||
|
//! It does **not** open a socket, call Core, or mutate state — squad is a
|
||||||
|
//! *stateful* slice and its GET (retrieval) and PUT (save) must migrate together
|
||||||
|
//! as one authority, which needs more captured evidence first. Until then this is
|
||||||
|
//! pure, unit-tested scaffolding.
|
||||||
|
//!
|
||||||
|
//! ## What the wire proves (2 captured retail saves, `fixtures/utas/`)
|
||||||
|
//!
|
||||||
|
//! * The client sends the **whole** squad on every save — a fixed 23-slot array
|
||||||
|
//! plus `formation`, `captain`, `kicktakers`, a 33-int `custom` string, and
|
||||||
|
//! client-reported `chemistry`/`rating`/`starRating`. A two-player edit changed
|
||||||
|
//! nine slots, so slot deltas never describe intent: the only honest operation
|
||||||
|
//! is *this is the squad now*.
|
||||||
|
//! * Each occupied slot carries its FIFA **wire item id** (`itemData.id`, the same
|
||||||
|
//! namespace as `/club` and the identity store); an **empty** slot is `id == 0`.
|
||||||
|
//! * `captain` and `kicktakers` reference the same wire item id space.
|
||||||
|
//!
|
||||||
|
//! ## What it does NOT prove (kept UNKNOWN, never invented)
|
||||||
|
//!
|
||||||
|
//! * The `index → (slot, bench)` layout for formations other than **f442**, and
|
||||||
|
//! the meaning/stability of the `custom` 33-int array. `custom` is preserved
|
||||||
|
//! **opaquely** so it can round-trip unchanged; its integers are not decoded.
|
||||||
|
//! * FIFA's chemistry/rating algorithm — client-reported values are carried in
|
||||||
|
//! [`ClientReportedSquadEval`] and never reconciled with Core's own evaluation.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// A `{ "id": <wire item id>, "dream": bool }` reference (player, manager, …).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SquadEntityRef {
|
||||||
|
pub id: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dream: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry of the fixed-length `players` array.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SquadSlotWire {
|
||||||
|
pub index: i64,
|
||||||
|
pub item_data: SquadEntityRef,
|
||||||
|
#[serde(default)]
|
||||||
|
pub kit_number: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `kicktakers` entry (penalty/corner/free-kick roles). Carries a wire item id;
|
||||||
|
/// role semantics are UNKNOWN and not modelled.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SquadKicktaker {
|
||||||
|
pub index: i64,
|
||||||
|
pub id: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dream: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The squad-save body exactly as FIFA 17 sends it. FIFA-only fields
|
||||||
|
/// (`custom`, `kicktakers`, `kit_number`, `manager`, `squad_type`) are captured
|
||||||
|
/// verbatim; their persistence/reconstruction rules await more evidence.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Fifa17SquadPut {
|
||||||
|
/// Squad id (the path `…/squad/0` and the body agree; `0` = active).
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub squad_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub formation: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub squad_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub chemistry: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rating: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub star_rating: Option<i64>,
|
||||||
|
/// Wire item id of the captain (must be one of the occupied slots).
|
||||||
|
#[serde(default)]
|
||||||
|
pub captain: Option<i64>,
|
||||||
|
/// Opaque 33-int array as a JSON-encoded string. Semantics UNKNOWN — carried
|
||||||
|
/// verbatim, never parsed or interpreted.
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub manager: Vec<SquadEntityRef>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub players: Vec<SquadSlotWire>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub kicktakers: Vec<SquadKicktaker>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverse-maps a FIFA wire item id to a Core owned-instance id. Implemented by
|
||||||
|
/// the host over `Fifa17IdentityResolver`; `None` = unknown id (never guessed).
|
||||||
|
pub trait SquadWireResolver {
|
||||||
|
fn owned_id_for_wire(&self, wire: i64) -> Option<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
||||||
|
pub struct ClientReportedSquadEval {
|
||||||
|
pub chemistry: Option<i64>,
|
||||||
|
pub rating: Option<i64>,
|
||||||
|
pub star_rating: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One resolved slot of a proposed replacement (game-independent).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProposedSlot {
|
||||||
|
pub owned_card_id: String,
|
||||||
|
/// FIFA player-array index (0-based). Core imposes its own slot numbering;
|
||||||
|
/// the adapter carries the index plus a bench flag from the f442 convention.
|
||||||
|
pub index: i64,
|
||||||
|
pub kit_number: i64,
|
||||||
|
pub is_captain: bool,
|
||||||
|
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.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProposedSquad {
|
||||||
|
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>,
|
||||||
|
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.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum SquadError {
|
||||||
|
Parse(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SquadError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SquadError::Parse(e) => write!(f, "squad wire parse error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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).
|
||||||
|
pub const FIFA17_STARTER_SLOTS: i64 = 11;
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> ProposedSquad {
|
||||||
|
let captain = put.captain.unwrap_or(0);
|
||||||
|
let mut slots = Vec::new();
|
||||||
|
let mut unresolved = Vec::new();
|
||||||
|
for p in &put.players {
|
||||||
|
if p.item_data.id == 0 {
|
||||||
|
continue; // empty slot — never a Core player
|
||||||
|
}
|
||||||
|
match resolver.owned_id_for_wire(p.item_data.id) {
|
||||||
|
Some(owned_card_id) => slots.push(ProposedSlot {
|
||||||
|
owned_card_id,
|
||||||
|
index: p.index,
|
||||||
|
kit_number: p.kit_number,
|
||||||
|
is_captain: captain != 0 && p.item_data.id == captain,
|
||||||
|
is_on_bench: p.index >= FIFA17_STARTER_SLOTS,
|
||||||
|
}),
|
||||||
|
None => unresolved.push(p.item_data.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
slots,
|
||||||
|
client_eval: ClientReportedSquadEval {
|
||||||
|
chemistry: put.chemistry,
|
||||||
|
rating: put.rating,
|
||||||
|
star_rating: put.star_rating,
|
||||||
|
},
|
||||||
|
unresolved_wire_ids: unresolved,
|
||||||
|
custom_opaque: put.custom.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The save acknowledgement FIFA expects: just the squad id (matches the oracle's
|
||||||
|
/// `{"id": <n>}`, 9 bytes — it does NOT echo the squad).
|
||||||
|
pub fn save_ack(squad_id: i64) -> serde_json::Value {
|
||||||
|
serde_json::json!({ "id": squad_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// The real captured f442 save body (decoded from `session-001.jsonl:21`).
|
||||||
|
const PUT_F442: &str = include_str!("../../fixtures/utas/squad_put_f442.json");
|
||||||
|
|
||||||
|
/// A resolver mapping every occupied wire id in the fixture to a Core id.
|
||||||
|
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 {
|
||||||
|
// indices 0..=10 (11 starters); the rest of the 23 slots are id==0 (empty).
|
||||||
|
let ids = [
|
||||||
|
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
|
||||||
|
100000001, 100000002, 100000025,
|
||||||
|
];
|
||||||
|
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_the_captured_full_squad_wire() {
|
||||||
|
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||||
|
assert_eq!(put.id, 0, "path/body squad id 0 = active");
|
||||||
|
assert_eq!(put.formation.as_deref(), Some("f442"));
|
||||||
|
assert_eq!(put.squad_type.as_deref(), Some("REGULAR_SQUAD"));
|
||||||
|
assert_eq!(put.chemistry, Some(52));
|
||||||
|
assert_eq!(put.rating, Some(90));
|
||||||
|
assert_eq!(put.star_rating, Some(90));
|
||||||
|
assert_eq!(put.captain, Some(100000001));
|
||||||
|
assert_eq!(put.players.len(), 23, "fixed 23-slot array");
|
||||||
|
assert_eq!(put.kicktakers.len(), 5);
|
||||||
|
// custom is carried opaquely, never parsed.
|
||||||
|
assert!(put.custom.as_deref().unwrap().starts_with("[0,0,0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_occupied_slots_drops_empties_and_flags_captain() {
|
||||||
|
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||||
|
let sq = to_proposed(&put, &full_resolver());
|
||||||
|
|
||||||
|
assert_eq!(sq.slots.len(), 11, "11 occupied; 12 empty (id==0) dropped");
|
||||||
|
assert!(
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Every occupied f442 slot is a starter (indices 0..=10).
|
||||||
|
assert!(sq.slots.iter().all(|s| !s.is_on_bench));
|
||||||
|
// The captain flag lands on exactly the captain's slot (id 100000001 @ index 8).
|
||||||
|
let caps: Vec<_> = sq.slots.iter().filter(|s| s.is_captain).collect();
|
||||||
|
assert_eq!(caps.len(), 1);
|
||||||
|
assert_eq!(caps[0].owned_card_id, "oc-100000001");
|
||||||
|
assert_eq!(caps[0].index, 8);
|
||||||
|
assert_eq!(caps[0].kit_number, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unresolved_occupied_id_is_reported_never_dropped_silently() {
|
||||||
|
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||||
|
// Resolver missing one occupied id (100000025).
|
||||||
|
let mut ids = full_resolver().0;
|
||||||
|
ids.remove(&100000025);
|
||||||
|
let sq = to_proposed(&put, &MapResolver(ids));
|
||||||
|
assert_eq!(
|
||||||
|
sq.slots.len(),
|
||||||
|
10,
|
||||||
|
"the unresolved slot is not emitted as a player"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sq.unresolved_wire_ids,
|
||||||
|
vec![100000025],
|
||||||
|
"reported for the caller to refuse"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_slot_is_dropped_not_resolved() {
|
||||||
|
// A minimal body: one occupied + one empty slot.
|
||||||
|
let body = br#"{"id":0,"formation":"f442","captain":0,"players":[
|
||||||
|
{"index":0,"itemData":{"id":100000003},"kitNumber":1},
|
||||||
|
{"index":11,"itemData":{"id":0},"kitNumber":0}]}"#;
|
||||||
|
let put = parse_squad_put(body).unwrap();
|
||||||
|
let sq = to_proposed(&put, &full_resolver());
|
||||||
|
assert_eq!(sq.slots.len(), 1);
|
||||||
|
assert_eq!(sq.slots[0].index, 0);
|
||||||
|
assert!(sq.unresolved_wire_ids.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_ack_is_the_bare_id() {
|
||||||
|
assert_eq!(save_ack(0), serde_json::json!({ "id": 0 }));
|
||||||
|
assert_eq!(save_ack(7), serde_json::json!({ "id": 7 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_body_errors_never_empty_squad() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse_squad_put(b"not json"),
|
||||||
|
Err(SquadError::Parse(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user