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 <rid> 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_<resourceId>,
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.
This commit is contained in:
funman300
2026-08-12 19:41:17 +00:00
parent a51947562c
commit c71c2a8d33
5 changed files with 720 additions and 316 deletions
Generated
+1
View File
@@ -3220,6 +3220,7 @@ dependencies = [
"anyhow", "anyhow",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
] ]
[[package]] [[package]]
+4 -1
View File
@@ -2,7 +2,7 @@
name = "openfut-import-fifa17" name = "openfut-import-fifa17"
version = "0.1.0" version = "0.1.0"
edition = "2021" 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]] [[bin]]
name = "openfut-import-fifa17" name = "openfut-import-fifa17"
@@ -16,3 +16,6 @@ path = "src/lib.rs"
anyhow = "1" anyhow = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
[dev-dependencies]
tempfile = "3"
+464 -199
View File
@@ -1,38 +1,33 @@
//! Evidence-driven import of a real FIFA 17 Python profile into OpenFUT Core. //! 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 //! Read-only analysis (`analyze`) + public/private content emission
//! proves — before anything is written — that the migration can be done //! (`emit_content`). It proves — before anything is written — that the
//! faithfully: //! 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 //! * disjoint item-type accounting must balance to the source total;
//! typo must never become a migration assumption); //! * every supported player card yields an HONEST [`ObservedDefinition`] keyed
//! * every observed player card yields an HONEST [`ObservedDefinition`] keyed by //! by `fifa17_<resourceId>` (base and versioned are distinct), sourced from
//! `fifa17_<resourceId>` (so a versioned/special card is a *distinct* //! profile evidence: name from the roster, nation/league/club resolved from
//! definition from its base, never collapsed onto it), sourced from the //! the committed tables, quality tier from the rating — never invented, and no
//! profile's own evidence and never fabricated; //! promo/program label guessed (`rare=SP` and TOTW/… stay UNKNOWN);
//! * multiple owned copies of one `resourceId` agree on definition-level data — //! * multiple owned copies of one `resourceId` must agree on definition-level
//! a disagreement is a HARD conflict, never silently reconciled; //! 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 //! * every owned instance keeps its existing Python wire id, and the source
//! allocation watermark (`nextItemId`) is preserved so a future Rust-minted id //! allocation watermark (`nextItemId`, next-to-issue) is preserved so a future
//! cannot collide with a historically burned one; //! Rust-minted id continues past historically burned ids.
//! * 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::collections::{BTreeMap, BTreeSet};
use std::path::Path; use std::path::Path;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
/// FIFA 17 owned-item wire-id floor (adapter policy /// FIFA 17 owned-item wire-id floor (adapter `Fifa17WireItemIdPolicy`).
/// `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 const OWNED_ITEM_BASE_FLOOR: i64 = 100_000_001;
pub mod model; pub mod model;
@@ -41,9 +36,8 @@ use model::{Item, Profile};
// ----------------------------------------------------------------- roster // ----------------------------------------------------------------- roster
/// Player names keyed by base asset id, loaded from `roster.json`. Names are /// Player names keyed by base asset id (`roster.json`). A card whose base asset
/// evidence for CardDefinitions; a card whose base asset id is absent here /// id is absent here cannot be honestly named and is DEFERRED, never fabricated.
/// cannot be honestly named and is reported unsupported (never fabricated).
pub struct Roster { pub struct Roster {
names: BTreeMap<i64, String>, names: BTreeMap<i64, String>,
} }
@@ -89,6 +83,73 @@ pub fn load_roster(path: impl AsRef<Path>) -> Result<Roster> {
Roster::from_json_str(&raw) 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<i64, String>,
nations: BTreeMap<i64, String>,
teams: BTreeMap<i64, String>,
}
fn load_table(path: &Path, id_key: &str, name_key: &str) -> Result<BTreeMap<i64, String>> {
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<Path>) -> Result<Self> {
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<i64, String>,
nations: BTreeMap<i64, String>,
teams: BTreeMap<i64, String>,
) -> 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<Path>) -> Result<Profile> { pub fn load_profile(path: impl AsRef<Path>) -> Result<Profile> {
let raw = std::fs::read_to_string(path.as_ref()) let raw = std::fs::read_to_string(path.as_ref())
.with_context(|| format!("reading profile {}", path.as_ref().display()))?; .with_context(|| format!("reading profile {}", path.as_ref().display()))?;
@@ -97,16 +158,12 @@ pub fn load_profile(path: impl AsRef<Path>) -> Result<Profile> {
// ------------------------------------------------------------- classification // ------------------------------------------------------------- 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemClass { pub enum ItemClass {
/// `itemType=player` with an `attributeList` — a real player card.
PlayerCard, PlayerCard,
/// `itemType=player` without an `attributeList` (consumable; has amount/pile).
Consumable, Consumable,
/// `itemType=staff` (manager/coach/etc.).
Staff, Staff,
/// Anything else — must be zero for a clean import.
Other, 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)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ItemCounts { pub struct ItemCounts {
pub total: usize, pub total: usize,
@@ -158,10 +214,8 @@ pub fn count_items(profile: &Profile) -> ItemCounts {
// -------------------------------------------------------------- definitions // -------------------------------------------------------------- definitions
/// The definition-level (per-card, not per-instance) fields of a player card. /// Definition-level (per-card) fields. Owned copies of one `resourceId` MUST
/// Two owned copies of one `resourceId` MUST have equal `DefFields`; owned /// agree on these; instance fields (wire id, contract, fitness, …) are excluded.
/// instance fields (wire id, contract, fitness, itemState, owners, untradeable)
/// are deliberately excluded from this identity.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct DefFields { pub struct DefFields {
pub asset_id: Option<i64>, pub asset_id: Option<i64>,
@@ -171,20 +225,33 @@ pub struct DefFields {
pub nation: Option<i64>, pub nation: Option<i64>,
pub league: Option<i64>, pub league: Option<i64>,
pub team: Option<i64>, pub team: Option<i64>,
/// The six attributes in `index` order (0..=5), if present.
pub attrs: Option<Vec<i64>>, pub attrs: Option<Vec<i64>>,
pub rareflag: Option<i64>, pub rareflag: Option<i64>,
} }
/// Why a `resourceId` cannot yield an honest CardDefinition. /// Why a `resourceId` cannot yield an honest CardDefinition (all DEFERRED).
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Unsupported { pub enum Unsupported {
/// `resourceId != (version << 24) | assetId` — unexpected id structure.
VersionFormula { resource_id: i64, asset_id: i64 }, 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 }, NoName { asset_id: i64 },
/// A required definition field (rating/position/nation/league/team/attrs) is missing.
MissingMetadata { fields: Vec<String> }, MissingMetadata { fields: Vec<String> },
UnresolvedEntity { fields: Vec<String> },
}
/// `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 /// An honest, profile-derived player CardDefinition proposal, keyed by
@@ -198,19 +265,46 @@ pub struct ObservedDefinition {
pub name: String, pub name: String,
pub rating: i64, pub rating: i64,
pub position: String, pub position: String,
pub nation: String,
pub league: String,
pub club: String,
pub nation_id: i64, pub nation_id: i64,
pub league_id: i64, pub league_id: i64,
pub team_id: i64, pub team_id: i64,
/// pace, shooting, passing, dribbling, defending, physical (index 0..=5). /// pace, shooting, passing, dribbling, defending, physical (index 0..=5).
pub attrs: [i64; 6], pub attrs: [i64; 6],
pub rareflag: i64, pub rarity: &'static str,
/// Owned-copy count of this exact resourceId in the source profile. /// Wire ids of the owned copies of this exact resourceId (preserved).
pub owned_copies: usize, pub wire_ids: Vec<i64>,
} }
/// `version = resourceId >> 24`; base cards are version 0 (`resourceId == assetId`). /// A deferred definition (unsupported), with its owned wire ids for the manifest.
pub fn version_of(resource_id: i64) -> i64 { #[derive(Debug, Clone)]
resource_id >> 24 pub struct DeferredDef {
pub resource_id: i64,
pub asset_id: Option<i64>,
pub wire_ids: Vec<i64>,
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<i64>,
pub distinct: Vec<DefFields>,
/// 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<ObservedDefinition>,
pub deferred: Vec<DeferredDef>,
pub conflicts: Vec<DefConflict>,
pub base_defs: usize,
pub versioned_defs: usize,
} }
fn def_fields(item: &Item) -> DefFields { 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 /// Build the definition proposal from player cards, applying the
/// conflict the importer must never silently reconcile. /// resourceId-group consistency gate, entity resolution, and honest
#[derive(Debug, Clone)] /// buildability. `approved_conflicts` are the ONLY conflicts allowed to defer.
pub struct DefConflict { pub fn plan_definitions(
pub resource_id: i64, profile: &Profile,
pub wire_ids: Vec<i64>, roster: &Roster,
pub distinct: Vec<DefFields>, entities: &Entities,
} approved_conflicts: &BTreeSet<i64>,
) -> DefinitionPlan {
/// Result of proposing definitions for all player cards.
#[derive(Debug, Default)]
pub struct DefinitionPlan {
pub supported: Vec<ObservedDefinition>,
pub unsupported: Vec<(i64, usize, Unsupported)>, // (resource_id, owned_copies, why)
pub conflicts: Vec<DefConflict>,
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<i64, Vec<&Item>> = BTreeMap::new(); let mut groups: BTreeMap<i64, Vec<&Item>> = BTreeMap::new();
for it in &profile.items { for it in &profile.items {
if classify(it) == ItemClass::PlayerCard { if classify(it) == ItemClass::PlayerCard {
@@ -264,7 +344,9 @@ pub fn plan_definitions(profile: &Profile, roster: &Roster) -> DefinitionPlan {
let mut plan = DefinitionPlan::default(); let mut plan = DefinitionPlan::default();
for (resource_id, items) in groups { for (resource_id, items) in groups {
// 1) consistency gate: all copies must share definition-level data. let wire_ids: Vec<i64> = items.iter().map(|i| i.id).collect();
// 1) consistency gate.
let first = def_fields(items[0]); let first = def_fields(items[0]);
let mut distinct = vec![first.clone()]; let mut distinct = vec![first.clone()];
for it in &items[1..] { for it in &items[1..] {
@@ -276,38 +358,42 @@ pub fn plan_definitions(profile: &Profile, roster: &Roster) -> DefinitionPlan {
if distinct.len() > 1 { if distinct.len() > 1 {
plan.conflicts.push(DefConflict { plan.conflicts.push(DefConflict {
resource_id, resource_id,
wire_ids: items.iter().map(|i| i.id).collect(), wire_ids,
distinct, distinct,
approved_defer: approved_conflicts.contains(&resource_id),
}); });
continue; // never pick a winner continue; // never pick a winner
} }
let owned_copies = items.len();
let f = &first; let f = &first;
let version = f.version;
if version == 0 { // helper to record a deferral
plan.base_defs += 1; macro_rules! defer {
} else { ($reason:expr) => {{
plan.versioned_defs += 1; plan.deferred.push(DeferredDef {
resource_id,
asset_id: f.asset_id,
wire_ids: wire_ids.clone(),
reason: $reason,
});
continue;
}};
} }
// 2) honest buildability. // 2) version formula.
let asset_id = f.asset_id; let Some(asset_id) = f.asset_id else {
// version formula: resourceId == (version << 24) | assetId defer!(Unsupported::MissingMetadata {
if let Some(asset_id) = asset_id { fields: vec!["assetId".into()]
if resource_id != (version << 24) | asset_id { });
plan.unsupported.push(( };
if resource_id != (f.version << 24) | asset_id {
defer!(Unsupported::VersionFormula {
resource_id, resource_id,
owned_copies, asset_id
Unsupported::VersionFormula { });
resource_id,
asset_id,
},
));
continue;
} }
}
// required metadata present? // 3) required metadata present?
let mut missing = Vec::new(); let mut missing = Vec::new();
if f.rating.is_none() { if f.rating.is_none() {
missing.push("rating".into()); missing.push("rating".into());
@@ -334,75 +420,74 @@ pub fn plan_definitions(profile: &Profile, roster: &Roster) -> DefinitionPlan {
if attrs6.is_none() { if attrs6.is_none() {
missing.push("attributeList[6]".into()); missing.push("attributeList[6]".into());
} }
if asset_id.is_none() {
missing.push("assetId".into());
}
if !missing.is_empty() { if !missing.is_empty() {
plan.unsupported.push(( defer!(Unsupported::MissingMetadata { fields: missing });
resource_id,
owned_copies,
Unsupported::MissingMetadata { fields: missing },
));
continue;
} }
let asset_id = asset_id.unwrap(); let (rating, position) = (f.rating.unwrap(), f.position.clone().unwrap());
// name: evidence, never fabricated. let (nation_id, league_id, team_id) =
let name = match roster.name_for(asset_id) { (f.nation.unwrap(), f.league.unwrap(), f.team.unwrap());
Some(n) => n.to_string(),
None => { // 4) name (roster) — evidence, never fabricated.
plan.unsupported.push(( let Some(name) = roster.name_for(asset_id) else {
resource_id, defer!(Unsupported::NoName { asset_id });
owned_copies, };
Unsupported::NoName { asset_id },
)); // 5) entity resolution (nation/league/club names).
continue; 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 { plan.supported.push(ObservedDefinition {
card_id: format!("fifa17_{resource_id}"), card_id: format!("fifa17_{resource_id}"),
resource_id, resource_id,
asset_id, asset_id,
version, version: f.version,
name, name: name.to_string(),
rating: f.rating.unwrap(), rating,
position: f.position.clone().unwrap(), position,
nation_id: f.nation.unwrap(), nation: nation.to_string(),
league_id: f.league.unwrap(), league: league.to_string(),
team_id: f.team.unwrap(), club: club.to_string(),
nation_id,
league_id,
team_id,
attrs: attrs6.unwrap(), attrs: attrs6.unwrap(),
rareflag: f.rareflag.unwrap_or(0), rarity: tier(rating),
owned_copies, wire_ids,
}); });
} }
plan.base_defs = plan.supported.iter().filter(|d| d.version == 0).count();
plan.versioned_defs = plan.supported.len() - plan.base_defs;
plan plan
} }
// --------------------------------------------------------------- identity // --------------------------------------------------------------- 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)] #[derive(Debug, Default)]
pub struct IdentityPlan { pub struct IdentityPlan {
/// Wire ids of the player items that WOULD import (supported definitions).
pub import_wire_ids: Vec<i64>, pub import_wire_ids: Vec<i64>,
/// Duplicate wire ids across ALL items (must be empty — a hard conflict).
pub duplicate_wire_ids: Vec<i64>, pub duplicate_wire_ids: Vec<i64>,
pub live_min: Option<i64>, pub live_min: Option<i64>,
pub live_max: Option<i64>, pub live_max: Option<i64>,
/// Source `nextItemId` — the next id Python would issue (issue-then-increment).
pub source_watermark: i64, 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 next_allocation: i64,
} }
pub fn plan_identity(profile: &Profile, supported_resource_ids: &[i64]) -> IdentityPlan { pub fn plan_identity(profile: &Profile, supported_resource_ids: &BTreeSet<i64>) -> IdentityPlan {
use std::collections::BTreeSet;
let supported: BTreeSet<i64> = supported_resource_ids.iter().copied().collect();
// duplicate detection across ALL items
let mut seen = BTreeSet::new(); let mut seen = BTreeSet::new();
let mut dups = BTreeSet::new(); let mut dups = BTreeSet::new();
for it in &profile.items { for it in &profile.items {
@@ -410,11 +495,13 @@ pub fn plan_identity(profile: &Profile, supported_resource_ids: &[i64]) -> Ident
dups.insert(it.id); dups.insert(it.id);
} }
} }
let mut import_wire_ids: Vec<i64> = profile let mut import_wire_ids: Vec<i64> = profile
.items .items
.iter() .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) .map(|it| it.id)
.collect(); .collect();
import_wire_ids.sort_unstable(); import_wire_ids.sort_unstable();
@@ -443,24 +530,18 @@ pub fn plan_identity(profile: &Profile, supported_resource_ids: &[i64]) -> Ident
// ----------------------------------------------------------------- squad // ----------------------------------------------------------------- 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)] #[derive(Debug, Default)]
pub struct SquadCoverage { pub struct SquadCoverage {
pub present: bool, pub present: bool,
pub formation: String, pub formation: String,
pub occupied_slots: usize, pub occupied_slots: usize,
pub supported_slots: usize, pub supported_slots: usize,
/// (slot index, wire id) for slots whose item is missing/unsupported.
pub unsupported_slots: Vec<(i64, i64)>, pub unsupported_slots: Vec<(i64, i64)>,
pub manager_wire_ids: Vec<i64>, pub manager_wire_ids: Vec<i64>,
pub captain_wire_id: Option<i64>, pub captain_wire_id: Option<i64>,
} }
pub fn plan_squad(profile: &Profile, supported_wire_ids: &[i64]) -> SquadCoverage { pub fn plan_squad(profile: &Profile, supported_wire_ids: &BTreeSet<i64>) -> SquadCoverage {
use std::collections::BTreeSet;
let supported: BTreeSet<i64> = supported_wire_ids.iter().copied().collect();
let Some(sq) = profile.squads.first() else { let Some(sq) = profile.squads.first() else {
return SquadCoverage::default(); return SquadCoverage::default();
}; };
@@ -479,10 +560,10 @@ pub fn plan_squad(profile: &Profile, supported_wire_ids: &[i64]) -> SquadCoverag
for slot in &sq.players { for slot in &sq.players {
let wid = slot.item_data.id; let wid = slot.item_data.id;
if wid == 0 { if wid == 0 {
continue; // empty slot continue;
} }
cov.occupied_slots += 1; cov.occupied_slots += 1;
if supported.contains(&wid) { if supported_wire_ids.contains(&wid) {
cov.supported_slots += 1; cov.supported_slots += 1;
} else { } else {
cov.unsupported_slots.push((slot.index, wid)); cov.unsupported_slots.push((slot.index, wid));
@@ -493,7 +574,6 @@ pub fn plan_squad(profile: &Profile, supported_wire_ids: &[i64]) -> SquadCoverag
// ----------------------------------------------------------------- report // ----------------------------------------------------------------- report
/// The full dry-run analysis.
#[derive(Debug)] #[derive(Debug)]
pub struct Report { pub struct Report {
pub game: String, pub game: String,
@@ -509,14 +589,31 @@ pub struct Report {
} }
impl Report { impl Report {
/// Unsafe conditions that MUST fail the dry-run (nonzero exit). Deferred /// Count of deferred player INSTANCES (owned copies), across unsupported
/// items (unsupported non-squad players, consumables, staff) are NOT here — /// definitions and (approved-)deferred conflicts.
/// they are reported, not blocking. 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<String> { pub fn blockers(&self) -> Vec<String> {
let mut b = Vec::new(); let mut b = Vec::new();
if !self.counts.balances() { if !self.counts.balances() {
b.push(format!( b.push(format!(
"item-type accounting does not balance: {}+{}+{}+{} != {}", "item-type accounting does not balance ({}+{}+{}+{} != {})",
self.counts.player_cards, self.counts.player_cards,
self.counts.consumables, self.counts.consumables,
self.counts.staff, self.counts.staff,
@@ -525,20 +622,22 @@ impl Report {
)); ));
} }
if self.counts.other > 0 { if self.counts.other > 0 {
b.push(format!( b.push(format!("{} item(s) in no known class", self.counts.other));
"{} item(s) fall in no known class (itemType neither player nor staff)",
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!( b.push(format!(
"{} resourceId group(s) disagree on definition-level data (hard conflict)", "{new_conflicts} NEW (unapproved) resourceId conflict(s) — pass --defer-conflict <rid> only for a reviewed one"
self.definitions.conflicts.len()
)); ));
} }
if !self.identity.duplicate_wire_ids.is_empty() { if !self.identity.duplicate_wire_ids.is_empty() {
b.push(format!( b.push(format!(
"{} duplicate owned wire id(s) — instance identity is not unique", "{} duplicate owned wire id(s)",
self.identity.duplicate_wire_ids.len() self.identity.duplicate_wire_ids.len()
)); ));
} }
@@ -557,16 +656,22 @@ impl Report {
} }
/// Run the full read-only analysis. Pure: no I/O, no writes. /// 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<i64>,
) -> Report {
let counts = count_items(profile); let counts = count_items(profile);
let definitions = plan_definitions(profile, roster); let definitions = plan_definitions(profile, roster, entities, approved_conflicts);
let supported_rids: Vec<i64> = definitions let supported_rids: BTreeSet<i64> = definitions
.supported .supported
.iter() .iter()
.map(|d| d.resource_id) .map(|d| d.resource_id)
.collect(); .collect();
let identity = plan_identity(profile, &supported_rids); let identity = plan_identity(profile, &supported_rids);
let squad = plan_squad(profile, &identity.import_wire_ids); let supported_wire: BTreeSet<i64> = identity.import_wire_ids.iter().copied().collect();
let squad = plan_squad(profile, &supported_wire);
Report { Report {
game: "fifa17".to_string(), game: "fifa17".to_string(),
persona_id: profile.persona_id, persona_id: profile.persona_id,
@@ -586,14 +691,8 @@ impl std::fmt::Display for Report {
let d = &self.definitions; let d = &self.definitions;
let id = &self.identity; let id = &self.identity;
let sq = &self.squad; let sq = &self.squad;
writeln!( writeln!(f, "OpenFUT FIFA17 real-profile import — analysis")?;
f, writeln!(f, "=============================================")?;
"OpenFUT FIFA17 real-profile import — DRY RUN (no writes)"
)?;
writeln!(
f,
"========================================================"
)?;
writeln!( writeln!(
f, f,
"TARGET game={} persona={}/{} club={}/{} coins={}", "TARGET game={} persona={}/{} club={}/{} coins={}",
@@ -604,10 +703,9 @@ impl std::fmt::Display for Report {
self.club_abbr, self.club_abbr,
self.coins self.coins
)?; )?;
writeln!(f, "\nSOURCE ITEMS (disjoint classes)")?;
writeln!( writeln!(
f, f,
" total={} player_cards={} consumables={} staff={} other={} balances={}", "\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} other={} balances={}",
self.counts.total, self.counts.total,
self.counts.player_cards, self.counts.player_cards,
self.counts.consumables, self.counts.consumables,
@@ -615,50 +713,48 @@ impl std::fmt::Display for Report {
self.counts.other, self.counts.other,
self.counts.balances() self.counts.balances()
)?; )?;
writeln!(f, "\nPLAYER DEFINITIONS (card_id = fifa17_<resourceId>)")?;
writeln!( writeln!(
f, f,
" supported={} (base={} versioned={}) unsupported={} conflicts={}", "\nPLAYER DEFINITIONS (fifa17_<resourceId>) supported={} (base={} versioned={}) deferred_defs={} conflicts={}",
d.supported.len(), d.supported.len(),
d.base_defs, d.base_defs,
d.versioned_defs, d.versioned_defs,
d.unsupported.len(), d.deferred.len(),
d.conflicts.len() d.conflicts.len()
)?; )?;
for (rid, copies, why) in &d.unsupported { for dd in &d.deferred {
writeln!( writeln!(
f, f,
" UNSUPPORTED resourceId={rid} copies={copies}: {why:?}" " DEFER resourceId={} copies={} {:?}",
dd.resource_id,
dd.wire_ids.len(),
dd.reason
)?; )?;
} }
for c in &d.conflicts { for c in &d.conflicts {
writeln!( writeln!(
f, f,
" CONFLICT resourceId={} wire_ids={:?} distinct_defs={}", " CONFLICT resourceId={} copies={} distinct={} approved_defer={}",
c.resource_id, c.resource_id,
c.wire_ids, c.wire_ids.len(),
c.distinct.len() c.distinct.len(),
c.approved_defer
)?; )?;
} }
writeln!(f, "\nIDENTITY (preserve existing Python wire ids)")?;
writeln!( writeln!(
f, f,
" import_instances={} live_min={:?} live_max={:?} duplicates={}", "\nIDENTITY import_instances={} live=[{:?}..{:?}] duplicates={} watermark={} next_alloc={}",
id.import_wire_ids.len(), id.import_wire_ids.len(),
id.live_min, id.live_min,
id.live_max, 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 { if sq.present {
writeln!( writeln!(
f, f,
" formation={} occupied={} supported={} unsupported={} captain={:?} manager_refs={:?}", "\nACTIVE SQUAD formation={} occupied={} supported={} unsupported={} captain={:?} manager={:?}",
sq.formation, sq.formation,
sq.occupied_slots, sq.occupied_slots,
sq.supported_slots, sq.supported_slots,
@@ -670,16 +766,15 @@ impl std::fmt::Display for Report {
writeln!(f, " UNSUPPORTED slot index={idx} wire_id={wid}")?; writeln!(f, " UNSUPPORTED slot index={idx} wire_id={wid}")?;
} }
} else { } else {
writeln!(f, " (no squad in profile)")?; writeln!(f, "\nACTIVE SQUAD (none)")?;
} }
writeln!(f, "\nRESULT")?;
writeln!( writeln!(
f, 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(), id.import_wire_ids.len(),
self.deferred_instances(),
self.counts.consumables, self.counts.consumables,
self.counts.staff, self.counts.staff
d.unsupported.len()
)?; )?;
let blockers = self.blockers(); let blockers = self.blockers();
if blockers.is_empty() { 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<EmitSummary> {
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<serde_json::Value> = 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<serde_json::Value> = 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<serde_json::Value> = 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<T: serde::Serialize>(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)] #[cfg(test)]
mod tests; mod tests;
+65 -18
View File
@@ -1,12 +1,19 @@
//! `openfut-import-fifa17` — real FIFA 17 profile import. //! `openfut-import-fifa17` — real FIFA 17 profile import.
//! //!
//! This phase implements the **read-only dry-run** only. `--apply` and //! This phase implements the read-only analysis and `--emit-content` (public
//! `--emit-content` are recognised but deliberately refuse to run (and exit //! content pack + host catalog + private manifest). `--apply` is recognised but
//! nonzero) so nothing is written before the apply/emission phase lands. //! deliberately refuses (and exits nonzero) so no Core DB / identity-store
//! writes happen before the apply phase lands.
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::collections::BTreeSet;
use std::path::Path;
use std::process::ExitCode; use std::process::ExitCode;
use openfut_import_fifa17::{
analyze, emit_content, fingerprint, load_roster, model::Profile, Entities,
};
fn main() -> ExitCode { fn main() -> ExitCode {
match run() { match run() {
Ok(code) => code, Ok(code) => code,
@@ -19,33 +26,44 @@ fn main() -> ExitCode {
fn print_help() { fn print_help() {
eprintln!( eprintln!(
"openfut-import-fifa17 --profile <fifa17_profile.json> [--roster <roster.json>]\n\ "openfut-import-fifa17 --profile <fifa17_profile.json> [options]\n\
\n\ \n\
Read-only dry-run: analyses a real FIFA 17 Python profile for a faithful\n\ Read-only analysis of a real FIFA 17 Python profile for a faithful Core\n\
OpenFUT Core import and reports item accounting, observed definitions,\n\ import, with optional public content emission. Exits nonzero on unsafe blockers.\n\
identity preservation, and squad coverage. Exits nonzero on unsafe blockers.\n\
\n\ \n\
--profile <path> (required) source fifa17_profile.json\n\ --profile <path> (required) source fifa17_profile.json\n\
--roster <path> roster.json for player names (default fifa17-recon/data/roster.json)\n\ --roster <path> roster.json for names (default fifa17-recon/data/roster.json)\n\
--apply REFUSED in this phase (no writes)\n\ --tables <dir> tables dir (default fifa17-recon/data/tables)\n\
--emit-content <dir> REFUSED in this phase (no generated content)\n" --defer-conflict <rid> explicitly approve deferring a reviewed resourceId conflict (repeatable)\n\
--emit-content <dir> write the production content pack + host catalog + private manifest\n\
--apply REFUSED in this phase (no Core/identity writes)\n"
); );
} }
fn run() -> Result<ExitCode> { fn run() -> Result<ExitCode> {
let mut profile_path: Option<String> = None; let mut profile_path: Option<String> = None;
let mut roster_path = "fifa17-recon/data/roster.json".to_string(); 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<String> = None;
let mut approved: BTreeSet<i64> = BTreeSet::new();
let mut args = std::env::args().skip(1); let mut args = std::env::args().skip(1);
while let Some(a) = args.next() { while let Some(a) = args.next() {
match a.as_str() { match a.as_str() {
"--profile" => profile_path = Some(args.next().context("--profile needs a path")?), "--profile" => profile_path = Some(args.next().context("--profile needs a path")?),
"--roster" => roster_path = args.next().context("--roster 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::<i64>()
.with_context(|| format!("--defer-conflict {rid} is not an integer"))?,
);
}
"--dry-run" => {} "--dry-run" => {}
"--apply" => bail!( "--apply" => bail!(
"--apply is not implemented in this phase (dry-run + identity import API only); refusing to write the Core DB or identity store" "--apply is not implemented in this phase; 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" => { "-h" | "--help" => {
print_help(); print_help();
@@ -56,13 +74,42 @@ fn run() -> Result<ExitCode> {
} }
let profile_path = profile_path.context("--profile <path> is required (try --help)")?; let profile_path = profile_path.context("--profile <path> is required (try --help)")?;
let profile = openfut_import_fifa17::load_profile(&profile_path)?; let raw =
let roster = openfut_import_fifa17::load_roster(&roster_path)?; std::fs::read(&profile_path).with_context(|| format!("reading profile {profile_path}"))?;
let report = openfut_import_fifa17::analyze(&profile, &roster); 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}"); print!("{report}");
if let Some(dir) = &emit_dir {
if report.has_blockers() { if report.has_blockers() {
eprintln!("import-fifa17: DRY-RUN FAILED — unsafe blockers present (see above)"); 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: FAILED — unsafe blockers present (see above)");
Ok(ExitCode::FAILURE) Ok(ExitCode::FAILURE)
} else { } else {
Ok(ExitCode::SUCCESS) Ok(ExitCode::SUCCESS)
+184 -96
View File
@@ -1,5 +1,6 @@
use super::*; use super::*;
use model::Profile; use model::Profile;
use std::collections::{BTreeMap, BTreeSet};
const VER5_176580: i64 = (5 << 24) | 176580; // versioned resourceId for asset 176580 const VER5_176580: i64 = (5 << 24) | 176580; // versioned resourceId for asset 176580
@@ -14,12 +15,28 @@ fn roster() -> Roster {
.unwrap() .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<i64> {
BTreeSet::new()
}
fn attrs() -> String { fn attrs() -> String {
r#"[{"index":0,"value":90},{"index":1,"value":91},{"index":2,"value":82}, 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}]"# {"index":3,"value":88},{"index":4,"value":30},{"index":5,"value":78}]"#
.to_string() .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 { fn player(id: i64, resource: i64, asset: i64, rating: i64) -> String {
format!( format!(
r#"{{"id":{id},"resourceId":{resource},"assetId":{asset},"itemType":"player", 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() Profile::from_json_str(&json).unwrap()
} }
fn defs(items: &[String]) -> DefinitionPlan {
plan_definitions(
&profile(items, "[]", 100000500),
&roster(),
&entities(),
&none(),
)
}
#[test] #[test]
fn classification_balances_across_disjoint_classes() { fn classification_balances_across_disjoint_classes() {
let items = vec![ let items = vec![
player(100000001, 20801, 20801, 94), // base player player(100000001, 20801, 20801, 94),
player(100000002, VER5_176580, 176580, 92), // versioned player player(100000002, VER5_176580, 176580, 92),
// consumable: player itemType, NO attributeList
r#"{"id":100000239,"resourceId":5003012,"assetId":5003012,"itemType":"player","rating":85}"#.to_string(), r#"{"id":100000239,"resourceId":5003012,"assetId":5003012,"itemType":"player","rating":85}"#.to_string(),
// staff
r#"{"id":100000427,"resourceId":3000083,"itemType":"staff"}"#.to_string(), r#"{"id":100000427,"resourceId":3000083,"itemType":"staff"}"#.to_string(),
]; ];
let p = profile(&items, "[]", 100000500); let c = count_items(&profile(&items, "[]", 100000500));
let c = count_items(&p); assert_eq!(
assert_eq!(c.total, 4); (c.total, c.player_cards, c.consumables, c.staff, c.other),
assert_eq!(c.player_cards, 2); (4, 2, 1, 1, 0)
assert_eq!(c.consumables, 1); );
assert_eq!(c.staff, 1);
assert_eq!(c.other, 0);
assert!(c.balances()); assert!(c.balances());
} }
#[test] #[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(20801), 0);
assert_eq!(version_of(VER5_176580), 5); assert_eq!(version_of(VER5_176580), 5);
let p = profile( let plan = defs(&[player(100000002, VER5_176580, 176580, 92)]);
&[player(100000002, VER5_176580, 176580, 92)],
"[]",
100000500,
);
let plan = plan_definitions(&p, &roster());
assert_eq!(plan.supported.len(), 1); assert_eq!(plan.supported.len(), 1);
let d = &plan.supported[0]; let d = &plan.supported[0];
assert_eq!(d.card_id, format!("fifa17_{VER5_176580}")); assert_eq!(d.card_id, format!("fifa17_{VER5_176580}"));
assert_eq!(d.version, 5); assert_eq!(d.version, 5);
assert_eq!(d.asset_id, 176580); 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] #[test]
fn base_and_versioned_are_distinct_definitions() { fn base_and_versioned_are_distinct_definitions() {
// same base asset (176580), base + version 5 => two distinct card_ids let plan = defs(&[
let items = vec![ player(100000010, 176580, 176580, 92),
player(100000010, 176580, 176580, 92), // base player(100000011, VER5_176580, 176580, 92),
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.supported.len(), 2, "base and special never collapse");
assert_eq!(plan.base_defs, 1); assert_eq!((plan.base_defs, plan.versioned_defs), (1, 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] #[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![ let items = vec![
player(100000001, 20801, 20801, 94), 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); // unapproved: a hard blocker
let plan = plan_definitions(&p, &roster()); let rep = analyze(
assert_eq!( &profile(&items, "[]", 100000500),
plan.supported.len(), &roster(),
1, &entities(),
"one definition for the shared resource" &none(),
); );
assert_eq!(plan.supported[0].owned_copies, 2); assert!(rep.definitions.supported.is_empty());
let idp = plan_identity(&p, &[20801]); assert_eq!(rep.definitions.conflicts.len(), 1);
assert_eq!(idp.import_wire_ids, vec![100000001, 100000015]); assert!(!rep.definitions.conflicts[0].approved_defer);
assert!(idp.duplicate_wire_ids.is_empty()); assert!(rep.has_blockers(), "a NEW conflict must block");
} // approved: deferred, not blocking
let ok = analyze(
#[test] &profile(&items, "[]", 100000500),
fn conflicting_same_resource_metadata_hard_fails() { &roster(),
// two copies of resourceId 20801 disagree on rating => conflict, no winner &entities(),
let items = vec![ &BTreeSet::from([20801]),
player(100000001, 20801, 20801, 94), );
player(100000002, 20801, 20801, 93), assert!(ok.definitions.conflicts[0].approved_defer);
];
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!( assert!(
rep.has_blockers(), !ok.has_blockers(),
"a definition conflict is an unsafe blocker" "an approved-deferred conflict does not block"
);
assert_eq!(
ok.deferred_instances(),
2,
"both copies counted as deferred"
); );
} }
#[test] #[test]
fn version_formula_violation_is_unsupported_not_fabricated() { fn version_formula_violation_defers_not_fabricates() {
// resourceId != (version<<24)|assetId (version 0 but resource != asset) let plan = defs(&[player(100000003, 999, 176580, 92)]); // 999 != (0<<24)|176580
let items = vec![player(100000003, 999, 176580, 92)];
let plan = plan_definitions(&profile(&items, "[]", 100000500), &roster());
assert!(plan.supported.is_empty()); assert!(plan.supported.is_empty());
assert!(matches!( assert!(matches!(
plan.unsupported.first(), plan.deferred.first().map(|d| &d.reason),
Some((_, _, Unsupported::VersionFormula { .. })) Some(Unsupported::VersionFormula { .. })
)); ));
} }
#[test] #[test]
fn missing_roster_name_is_unsupported_never_faked() { fn missing_roster_name_defers_never_faked() {
// asset 777777 is not in the roster => cannot honestly name it let plan = defs(&[player(100000004, 777777, 777777, 80)]); // 777777 not in roster
let items = vec![player(100000004, 777777, 777777, 80)];
let plan = plan_definitions(&profile(&items, "[]", 100000500), &roster());
assert!(plan.supported.is_empty()); assert!(plan.supported.is_empty());
assert!(matches!( assert!(matches!(
plan.unsupported.first(), plan.deferred.first().map(|d| &d.reason),
Some((_, _, Unsupported::NoName { asset_id: 777777 })) 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] #[test]
fn identity_preserves_watermark_over_live_max() { fn identity_preserves_watermark_over_live_max() {
// live ids up to 100004605, watermark (nextItemId) 100004617 above it
let items = vec![ let items = vec![
player(100000001, 20801, 20801, 94), player(100000001, 20801, 20801, 94),
player(100004605, 176580, 176580, 92), player(100004605, 176580, 176580, 92),
]; ];
let p = profile(&items, "[]", 100004617); let idp = plan_identity(
let idp = plan_identity(&p, &[20801, 176580]); &profile(&items, "[]", 100004617),
assert_eq!(idp.live_min, Some(100000001)); &BTreeSet::from([20801, 176580]),
assert_eq!(idp.live_max, Some(100004605)); );
assert_eq!(
(idp.live_min, idp.live_max),
(Some(100000001), Some(100004605))
);
assert_eq!(idp.source_watermark, 100004617); assert_eq!(idp.source_watermark, 100004617);
// next allocation is the source next-to-issue, NOT live_max+1 (=100004606) assert_eq!(
assert_eq!(idp.next_allocation, 100004617); idp.next_allocation, 100004617,
"next-to-issue, not live_max+1"
);
} }
#[test] #[test]
fn squad_coverage_flags_unsupported_starter_and_keeps_manager_ref() { fn squad_coverage_flags_unsupported_starter_and_keeps_manager() {
let items = vec![ let items = vec![
player(100000001, 20801, 20801, 94), player(100000001, 20801, 20801, 94),
player(100000002, 176580, 176580, 92), 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#"[{ let squads = r#"[{
"formation":"f433","squadName":"OpenFUT","captain":100000001, "formation":"f433","squadName":"OpenFUT","captain":100000001,
"manager":[{"id":100000427,"dream":false}], "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":0,"itemData":{"id":100000001},"kitNumber":7},
{"index":1,"itemData":{"id":999999999},"kitNumber":9}, {"index":1,"itemData":{"id":999999999},"kitNumber":9},
{"index":2,"itemData":{"id":0},"kitNumber":0} {"index":2,"itemData":{"id":0},"kitNumber":0}
] ]}]"#;
}]"#; let rep = analyze(
let p = profile(&items, squads, 100000500); &profile(&items, squads, 100000500),
let rep = analyze(&p, &roster()); &roster(),
&entities(),
&none(),
);
let sq = &rep.squad; let sq = &rep.squad;
assert!(sq.present); assert_eq!((sq.occupied_slots, sq.supported_slots), (2, 1));
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.unsupported_slots, vec![(1, 999999999)]);
assert_eq!(sq.manager_wire_ids, vec![100000427]); assert_eq!(sq.manager_wire_ids, vec![100000427]);
assert_eq!(sq.captain_wire_id, Some(100000001)); assert_eq!(sq.captain_wire_id, Some(100000001));
assert!(rep.has_blockers(), "an unsupported squad starter blocks"); assert!(rep.has_blockers());
} }
#[test] #[test]
@@ -207,14 +248,14 @@ fn analyze_is_deterministic() {
player(100000001, 20801, 20801, 94), player(100000001, 20801, 20801, 94),
]; ];
let p = profile(&items, "[]", 100000500); let p = profile(&items, "[]", 100000500);
let a = analyze(&p, &roster()); let a = analyze(&p, &roster(), &entities(), &none());
let b = analyze(&p, &roster()); let b = analyze(&p, &roster(), &entities(), &none());
assert_eq!(a.definitions.supported, b.definitions.supported); assert_eq!(a.definitions.supported, b.definitions.supported);
assert_eq!(a.identity.import_wire_ids, b.identity.import_wire_ids); assert_eq!(a.identity.import_wire_ids, b.identity.import_wire_ids);
} }
#[test] #[test]
fn clean_profile_has_no_blockers_and_defers_non_players() { fn emit_content_writes_public_pack_catalog_and_private_manifest() {
let items = vec![ let items = vec![
player(100000001, 20801, 20801, 94), player(100000001, 20801, 20801, 94),
player(100000002, VER5_176580, 176580, 92), 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, let squads = r#"[{"formation":"f433","squadName":"OpenFUT","captain":100000001,
"manager":[{"id":100000427}], "manager":[{"id":100000427}],
"players":[{"index":0,"itemData":{"id":100000001}},{"index":1,"itemData":{"id":100000002}}]}]"#; "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!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
assert_eq!(rep.identity.import_wire_ids.len(), 2); // players only
assert_eq!(rep.counts.consumables, 1); let dir = tempfile::tempdir().unwrap();
assert_eq!(rep.counts.staff, 1); 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());
} }