//! Evidence-driven import of a real FIFA 17 Python profile into OpenFUT Core. //! //! 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 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`, next-to-issue) is preserved so a future //! Rust-minted id continues past historically burned ids. use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use anyhow::{Context, Result}; /// FIFA 17 owned-item wire-id floor (adapter `Fifa17WireItemIdPolicy`). pub const OWNED_ITEM_BASE_FLOOR: i64 = 100_000_001; pub mod apply; pub mod model; use model::{Item, Profile}; use openfut_adapter_fifa17::fut::content_taxonomy::{consumable_family, staff_role, ContentKind}; // ----------------------------------------------------------------- roster /// 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, } 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) } // --------------------------------------------------------------- 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()))?; Profile::from_json_str(&raw) } // ------------------------------------------------------------- classification /// The disjoint source item classes. Every source item is exactly one. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ItemClass { PlayerCard, Consumable, Staff, Kit, Other, } pub fn classify(item: &Item) -> ItemClass { if item.cardsubtypeid == Some(9) && (6_300_000..=6_400_654).contains(&item.resource_id) { return ItemClass::Kit; } match item.item_type.as_str() { "staff" => ItemClass::Staff, "player" => { if item.attribute_list.is_some() { ItemClass::PlayerCard } else { ItemClass::Consumable } } _ => ItemClass::Other, } } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ItemCounts { pub total: usize, pub player_cards: usize, pub consumables: usize, pub staff: usize, pub kits: usize, pub other: usize, } impl ItemCounts { pub fn balances(&self) -> bool { self.player_cards + self.consumables + self.staff + self.kits + 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::Kit => c.kits += 1, ItemClass::Other => c.other += 1, } } c } // -------------------------------------------------------------- definitions /// Definition-level fields carried per owned copy. The DEFINITION IDENTITY /// subset (`asset_id`/`version`/`rating`/`position`/`attrs`/`rareflag`) MUST /// agree across copies of one `resourceId`; `nation`/`league`/`team` are /// observed INSTANCE metadata that MAY legitimately vary per owned copy (a /// club-affiliation snapshot — evidence: `resourceId` 169193) and are EXCLUDED /// from the identity gate. Instance fields (wire id, contract, fitness, …) are /// not modelled here at all. #[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, pub attrs: Option>, pub rareflag: Option, } /// Why a `resourceId` cannot yield an honest CardDefinition (all DEFERRED). #[derive(Debug, Clone, PartialEq, Eq)] pub enum Unsupported { VersionFormula { resource_id: i64, asset_id: i64 }, NoName { asset_id: i64 }, 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 /// `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: 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 rarity: &'static str, /// Observed FIFA wire `rareflag` (the card's rare/special TYPE, e.g. 3=inform, /// 21..=24 = special programmes). Carried verbatim from the profile — the raw /// integer, never a guessed marketing label — so specials render as specials. pub rareflag: i64, /// Wire ids of the owned copies of this exact resourceId (preserved). pub wire_ids: Vec, } /// 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 { 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, } } /// The definition-IDENTITY projection of [`DefFields`]: the subset that MUST be /// identical across every owned copy of one `resourceId`. Deliberately omits /// `nation`/`league`/`team`, which are observed instance metadata (see /// [`DefFields`]) — a club-only difference between two copies is NOT a conflict. #[derive(Debug, Clone, PartialEq, Eq)] struct DefIdentity { asset_id: Option, version: i64, rating: Option, position: Option, attrs: Option>, rareflag: Option, } impl DefIdentity { fn of(f: &DefFields) -> Self { DefIdentity { asset_id: f.asset_id, version: f.version, rating: f.rating, position: f.position.clone(), attrs: f.attrs.clone(), rareflag: f.rareflag, } } } /// 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 { groups.entry(it.resource_id).or_default().push(it); } } let mut plan = DefinitionPlan::default(); for (resource_id, items) in groups { let wire_ids: Vec = items.iter().map(|i| i.id).collect(); // 1) consistency gate — compare DEFINITION IDENTITY only. A club-only // difference (nation/league/team) between copies is observed instance // metadata, never a conflict (evidence: resourceId 169193's 4 copies are // identical but for club). `distinct` keeps only identity-distinct // variants so a real conflict (differing rating/position/attrs/asset) // still trips the gate. let first = def_fields(items[0]); let mut distinct = vec![first.clone()]; for it in &items[1..] { let f = def_fields(it); if !distinct .iter() .any(|d| DefIdentity::of(d) == DefIdentity::of(&f)) { distinct.push(f); } } if distinct.len() > 1 { plan.conflicts.push(DefConflict { resource_id, wire_ids, distinct, approved_defer: approved_conflicts.contains(&resource_id), }); continue; // never pick a winner } let f = &first; // 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) 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 }); } // 3) 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 !missing.is_empty() { defer!(Unsupported::MissingMetadata { fields: missing }); } 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: 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(), rarity: tier(rating), rareflag: f.rareflag.unwrap_or(1), wire_ids, }); } plan.base_defs = plan.supported.iter().filter(|d| d.version == 0).count(); plan.versioned_defs = plan.supported.len() - plan.base_defs; plan } // ------------------------------------------------------- non-player content /// An honest, profile-derived non-player CardDefinition proposal, keyed by /// `fifa17_`. Neutral player fields are supplied at emit time; FIFA /// render metadata stays here and in the adapter catalog, never generic Core. #[derive(Debug, Clone, PartialEq, Eq)] pub struct NonPlayerDefinition { pub card_id: String, pub resource_id: i64, /// Base asset id when the source carries one. pub asset_id: Option, pub kind: ContentKind, /// FIFA `cardsubtypeid` (consumable family, staff role, or kit family). pub subtype: i64, /// Non-player card-art id (`35` for kits), when present. pub card_asset_id: Option, /// Source team id for a kit definition, when present. pub team_id: Option, /// Consumable effect magnitude (`amount`), when the source carries one. /// /// Definition-level, and measured to be so: across every owned consumable in /// the real profile the observed `amount` equals its `fcc_*` table row /// (1, 2, 4, 5, 10, 15 — no disagreements), and a wire omission corresponds /// to a table `amount` of 0. It is NOT a stack count: two copies of 5003068 /// arrive as two separate instances, each with the same amount. pub amount: Option, /// Contract-card payload (`contract`), when the source carries one. Present /// on exactly the contract families (subtypes 201/202) and absent from every /// other owned consumable, so it is the card's own field rather than the /// generic per-item contract atom that players and staff carry. pub contract: Option, /// Card rating, when the source carries one (matches the `fcc_*` row). pub rating: Option, /// Honest functional label (e.g. "Player Contract", "GK Coach", "Kit"). pub name: String, /// Wire ids of every owned copy of this resourceId (preserved). pub wire_ids: Vec, } /// A non-player group that cannot be honestly classified (DEFERRED, never /// fabricated). Mirrors the player NoName gate. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeferredNonPlayer { pub resource_id: i64, /// The agreed subtype when present; `None` when absent or in conflict. pub subtype: Option, pub wire_ids: Vec, pub reason: String, } #[derive(Debug, Default)] pub struct NonPlayerPlan { pub supported: Vec, pub deferred: Vec, /// Count of SUPPORTED consumable definitions. pub consumables: usize, /// Count of SUPPORTED staff definitions. pub staff: usize, /// Count of SUPPORTED kit definitions. pub kits: usize, } impl NonPlayerPlan { /// Deferred non-player INSTANCES (owned copies) across all deferred groups. pub fn deferred_instances(&self) -> usize { self.deferred.iter().map(|d| d.wire_ids.len()).sum() } } /// Plan non-player CardDefinitions. Consumable, Staff, and Kit groups must agree /// on their definition-level metadata across every owned copy; disagreement or /// missing required metadata defers the whole group, never fabricates a value. pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan { let mut groups: BTreeMap> = BTreeMap::new(); for it in &profile.items { if matches!( classify(it), ItemClass::Consumable | ItemClass::Staff | ItemClass::Kit ) { groups.entry(it.resource_id).or_default().push(it); } } let mut plan = NonPlayerPlan::default(); for (resource_id, items) in groups { let wire_ids: Vec = items.iter().map(|i| i.id).collect(); // Class agreement (a resourceId is either all-consumable or all-staff). let class = classify(items[0]); if items.iter().any(|i| classify(i) != class) { plan.deferred.push(DeferredNonPlayer { resource_id, subtype: None, wire_ids, reason: "class_conflict".to_string(), }); continue; } // Subtype must agree across every owned copy (identity invariant). let first_subtype = items[0].cardsubtypeid; if items.iter().any(|i| i.cardsubtypeid != first_subtype) { plan.deferred.push(DeferredNonPlayer { resource_id, subtype: None, wire_ids, reason: "subtype_conflict".to_string(), }); continue; } let Some(subtype) = first_subtype else { plan.deferred.push(DeferredNonPlayer { resource_id, subtype: None, wire_ids, reason: "missing_cardsubtypeid".to_string(), }); continue; }; let card_asset_id = items[0].cardassetid; let team_id = items[0].teamid; // These are definition-level, so every owned copy must agree. Two copies // of one consumable that disagreed would mean the field is really // per-instance, and silently taking the first copy's value would bake a // guess into the catalog — so defer the whole group instead. let amount = items[0].amount; let contract = items[0].contract; let rating = items[0].rating; if items.iter().any(|item| { item.cardassetid != card_asset_id || item.teamid != team_id || item.amount != amount || item.contract != contract || item.rating != rating }) { plan.deferred.push(DeferredNonPlayer { resource_id, subtype: Some(subtype), wire_ids, reason: "render_metadata_conflict".to_string(), }); continue; } let (kind, label) = match class { ItemClass::Consumable => match consumable_family(subtype) { Some((_family, label)) => (ContentKind::Consumable, label), None => { plan.deferred.push(DeferredNonPlayer { resource_id, subtype: Some(subtype), wire_ids, reason: "unknown_subtype".to_string(), }); continue; } }, ItemClass::Staff => match staff_role(subtype) { Some((_role, label)) => (ContentKind::Staff, label), None => { plan.deferred.push(DeferredNonPlayer { resource_id, subtype: Some(subtype), wire_ids, reason: "unknown_subtype".to_string(), }); continue; } }, ItemClass::Kit if subtype == 9 => { if card_asset_id != Some(35) || team_id.is_none() { plan.deferred.push(DeferredNonPlayer { resource_id, subtype: Some(subtype), wire_ids, reason: "missing_kit_render_metadata".to_string(), }); continue; } (ContentKind::Kit, "Kit") } _ => unreachable!("only Consumable/Staff/Kit were grouped"), }; plan.supported.push(NonPlayerDefinition { card_id: format!("fifa17_{resource_id}"), resource_id, asset_id: if class == ItemClass::Kit { Some(resource_id) } else { items[0].asset_id }, kind, subtype, card_asset_id, team_id, amount, contract, rating, name: label.to_string(), wire_ids, }); } plan.consumables = plan .supported .iter() .filter(|d| d.kind == ContentKind::Consumable) .count(); plan.staff = plan .supported .iter() .filter(|d| d.kind == ContentKind::Staff) .count(); plan.kits = plan .supported .iter() .filter(|definition| definition.kind == ContentKind::Kit) .count(); plan } // --------------------------------------------------------------- identity #[derive(Debug, Default)] pub struct IdentityPlan { pub import_wire_ids: Vec, pub duplicate_wire_ids: Vec, pub live_min: Option, pub live_max: Option, pub source_watermark: i64, pub next_allocation: i64, } 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 { 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_resource_ids.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 #[derive(Debug, Default)] pub struct SquadCoverage { pub present: bool, pub formation: String, pub occupied_slots: usize, pub supported_slots: usize, 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: &BTreeSet) -> SquadCoverage { 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; } cov.occupied_slots += 1; if supported_wire_ids.contains(&wid) { cov.supported_slots += 1; } else { cov.unsupported_slots.push((slot.index, wid)); } } cov } // ----------------------------------------------------------------- report #[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, /// Consumable + staff content (supported definitions + deferred groups). pub non_player: NonPlayerPlan, /// Unconsumed pack entitlements to seed (from `unopenedPackIds`). pub unopened_pack_ids: Vec, } impl Report { /// 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 ({}+{}+{}+{}+{} != {})", self.counts.player_cards, self.counts.consumables, self.counts.staff, self.counts.kits, self.counts.other, self.counts.total )); } if self.counts.other > 0 { b.push(format!("{} item(s) in no known class", self.counts.other)); } let new_conflicts = self .definitions .conflicts .iter() .filter(|c| !c.approved_defer) .count(); if new_conflicts > 0 { b.push(format!( "{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)", 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, entities: &Entities, approved_conflicts: &BTreeSet, ) -> Report { let counts = count_items(profile); 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 supported_wire: BTreeSet = identity.import_wire_ids.iter().copied().collect(); let squad = plan_squad(profile, &supported_wire); let non_player = plan_non_player_definitions(profile); 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, non_player, unopened_pack_ids: profile.unopened_pack_ids.clone(), } } 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; let np = &self.non_player; writeln!(f, "OpenFUT FIFA17 real-profile import — analysis")?; 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 total={} player_cards={} consumables={} staff={} kits={} other={} balances={}", self.counts.total, self.counts.player_cards, self.counts.consumables, self.counts.staff, self.counts.kits, self.counts.other, self.counts.balances() )?; writeln!( f, "\nPLAYER DEFINITIONS (fifa17_) supported={} (base={} versioned={}) deferred_defs={} conflicts={}", d.supported.len(), d.base_defs, d.versioned_defs, d.deferred.len(), d.conflicts.len() )?; for dd in &d.deferred { writeln!( f, " DEFER resourceId={} copies={} {:?}", dd.resource_id, dd.wire_ids.len(), dd.reason )?; } for c in &d.conflicts { writeln!( f, " CONFLICT resourceId={} copies={} distinct={} approved_defer={}", c.resource_id, c.wire_ids.len(), c.distinct.len(), c.approved_defer )?; } writeln!( f, "\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.source_watermark, id.next_allocation )?; if sq.present { writeln!( f, "\nACTIVE SQUAD formation={} occupied={} supported={} unsupported={} captain={:?} manager={:?}", 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, "\nACTIVE SQUAD (none)")?; } writeln!( f, "\nNON-PLAYER CONTENT (consumable/staff/kit) supported={} (consumables={} staff={} kits={}) deferred_groups={} deferred_instances={}", np.supported.len(), np.consumables, np.staff, np.kits, np.deferred.len(), np.deferred_instances() )?; for nd in &np.deferred { writeln!( f, " DEFER resourceId={} subtype={:?} copies={} reason={}", nd.resource_id, nd.subtype, nd.wire_ids.len(), nd.reason )?; } writeln!( f, "\nRESULT would_import_players={} would_import_non_players={} deferred_player_instances={} deferred_non_player_instances={}", id.import_wire_ids.len(), np.supported.iter().map(|d| d.wire_ids.len()).sum::(), self.deferred_instances(), np.deferred_instances() )?; 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(()) } } // ----------------------------------------------------------------- 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, /// Core `reclassify` request correcting the content_kind of rows imported /// before the taxonomy existed. pub reclassify: std::path::PathBuf, pub definitions: usize, pub catalog_entries: usize, pub supported_instances: usize, pub deferred_instances: usize, /// Non-player (consumable/staff) supported definitions written. pub non_player_definitions: usize, /// Non-player supported owned INSTANCES (owned copies across those defs). pub non_player_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 mut 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(); // Non-player CardDefinitions use NEUTRAL player fields + the honest family/ // role name; Core stores them like any other definition (no FIFA concept). for d in &report.non_player.supported { defs.push(serde_json::json!({ "id": d.card_id, "name": d.name, "overall": 0, "position": "", "nation": "", "league": "", "club": "", "pace": 0, "shooting": 0, "passing": 0, "dribbling": 0, "defending": 0, "physical": 0, "rarity": "bronze", "image_path": serde_json::Value::Null, })); } 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, rareflag}} ---- let mut cards = serde_json::Map::new(); for d in &report.definitions.supported { // Players carry an explicit kind:"player" + subtype:0 so the adapter can // classify EVERY catalogued card (not just non-players). cards.insert( d.card_id.clone(), serde_json::json!({ "asset_id": d.asset_id, "version": d.version, "rareflag": d.rareflag, "kind": "player", "subtype": 0, }), ); } for d in &report.non_player.supported { // asset_id falls back to resource_id (staff carry no assetId); version 0, // rareflag 0 — a consumable/staff never renders as a special card. cards.insert( d.card_id.clone(), serde_json::json!({ "card_asset_id": d.card_asset_id, "team_id": d.team_id, // A consumable is unrenderable without these: the adapter refuses // to emit a card whose art id it does not know, and the families // that read `amount`/`contract` draw "-1" or grant nothing when // the field is missing. Omitting them here is what kept every // owned consumable off the wire. "amount": d.amount, "contract": d.contract, "rating": d.rating, "asset_id": d.asset_id.unwrap_or(d.resource_id), "version": 0, "rareflag": 0, "kind": d.kind.as_str(), "subtype": d.subtype, }), ); } 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)?; // ---- Core reclassify request (content_kind of already-imported rows) ---- // A profile import is once-only, so a club imported before the taxonomy // existed still records its coaches, kits and consumables as players. Core // cannot derive the right kind (it is FIFA17 taxonomy), so hand it the // mapping. Players are included too: a definition that was mis-set stays // correctable, and a rerun changes nothing. let assignments: Vec = report .definitions .supported .iter() .map(|d| serde_json::json!({ "card_id": d.card_id, "content_kind": "player" })) .chain(report.non_player.supported.iter().map(|d| { serde_json::json!({ "card_id": d.card_id, "content_kind": d.kind.as_str() }) })) .collect(); let reclassify = content_dir.join("fifa17-reclassify.json"); write_json_pretty( &reclassify, &serde_json::json!({ "game_id": "fifa17", "assignments": assignments }), )?; // ---- 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 non_player_supported: Vec = report .non_player .supported .iter() .map(|d| { serde_json::json!({ "card_asset_id": d.card_asset_id, "team_id": d.team_id, "card_id": d.card_id, "resource_id": d.resource_id, "asset_id": d.asset_id, "kind": d.kind.as_str(), "subtype": d.subtype, "name": d.name, "wire_ids": d.wire_ids, }) }) .collect(); let non_player_deferred: Vec = report .non_player .deferred .iter() .map(|dd| { serde_json::json!({ "resource_id": dd.resource_id, "subtype": dd.subtype, "wire_ids": dd.wire_ids, "reason": dd.reason, }) }) .collect(); let supported_instances = report.identity.import_wire_ids.len(); let deferred_instances = report.deferred_instances(); let non_player_definitions = report.non_player.supported.len(); let non_player_instances: usize = report .non_player .supported .iter() .map(|d| d.wire_ids.len()) .sum(); 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, "non_player": { "supported_definitions": non_player_supported, "supported_instances": non_player_instances, "deferred": non_player_deferred, "deferred_instances": report.non_player.deferred_instances(), }, }); let manifest_path = manifest_dir.join("fifa17-import-manifest.json"); write_json_pretty(&manifest_path, &manifest)?; Ok(EmitSummary { content_pack, host_catalog, reclassify, manifest: manifest_path, definitions: report.definitions.supported.len(), catalog_entries: report.definitions.supported.len() + non_player_definitions, supported_instances, deferred_instances, non_player_definitions, non_player_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;