From a51947562ccd7aa4d4a026185267764294032f73 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 12 Aug 2026 19:23:19 +0000 Subject: [PATCH] feat(import): identity import API + FIFA17 real-profile dry-run importer openfut-identity: - insert_existing_mapping(game,kind,core_id,external_id): preserve an existing external wire id instead of minting; idempotent for an identical mapping, rejects conflicting forward/reverse with IdError::Conflict, persists atomically. - persisted per-scope allocator watermark (set_watermark/watermark_for) so a future mint continues past the source high-water even across burned-id gaps; next id = max(base_floor, live_max+1, watermark). Backward-compatible on-disk format (legacy bare [Row] still loads). +4 tests (10 total). openfut-import-fifa17 (new): read-only dry-run analysis of a real FIFA17 Python profile for a faithful Core import. Enforces disjoint item-class balance; proposes profile-derived CardDefinitions keyed fifa17_ (base vs versioned never collapse) with a resourceId-group consistency gate (hard-fail on disagreement, never pick a winner) and honest buildability (roster name + version formula + metadata, never fabricated); plans owned-instance identity (preserve Python wire ids, preserve nextItemId watermark); checks active-squad coverage. --apply/--emit-content refuse to write in this phase. 11 tests. Real profile (33068179/CAGE) dry-run: 1982 items balance (1962 players + 17 consumables + 3 staff); 1681 supported defs (155 base + 1535 versioned), 9 NoName unsupported, 1 hard conflict (resourceId 169193: one of 4 copies has a divergent nation/team/league); 1949 importable player instances, watermark 100004617 -> first new alloc 100004617; active squad f433 fully supported. fmt + clippy -D warnings clean. --- Cargo.lock | 9 + Cargo.toml | 1 + openfut-identity/src/lib.rs | 262 ++++++++++- openfut-import-fifa17/Cargo.toml | 18 + openfut-import-fifa17/src/lib.rs | 698 +++++++++++++++++++++++++++++ openfut-import-fifa17/src/main.rs | 70 +++ openfut-import-fifa17/src/model.rs | 99 ++++ openfut-import-fifa17/src/tests.rs | 232 ++++++++++ 8 files changed, 1380 insertions(+), 9 deletions(-) create mode 100644 openfut-import-fifa17/Cargo.toml create mode 100644 openfut-import-fifa17/src/lib.rs create mode 100644 openfut-import-fifa17/src/main.rs create mode 100644 openfut-import-fifa17/src/model.rs create mode 100644 openfut-import-fifa17/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index b2355cf..415fa25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3213,6 +3213,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "openfut-import-fifa17" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", +] + [[package]] name = "openfut-launcher" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6d501ff..893921d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "openfut-roster-host", "openfut-utas-host", "openfut-identity", + "openfut-import-fifa17", "openfut-bridge", "openfut-launcher", "openfut-launcher/openfut-hook", diff --git a/openfut-identity/src/lib.rs b/openfut-identity/src/lib.rs index 8062a4a..e8d44cd 100644 --- a/openfut-identity/src/lib.rs +++ b/openfut-identity/src/lib.rs @@ -37,6 +37,8 @@ use serde::{Deserialize, Serialize}; pub enum IdError { Io(std::io::Error), Corrupt(String), + /// A caller-supplied import mapping conflicts with an existing one. + Conflict(String), } impl std::fmt::Display for IdError { @@ -44,6 +46,7 @@ impl std::fmt::Display for IdError { match self { IdError::Io(e) => write!(f, "identity store io: {e}"), IdError::Corrupt(e) => write!(f, "identity store corrupt: {e}"), + IdError::Conflict(e) => write!(f, "identity store conflict: {e}"), } } } @@ -80,12 +83,36 @@ struct Row { external_id: i64, } +/// A persisted per-scope allocator watermark: the next external id to issue, +/// imported from a source system so historical allocation (including ids burned +/// in gaps) is preserved, not just currently-live mappings. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Watermark { + game_id: String, + entity_kind: String, + next_id: i64, +} + +/// On-disk store file. Backward-compatible: a legacy bare `[Row, ...]` array is +/// still accepted on load; new writes always use this object form so watermarks +/// persist alongside the rows. +#[derive(Debug, Default, Serialize, Deserialize)] +struct StoreFile { + #[serde(default)] + rows: Vec, + #[serde(default)] + watermarks: Vec, +} + #[derive(Default)] struct Index { rows: Vec, fwd: HashMap<(String, String, String), i64>, rev: HashMap<(String, String, i64), String>, max: HashMap<(String, String), i64>, + /// Per-scope imported allocation watermark (next id to issue): a floor that + /// preserves source allocation history beyond the live `max`. + watermarks: HashMap<(String, String), i64>, } impl Index { @@ -115,6 +142,21 @@ impl Index { } } +/// The next external id to issue in a scope: the maximum of the namespace floor, +/// one past the highest live mapping, and any imported watermark. Taking the max +/// of all three preserves source allocation history (burned-id gaps) while never +/// reissuing a live id nor dropping below the adapter's policy floor. +fn next_external(ix: &Index, game: &str, kind: &str, base_floor: i64) -> i64 { + let scope = (game.to_string(), kind.to_string()); + let from_max = ix.max.get(&scope).map(|m| m + 1); + let from_wm = ix.watermarks.get(&scope).copied(); + [Some(base_floor), from_max, from_wm] + .into_iter() + .flatten() + .max() + .unwrap_or(base_floor) +} + /// JSON-file backed [`ExternalIdentityStore`]. Single-writer (one host process). pub struct JsonIdentityStore { path: PathBuf, @@ -129,9 +171,20 @@ impl JsonIdentityStore { if path.exists() { let raw = std::fs::read_to_string(&path).map_err(IdError::Io)?; if !raw.trim().is_empty() { - let rows: Vec = - serde_json::from_str(&raw).map_err(|e| IdError::Corrupt(e.to_string()))?; - for row in rows { + // Accept the modern object form; fall back to the legacy bare + // `[Row, ...]` array so pre-watermark stores keep loading. + let file: StoreFile = match serde_json::from_str::(&raw) { + Ok(f) => f, + Err(_) => { + let rows: Vec = serde_json::from_str(&raw) + .map_err(|e| IdError::Corrupt(e.to_string()))?; + StoreFile { + rows, + watermarks: Vec::new(), + } + } + }; + for row in file.rows { // Reject a torn file that violates reverse-uniqueness rather // than silently serving an ambiguous reverse lookup. let rkey = ( @@ -147,6 +200,11 @@ impl JsonIdentityStore { } index.insert(row); } + for wm in file.watermarks { + index + .watermarks + .insert((wm.game_id, wm.entity_kind), wm.next_id); + } } } Ok(JsonIdentityStore { @@ -157,13 +215,123 @@ impl JsonIdentityStore { /// Atomically persist the full row set (temp + rename). fn persist(&self, index: &Index) -> Result<(), IdError> { - let json = - serde_json::to_vec_pretty(&index.rows).map_err(|e| IdError::Corrupt(e.to_string()))?; + let file = StoreFile { + rows: index.rows.clone(), + watermarks: index + .watermarks + .iter() + .map(|((g, k), &n)| Watermark { + game_id: g.clone(), + entity_kind: k.clone(), + next_id: n, + }) + .collect(), + }; + let json = serde_json::to_vec_pretty(&file).map_err(|e| IdError::Corrupt(e.to_string()))?; let tmp = self.path.with_extension("tmp"); std::fs::write(&tmp, &json).map_err(IdError::Io)?; std::fs::rename(&tmp, &self.path).map_err(IdError::Io)?; Ok(()) } + + /// Register an EXISTING external id for `(game, kind, core_id)` instead of + /// allocating one — the import path that preserves a source system's wire + /// ids. Idempotent for an identical mapping; rejects a conflicting forward + /// (core already mapped elsewhere) or reverse (external already owned) + /// mapping with [`IdError::Conflict`]; persists both directions atomically. + pub fn insert_existing_mapping( + &self, + game: &str, + kind: &str, + core_id: &str, + external_id: i64, + ) -> Result<(), IdError> { + let mut ix = self.inner.lock(); + let fkey = (game.to_string(), kind.to_string(), core_id.to_string()); + let rkey = (game.to_string(), kind.to_string(), external_id); + let fwd_existing = ix.fwd.get(&fkey).copied(); + let rev_existing = ix.rev.get(&rkey).cloned(); + if let Some(e) = fwd_existing { + if e == external_id && rev_existing.as_deref() == Some(core_id) { + return Ok(()); // identical mapping — idempotent + } + return Err(IdError::Conflict(format!( + "core '{core_id}' already maps to {e} in ({game}, {kind}), not {external_id}" + ))); + } + if let Some(c) = rev_existing { + return Err(IdError::Conflict(format!( + "external {external_id} already owned by core '{c}' in ({game}, {kind}), not '{core_id}'" + ))); + } + ix.insert(Row { + game_id: game.to_string(), + entity_kind: kind.to_string(), + core_id: core_id.to_string(), + external_id, + }); + if let Err(e) = self.persist(&ix) { + // roll the in-memory insert back so memory and disk never diverge + ix.rows.pop(); + ix.fwd.remove(&fkey); + ix.rev.remove(&rkey); + let scope = (game.to_string(), kind.to_string()); + let new_max = ix + .rows + .iter() + .filter(|r| r.game_id == game && r.entity_kind == kind) + .map(|r| r.external_id) + .max(); + match new_max { + Some(m) => { + ix.max.insert(scope, m); + } + None => { + ix.max.remove(&scope); + } + } + return Err(e); + } + Ok(()) + } + + /// Set the per-scope allocator watermark (the next external id to issue), + /// imported from the source system so future allocations continue past its + /// historical high-water even where those ids left no live mapping (gaps). + /// A floor: it never lowers the effective next id below the live max. + pub fn set_watermark(&self, game: &str, kind: &str, next_id: i64) -> Result<(), IdError> { + let mut ix = self.inner.lock(); + let scope = (game.to_string(), kind.to_string()); + let prev = ix.watermarks.insert(scope.clone(), next_id); + if let Err(e) = self.persist(&ix) { + match prev { + Some(p) => { + ix.watermarks.insert(scope, p); + } + None => { + ix.watermarks.remove(&scope); + } + } + return Err(e); + } + Ok(()) + } + + /// The imported watermark for a scope, if any. + pub fn watermark_for(&self, game: &str, kind: &str) -> Option { + self.inner + .lock() + .watermarks + .get(&(game.to_string(), kind.to_string())) + .copied() + } + + /// The external id the next allocation in this scope WOULD receive, without + /// allocating. For dry-run reporting. + pub fn peek_next_external(&self, game: &str, kind: &str, base_floor: i64) -> i64 { + let ix = self.inner.lock(); + next_external(&ix, game, kind, base_floor) + } } impl ExternalIdentityStore for JsonIdentityStore { @@ -180,10 +348,7 @@ impl ExternalIdentityStore for JsonIdentityStore { return Ok(ext); } let scope = (game.to_string(), kind.to_string()); - let next = match ix.max.get(&scope) { - Some(&m) => (m + 1).max(base_floor), - None => base_floor, - }; + let next = next_external(&ix, game, kind, base_floor); let row = Row { game_id: game.to_string(), entity_kind: kind.to_string(), @@ -334,4 +499,83 @@ mod tests { "ambiguous reverse must not load" ); } + + #[test] + fn insert_existing_mapping_preserves_and_is_idempotent() { + let (s, _d) = store(); + s.insert_existing_mapping(G, K, "oc-A", 100_004_600) + .unwrap(); + assert_eq!(s.external_for(G, K, "oc-A").unwrap(), Some(100_004_600)); + assert_eq!( + s.core_for(G, K, 100_004_600).unwrap().as_deref(), + Some("oc-A") + ); + // identical re-insert is idempotent + s.insert_existing_mapping(G, K, "oc-A", 100_004_600) + .unwrap(); + } + + #[test] + fn insert_existing_mapping_rejects_conflicts() { + let (s, _d) = store(); + s.insert_existing_mapping(G, K, "oc-A", 100_000_010) + .unwrap(); + // same core -> different external: reject + assert!(matches!( + s.insert_existing_mapping(G, K, "oc-A", 100_000_011), + Err(IdError::Conflict(_)) + )); + // same external -> different core: reject + assert!(matches!( + s.insert_existing_mapping(G, K, "oc-B", 100_000_010), + Err(IdError::Conflict(_)) + )); + } + + #[test] + fn watermark_preserves_allocation_history_past_gaps() { + let (s, _d) = store(); + // a live mapping (highest surviving id) plus a source watermark that sits + // ABOVE it because ids in between were issued then removed (burned gap). + s.insert_existing_mapping(G, K, "oc-live", 100_004_605) + .unwrap(); + s.set_watermark(G, K, 100_004_617).unwrap(); + assert_eq!(s.peek_next_external(G, K, BASE), 100_004_617); + // first NEW allocation is the source next-to-issue, NOT live_max+1 + assert_eq!( + s.resolve_or_allocate(G, K, "oc-new", BASE).unwrap(), + 100_004_617 + ); + // then continues monotonically + assert_eq!( + s.resolve_or_allocate(G, K, "oc-new2", BASE).unwrap(), + 100_004_618 + ); + } + + #[test] + fn watermark_and_imported_ids_survive_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ids.json"); + { + let s = JsonIdentityStore::open(&path).unwrap(); + s.insert_existing_mapping(G, K, "oc-A", 100_000_001) + .unwrap(); + s.insert_existing_mapping(G, K, "oc-B", 100_004_605) + .unwrap(); + s.set_watermark(G, K, 100_004_617).unwrap(); + } + let s = JsonIdentityStore::open(&path).unwrap(); + assert_eq!(s.external_for(G, K, "oc-A").unwrap(), Some(100_000_001)); + assert_eq!( + s.core_for(G, K, 100_004_605).unwrap().as_deref(), + Some("oc-B") + ); + assert_eq!(s.watermark_for(G, K), Some(100_004_617)); + // sparse import: next allocation respects the persisted watermark + assert_eq!( + s.resolve_or_allocate(G, K, "oc-C", BASE).unwrap(), + 100_004_617 + ); + } } diff --git a/openfut-import-fifa17/Cargo.toml b/openfut-import-fifa17/Cargo.toml new file mode 100644 index 0000000..636e2be --- /dev/null +++ b/openfut-import-fifa17/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "openfut-import-fifa17" +version = "0.1.0" +edition = "2021" +description = "Evidence-driven import of a real FIFA 17 Python profile into OpenFUT Core: read-only dry-run analysis (item accounting, observed-definition generation with a consistency gate, owned-instance identity preservation, squad coverage). Writes nothing without an explicit apply/emit phase." + +[[bin]] +name = "openfut-import-fifa17" +path = "src/main.rs" + +[lib] +name = "openfut_import_fifa17" +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/openfut-import-fifa17/src/lib.rs b/openfut-import-fifa17/src/lib.rs new file mode 100644 index 0000000..b1857f6 --- /dev/null +++ b/openfut-import-fifa17/src/lib.rs @@ -0,0 +1,698 @@ +//! Evidence-driven import of a real FIFA 17 Python profile into OpenFUT Core. +//! +//! This crate is the **read-only dry-run analysis** half of the importer. It +//! proves — before anything is written — that the migration can be done +//! faithfully: +//! +//! * disjoint item-type accounting balances to the source total (a reporting +//! typo must never become a migration assumption); +//! * every observed player card yields an HONEST [`ObservedDefinition`] keyed by +//! `fifa17_` (so a versioned/special card is a *distinct* +//! definition from its base, never collapsed onto it), sourced from the +//! profile's own evidence and never fabricated; +//! * multiple owned copies of one `resourceId` agree on definition-level data — +//! a disagreement is a HARD conflict, never silently reconciled; +//! * every owned instance keeps its existing Python wire id, and the source +//! allocation watermark (`nextItemId`) is preserved so a future Rust-minted id +//! cannot collide with a historically burned one; +//! * the active squad's players all resolve to supported definitions. +//! +//! What it deliberately does NOT do: write the Core DB, the identity store, or +//! any generated content, and it does not resolve nation/league/club numeric +//! ids to names (that is deterministic table resolution performed in the +//! content-emission phase). Program/promo taxonomy (TOTW/TOTS/…) and `rare=SP` +//! semantics stay UNKNOWN — the profile evidences that a versioned card exists +//! and its stats, not its marketing label. + +use std::collections::BTreeMap; +use std::path::Path; + +use anyhow::{Context, Result}; + +/// FIFA 17 owned-item wire-id floor (adapter policy +/// `Fifa17WireItemIdPolicy::owned_item_base_floor`). The first ever allocation +/// in an empty store starts here; a real import overrides it with the source +/// watermark. +pub const OWNED_ITEM_BASE_FLOOR: i64 = 100_000_001; + +pub mod model; + +use model::{Item, Profile}; + +// ----------------------------------------------------------------- roster + +/// Player names keyed by base asset id, loaded from `roster.json`. Names are +/// evidence for CardDefinitions; a card whose base asset id is absent here +/// cannot be honestly named and is reported unsupported (never fabricated). +pub struct Roster { + names: BTreeMap, +} + +impl Roster { + pub fn from_json_str(raw: &str) -> Result { + #[derive(serde::Deserialize)] + struct Row { + id: i64, + #[serde(default)] + first: String, + #[serde(default)] + last: String, + #[serde(default)] + common: String, + } + let rows: Vec = + serde_json::from_str(raw).context("parsing roster.json (expected an array)")?; + let mut names = BTreeMap::new(); + for r in rows { + let name = if !r.common.trim().is_empty() { + r.common.trim().to_string() + } else { + format!("{} {}", r.first.trim(), r.last.trim()) + .trim() + .to_string() + }; + if !name.is_empty() { + names.insert(r.id, name); + } + } + Ok(Roster { names }) + } + + pub fn name_for(&self, asset_id: i64) -> Option<&str> { + self.names.get(&asset_id).map(String::as_str) + } +} + +pub fn load_roster(path: impl AsRef) -> Result { + let raw = std::fs::read_to_string(path.as_ref()) + .with_context(|| format!("reading roster {}", path.as_ref().display()))?; + Roster::from_json_str(&raw) +} + +pub fn load_profile(path: impl AsRef) -> Result { + let raw = std::fs::read_to_string(path.as_ref()) + .with_context(|| format!("reading profile {}", path.as_ref().display()))?; + Profile::from_json_str(&raw) +} + +// ------------------------------------------------------------- classification + +/// The disjoint source item classes. Every source item is exactly one of these. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ItemClass { + /// `itemType=player` with an `attributeList` — a real player card. + PlayerCard, + /// `itemType=player` without an `attributeList` (consumable; has amount/pile). + Consumable, + /// `itemType=staff` (manager/coach/etc.). + Staff, + /// Anything else — must be zero for a clean import. + Other, +} + +pub fn classify(item: &Item) -> ItemClass { + match item.item_type.as_str() { + "staff" => ItemClass::Staff, + "player" => { + if item.attribute_list.is_some() { + ItemClass::PlayerCard + } else { + ItemClass::Consumable + } + } + _ => ItemClass::Other, + } +} + +/// Disjoint counts; `balances()` is the invariant the dry-run enforces. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ItemCounts { + pub total: usize, + pub player_cards: usize, + pub consumables: usize, + pub staff: usize, + pub other: usize, +} + +impl ItemCounts { + pub fn balances(&self) -> bool { + self.player_cards + self.consumables + self.staff + self.other == self.total + } +} + +pub fn count_items(profile: &Profile) -> ItemCounts { + let mut c = ItemCounts { + total: profile.items.len(), + ..Default::default() + }; + for it in &profile.items { + match classify(it) { + ItemClass::PlayerCard => c.player_cards += 1, + ItemClass::Consumable => c.consumables += 1, + ItemClass::Staff => c.staff += 1, + ItemClass::Other => c.other += 1, + } + } + c +} + +// -------------------------------------------------------------- definitions + +/// The definition-level (per-card, not per-instance) fields of a player card. +/// Two owned copies of one `resourceId` MUST have equal `DefFields`; owned +/// instance fields (wire id, contract, fitness, itemState, owners, untradeable) +/// are deliberately excluded from this identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DefFields { + pub asset_id: Option, + pub version: i64, + pub rating: Option, + pub position: Option, + pub nation: Option, + pub league: Option, + pub team: Option, + /// The six attributes in `index` order (0..=5), if present. + pub attrs: Option>, + pub rareflag: Option, +} + +/// Why a `resourceId` cannot yield an honest CardDefinition. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Unsupported { + /// `resourceId != (version << 24) | assetId` — unexpected id structure. + VersionFormula { resource_id: i64, asset_id: i64 }, + /// No player name for the base asset id in the roster (would have to fabricate). + NoName { asset_id: i64 }, + /// A required definition field (rating/position/nation/league/team/attrs) is missing. + MissingMetadata { fields: Vec }, +} + +/// An honest, profile-derived player CardDefinition proposal, keyed by +/// `fifa17_`. Carries provenance, not marketing taxonomy. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObservedDefinition { + pub card_id: String, + pub resource_id: i64, + pub asset_id: i64, + pub version: i64, + pub name: String, + pub rating: i64, + pub position: String, + pub nation_id: i64, + pub league_id: i64, + pub team_id: i64, + /// pace, shooting, passing, dribbling, defending, physical (index 0..=5). + pub attrs: [i64; 6], + pub rareflag: i64, + /// Owned-copy count of this exact resourceId in the source profile. + pub owned_copies: usize, +} + +/// `version = resourceId >> 24`; base cards are version 0 (`resourceId == assetId`). +pub fn version_of(resource_id: i64) -> i64 { + resource_id >> 24 +} + +fn def_fields(item: &Item) -> DefFields { + let attrs = item.attribute_list.as_ref().map(|list| { + let mut v: Vec<(i64, i64)> = list.iter().map(|a| (a.index, a.value)).collect(); + v.sort_by_key(|(i, _)| *i); + v.into_iter().map(|(_, val)| val).collect::>() + }); + DefFields { + asset_id: item.asset_id, + version: version_of(item.resource_id), + rating: item.rating, + position: item.preferred_position.clone(), + nation: item.nation, + league: item.league_id, + team: item.teamid, + attrs, + rareflag: item.rareflag, + } +} + +/// A `resourceId` whose owned copies disagree on definition-level data — a hard +/// conflict the importer must never silently reconcile. +#[derive(Debug, Clone)] +pub struct DefConflict { + pub resource_id: i64, + pub wire_ids: Vec, + pub distinct: Vec, +} + +/// Result of proposing definitions for all player cards. +#[derive(Debug, Default)] +pub struct DefinitionPlan { + pub supported: Vec, + pub unsupported: Vec<(i64, usize, Unsupported)>, // (resource_id, owned_copies, why) + pub conflicts: Vec, + pub base_defs: usize, + pub versioned_defs: usize, +} + +/// Build the definition proposal from the player cards, applying the +/// resourceId-group consistency gate and the honest-buildability test. +pub fn plan_definitions(profile: &Profile, roster: &Roster) -> DefinitionPlan { + // group player cards by resourceId, preserving wire ids for reporting + let mut groups: BTreeMap> = BTreeMap::new(); + for it in &profile.items { + if classify(it) == ItemClass::PlayerCard { + groups.entry(it.resource_id).or_default().push(it); + } + } + + let mut plan = DefinitionPlan::default(); + for (resource_id, items) in groups { + // 1) consistency gate: all copies must share definition-level data. + let first = def_fields(items[0]); + let mut distinct = vec![first.clone()]; + for it in &items[1..] { + let f = def_fields(it); + if !distinct.contains(&f) { + distinct.push(f); + } + } + if distinct.len() > 1 { + plan.conflicts.push(DefConflict { + resource_id, + wire_ids: items.iter().map(|i| i.id).collect(), + distinct, + }); + continue; // never pick a winner + } + + let owned_copies = items.len(); + let f = &first; + let version = f.version; + if version == 0 { + plan.base_defs += 1; + } else { + plan.versioned_defs += 1; + } + + // 2) honest buildability. + let asset_id = f.asset_id; + // version formula: resourceId == (version << 24) | assetId + if let Some(asset_id) = asset_id { + if resource_id != (version << 24) | asset_id { + plan.unsupported.push(( + resource_id, + owned_copies, + Unsupported::VersionFormula { + resource_id, + asset_id, + }, + )); + continue; + } + } + // required metadata present? + let mut missing = Vec::new(); + if f.rating.is_none() { + missing.push("rating".into()); + } + if f.position.is_none() { + missing.push("position".into()); + } + if f.nation.is_none() { + missing.push("nation".into()); + } + if f.league.is_none() { + missing.push("league".into()); + } + if f.team.is_none() { + missing.push("team".into()); + } + let attrs6: Option<[i64; 6]> = f.attrs.as_ref().and_then(|a| { + if a.len() == 6 { + Some([a[0], a[1], a[2], a[3], a[4], a[5]]) + } else { + None + } + }); + if attrs6.is_none() { + missing.push("attributeList[6]".into()); + } + if asset_id.is_none() { + missing.push("assetId".into()); + } + if !missing.is_empty() { + plan.unsupported.push(( + resource_id, + owned_copies, + Unsupported::MissingMetadata { fields: missing }, + )); + continue; + } + let asset_id = asset_id.unwrap(); + // name: evidence, never fabricated. + let name = match roster.name_for(asset_id) { + Some(n) => n.to_string(), + None => { + plan.unsupported.push(( + resource_id, + owned_copies, + Unsupported::NoName { asset_id }, + )); + continue; + } + }; + + plan.supported.push(ObservedDefinition { + card_id: format!("fifa17_{resource_id}"), + resource_id, + asset_id, + version, + name, + rating: f.rating.unwrap(), + position: f.position.clone().unwrap(), + nation_id: f.nation.unwrap(), + league_id: f.league.unwrap(), + team_id: f.team.unwrap(), + attrs: attrs6.unwrap(), + rareflag: f.rareflag.unwrap_or(0), + owned_copies, + }); + } + plan +} + +// --------------------------------------------------------------- identity + +/// The owned-instance identity plan: preserve every supported player item's +/// existing Python wire id, and preserve the source allocation watermark. +#[derive(Debug, Default)] +pub struct IdentityPlan { + /// Wire ids of the player items that WOULD import (supported definitions). + pub import_wire_ids: Vec, + /// Duplicate wire ids across ALL items (must be empty — a hard conflict). + pub duplicate_wire_ids: Vec, + pub live_min: Option, + pub live_max: Option, + /// Source `nextItemId` — the next id Python would issue (issue-then-increment). + pub source_watermark: i64, + /// The first id a Rust-minted new item would receive after this import: + /// `max(floor, live_max+1, watermark)`. With the watermark this equals the + /// source `nextItemId`, never a burned-gap id. + pub next_allocation: i64, +} + +pub fn plan_identity(profile: &Profile, supported_resource_ids: &[i64]) -> IdentityPlan { + use std::collections::BTreeSet; + let supported: BTreeSet = supported_resource_ids.iter().copied().collect(); + + // duplicate detection across ALL items + let mut seen = BTreeSet::new(); + let mut dups = BTreeSet::new(); + for it in &profile.items { + if !seen.insert(it.id) { + dups.insert(it.id); + } + } + + let mut import_wire_ids: Vec = profile + .items + .iter() + .filter(|it| classify(it) == ItemClass::PlayerCard && supported.contains(&it.resource_id)) + .map(|it| it.id) + .collect(); + import_wire_ids.sort_unstable(); + + let live_min = profile.items.iter().map(|i| i.id).min(); + let live_max = profile.items.iter().map(|i| i.id).max(); + let watermark = profile.next_item_id; + let next_allocation = [ + OWNED_ITEM_BASE_FLOOR, + live_max.map(|m| m + 1).unwrap_or(OWNED_ITEM_BASE_FLOOR), + watermark, + ] + .into_iter() + .max() + .unwrap(); + + IdentityPlan { + import_wire_ids, + duplicate_wire_ids: dups.into_iter().collect(), + live_min, + live_max, + source_watermark: watermark, + next_allocation, + } +} + +// ----------------------------------------------------------------- squad + +/// Coverage of the active squad: every starter/sub must resolve to a supported +/// player definition; the manager stays an opaque staff wire ref in the squad +/// extension (not promoted to a Core OwnedCard). +#[derive(Debug, Default)] +pub struct SquadCoverage { + pub present: bool, + pub formation: String, + pub occupied_slots: usize, + pub supported_slots: usize, + /// (slot index, wire id) for slots whose item is missing/unsupported. + pub unsupported_slots: Vec<(i64, i64)>, + pub manager_wire_ids: Vec, + pub captain_wire_id: Option, +} + +pub fn plan_squad(profile: &Profile, supported_wire_ids: &[i64]) -> SquadCoverage { + use std::collections::BTreeSet; + let supported: BTreeSet = supported_wire_ids.iter().copied().collect(); + let Some(sq) = profile.squads.first() else { + return SquadCoverage::default(); + }; + let mut cov = SquadCoverage { + present: true, + formation: sq.formation.clone(), + captain_wire_id: sq.captain.filter(|&c| c != 0), + manager_wire_ids: sq + .manager + .iter() + .filter_map(|m| m.get("id").and_then(|v| v.as_i64())) + .filter(|&id| id != 0) + .collect(), + ..Default::default() + }; + for slot in &sq.players { + let wid = slot.item_data.id; + if wid == 0 { + continue; // empty slot + } + cov.occupied_slots += 1; + if supported.contains(&wid) { + cov.supported_slots += 1; + } else { + cov.unsupported_slots.push((slot.index, wid)); + } + } + cov +} + +// ----------------------------------------------------------------- report + +/// The full dry-run analysis. +#[derive(Debug)] +pub struct Report { + pub game: String, + pub persona_id: i64, + pub persona_name: String, + pub club_name: String, + pub club_abbr: String, + pub coins: i64, + pub counts: ItemCounts, + pub definitions: DefinitionPlan, + pub identity: IdentityPlan, + pub squad: SquadCoverage, +} + +impl Report { + /// Unsafe conditions that MUST fail the dry-run (nonzero exit). Deferred + /// items (unsupported non-squad players, consumables, staff) are NOT here — + /// they are reported, not blocking. + pub fn blockers(&self) -> Vec { + let mut b = Vec::new(); + if !self.counts.balances() { + b.push(format!( + "item-type accounting does not balance: {}+{}+{}+{} != {}", + self.counts.player_cards, + self.counts.consumables, + self.counts.staff, + self.counts.other, + self.counts.total + )); + } + if self.counts.other > 0 { + b.push(format!( + "{} item(s) fall in no known class (itemType neither player nor staff)", + self.counts.other + )); + } + if !self.definitions.conflicts.is_empty() { + b.push(format!( + "{} resourceId group(s) disagree on definition-level data (hard conflict)", + self.definitions.conflicts.len() + )); + } + if !self.identity.duplicate_wire_ids.is_empty() { + b.push(format!( + "{} duplicate owned wire id(s) — instance identity is not unique", + self.identity.duplicate_wire_ids.len() + )); + } + if self.squad.present && !self.squad.unsupported_slots.is_empty() { + b.push(format!( + "{} active-squad slot(s) reference an unsupported/missing item", + self.squad.unsupported_slots.len() + )); + } + b + } + + pub fn has_blockers(&self) -> bool { + !self.blockers().is_empty() + } +} + +/// Run the full read-only analysis. Pure: no I/O, no writes. +pub fn analyze(profile: &Profile, roster: &Roster) -> Report { + let counts = count_items(profile); + let definitions = plan_definitions(profile, roster); + let supported_rids: Vec = definitions + .supported + .iter() + .map(|d| d.resource_id) + .collect(); + let identity = plan_identity(profile, &supported_rids); + let squad = plan_squad(profile, &identity.import_wire_ids); + Report { + game: "fifa17".to_string(), + persona_id: profile.persona_id, + persona_name: profile.persona_name.clone(), + club_name: profile.club_name.clone(), + club_abbr: profile.club_abbr.clone(), + coins: profile.coins, + counts, + definitions, + identity, + squad, + } +} + +impl std::fmt::Display for Report { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let d = &self.definitions; + let id = &self.identity; + let sq = &self.squad; + writeln!( + f, + "OpenFUT FIFA17 real-profile import — DRY RUN (no writes)" + )?; + writeln!( + f, + "========================================================" + )?; + writeln!( + f, + "TARGET game={} persona={}/{} club={}/{} coins={}", + self.game, + self.persona_id, + self.persona_name, + self.club_name, + self.club_abbr, + self.coins + )?; + writeln!(f, "\nSOURCE ITEMS (disjoint classes)")?; + writeln!( + f, + " total={} player_cards={} consumables={} staff={} other={} balances={}", + self.counts.total, + self.counts.player_cards, + self.counts.consumables, + self.counts.staff, + self.counts.other, + self.counts.balances() + )?; + writeln!(f, "\nPLAYER DEFINITIONS (card_id = fifa17_)")?; + writeln!( + f, + " supported={} (base={} versioned={}) unsupported={} conflicts={}", + d.supported.len(), + d.base_defs, + d.versioned_defs, + d.unsupported.len(), + d.conflicts.len() + )?; + for (rid, copies, why) in &d.unsupported { + writeln!( + f, + " UNSUPPORTED resourceId={rid} copies={copies}: {why:?}" + )?; + } + for c in &d.conflicts { + writeln!( + f, + " CONFLICT resourceId={} wire_ids={:?} distinct_defs={}", + c.resource_id, + c.wire_ids, + c.distinct.len() + )?; + } + writeln!(f, "\nIDENTITY (preserve existing Python wire ids)")?; + writeln!( + f, + " import_instances={} live_min={:?} live_max={:?} duplicates={}", + id.import_wire_ids.len(), + id.live_min, + id.live_max, + id.duplicate_wire_ids.len() + )?; + writeln!( + f, + " source_watermark(nextItemId)={} => next_rust_allocation={}", + id.source_watermark, id.next_allocation + )?; + writeln!(f, "\nACTIVE SQUAD")?; + if sq.present { + writeln!( + f, + " formation={} occupied={} supported={} unsupported={} captain={:?} manager_refs={:?}", + sq.formation, + sq.occupied_slots, + sq.supported_slots, + sq.unsupported_slots.len(), + sq.captain_wire_id, + sq.manager_wire_ids + )?; + for (idx, wid) in &sq.unsupported_slots { + writeln!(f, " UNSUPPORTED slot index={idx} wire_id={wid}")?; + } + } else { + writeln!(f, " (no squad in profile)")?; + } + writeln!(f, "\nRESULT")?; + writeln!( + f, + " would_import_players={} deferred_consumables={} deferred_staff={} unsupported_players(defs)={}", + id.import_wire_ids.len(), + self.counts.consumables, + self.counts.staff, + d.unsupported.len() + )?; + let blockers = self.blockers(); + if blockers.is_empty() { + writeln!(f, " BLOCKERS: none")?; + } else { + writeln!(f, " BLOCKERS ({}):", blockers.len())?; + for b in &blockers { + writeln!(f, " - {b}")?; + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/openfut-import-fifa17/src/main.rs b/openfut-import-fifa17/src/main.rs new file mode 100644 index 0000000..f3ac102 --- /dev/null +++ b/openfut-import-fifa17/src/main.rs @@ -0,0 +1,70 @@ +//! `openfut-import-fifa17` — real FIFA 17 profile import. +//! +//! This phase implements the **read-only dry-run** only. `--apply` and +//! `--emit-content` are recognised but deliberately refuse to run (and exit +//! nonzero) so nothing is written before the apply/emission phase lands. + +use anyhow::{bail, Context, Result}; +use std::process::ExitCode; + +fn main() -> ExitCode { + match run() { + Ok(code) => code, + Err(e) => { + eprintln!("import-fifa17: {e:#}"); + ExitCode::FAILURE + } + } +} + +fn print_help() { + eprintln!( + "openfut-import-fifa17 --profile [--roster ]\n\ + \n\ + Read-only dry-run: analyses a real FIFA 17 Python profile for a faithful\n\ + OpenFUT Core import and reports item accounting, observed definitions,\n\ + identity preservation, and squad coverage. Exits nonzero on unsafe blockers.\n\ + \n\ + --profile (required) source fifa17_profile.json\n\ + --roster roster.json for player names (default fifa17-recon/data/roster.json)\n\ + --apply REFUSED in this phase (no writes)\n\ + --emit-content REFUSED in this phase (no generated content)\n" + ); +} + +fn run() -> Result { + let mut profile_path: Option = None; + let mut roster_path = "fifa17-recon/data/roster.json".to_string(); + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + match a.as_str() { + "--profile" => profile_path = Some(args.next().context("--profile needs a path")?), + "--roster" => roster_path = args.next().context("--roster needs a path")?, + "--dry-run" => {} + "--apply" => bail!( + "--apply is not implemented in this phase (dry-run + identity import API only); refusing to write the Core DB or identity store" + ), + "--emit-content" => bail!( + "--emit-content is not implemented in this phase; refusing to write generated content" + ), + "-h" | "--help" => { + print_help(); + return Ok(ExitCode::SUCCESS); + } + other => bail!("unknown argument: {other} (try --help)"), + } + } + let profile_path = profile_path.context("--profile is required (try --help)")?; + + let profile = openfut_import_fifa17::load_profile(&profile_path)?; + let roster = openfut_import_fifa17::load_roster(&roster_path)?; + let report = openfut_import_fifa17::analyze(&profile, &roster); + print!("{report}"); + + if report.has_blockers() { + eprintln!("import-fifa17: DRY-RUN FAILED — unsafe blockers present (see above)"); + Ok(ExitCode::FAILURE) + } else { + Ok(ExitCode::SUCCESS) + } +} diff --git a/openfut-import-fifa17/src/model.rs b/openfut-import-fifa17/src/model.rs new file mode 100644 index 0000000..db8f1f2 --- /dev/null +++ b/openfut-import-fifa17/src/model.rs @@ -0,0 +1,99 @@ +//! Serde model of the real FIFA 17 Python profile (`fifa17_profile.json`). +//! Only the fields the importer reasons about are modelled; every field is +//! permissive (`Option`/`default`) because the source is external and unknown +//! fields are ignored. Nothing here is written back. + +use anyhow::{Context, Result}; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct Profile { + #[serde(rename = "personaId", default)] + pub persona_id: i64, + #[serde(rename = "personaName", default)] + pub persona_name: String, + #[serde(rename = "clubName", default)] + pub club_name: String, + #[serde(rename = "clubAbbr", default)] + pub club_abbr: String, + #[serde(default)] + pub coins: i64, + /// Next wire id to issue (issue-then-increment: `fut_store.py` assigns this + /// value then increments). The allocation watermark to preserve. + #[serde(rename = "nextItemId", default)] + pub next_item_id: i64, + #[serde(default)] + pub items: Vec, + #[serde(default)] + pub squads: Vec, +} + +impl Profile { + pub fn from_json_str(raw: &str) -> Result { + serde_json::from_str(raw).context("parsing fifa17_profile.json") + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Item { + /// The per-INSTANCE wire id (preserved on import). + pub id: i64, + /// The per-DEFINITION versioned id: `resourceId = (version << 24) | assetId`. + #[serde(rename = "resourceId", default)] + pub resource_id: i64, + #[serde(rename = "assetId", default)] + pub asset_id: Option, + #[serde(rename = "itemType", default)] + pub item_type: String, + #[serde(default)] + pub rareflag: Option, + #[serde(default)] + pub rating: Option, + #[serde(rename = "preferredPosition", default)] + pub preferred_position: Option, + #[serde(default)] + pub nation: Option, + #[serde(default)] + pub teamid: Option, + #[serde(rename = "leagueId", default)] + pub league_id: Option, + #[serde(rename = "attributeList", default)] + pub attribute_list: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Attr { + pub index: i64, + pub value: i64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Squad { + #[serde(default)] + pub formation: String, + #[serde(rename = "squadName", default)] + pub squad_name: String, + #[serde(default)] + pub captain: Option, + /// Opaque staff refs; each is `{ "id": , ... }`. + #[serde(default)] + pub manager: Vec, + #[serde(default)] + pub players: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SquadSlot { + pub index: i64, + #[serde(rename = "itemData", default)] + pub item_data: SlotItem, + #[serde(rename = "kitNumber", default)] + pub kit_number: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct SlotItem { + /// Wire id of the owned instance in this slot; `0` = empty slot. + #[serde(default)] + pub id: i64, +} diff --git a/openfut-import-fifa17/src/tests.rs b/openfut-import-fifa17/src/tests.rs new file mode 100644 index 0000000..df8ced8 --- /dev/null +++ b/openfut-import-fifa17/src/tests.rs @@ -0,0 +1,232 @@ +use super::*; +use model::Profile; + +const VER5_176580: i64 = (5 << 24) | 176580; // versioned resourceId for asset 176580 + +fn roster() -> Roster { + Roster::from_json_str( + r#"[ + {"id":20801,"first":"Cristiano","last":"Ronaldo","common":""}, + {"id":176580,"first":"Luis","last":"Suárez","common":""}, + {"id":158023,"first":"Lionel","last":"Messi","common":""} + ]"#, + ) + .unwrap() +} + +fn attrs() -> String { + r#"[{"index":0,"value":90},{"index":1,"value":91},{"index":2,"value":82}, + {"index":3,"value":88},{"index":4,"value":30},{"index":5,"value":78}]"# + .to_string() +} + +fn player(id: i64, resource: i64, asset: i64, rating: i64) -> String { + format!( + r#"{{"id":{id},"resourceId":{resource},"assetId":{asset},"itemType":"player", + "rareflag":1,"rating":{rating},"preferredPosition":"ST","nation":38, + "teamid":243,"leagueId":53,"attributeList":{}}}"#, + attrs() + ) +} + +fn profile(items: &[String], squads: &str, next_item_id: i64) -> Profile { + let json = format!( + r#"{{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC", + "coins":1000,"nextItemId":{next_item_id},"items":[{}],"squads":{}}}"#, + items.join(","), + squads + ); + Profile::from_json_str(&json).unwrap() +} + +#[test] +fn classification_balances_across_disjoint_classes() { + let items = vec![ + player(100000001, 20801, 20801, 94), // base player + player(100000002, VER5_176580, 176580, 92), // versioned player + // consumable: player itemType, NO attributeList + r#"{"id":100000239,"resourceId":5003012,"assetId":5003012,"itemType":"player","rating":85}"#.to_string(), + // staff + r#"{"id":100000427,"resourceId":3000083,"itemType":"staff"}"#.to_string(), + ]; + let p = profile(&items, "[]", 100000500); + let c = count_items(&p); + assert_eq!(c.total, 4); + assert_eq!(c.player_cards, 2); + assert_eq!(c.consumables, 1); + assert_eq!(c.staff, 1); + assert_eq!(c.other, 0); + assert!(c.balances()); +} + +#[test] +fn version_formula_and_card_id() { + assert_eq!(version_of(20801), 0); + assert_eq!(version_of(VER5_176580), 5); + let p = profile( + &[player(100000002, VER5_176580, 176580, 92)], + "[]", + 100000500, + ); + let plan = plan_definitions(&p, &roster()); + assert_eq!(plan.supported.len(), 1); + let d = &plan.supported[0]; + assert_eq!(d.card_id, format!("fifa17_{VER5_176580}")); + assert_eq!(d.version, 5); + assert_eq!(d.asset_id, 176580); + assert_eq!(d.name, "Luis Suárez"); // base-asset name, not fabricated +} + +#[test] +fn base_and_versioned_are_distinct_definitions() { + // same base asset (176580), base + version 5 => two distinct card_ids + let items = vec![ + player(100000010, 176580, 176580, 92), // base + player(100000011, VER5_176580, 176580, 92), // versioned + ]; + let plan = plan_definitions(&profile(&items, "[]", 100000500), &roster()); + assert_eq!(plan.supported.len(), 2, "base and special never collapse"); + assert_eq!(plan.base_defs, 1); + assert_eq!(plan.versioned_defs, 1); + let ids: Vec<&str> = plan.supported.iter().map(|d| d.card_id.as_str()).collect(); + assert!(ids.contains(&"fifa17_176580")); + assert!(ids.contains(&format!("fifa17_{VER5_176580}").as_str())); +} + +#[test] +fn duplicate_copies_share_definition_but_preserve_distinct_wire_ids() { + let items = vec![ + player(100000001, 20801, 20801, 94), + player(100000015, 20801, 20801, 94), // second owned copy + ]; + let p = profile(&items, "[]", 100000500); + let plan = plan_definitions(&p, &roster()); + assert_eq!( + plan.supported.len(), + 1, + "one definition for the shared resource" + ); + assert_eq!(plan.supported[0].owned_copies, 2); + let idp = plan_identity(&p, &[20801]); + assert_eq!(idp.import_wire_ids, vec![100000001, 100000015]); + assert!(idp.duplicate_wire_ids.is_empty()); +} + +#[test] +fn conflicting_same_resource_metadata_hard_fails() { + // two copies of resourceId 20801 disagree on rating => conflict, no winner + let items = vec![ + player(100000001, 20801, 20801, 94), + player(100000002, 20801, 20801, 93), + ]; + let p = profile(&items, "[]", 100000500); + let plan = plan_definitions(&p, &roster()); + assert!(plan.supported.is_empty()); + assert_eq!(plan.conflicts.len(), 1); + let rep = analyze(&p, &roster()); + assert!( + rep.has_blockers(), + "a definition conflict is an unsafe blocker" + ); +} + +#[test] +fn version_formula_violation_is_unsupported_not_fabricated() { + // resourceId != (version<<24)|assetId (version 0 but resource != asset) + let items = vec![player(100000003, 999, 176580, 92)]; + let plan = plan_definitions(&profile(&items, "[]", 100000500), &roster()); + assert!(plan.supported.is_empty()); + assert!(matches!( + plan.unsupported.first(), + Some((_, _, Unsupported::VersionFormula { .. })) + )); +} + +#[test] +fn missing_roster_name_is_unsupported_never_faked() { + // asset 777777 is not in the roster => cannot honestly name it + let items = vec![player(100000004, 777777, 777777, 80)]; + let plan = plan_definitions(&profile(&items, "[]", 100000500), &roster()); + assert!(plan.supported.is_empty()); + assert!(matches!( + plan.unsupported.first(), + Some((_, _, Unsupported::NoName { asset_id: 777777 })) + )); +} + +#[test] +fn identity_preserves_watermark_over_live_max() { + // live ids up to 100004605, watermark (nextItemId) 100004617 above it + let items = vec![ + player(100000001, 20801, 20801, 94), + player(100004605, 176580, 176580, 92), + ]; + let p = profile(&items, "[]", 100004617); + let idp = plan_identity(&p, &[20801, 176580]); + assert_eq!(idp.live_min, Some(100000001)); + assert_eq!(idp.live_max, Some(100004605)); + assert_eq!(idp.source_watermark, 100004617); + // next allocation is the source next-to-issue, NOT live_max+1 (=100004606) + assert_eq!(idp.next_allocation, 100004617); +} + +#[test] +fn squad_coverage_flags_unsupported_starter_and_keeps_manager_ref() { + let items = vec![ + player(100000001, 20801, 20801, 94), + player(100000002, 176580, 176580, 92), + ]; + // squad: slot0 supported, slot1 references an UNKNOWN wire id (unsupported), + // an empty slot (id 0), a captain and a manager staff ref. + let squads = r#"[{ + "formation":"f433","squadName":"OpenFUT","captain":100000001, + "manager":[{"id":100000427,"dream":false}], + "players":[ + {"index":0,"itemData":{"id":100000001},"kitNumber":7}, + {"index":1,"itemData":{"id":999999999},"kitNumber":9}, + {"index":2,"itemData":{"id":0},"kitNumber":0} + ] + }]"#; + let p = profile(&items, squads, 100000500); + let rep = analyze(&p, &roster()); + let sq = &rep.squad; + assert!(sq.present); + assert_eq!(sq.formation, "f433"); + assert_eq!(sq.occupied_slots, 2); // the id=0 slot is empty + assert_eq!(sq.supported_slots, 1); + assert_eq!(sq.unsupported_slots, vec![(1, 999999999)]); + assert_eq!(sq.manager_wire_ids, vec![100000427]); + assert_eq!(sq.captain_wire_id, Some(100000001)); + assert!(rep.has_blockers(), "an unsupported squad starter blocks"); +} + +#[test] +fn analyze_is_deterministic() { + let items = vec![ + player(100000002, VER5_176580, 176580, 92), + player(100000001, 20801, 20801, 94), + ]; + let p = profile(&items, "[]", 100000500); + let a = analyze(&p, &roster()); + let b = analyze(&p, &roster()); + assert_eq!(a.definitions.supported, b.definitions.supported); + assert_eq!(a.identity.import_wire_ids, b.identity.import_wire_ids); +} + +#[test] +fn clean_profile_has_no_blockers_and_defers_non_players() { + let items = vec![ + player(100000001, 20801, 20801, 94), + player(100000002, VER5_176580, 176580, 92), + r#"{"id":100000239,"resourceId":5003012,"assetId":5003012,"itemType":"player","rating":85}"#.to_string(), + r#"{"id":100000427,"resourceId":3000083,"itemType":"staff"}"#.to_string(), + ]; + let squads = r#"[{"formation":"f433","squadName":"OpenFUT","captain":100000001, + "manager":[{"id":100000427}], + "players":[{"index":0,"itemData":{"id":100000001}},{"index":1,"itemData":{"id":100000002}}]}]"#; + let rep = analyze(&profile(&items, squads, 100000500), &roster()); + assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers()); + assert_eq!(rep.identity.import_wire_ids.len(), 2); // players only + assert_eq!(rep.counts.consumables, 1); + assert_eq!(rep.counts.staff, 1); +}