From c71c2a8d33eba4de63d728a5fc5a29da9a754521 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 12 Aug 2026 19:41:17 +0000 Subject: [PATCH] feat(import): --emit-content (production pack + host catalog + private manifest) Fold entity resolution (nation/league/club id->name via committed tables, mirroring seed_fifa17_cards.py) and quality-tier rarity into the analysis, so the supported set is honest about unresolved entities too. Add an explicit --defer-conflict allowlist: a reviewed conflict (169193) defers, any NEW conflict still hard-fails (defer never becomes a silent conflict suppressor). --emit-content writes three files, PUBLIC content separated from PRIVATE account state: fifa17-production-cards.json (Core CardDefinition[] keyed fifa17_, base+versioned, tier rarity, profile-derived, no promo labels), a versioned host identity catalog {card_id:{asset_id,version}}, and a private import manifest (supported instances' wire ids + deferred set with reasons + preserved watermark + target profile + snapshot fingerprint). Emit refuses while blockers exist. Real profile (33068179/CAGE): 1681 supported defs (150 base + 1531 versioned), 9 NoName deferred, 1 approved-deferred conflict (169193, 4 copies), 1949 importable instances, watermark 100004617 -> next 100004617, active squad f433 11/11 supported. fmt + clippy -D warnings clean; 13 tests. --- Cargo.lock | 1 + openfut-import-fifa17/Cargo.toml | 5 +- openfut-import-fifa17/src/lib.rs | 665 ++++++++++++++++++++--------- openfut-import-fifa17/src/main.rs | 85 +++- openfut-import-fifa17/src/tests.rs | 280 +++++++----- 5 files changed, 720 insertions(+), 316 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 415fa25..3f787ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3220,6 +3220,7 @@ dependencies = [ "anyhow", "serde", "serde_json", + "tempfile", ] [[package]] diff --git a/openfut-import-fifa17/Cargo.toml b/openfut-import-fifa17/Cargo.toml index 636e2be..a35837c 100644 --- a/openfut-import-fifa17/Cargo.toml +++ b/openfut-import-fifa17/Cargo.toml @@ -2,7 +2,7 @@ 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." +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) plus public content-pack / host-catalog emission and a private import manifest. Writes no Core/identity state without an explicit apply phase." [[bin]] name = "openfut-import-fifa17" @@ -16,3 +16,6 @@ path = "src/lib.rs" anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/openfut-import-fifa17/src/lib.rs b/openfut-import-fifa17/src/lib.rs index b1857f6..798875d 100644 --- a/openfut-import-fifa17/src/lib.rs +++ b/openfut-import-fifa17/src/lib.rs @@ -1,38 +1,33 @@ //! 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: +//! Read-only analysis (`analyze`) + public/private content emission +//! (`emit_content`). It proves — before anything is written — that the +//! migration can be done faithfully, and then generates exactly the supported +//! definitions. It never fabricates identity, never collapses a versioned card +//! onto its base, and never picks a winner for a conflicted definition. //! -//! * 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; +//! * disjoint item-type accounting must balance to the source total; +//! * every supported player card yields an HONEST [`ObservedDefinition`] keyed +//! by `fifa17_` (base and versioned are distinct), sourced from +//! profile evidence: name from the roster, nation/league/club resolved from +//! the committed tables, quality tier from the rating — never invented, and no +//! promo/program label guessed (`rare=SP` and TOTW/… stay UNKNOWN); +//! * multiple owned copies of one `resourceId` must agree on definition-level +//! data — a disagreement is a HARD conflict; a specific conflict may be +//! *explicitly* deferred (an allowlist), but a NEW one always fails; +//! * un-nameable / unresolved / conflicted definitions are DEFERRED (recorded in +//! the private manifest with their wire ids), never imported and never given a +//! placeholder; //! * 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. +//! allocation watermark (`nextItemId`, next-to-issue) is preserved so a future +//! Rust-minted id continues past historically burned ids. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; 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. +/// FIFA 17 owned-item wire-id floor (adapter `Fifa17WireItemIdPolicy`). pub const OWNED_ITEM_BASE_FLOOR: i64 = 100_000_001; pub mod model; @@ -41,9 +36,8 @@ 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). +/// Player names keyed by base asset id (`roster.json`). A card whose base asset +/// id is absent here cannot be honestly named and is DEFERRED, never fabricated. pub struct Roster { names: BTreeMap, } @@ -89,6 +83,73 @@ pub fn load_roster(path: impl AsRef) -> Result { Roster::from_json_str(&raw) } +// --------------------------------------------------------------- entities + +/// Forward numeric-id -> name resolution for the committed FIFA 17 tables +/// (`leagues.json`/`nations.json`/`teams.json`, `{schema, rows:[…]}` dump). +/// Mirrors `scripts/seed_fifa17_cards.py` (`_table_map`). +#[derive(Default)] +pub struct Entities { + leagues: BTreeMap, + nations: BTreeMap, + teams: BTreeMap, +} + +fn load_table(path: &Path, id_key: &str, name_key: &str) -> Result> { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("reading table {}", path.display()))?; + let doc: serde_json::Value = + serde_json::from_str(&raw).with_context(|| format!("parsing table {}", path.display()))?; + let rows = doc + .get("rows") + .and_then(|r| r.as_array()) + .with_context(|| format!("table {} has no rows[]", path.display()))?; + let mut map = BTreeMap::new(); + for row in rows { + if let (Some(id), Some(name)) = ( + row.get(id_key).and_then(|v| v.as_i64()), + row.get(name_key).and_then(|v| v.as_str()), + ) { + map.insert(id, name.to_string()); + } + } + Ok(map) +} + +impl Entities { + pub fn from_tables_dir(dir: impl AsRef) -> Result { + let dir = dir.as_ref(); + Ok(Entities { + leagues: load_table(&dir.join("leagues.json"), "leagueid", "leaguename")?, + nations: load_table(&dir.join("nations.json"), "nationid", "nationname")?, + teams: load_table(&dir.join("teams.json"), "teamid", "teamname")?, + }) + } + + /// Build directly from id->name maps (tests). + pub fn from_maps( + leagues: BTreeMap, + nations: BTreeMap, + teams: BTreeMap, + ) -> Self { + Entities { + leagues, + nations, + teams, + } + } + + pub fn league(&self, id: i64) -> Option<&str> { + self.leagues.get(&id).map(String::as_str) + } + pub fn nation(&self, id: i64) -> Option<&str> { + self.nations.get(&id).map(String::as_str) + } + pub fn club(&self, team_id: i64) -> Option<&str> { + self.teams.get(&team_id).map(String::as_str) + } +} + 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()))?; @@ -97,16 +158,12 @@ pub fn load_profile(path: impl AsRef) -> Result { // ------------------------------------------------------------- classification -/// The disjoint source item classes. Every source item is exactly one of these. +/// The disjoint source item classes. Every source item is exactly one. #[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, } @@ -124,7 +181,6 @@ pub fn classify(item: &Item) -> ItemClass { } } -/// Disjoint counts; `balances()` is the invariant the dry-run enforces. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ItemCounts { pub total: usize, @@ -158,10 +214,8 @@ pub fn count_items(profile: &Profile) -> ItemCounts { // -------------------------------------------------------------- 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. +/// Definition-level (per-card) fields. Owned copies of one `resourceId` MUST +/// agree on these; instance fields (wire id, contract, fitness, …) are excluded. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DefFields { pub asset_id: Option, @@ -171,20 +225,33 @@ pub struct DefFields { 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. +/// Why a `resourceId` cannot yield an honest CardDefinition (all DEFERRED). #[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 }, + UnresolvedEntity { fields: Vec }, +} + +/// `version = resourceId >> 24`; base cards are version 0 (`resourceId == assetId`). +pub fn version_of(resource_id: i64) -> i64 { + resource_id >> 24 +} + +/// Quality tier from a base rating (`fut_cards.py` / `seed_fifa17_cards.py`). +pub fn tier(overall: i64) -> &'static str { + if overall >= 75 { + "gold" + } else if overall >= 65 { + "silver" + } else { + "bronze" + } } /// An honest, profile-derived player CardDefinition proposal, keyed by @@ -198,19 +265,46 @@ pub struct ObservedDefinition { pub name: String, pub rating: i64, pub position: String, + pub nation: String, + pub league: String, + pub club: 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, + pub rarity: &'static str, + /// Wire ids of the owned copies of this exact resourceId (preserved). + pub wire_ids: Vec, } -/// `version = resourceId >> 24`; base cards are version 0 (`resourceId == assetId`). -pub fn version_of(resource_id: i64) -> i64 { - resource_id >> 24 +/// A deferred definition (unsupported), with its owned wire ids for the manifest. +#[derive(Debug, Clone)] +pub struct DeferredDef { + pub resource_id: i64, + pub asset_id: Option, + pub wire_ids: Vec, + pub reason: Unsupported, +} + +/// A `resourceId` whose owned copies disagree on definition-level data. +#[derive(Debug, Clone)] +pub struct DefConflict { + pub resource_id: i64, + pub wire_ids: Vec, + pub distinct: Vec, + /// True if this conflict was explicitly approved for deferral (allowlist); + /// false conflicts are unsafe blockers. + pub approved_defer: bool, +} + +#[derive(Debug, Default)] +pub struct DefinitionPlan { + pub supported: Vec, + pub deferred: Vec, + pub conflicts: Vec, + pub base_defs: usize, + pub versioned_defs: usize, } fn def_fields(item: &Item) -> DefFields { @@ -232,29 +326,15 @@ fn def_fields(item: &Item) -> DefFields { } } -/// 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 +/// Build the definition proposal from player cards, applying the +/// resourceId-group consistency gate, entity resolution, and honest +/// buildability. `approved_conflicts` are the ONLY conflicts allowed to defer. +pub fn plan_definitions( + profile: &Profile, + roster: &Roster, + entities: &Entities, + approved_conflicts: &BTreeSet, +) -> DefinitionPlan { let mut groups: BTreeMap> = BTreeMap::new(); for it in &profile.items { if classify(it) == ItemClass::PlayerCard { @@ -264,7 +344,9 @@ pub fn plan_definitions(profile: &Profile, roster: &Roster) -> DefinitionPlan { let mut plan = DefinitionPlan::default(); for (resource_id, items) in groups { - // 1) consistency gate: all copies must share definition-level data. + let wire_ids: Vec = items.iter().map(|i| i.id).collect(); + + // 1) consistency gate. let first = def_fields(items[0]); let mut distinct = vec![first.clone()]; for it in &items[1..] { @@ -276,38 +358,42 @@ pub fn plan_definitions(profile: &Profile, roster: &Roster) -> DefinitionPlan { if distinct.len() > 1 { plan.conflicts.push(DefConflict { resource_id, - wire_ids: items.iter().map(|i| i.id).collect(), + wire_ids, distinct, + approved_defer: approved_conflicts.contains(&resource_id), }); 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; + + // helper to record a deferral + macro_rules! defer { + ($reason:expr) => {{ + plan.deferred.push(DeferredDef { + resource_id, + asset_id: f.asset_id, + wire_ids: wire_ids.clone(), + reason: $reason, + }); + continue; + }}; } - // 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; - } + // 2) version formula. + let Some(asset_id) = f.asset_id else { + defer!(Unsupported::MissingMetadata { + fields: vec!["assetId".into()] + }); + }; + if resource_id != (f.version << 24) | asset_id { + defer!(Unsupported::VersionFormula { + resource_id, + asset_id + }); } - // required metadata present? + + // 3) required metadata present? let mut missing = Vec::new(); if f.rating.is_none() { missing.push("rating".into()); @@ -334,75 +420,74 @@ pub fn plan_definitions(profile: &Profile, roster: &Roster) -> DefinitionPlan { 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; + defer!(Unsupported::MissingMetadata { fields: missing }); } - 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; + let (rating, position) = (f.rating.unwrap(), f.position.clone().unwrap()); + let (nation_id, league_id, team_id) = + (f.nation.unwrap(), f.league.unwrap(), f.team.unwrap()); + + // 4) name (roster) — evidence, never fabricated. + let Some(name) = roster.name_for(asset_id) else { + defer!(Unsupported::NoName { asset_id }); + }; + + // 5) entity resolution (nation/league/club names). + let (Some(nation), Some(league), Some(club)) = ( + entities.nation(nation_id), + entities.league(league_id), + entities.club(team_id), + ) else { + let mut fields = Vec::new(); + if entities.nation(nation_id).is_none() { + fields.push(format!("nation:{nation_id}")); } + if entities.league(league_id).is_none() { + fields.push(format!("league:{league_id}")); + } + if entities.club(team_id).is_none() { + fields.push(format!("team:{team_id}")); + } + defer!(Unsupported::UnresolvedEntity { fields }); }; 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(), + version: f.version, + name: name.to_string(), + rating, + position, + nation: nation.to_string(), + league: league.to_string(), + club: club.to_string(), + nation_id, + league_id, + team_id, attrs: attrs6.unwrap(), - rareflag: f.rareflag.unwrap_or(0), - owned_copies, + rarity: tier(rating), + wire_ids, }); } + plan.base_defs = plan.supported.iter().filter(|d| d.version == 0).count(); + plan.versioned_defs = plan.supported.len() - plan.base_defs; 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 +pub fn plan_identity(profile: &Profile, supported_resource_ids: &BTreeSet) -> IdentityPlan { let mut seen = BTreeSet::new(); let mut dups = BTreeSet::new(); for it in &profile.items { @@ -410,11 +495,13 @@ pub fn plan_identity(profile: &Profile, supported_resource_ids: &[i64]) -> Ident dups.insert(it.id); } } - let mut import_wire_ids: Vec = profile .items .iter() - .filter(|it| classify(it) == ItemClass::PlayerCard && supported.contains(&it.resource_id)) + .filter(|it| { + classify(it) == ItemClass::PlayerCard + && supported_resource_ids.contains(&it.resource_id) + }) .map(|it| it.id) .collect(); import_wire_ids.sort_unstable(); @@ -443,24 +530,18 @@ pub fn plan_identity(profile: &Profile, supported_resource_ids: &[i64]) -> Ident // ----------------------------------------------------------------- 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(); +pub fn plan_squad(profile: &Profile, supported_wire_ids: &BTreeSet) -> SquadCoverage { let Some(sq) = profile.squads.first() else { return SquadCoverage::default(); }; @@ -479,10 +560,10 @@ pub fn plan_squad(profile: &Profile, supported_wire_ids: &[i64]) -> SquadCoverag for slot in &sq.players { let wid = slot.item_data.id; if wid == 0 { - continue; // empty slot + continue; } cov.occupied_slots += 1; - if supported.contains(&wid) { + if supported_wire_ids.contains(&wid) { cov.supported_slots += 1; } else { cov.unsupported_slots.push((slot.index, wid)); @@ -493,7 +574,6 @@ pub fn plan_squad(profile: &Profile, supported_wire_ids: &[i64]) -> SquadCoverag // ----------------------------------------------------------------- report -/// The full dry-run analysis. #[derive(Debug)] pub struct Report { pub game: String, @@ -509,14 +589,31 @@ pub struct Report { } 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. + /// Count of deferred player INSTANCES (owned copies), across unsupported + /// definitions and (approved-)deferred conflicts. + pub fn deferred_instances(&self) -> usize { + let d: usize = self + .definitions + .deferred + .iter() + .map(|d| d.wire_ids.len()) + .sum(); + let c: usize = self + .definitions + .conflicts + .iter() + .map(|c| c.wire_ids.len()) + .sum(); + d + c + } + + /// Unsafe conditions that MUST fail (nonzero). Explicitly-deferred conflicts, + /// NoName/unresolved/version deferrals are NOT here — they are recorded. pub fn blockers(&self) -> Vec { let mut b = Vec::new(); if !self.counts.balances() { b.push(format!( - "item-type accounting does not balance: {}+{}+{}+{} != {}", + "item-type accounting does not balance ({}+{}+{}+{} != {})", self.counts.player_cards, self.counts.consumables, self.counts.staff, @@ -525,20 +622,22 @@ impl Report { )); } if self.counts.other > 0 { - b.push(format!( - "{} item(s) fall in no known class (itemType neither player nor staff)", - self.counts.other - )); + b.push(format!("{} item(s) in no known class", self.counts.other)); } - if !self.definitions.conflicts.is_empty() { + let new_conflicts = self + .definitions + .conflicts + .iter() + .filter(|c| !c.approved_defer) + .count(); + if new_conflicts > 0 { b.push(format!( - "{} resourceId group(s) disagree on definition-level data (hard conflict)", - self.definitions.conflicts.len() + "{new_conflicts} NEW (unapproved) resourceId conflict(s) — pass --defer-conflict only for a reviewed one" )); } if !self.identity.duplicate_wire_ids.is_empty() { b.push(format!( - "{} duplicate owned wire id(s) — instance identity is not unique", + "{} duplicate owned wire id(s)", self.identity.duplicate_wire_ids.len() )); } @@ -557,16 +656,22 @@ impl Report { } /// Run the full read-only analysis. Pure: no I/O, no writes. -pub fn analyze(profile: &Profile, roster: &Roster) -> Report { +pub fn analyze( + profile: &Profile, + roster: &Roster, + entities: &Entities, + approved_conflicts: &BTreeSet, +) -> Report { let counts = count_items(profile); - let definitions = plan_definitions(profile, roster); - let supported_rids: Vec = definitions + let definitions = plan_definitions(profile, roster, entities, approved_conflicts); + let supported_rids: BTreeSet = 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); + let supported_wire: BTreeSet = identity.import_wire_ids.iter().copied().collect(); + let squad = plan_squad(profile, &supported_wire); Report { game: "fifa17".to_string(), persona_id: profile.persona_id, @@ -586,14 +691,8 @@ impl std::fmt::Display for Report { 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, "OpenFUT FIFA17 real-profile import — analysis")?; + writeln!(f, "=============================================")?; writeln!( f, "TARGET game={} persona={}/{} club={}/{} coins={}", @@ -604,10 +703,9 @@ impl std::fmt::Display for Report { self.club_abbr, self.coins )?; - writeln!(f, "\nSOURCE ITEMS (disjoint classes)")?; writeln!( f, - " total={} player_cards={} consumables={} staff={} other={} balances={}", + "\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} other={} balances={}", self.counts.total, self.counts.player_cards, self.counts.consumables, @@ -615,50 +713,48 @@ impl std::fmt::Display for Report { self.counts.other, self.counts.balances() )?; - writeln!(f, "\nPLAYER DEFINITIONS (card_id = fifa17_)")?; writeln!( f, - " supported={} (base={} versioned={}) unsupported={} conflicts={}", + "\nPLAYER DEFINITIONS (fifa17_) supported={} (base={} versioned={}) deferred_defs={} conflicts={}", d.supported.len(), d.base_defs, d.versioned_defs, - d.unsupported.len(), + d.deferred.len(), d.conflicts.len() )?; - for (rid, copies, why) in &d.unsupported { + for dd in &d.deferred { writeln!( f, - " UNSUPPORTED resourceId={rid} copies={copies}: {why:?}" + " DEFER resourceId={} copies={} {:?}", + dd.resource_id, + dd.wire_ids.len(), + dd.reason )?; } for c in &d.conflicts { writeln!( f, - " CONFLICT resourceId={} wire_ids={:?} distinct_defs={}", + " CONFLICT resourceId={} copies={} distinct={} approved_defer={}", c.resource_id, - c.wire_ids, - c.distinct.len() + c.wire_ids.len(), + c.distinct.len(), + c.approved_defer )?; } - writeln!(f, "\nIDENTITY (preserve existing Python wire ids)")?; writeln!( f, - " import_instances={} live_min={:?} live_max={:?} duplicates={}", + "\nIDENTITY import_instances={} live=[{:?}..{:?}] duplicates={} watermark={} next_alloc={}", id.import_wire_ids.len(), id.live_min, id.live_max, - id.duplicate_wire_ids.len() + id.duplicate_wire_ids.len(), + id.source_watermark, + id.next_allocation )?; - 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={:?}", + "\nACTIVE SQUAD formation={} occupied={} supported={} unsupported={} captain={:?} manager={:?}", sq.formation, sq.occupied_slots, sq.supported_slots, @@ -670,16 +766,15 @@ impl std::fmt::Display for Report { writeln!(f, " UNSUPPORTED slot index={idx} wire_id={wid}")?; } } else { - writeln!(f, " (no squad in profile)")?; + writeln!(f, "\nACTIVE SQUAD (none)")?; } - writeln!(f, "\nRESULT")?; writeln!( f, - " would_import_players={} deferred_consumables={} deferred_staff={} unsupported_players(defs)={}", + "\nRESULT would_import_players={} deferred_player_instances={} deferred_consumables={} deferred_staff={}", id.import_wire_ids.len(), + self.deferred_instances(), self.counts.consumables, - self.counts.staff, - d.unsupported.len() + self.counts.staff )?; let blockers = self.blockers(); if blockers.is_empty() { @@ -694,5 +789,175 @@ impl std::fmt::Display for Report { } } +// ----------------------------------------------------------------- emit + +/// Dependency-free 64-bit FNV-1a hex fingerprint of the source snapshot (a +/// provenance handle, not a cryptographic digest). +pub fn fingerprint(bytes: &[u8]) -> String { + let mut h: u64 = 0xcbf29ce484222325; + for &b in bytes { + h ^= b as u64; + h = h.wrapping_mul(0x100000001b3); + } + format!("{h:016x}") +} + +/// What `emit_content` wrote. +#[derive(Debug)] +pub struct EmitSummary { + pub content_pack: std::path::PathBuf, + pub host_catalog: std::path::PathBuf, + pub manifest: std::path::PathBuf, + pub definitions: usize, + pub catalog_entries: usize, + pub supported_instances: usize, + pub deferred_instances: usize, +} + +/// Emit the PUBLIC content pack + host catalog for supported definitions, and a +/// PRIVATE import manifest (wire ids + deferred set + watermark + target). Never +/// writes Core or the identity store. Refuses if the report has blockers. +pub fn emit_content( + report: &Report, + out_dir: &Path, + snapshot_fingerprint: &str, +) -> Result { + if report.has_blockers() { + anyhow::bail!("refusing to emit content while blockers are present (resolve them first)"); + } + let content_dir = out_dir.join("content"); + let manifest_dir = out_dir.join("manifest"); + std::fs::create_dir_all(&content_dir)?; + std::fs::create_dir_all(&manifest_dir)?; + + // ---- PUBLIC: Core CardDefinition[] (matches openfut-core models::card) ---- + let defs: Vec = report + .definitions + .supported + .iter() + .map(|d| { + serde_json::json!({ + "id": d.card_id, + "name": d.name, + "overall": d.rating, + "position": d.position, + "nation": d.nation, + "league": d.league, + "club": d.club, + "pace": d.attrs[0], + "shooting": d.attrs[1], + "passing": d.attrs[2], + "dribbling": d.attrs[3], + "defending": d.attrs[4], + "physical": d.attrs[5], + "rarity": d.rarity, + "image_path": serde_json::Value::Null, + }) + }) + .collect(); + let content_pack = content_dir.join("fifa17-production-cards.json"); + write_json_pretty(&content_pack, &defs)?; + + // ---- PUBLIC: host identity catalog {card_id: {asset_id, version}} ---- + let mut cards = serde_json::Map::new(); + for d in &report.definitions.supported { + cards.insert( + d.card_id.clone(), + serde_json::json!({ "asset_id": d.asset_id, "version": d.version }), + ); + } + let catalog = serde_json::json!({ + "schema_version": 1, + "game": "fifa17", + "_provenance": { + "generator": "openfut-import-fifa17", + "source_kind": "python-profile-observation", + "snapshot_fingerprint": snapshot_fingerprint, + "base_cards_only": false, + }, + "cards": cards, + }); + let host_catalog = content_dir.join("fifa17-production-catalog.json"); + write_json_pretty(&host_catalog, &catalog)?; + + // ---- PRIVATE: import manifest (wire ids + deferred set) ---- + let supported: Vec = report + .definitions + .supported + .iter() + .map(|d| { + serde_json::json!({ + "card_id": d.card_id, + "resource_id": d.resource_id, + "asset_id": d.asset_id, + "version": d.version, + "wire_ids": d.wire_ids, + }) + }) + .collect(); + let mut deferred: Vec = report + .definitions + .deferred + .iter() + .map(|dd| { + serde_json::json!({ + "resource_id": dd.resource_id, + "asset_id": dd.asset_id, + "wire_ids": dd.wire_ids, + "reason": format!("{:?}", dd.reason), + }) + }) + .collect(); + for c in &report.definitions.conflicts { + deferred.push(serde_json::json!({ + "resource_id": c.resource_id, + "wire_ids": c.wire_ids, + "reason": "definition_metadata_conflict", + "approved_defer": c.approved_defer, + "distinct_variants": c.distinct.len(), + })); + } + let supported_instances = report.identity.import_wire_ids.len(); + let deferred_instances = report.deferred_instances(); + let manifest = serde_json::json!({ + "generator": "openfut-import-fifa17", + "source_kind": "python-profile-observation", + "snapshot_fingerprint": snapshot_fingerprint, + "target": { + "game": report.game, + "persona_id": report.persona_id, + "persona_name": report.persona_name, + "club_name": report.club_name, + "club_abbr": report.club_abbr, + }, + "identity": { + "base_floor": OWNED_ITEM_BASE_FLOOR, + "source_watermark": report.identity.source_watermark, + "next_allocation": report.identity.next_allocation, + "supported_instances": supported_instances, + }, + "supported_definitions": supported, + "deferred": deferred, + }); + let manifest_path = manifest_dir.join("fifa17-import-manifest.json"); + write_json_pretty(&manifest_path, &manifest)?; + + Ok(EmitSummary { + content_pack, + host_catalog, + manifest: manifest_path, + definitions: report.definitions.supported.len(), + catalog_entries: report.definitions.supported.len(), + supported_instances, + deferred_instances, + }) +} + +fn write_json_pretty(path: &Path, value: &T) -> Result<()> { + let json = serde_json::to_vec_pretty(value)?; + std::fs::write(path, &json).with_context(|| format!("writing {}", path.display()))?; + Ok(()) +} + #[cfg(test)] mod tests; diff --git a/openfut-import-fifa17/src/main.rs b/openfut-import-fifa17/src/main.rs index f3ac102..942a21c 100644 --- a/openfut-import-fifa17/src/main.rs +++ b/openfut-import-fifa17/src/main.rs @@ -1,12 +1,19 @@ //! `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. +//! This phase implements the read-only analysis and `--emit-content` (public +//! content pack + host catalog + private manifest). `--apply` is recognised but +//! deliberately refuses (and exits nonzero) so no Core DB / identity-store +//! writes happen before the apply phase lands. use anyhow::{bail, Context, Result}; +use std::collections::BTreeSet; +use std::path::Path; use std::process::ExitCode; +use openfut_import_fifa17::{ + analyze, emit_content, fingerprint, load_roster, model::Profile, Entities, +}; + fn main() -> ExitCode { match run() { Ok(code) => code, @@ -19,33 +26,44 @@ fn main() -> ExitCode { fn print_help() { eprintln!( - "openfut-import-fifa17 --profile [--roster ]\n\ + "openfut-import-fifa17 --profile [options]\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\ + Read-only analysis of a real FIFA 17 Python profile for a faithful Core\n\ + import, with optional public content emission. 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" + --profile (required) source fifa17_profile.json\n\ + --roster roster.json for names (default fifa17-recon/data/roster.json)\n\ + --tables tables dir (default fifa17-recon/data/tables)\n\ + --defer-conflict explicitly approve deferring a reviewed resourceId conflict (repeatable)\n\ + --emit-content write the production content pack + host catalog + private manifest\n\ + --apply REFUSED in this phase (no Core/identity writes)\n" ); } fn run() -> Result { let mut profile_path: Option = None; let mut roster_path = "fifa17-recon/data/roster.json".to_string(); + let mut tables_dir = "fifa17-recon/data/tables".to_string(); + let mut emit_dir: Option = None; + let mut approved: BTreeSet = BTreeSet::new(); + 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")?, + "--tables" => tables_dir = args.next().context("--tables needs a dir")?, + "--emit-content" => emit_dir = Some(args.next().context("--emit-content needs a dir")?), + "--defer-conflict" => { + let rid = args.next().context("--defer-conflict needs a resourceId")?; + approved.insert( + rid.parse::() + .with_context(|| format!("--defer-conflict {rid} is not an integer"))?, + ); + } "--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" + "--apply is not implemented in this phase; refusing to write the Core DB or identity store" ), "-h" | "--help" => { print_help(); @@ -56,13 +74,42 @@ fn run() -> Result { } 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); + let raw = + std::fs::read(&profile_path).with_context(|| format!("reading profile {profile_path}"))?; + let profile = Profile::from_json_str(&String::from_utf8_lossy(&raw))?; + let roster = load_roster(&roster_path)?; + let entities = Entities::from_tables_dir(&tables_dir)?; + + let report = analyze(&profile, &roster, &entities, &approved); print!("{report}"); + if let Some(dir) = &emit_dir { + if report.has_blockers() { + bail!("not emitting content: unsafe blockers present (resolve them first)"); + } + let fp = fingerprint(&raw); + let sum = emit_content(&report, Path::new(dir), &fp)?; + println!("\nEMITTED (fingerprint {fp})"); + println!( + " content pack : {} ({} definitions)", + sum.content_pack.display(), + sum.definitions + ); + println!( + " host catalog : {} ({} entries)", + sum.host_catalog.display(), + sum.catalog_entries + ); + println!( + " manifest : {} (supported_instances={} deferred_instances={})", + sum.manifest.display(), + sum.supported_instances, + sum.deferred_instances + ); + } + if report.has_blockers() { - eprintln!("import-fifa17: DRY-RUN FAILED — unsafe blockers present (see above)"); + eprintln!("import-fifa17: FAILED — unsafe blockers present (see above)"); Ok(ExitCode::FAILURE) } else { Ok(ExitCode::SUCCESS) diff --git a/openfut-import-fifa17/src/tests.rs b/openfut-import-fifa17/src/tests.rs index df8ced8..ac5438d 100644 --- a/openfut-import-fifa17/src/tests.rs +++ b/openfut-import-fifa17/src/tests.rs @@ -1,5 +1,6 @@ use super::*; use model::Profile; +use std::collections::{BTreeMap, BTreeSet}; const VER5_176580: i64 = (5 << 24) | 176580; // versioned resourceId for asset 176580 @@ -14,12 +15,28 @@ fn roster() -> Roster { .unwrap() } +fn entities() -> Entities { + Entities::from_maps( + BTreeMap::from([(53, "LaLiga".to_string()), (16, "Serie A".to_string())]), + BTreeMap::from([(38, "Portugal".to_string()), (45, "Spain".to_string())]), + BTreeMap::from([ + (243, "Real Madrid".to_string()), + (21, "Juventus".to_string()), + ]), + ) +} + +fn none() -> BTreeSet { + BTreeSet::new() +} + 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() } +/// A resolvable player (nation 38, team 243, league 53 all in `entities()`). fn player(id: i64, resource: i64, asset: i64, rating: i64) -> String { format!( r#"{{"id":{id},"resourceId":{resource},"assetId":{asset},"itemType":"player", @@ -39,145 +56,169 @@ fn profile(items: &[String], squads: &str, next_item_id: i64) -> Profile { Profile::from_json_str(&json).unwrap() } +fn defs(items: &[String]) -> DefinitionPlan { + plan_definitions( + &profile(items, "[]", 100000500), + &roster(), + &entities(), + &none(), + ) +} + #[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 + player(100000001, 20801, 20801, 94), + player(100000002, VER5_176580, 176580, 92), 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); + let c = count_items(&profile(&items, "[]", 100000500)); + assert_eq!( + (c.total, c.player_cards, c.consumables, c.staff, c.other), + (4, 2, 1, 1, 0) + ); assert!(c.balances()); } #[test] -fn version_formula_and_card_id() { +fn version_formula_card_id_and_resolved_names() { 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()); + let plan = defs(&[player(100000002, VER5_176580, 176580, 92)]); 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 + assert_eq!(d.name, "Luis Suárez"); // base-asset name, never fabricated + assert_eq!( + (d.nation.as_str(), d.league.as_str(), d.club.as_str()), + ("Portugal", "LaLiga", "Real Madrid") + ); + assert_eq!(d.rarity, "gold"); // rating 92 -> tier, NOT a promo label } #[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()); + let plan = defs(&[ + player(100000010, 176580, 176580, 92), + player(100000011, VER5_176580, 176580, 92), + ]); 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())); + assert_eq!((plan.base_defs, plan.versioned_defs), (1, 1)); } #[test] -fn duplicate_copies_share_definition_but_preserve_distinct_wire_ids() { +fn duplicate_copies_share_definition_but_keep_distinct_wire_ids() { + let plan = defs(&[ + player(100000001, 20801, 20801, 94), + player(100000015, 20801, 20801, 94), + ]); + assert_eq!(plan.supported.len(), 1); + assert_eq!(plan.supported[0].wire_ids, vec![100000001, 100000015]); +} + +#[test] +fn new_conflict_blocks_but_approved_conflict_defers() { let items = vec![ player(100000001, 20801, 20801, 94), - player(100000015, 20801, 20801, 94), // second owned copy + player(100000002, 20801, 20801, 93), // disagree on rating ]; - let p = profile(&items, "[]", 100000500); - let plan = plan_definitions(&p, &roster()); - assert_eq!( - plan.supported.len(), - 1, - "one definition for the shared resource" + // unapproved: a hard blocker + let rep = analyze( + &profile(&items, "[]", 100000500), + &roster(), + &entities(), + &none(), ); - 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.definitions.supported.is_empty()); + assert_eq!(rep.definitions.conflicts.len(), 1); + assert!(!rep.definitions.conflicts[0].approved_defer); + assert!(rep.has_blockers(), "a NEW conflict must block"); + // approved: deferred, not blocking + let ok = analyze( + &profile(&items, "[]", 100000500), + &roster(), + &entities(), + &BTreeSet::from([20801]), + ); + assert!(ok.definitions.conflicts[0].approved_defer); assert!( - rep.has_blockers(), - "a definition conflict is an unsafe blocker" + !ok.has_blockers(), + "an approved-deferred conflict does not block" + ); + assert_eq!( + ok.deferred_instances(), + 2, + "both copies counted as deferred" ); } #[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()); +fn version_formula_violation_defers_not_fabricates() { + let plan = defs(&[player(100000003, 999, 176580, 92)]); // 999 != (0<<24)|176580 assert!(plan.supported.is_empty()); assert!(matches!( - plan.unsupported.first(), - Some((_, _, Unsupported::VersionFormula { .. })) + plan.deferred.first().map(|d| &d.reason), + 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()); +fn missing_roster_name_defers_never_faked() { + let plan = defs(&[player(100000004, 777777, 777777, 80)]); // 777777 not in roster assert!(plan.supported.is_empty()); assert!(matches!( - plan.unsupported.first(), - Some((_, _, Unsupported::NoName { asset_id: 777777 })) + plan.deferred.first().map(|d| &d.reason), + Some(Unsupported::NoName { asset_id: 777777 }) + )); +} + +#[test] +fn unresolved_entity_defers() { + // nation 999 not in entities() + let item = format!( + r#"{{"id":100000005,"resourceId":20801,"assetId":20801,"itemType":"player","rareflag":1, + "rating":94,"preferredPosition":"ST","nation":999,"teamid":243,"leagueId":53, + "attributeList":{}}}"#, + attrs() + ); + let plan = defs(&[item]); + assert!(plan.supported.is_empty()); + assert!(matches!( + plan.deferred.first().map(|d| &d.reason), + Some(Unsupported::UnresolvedEntity { .. }) )); } #[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)); + let idp = plan_identity( + &profile(&items, "[]", 100004617), + &BTreeSet::from([20801, 176580]), + ); + assert_eq!( + (idp.live_min, idp.live_max), + (Some(100000001), 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); + assert_eq!( + idp.next_allocation, 100004617, + "next-to-issue, not live_max+1" + ); } #[test] -fn squad_coverage_flags_unsupported_starter_and_keeps_manager_ref() { +fn squad_coverage_flags_unsupported_starter_and_keeps_manager() { 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}], @@ -185,19 +226,19 @@ fn squad_coverage_flags_unsupported_starter_and_keeps_manager_ref() { {"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 rep = analyze( + &profile(&items, squads, 100000500), + &roster(), + &entities(), + &none(), + ); 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.occupied_slots, sq.supported_slots), (2, 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"); + assert!(rep.has_blockers()); } #[test] @@ -207,14 +248,14 @@ fn analyze_is_deterministic() { player(100000001, 20801, 20801, 94), ]; let p = profile(&items, "[]", 100000500); - let a = analyze(&p, &roster()); - let b = analyze(&p, &roster()); + let a = analyze(&p, &roster(), &entities(), &none()); + let b = analyze(&p, &roster(), &entities(), &none()); 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() { +fn emit_content_writes_public_pack_catalog_and_private_manifest() { let items = vec![ player(100000001, 20801, 20801, 94), player(100000002, VER5_176580, 176580, 92), @@ -224,9 +265,56 @@ fn clean_profile_has_no_blockers_and_defers_non_players() { 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()); + let p = profile(&items, squads, 100000500); + let rep = analyze(&p, &roster(), &entities(), &none()); 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); + + let dir = tempfile::tempdir().unwrap(); + let sum = emit_content(&rep, dir.path(), "deadbeef").unwrap(); + assert_eq!(sum.definitions, 2); + assert_eq!(sum.supported_instances, 2); + assert_eq!(sum.deferred_instances, 0); + assert!(sum.content_pack.exists()); + assert!(sum.host_catalog.exists()); + assert!(sum.manifest.exists()); + + // content pack is a Core CardDefinition[] with our card_id + resolved names + let pack: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&sum.content_pack).unwrap()).unwrap(); + let ids: Vec<&str> = pack + .as_array() + .unwrap() + .iter() + .map(|c| c["id"].as_str().unwrap()) + .collect(); + assert!(ids.contains(&"fifa17_20801")); + assert!(ids.contains(&format!("fifa17_{VER5_176580}").as_str())); + + // catalog carries versioned entries + let cat: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&sum.host_catalog).unwrap()).unwrap(); + assert_eq!(cat["cards"][format!("fifa17_{VER5_176580}")]["version"], 5); + assert_eq!(cat["cards"]["fifa17_20801"]["version"], 0); + + // manifest preserves wire ids and is separate from public content + let man: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&sum.manifest).unwrap()).unwrap(); + assert_eq!(man["identity"]["source_watermark"], 100000500); + assert_eq!(man["target"]["persona_id"], 33068179); +} + +#[test] +fn emit_refuses_when_blocked() { + let items = vec![ + player(100000001, 20801, 20801, 94), + player(100000002, 20801, 20801, 93), // unapproved conflict -> blocker + ]; + let rep = analyze( + &profile(&items, "[]", 100000500), + &roster(), + &entities(), + &none(), + ); + let dir = tempfile::tempdir().unwrap(); + assert!(emit_content(&rep, dir.path(), "x").is_err()); }