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:
Generated
+1
@@ -3220,6 +3220,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "openfut-import-fifa17"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Evidence-driven import of a real FIFA 17 Python profile into OpenFUT Core: read-only dry-run analysis (item accounting, observed-definition generation with a consistency gate, owned-instance identity preservation, squad coverage). Writes nothing without an explicit apply/emit phase."
|
||||
description = "Evidence-driven import of a real FIFA 17 Python profile into OpenFUT Core: read-only dry-run analysis (item accounting, observed-definition generation with a consistency gate, owned-instance identity preservation, squad coverage) plus public content-pack / host-catalog emission and a private import manifest. Writes no Core/identity state without an explicit apply phase."
|
||||
|
||||
[[bin]]
|
||||
name = "openfut-import-fifa17"
|
||||
@@ -16,3 +16,6 @@ path = "src/lib.rs"
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
+465
-200
@@ -1,38 +1,33 @@
|
||||
//! Evidence-driven import of a real FIFA 17 Python profile into OpenFUT Core.
|
||||
//!
|
||||
//! This crate is the **read-only dry-run analysis** half of the importer. It
|
||||
//! proves — before anything is written — that the migration can be done
|
||||
//! faithfully:
|
||||
//! Read-only analysis (`analyze`) + public/private content emission
|
||||
//! (`emit_content`). It proves — before anything is written — that the
|
||||
//! migration can be done faithfully, and then generates exactly the supported
|
||||
//! definitions. It never fabricates identity, never collapses a versioned card
|
||||
//! onto its base, and never picks a winner for a conflicted definition.
|
||||
//!
|
||||
//! * disjoint item-type accounting balances to the source total (a reporting
|
||||
//! typo must never become a migration assumption);
|
||||
//! * every observed player card yields an HONEST [`ObservedDefinition`] keyed by
|
||||
//! `fifa17_<resourceId>` (so a versioned/special card is a *distinct*
|
||||
//! definition from its base, never collapsed onto it), sourced from the
|
||||
//! profile's own evidence and never fabricated;
|
||||
//! * multiple owned copies of one `resourceId` agree on definition-level data —
|
||||
//! a disagreement is a HARD conflict, never silently reconciled;
|
||||
//! * disjoint item-type accounting must balance to the source total;
|
||||
//! * every supported player card yields an HONEST [`ObservedDefinition`] keyed
|
||||
//! by `fifa17_<resourceId>` (base and versioned are distinct), sourced from
|
||||
//! profile evidence: name from the roster, nation/league/club resolved from
|
||||
//! the committed tables, quality tier from the rating — never invented, and no
|
||||
//! promo/program label guessed (`rare=SP` and TOTW/… stay UNKNOWN);
|
||||
//! * multiple owned copies of one `resourceId` must agree on definition-level
|
||||
//! data — a disagreement is a HARD conflict; a specific conflict may be
|
||||
//! *explicitly* deferred (an allowlist), but a NEW one always fails;
|
||||
//! * un-nameable / unresolved / conflicted definitions are DEFERRED (recorded in
|
||||
//! the private manifest with their wire ids), never imported and never given a
|
||||
//! placeholder;
|
||||
//! * every owned instance keeps its existing Python wire id, and the source
|
||||
//! allocation watermark (`nextItemId`) is preserved so a future Rust-minted id
|
||||
//! cannot collide with a historically burned one;
|
||||
//! * the active squad's players all resolve to supported definitions.
|
||||
//!
|
||||
//! What it deliberately does NOT do: write the Core DB, the identity store, or
|
||||
//! any generated content, and it does not resolve nation/league/club numeric
|
||||
//! ids to names (that is deterministic table resolution performed in the
|
||||
//! content-emission phase). Program/promo taxonomy (TOTW/TOTS/…) and `rare=SP`
|
||||
//! semantics stay UNKNOWN — the profile evidences that a versioned card exists
|
||||
//! and its stats, not its marketing label.
|
||||
//! allocation watermark (`nextItemId`, next-to-issue) is preserved so a future
|
||||
//! Rust-minted id continues past historically burned ids.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// FIFA 17 owned-item wire-id floor (adapter policy
|
||||
/// `Fifa17WireItemIdPolicy::owned_item_base_floor`). The first ever allocation
|
||||
/// in an empty store starts here; a real import overrides it with the source
|
||||
/// watermark.
|
||||
/// FIFA 17 owned-item wire-id floor (adapter `Fifa17WireItemIdPolicy`).
|
||||
pub const OWNED_ITEM_BASE_FLOOR: i64 = 100_000_001;
|
||||
|
||||
pub mod model;
|
||||
@@ -41,9 +36,8 @@ use model::{Item, Profile};
|
||||
|
||||
// ----------------------------------------------------------------- roster
|
||||
|
||||
/// Player names keyed by base asset id, loaded from `roster.json`. Names are
|
||||
/// evidence for CardDefinitions; a card whose base asset id is absent here
|
||||
/// cannot be honestly named and is reported unsupported (never fabricated).
|
||||
/// Player names keyed by base asset id (`roster.json`). A card whose base asset
|
||||
/// id is absent here cannot be honestly named and is DEFERRED, never fabricated.
|
||||
pub struct Roster {
|
||||
names: BTreeMap<i64, String>,
|
||||
}
|
||||
@@ -89,6 +83,73 @@ pub fn load_roster(path: impl AsRef<Path>) -> Result<Roster> {
|
||||
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> {
|
||||
let raw = std::fs::read_to_string(path.as_ref())
|
||||
.with_context(|| format!("reading profile {}", path.as_ref().display()))?;
|
||||
@@ -97,16 +158,12 @@ pub fn load_profile(path: impl AsRef<Path>) -> Result<Profile> {
|
||||
|
||||
// ------------------------------------------------------------- classification
|
||||
|
||||
/// The disjoint source item classes. Every source item is exactly one of these.
|
||||
/// The disjoint source item classes. Every source item is exactly one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ItemClass {
|
||||
/// `itemType=player` with an `attributeList` — a real player card.
|
||||
PlayerCard,
|
||||
/// `itemType=player` without an `attributeList` (consumable; has amount/pile).
|
||||
Consumable,
|
||||
/// `itemType=staff` (manager/coach/etc.).
|
||||
Staff,
|
||||
/// Anything else — must be zero for a clean import.
|
||||
Other,
|
||||
}
|
||||
|
||||
@@ -124,7 +181,6 @@ pub fn classify(item: &Item) -> ItemClass {
|
||||
}
|
||||
}
|
||||
|
||||
/// Disjoint counts; `balances()` is the invariant the dry-run enforces.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ItemCounts {
|
||||
pub total: usize,
|
||||
@@ -158,10 +214,8 @@ pub fn count_items(profile: &Profile) -> ItemCounts {
|
||||
|
||||
// -------------------------------------------------------------- definitions
|
||||
|
||||
/// The definition-level (per-card, not per-instance) fields of a player card.
|
||||
/// Two owned copies of one `resourceId` MUST have equal `DefFields`; owned
|
||||
/// instance fields (wire id, contract, fitness, itemState, owners, untradeable)
|
||||
/// are deliberately excluded from this identity.
|
||||
/// Definition-level (per-card) fields. Owned copies of one `resourceId` MUST
|
||||
/// agree on these; instance fields (wire id, contract, fitness, …) are excluded.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DefFields {
|
||||
pub asset_id: Option<i64>,
|
||||
@@ -171,20 +225,33 @@ pub struct DefFields {
|
||||
pub nation: Option<i64>,
|
||||
pub league: Option<i64>,
|
||||
pub team: Option<i64>,
|
||||
/// The six attributes in `index` order (0..=5), if present.
|
||||
pub attrs: Option<Vec<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)]
|
||||
pub enum Unsupported {
|
||||
/// `resourceId != (version << 24) | assetId` — unexpected id structure.
|
||||
VersionFormula { resource_id: i64, asset_id: i64 },
|
||||
/// No player name for the base asset id in the roster (would have to fabricate).
|
||||
NoName { asset_id: i64 },
|
||||
/// A required definition field (rating/position/nation/league/team/attrs) is missing.
|
||||
MissingMetadata { fields: Vec<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
|
||||
@@ -198,19 +265,46 @@ pub struct ObservedDefinition {
|
||||
pub name: String,
|
||||
pub rating: i64,
|
||||
pub position: String,
|
||||
pub nation: String,
|
||||
pub league: String,
|
||||
pub club: String,
|
||||
pub nation_id: i64,
|
||||
pub league_id: i64,
|
||||
pub team_id: i64,
|
||||
/// pace, shooting, passing, dribbling, defending, physical (index 0..=5).
|
||||
pub attrs: [i64; 6],
|
||||
pub rareflag: i64,
|
||||
/// Owned-copy count of this exact resourceId in the source profile.
|
||||
pub owned_copies: usize,
|
||||
pub rarity: &'static str,
|
||||
/// Wire ids of the owned copies of this exact resourceId (preserved).
|
||||
pub wire_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
/// `version = resourceId >> 24`; base cards are version 0 (`resourceId == assetId`).
|
||||
pub fn version_of(resource_id: i64) -> i64 {
|
||||
resource_id >> 24
|
||||
/// A deferred definition (unsupported), with its owned wire ids for the manifest.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeferredDef {
|
||||
pub resource_id: i64,
|
||||
pub asset_id: Option<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 {
|
||||
@@ -232,29 +326,15 @@ fn def_fields(item: &Item) -> DefFields {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `resourceId` whose owned copies disagree on definition-level data — a hard
|
||||
/// conflict the importer must never silently reconcile.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DefConflict {
|
||||
pub resource_id: i64,
|
||||
pub wire_ids: Vec<i64>,
|
||||
pub distinct: Vec<DefFields>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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<i64>,
|
||||
) -> DefinitionPlan {
|
||||
let mut groups: BTreeMap<i64, Vec<&Item>> = BTreeMap::new();
|
||||
for it in &profile.items {
|
||||
if classify(it) == ItemClass::PlayerCard {
|
||||
@@ -264,7 +344,9 @@ pub fn plan_definitions(profile: &Profile, roster: &Roster) -> DefinitionPlan {
|
||||
|
||||
let mut plan = DefinitionPlan::default();
|
||||
for (resource_id, items) in groups {
|
||||
// 1) consistency gate: all copies must share definition-level data.
|
||||
let wire_ids: Vec<i64> = items.iter().map(|i| i.id).collect();
|
||||
|
||||
// 1) consistency gate.
|
||||
let first = def_fields(items[0]);
|
||||
let mut distinct = vec![first.clone()];
|
||||
for it in &items[1..] {
|
||||
@@ -276,38 +358,42 @@ pub fn plan_definitions(profile: &Profile, roster: &Roster) -> DefinitionPlan {
|
||||
if distinct.len() > 1 {
|
||||
plan.conflicts.push(DefConflict {
|
||||
resource_id,
|
||||
wire_ids: items.iter().map(|i| i.id).collect(),
|
||||
wire_ids,
|
||||
distinct,
|
||||
approved_defer: approved_conflicts.contains(&resource_id),
|
||||
});
|
||||
continue; // never pick a winner
|
||||
}
|
||||
|
||||
let owned_copies = items.len();
|
||||
let f = &first;
|
||||
let version = f.version;
|
||||
if version == 0 {
|
||||
plan.base_defs += 1;
|
||||
} else {
|
||||
plan.versioned_defs += 1;
|
||||
|
||||
// helper to record a deferral
|
||||
macro_rules! defer {
|
||||
($reason:expr) => {{
|
||||
plan.deferred.push(DeferredDef {
|
||||
resource_id,
|
||||
asset_id: f.asset_id,
|
||||
wire_ids: wire_ids.clone(),
|
||||
reason: $reason,
|
||||
});
|
||||
continue;
|
||||
}};
|
||||
}
|
||||
|
||||
// 2) honest buildability.
|
||||
let asset_id = f.asset_id;
|
||||
// version formula: resourceId == (version << 24) | assetId
|
||||
if let Some(asset_id) = asset_id {
|
||||
if resource_id != (version << 24) | asset_id {
|
||||
plan.unsupported.push((
|
||||
resource_id,
|
||||
owned_copies,
|
||||
Unsupported::VersionFormula {
|
||||
resource_id,
|
||||
asset_id,
|
||||
},
|
||||
));
|
||||
continue;
|
||||
}
|
||||
// 2) version formula.
|
||||
let Some(asset_id) = f.asset_id else {
|
||||
defer!(Unsupported::MissingMetadata {
|
||||
fields: vec!["assetId".into()]
|
||||
});
|
||||
};
|
||||
if resource_id != (f.version << 24) | asset_id {
|
||||
defer!(Unsupported::VersionFormula {
|
||||
resource_id,
|
||||
asset_id
|
||||
});
|
||||
}
|
||||
// required metadata present?
|
||||
|
||||
// 3) required metadata present?
|
||||
let mut missing = Vec::new();
|
||||
if f.rating.is_none() {
|
||||
missing.push("rating".into());
|
||||
@@ -334,75 +420,74 @@ pub fn plan_definitions(profile: &Profile, roster: &Roster) -> DefinitionPlan {
|
||||
if attrs6.is_none() {
|
||||
missing.push("attributeList[6]".into());
|
||||
}
|
||||
if asset_id.is_none() {
|
||||
missing.push("assetId".into());
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
plan.unsupported.push((
|
||||
resource_id,
|
||||
owned_copies,
|
||||
Unsupported::MissingMetadata { fields: missing },
|
||||
));
|
||||
continue;
|
||||
defer!(Unsupported::MissingMetadata { fields: missing });
|
||||
}
|
||||
let asset_id = asset_id.unwrap();
|
||||
// name: evidence, never fabricated.
|
||||
let name = match roster.name_for(asset_id) {
|
||||
Some(n) => n.to_string(),
|
||||
None => {
|
||||
plan.unsupported.push((
|
||||
resource_id,
|
||||
owned_copies,
|
||||
Unsupported::NoName { asset_id },
|
||||
));
|
||||
continue;
|
||||
let (rating, position) = (f.rating.unwrap(), f.position.clone().unwrap());
|
||||
let (nation_id, league_id, team_id) =
|
||||
(f.nation.unwrap(), f.league.unwrap(), f.team.unwrap());
|
||||
|
||||
// 4) name (roster) — evidence, never fabricated.
|
||||
let Some(name) = roster.name_for(asset_id) else {
|
||||
defer!(Unsupported::NoName { asset_id });
|
||||
};
|
||||
|
||||
// 5) entity resolution (nation/league/club names).
|
||||
let (Some(nation), Some(league), Some(club)) = (
|
||||
entities.nation(nation_id),
|
||||
entities.league(league_id),
|
||||
entities.club(team_id),
|
||||
) else {
|
||||
let mut fields = Vec::new();
|
||||
if entities.nation(nation_id).is_none() {
|
||||
fields.push(format!("nation:{nation_id}"));
|
||||
}
|
||||
if entities.league(league_id).is_none() {
|
||||
fields.push(format!("league:{league_id}"));
|
||||
}
|
||||
if entities.club(team_id).is_none() {
|
||||
fields.push(format!("team:{team_id}"));
|
||||
}
|
||||
defer!(Unsupported::UnresolvedEntity { fields });
|
||||
};
|
||||
|
||||
plan.supported.push(ObservedDefinition {
|
||||
card_id: format!("fifa17_{resource_id}"),
|
||||
resource_id,
|
||||
asset_id,
|
||||
version,
|
||||
name,
|
||||
rating: f.rating.unwrap(),
|
||||
position: f.position.clone().unwrap(),
|
||||
nation_id: f.nation.unwrap(),
|
||||
league_id: f.league.unwrap(),
|
||||
team_id: f.team.unwrap(),
|
||||
version: f.version,
|
||||
name: name.to_string(),
|
||||
rating,
|
||||
position,
|
||||
nation: nation.to_string(),
|
||||
league: league.to_string(),
|
||||
club: club.to_string(),
|
||||
nation_id,
|
||||
league_id,
|
||||
team_id,
|
||||
attrs: attrs6.unwrap(),
|
||||
rareflag: f.rareflag.unwrap_or(0),
|
||||
owned_copies,
|
||||
rarity: tier(rating),
|
||||
wire_ids,
|
||||
});
|
||||
}
|
||||
plan.base_defs = plan.supported.iter().filter(|d| d.version == 0).count();
|
||||
plan.versioned_defs = plan.supported.len() - plan.base_defs;
|
||||
plan
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- identity
|
||||
|
||||
/// The owned-instance identity plan: preserve every supported player item's
|
||||
/// existing Python wire id, and preserve the source allocation watermark.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct IdentityPlan {
|
||||
/// Wire ids of the player items that WOULD import (supported definitions).
|
||||
pub import_wire_ids: Vec<i64>,
|
||||
/// Duplicate wire ids across ALL items (must be empty — a hard conflict).
|
||||
pub duplicate_wire_ids: Vec<i64>,
|
||||
pub live_min: Option<i64>,
|
||||
pub live_max: Option<i64>,
|
||||
/// Source `nextItemId` — the next id Python would issue (issue-then-increment).
|
||||
pub source_watermark: i64,
|
||||
/// The first id a Rust-minted new item would receive after this import:
|
||||
/// `max(floor, live_max+1, watermark)`. With the watermark this equals the
|
||||
/// source `nextItemId`, never a burned-gap id.
|
||||
pub next_allocation: i64,
|
||||
}
|
||||
|
||||
pub fn plan_identity(profile: &Profile, supported_resource_ids: &[i64]) -> IdentityPlan {
|
||||
use std::collections::BTreeSet;
|
||||
let supported: BTreeSet<i64> = supported_resource_ids.iter().copied().collect();
|
||||
|
||||
// duplicate detection across ALL items
|
||||
pub fn plan_identity(profile: &Profile, supported_resource_ids: &BTreeSet<i64>) -> IdentityPlan {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut dups = BTreeSet::new();
|
||||
for it in &profile.items {
|
||||
@@ -410,11 +495,13 @@ pub fn plan_identity(profile: &Profile, supported_resource_ids: &[i64]) -> Ident
|
||||
dups.insert(it.id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut import_wire_ids: Vec<i64> = profile
|
||||
.items
|
||||
.iter()
|
||||
.filter(|it| classify(it) == ItemClass::PlayerCard && supported.contains(&it.resource_id))
|
||||
.filter(|it| {
|
||||
classify(it) == ItemClass::PlayerCard
|
||||
&& supported_resource_ids.contains(&it.resource_id)
|
||||
})
|
||||
.map(|it| it.id)
|
||||
.collect();
|
||||
import_wire_ids.sort_unstable();
|
||||
@@ -443,24 +530,18 @@ pub fn plan_identity(profile: &Profile, supported_resource_ids: &[i64]) -> Ident
|
||||
|
||||
// ----------------------------------------------------------------- squad
|
||||
|
||||
/// Coverage of the active squad: every starter/sub must resolve to a supported
|
||||
/// player definition; the manager stays an opaque staff wire ref in the squad
|
||||
/// extension (not promoted to a Core OwnedCard).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SquadCoverage {
|
||||
pub present: bool,
|
||||
pub formation: String,
|
||||
pub occupied_slots: usize,
|
||||
pub supported_slots: usize,
|
||||
/// (slot index, wire id) for slots whose item is missing/unsupported.
|
||||
pub unsupported_slots: Vec<(i64, i64)>,
|
||||
pub manager_wire_ids: Vec<i64>,
|
||||
pub captain_wire_id: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn plan_squad(profile: &Profile, supported_wire_ids: &[i64]) -> SquadCoverage {
|
||||
use std::collections::BTreeSet;
|
||||
let supported: BTreeSet<i64> = supported_wire_ids.iter().copied().collect();
|
||||
pub fn plan_squad(profile: &Profile, supported_wire_ids: &BTreeSet<i64>) -> SquadCoverage {
|
||||
let Some(sq) = profile.squads.first() else {
|
||||
return SquadCoverage::default();
|
||||
};
|
||||
@@ -479,10 +560,10 @@ pub fn plan_squad(profile: &Profile, supported_wire_ids: &[i64]) -> SquadCoverag
|
||||
for slot in &sq.players {
|
||||
let wid = slot.item_data.id;
|
||||
if wid == 0 {
|
||||
continue; // empty slot
|
||||
continue;
|
||||
}
|
||||
cov.occupied_slots += 1;
|
||||
if supported.contains(&wid) {
|
||||
if supported_wire_ids.contains(&wid) {
|
||||
cov.supported_slots += 1;
|
||||
} else {
|
||||
cov.unsupported_slots.push((slot.index, wid));
|
||||
@@ -493,7 +574,6 @@ pub fn plan_squad(profile: &Profile, supported_wire_ids: &[i64]) -> SquadCoverag
|
||||
|
||||
// ----------------------------------------------------------------- report
|
||||
|
||||
/// The full dry-run analysis.
|
||||
#[derive(Debug)]
|
||||
pub struct Report {
|
||||
pub game: String,
|
||||
@@ -509,14 +589,31 @@ pub struct Report {
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// Unsafe conditions that MUST fail the dry-run (nonzero exit). Deferred
|
||||
/// items (unsupported non-squad players, consumables, staff) are NOT here —
|
||||
/// they are reported, not blocking.
|
||||
/// Count of deferred player INSTANCES (owned copies), across unsupported
|
||||
/// definitions and (approved-)deferred conflicts.
|
||||
pub fn deferred_instances(&self) -> usize {
|
||||
let d: usize = self
|
||||
.definitions
|
||||
.deferred
|
||||
.iter()
|
||||
.map(|d| d.wire_ids.len())
|
||||
.sum();
|
||||
let c: usize = self
|
||||
.definitions
|
||||
.conflicts
|
||||
.iter()
|
||||
.map(|c| c.wire_ids.len())
|
||||
.sum();
|
||||
d + c
|
||||
}
|
||||
|
||||
/// Unsafe conditions that MUST fail (nonzero). Explicitly-deferred conflicts,
|
||||
/// NoName/unresolved/version deferrals are NOT here — they are recorded.
|
||||
pub fn blockers(&self) -> Vec<String> {
|
||||
let mut b = Vec::new();
|
||||
if !self.counts.balances() {
|
||||
b.push(format!(
|
||||
"item-type accounting does not balance: {}+{}+{}+{} != {}",
|
||||
"item-type accounting does not balance ({}+{}+{}+{} != {})",
|
||||
self.counts.player_cards,
|
||||
self.counts.consumables,
|
||||
self.counts.staff,
|
||||
@@ -525,20 +622,22 @@ impl Report {
|
||||
));
|
||||
}
|
||||
if self.counts.other > 0 {
|
||||
b.push(format!(
|
||||
"{} item(s) fall in no known class (itemType neither player nor staff)",
|
||||
self.counts.other
|
||||
));
|
||||
b.push(format!("{} item(s) in no known class", self.counts.other));
|
||||
}
|
||||
if !self.definitions.conflicts.is_empty() {
|
||||
let new_conflicts = self
|
||||
.definitions
|
||||
.conflicts
|
||||
.iter()
|
||||
.filter(|c| !c.approved_defer)
|
||||
.count();
|
||||
if new_conflicts > 0 {
|
||||
b.push(format!(
|
||||
"{} resourceId group(s) disagree on definition-level data (hard conflict)",
|
||||
self.definitions.conflicts.len()
|
||||
"{new_conflicts} NEW (unapproved) resourceId conflict(s) — pass --defer-conflict <rid> only for a reviewed one"
|
||||
));
|
||||
}
|
||||
if !self.identity.duplicate_wire_ids.is_empty() {
|
||||
b.push(format!(
|
||||
"{} duplicate owned wire id(s) — instance identity is not unique",
|
||||
"{} duplicate owned wire id(s)",
|
||||
self.identity.duplicate_wire_ids.len()
|
||||
));
|
||||
}
|
||||
@@ -557,16 +656,22 @@ impl Report {
|
||||
}
|
||||
|
||||
/// Run the full read-only analysis. Pure: no I/O, no writes.
|
||||
pub fn analyze(profile: &Profile, roster: &Roster) -> Report {
|
||||
pub fn analyze(
|
||||
profile: &Profile,
|
||||
roster: &Roster,
|
||||
entities: &Entities,
|
||||
approved_conflicts: &BTreeSet<i64>,
|
||||
) -> Report {
|
||||
let counts = count_items(profile);
|
||||
let definitions = plan_definitions(profile, roster);
|
||||
let supported_rids: Vec<i64> = definitions
|
||||
let definitions = plan_definitions(profile, roster, entities, approved_conflicts);
|
||||
let supported_rids: BTreeSet<i64> = definitions
|
||||
.supported
|
||||
.iter()
|
||||
.map(|d| d.resource_id)
|
||||
.collect();
|
||||
let identity = plan_identity(profile, &supported_rids);
|
||||
let squad = plan_squad(profile, &identity.import_wire_ids);
|
||||
let supported_wire: BTreeSet<i64> = identity.import_wire_ids.iter().copied().collect();
|
||||
let squad = plan_squad(profile, &supported_wire);
|
||||
Report {
|
||||
game: "fifa17".to_string(),
|
||||
persona_id: profile.persona_id,
|
||||
@@ -586,14 +691,8 @@ impl std::fmt::Display for Report {
|
||||
let d = &self.definitions;
|
||||
let id = &self.identity;
|
||||
let sq = &self.squad;
|
||||
writeln!(
|
||||
f,
|
||||
"OpenFUT FIFA17 real-profile import — DRY RUN (no writes)"
|
||||
)?;
|
||||
writeln!(
|
||||
f,
|
||||
"========================================================"
|
||||
)?;
|
||||
writeln!(f, "OpenFUT FIFA17 real-profile import — analysis")?;
|
||||
writeln!(f, "=============================================")?;
|
||||
writeln!(
|
||||
f,
|
||||
"TARGET game={} persona={}/{} club={}/{} coins={}",
|
||||
@@ -604,10 +703,9 @@ impl std::fmt::Display for Report {
|
||||
self.club_abbr,
|
||||
self.coins
|
||||
)?;
|
||||
writeln!(f, "\nSOURCE ITEMS (disjoint classes)")?;
|
||||
writeln!(
|
||||
f,
|
||||
" total={} player_cards={} consumables={} staff={} other={} balances={}",
|
||||
"\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} other={} balances={}",
|
||||
self.counts.total,
|
||||
self.counts.player_cards,
|
||||
self.counts.consumables,
|
||||
@@ -615,50 +713,48 @@ impl std::fmt::Display for Report {
|
||||
self.counts.other,
|
||||
self.counts.balances()
|
||||
)?;
|
||||
writeln!(f, "\nPLAYER DEFINITIONS (card_id = fifa17_<resourceId>)")?;
|
||||
writeln!(
|
||||
f,
|
||||
" supported={} (base={} versioned={}) unsupported={} conflicts={}",
|
||||
"\nPLAYER DEFINITIONS (fifa17_<resourceId>) supported={} (base={} versioned={}) deferred_defs={} conflicts={}",
|
||||
d.supported.len(),
|
||||
d.base_defs,
|
||||
d.versioned_defs,
|
||||
d.unsupported.len(),
|
||||
d.deferred.len(),
|
||||
d.conflicts.len()
|
||||
)?;
|
||||
for (rid, copies, why) in &d.unsupported {
|
||||
for dd in &d.deferred {
|
||||
writeln!(
|
||||
f,
|
||||
" UNSUPPORTED resourceId={rid} copies={copies}: {why:?}"
|
||||
" DEFER resourceId={} copies={} {:?}",
|
||||
dd.resource_id,
|
||||
dd.wire_ids.len(),
|
||||
dd.reason
|
||||
)?;
|
||||
}
|
||||
for c in &d.conflicts {
|
||||
writeln!(
|
||||
f,
|
||||
" CONFLICT resourceId={} wire_ids={:?} distinct_defs={}",
|
||||
" CONFLICT resourceId={} copies={} distinct={} approved_defer={}",
|
||||
c.resource_id,
|
||||
c.wire_ids,
|
||||
c.distinct.len()
|
||||
c.wire_ids.len(),
|
||||
c.distinct.len(),
|
||||
c.approved_defer
|
||||
)?;
|
||||
}
|
||||
writeln!(f, "\nIDENTITY (preserve existing Python wire ids)")?;
|
||||
writeln!(
|
||||
f,
|
||||
" import_instances={} live_min={:?} live_max={:?} duplicates={}",
|
||||
"\nIDENTITY import_instances={} live=[{:?}..{:?}] duplicates={} watermark={} next_alloc={}",
|
||||
id.import_wire_ids.len(),
|
||||
id.live_min,
|
||||
id.live_max,
|
||||
id.duplicate_wire_ids.len()
|
||||
id.duplicate_wire_ids.len(),
|
||||
id.source_watermark,
|
||||
id.next_allocation
|
||||
)?;
|
||||
writeln!(
|
||||
f,
|
||||
" source_watermark(nextItemId)={} => next_rust_allocation={}",
|
||||
id.source_watermark, id.next_allocation
|
||||
)?;
|
||||
writeln!(f, "\nACTIVE SQUAD")?;
|
||||
if sq.present {
|
||||
writeln!(
|
||||
f,
|
||||
" formation={} occupied={} supported={} unsupported={} captain={:?} manager_refs={:?}",
|
||||
"\nACTIVE SQUAD formation={} occupied={} supported={} unsupported={} captain={:?} manager={:?}",
|
||||
sq.formation,
|
||||
sq.occupied_slots,
|
||||
sq.supported_slots,
|
||||
@@ -670,16 +766,15 @@ impl std::fmt::Display for Report {
|
||||
writeln!(f, " UNSUPPORTED slot index={idx} wire_id={wid}")?;
|
||||
}
|
||||
} else {
|
||||
writeln!(f, " (no squad in profile)")?;
|
||||
writeln!(f, "\nACTIVE SQUAD (none)")?;
|
||||
}
|
||||
writeln!(f, "\nRESULT")?;
|
||||
writeln!(
|
||||
f,
|
||||
" would_import_players={} deferred_consumables={} deferred_staff={} unsupported_players(defs)={}",
|
||||
"\nRESULT would_import_players={} deferred_player_instances={} deferred_consumables={} deferred_staff={}",
|
||||
id.import_wire_ids.len(),
|
||||
self.deferred_instances(),
|
||||
self.counts.consumables,
|
||||
self.counts.staff,
|
||||
d.unsupported.len()
|
||||
self.counts.staff
|
||||
)?;
|
||||
let blockers = self.blockers();
|
||||
if blockers.is_empty() {
|
||||
@@ -694,5 +789,175 @@ impl std::fmt::Display for Report {
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- emit
|
||||
|
||||
/// Dependency-free 64-bit FNV-1a hex fingerprint of the source snapshot (a
|
||||
/// provenance handle, not a cryptographic digest).
|
||||
pub fn fingerprint(bytes: &[u8]) -> String {
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
for &b in bytes {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
format!("{h:016x}")
|
||||
}
|
||||
|
||||
/// What `emit_content` wrote.
|
||||
#[derive(Debug)]
|
||||
pub struct EmitSummary {
|
||||
pub content_pack: std::path::PathBuf,
|
||||
pub host_catalog: std::path::PathBuf,
|
||||
pub manifest: std::path::PathBuf,
|
||||
pub definitions: usize,
|
||||
pub catalog_entries: usize,
|
||||
pub supported_instances: usize,
|
||||
pub deferred_instances: usize,
|
||||
}
|
||||
|
||||
/// Emit the PUBLIC content pack + host catalog for supported definitions, and a
|
||||
/// PRIVATE import manifest (wire ids + deferred set + watermark + target). Never
|
||||
/// writes Core or the identity store. Refuses if the report has blockers.
|
||||
pub fn emit_content(
|
||||
report: &Report,
|
||||
out_dir: &Path,
|
||||
snapshot_fingerprint: &str,
|
||||
) -> Result<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)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
//! `openfut-import-fifa17` — real FIFA 17 profile import.
|
||||
//!
|
||||
//! This phase implements the **read-only dry-run** only. `--apply` and
|
||||
//! `--emit-content` are recognised but deliberately refuse to run (and exit
|
||||
//! nonzero) so nothing is written before the apply/emission phase lands.
|
||||
//! This phase implements the read-only analysis and `--emit-content` (public
|
||||
//! content pack + host catalog + private manifest). `--apply` is recognised but
|
||||
//! deliberately refuses (and exits nonzero) so no Core DB / identity-store
|
||||
//! writes happen before the apply phase lands.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use openfut_import_fifa17::{
|
||||
analyze, emit_content, fingerprint, load_roster, model::Profile, Entities,
|
||||
};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(code) => code,
|
||||
@@ -19,33 +26,44 @@ fn main() -> ExitCode {
|
||||
|
||||
fn print_help() {
|
||||
eprintln!(
|
||||
"openfut-import-fifa17 --profile <fifa17_profile.json> [--roster <roster.json>]\n\
|
||||
"openfut-import-fifa17 --profile <fifa17_profile.json> [options]\n\
|
||||
\n\
|
||||
Read-only dry-run: analyses a real FIFA 17 Python profile for a faithful\n\
|
||||
OpenFUT Core import and reports item accounting, observed definitions,\n\
|
||||
identity preservation, and squad coverage. Exits nonzero on unsafe blockers.\n\
|
||||
Read-only analysis of a real FIFA 17 Python profile for a faithful Core\n\
|
||||
import, with optional public content emission. Exits nonzero on unsafe blockers.\n\
|
||||
\n\
|
||||
--profile <path> (required) source fifa17_profile.json\n\
|
||||
--roster <path> roster.json for player names (default fifa17-recon/data/roster.json)\n\
|
||||
--apply REFUSED in this phase (no writes)\n\
|
||||
--emit-content <dir> REFUSED in this phase (no generated content)\n"
|
||||
--profile <path> (required) source fifa17_profile.json\n\
|
||||
--roster <path> roster.json for names (default fifa17-recon/data/roster.json)\n\
|
||||
--tables <dir> tables dir (default fifa17-recon/data/tables)\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> {
|
||||
let mut profile_path: Option<String> = None;
|
||||
let mut roster_path = "fifa17-recon/data/roster.json".to_string();
|
||||
let mut tables_dir = "fifa17-recon/data/tables".to_string();
|
||||
let mut emit_dir: Option<String> = None;
|
||||
let mut approved: BTreeSet<i64> = BTreeSet::new();
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--profile" => profile_path = Some(args.next().context("--profile needs a path")?),
|
||||
"--roster" => roster_path = args.next().context("--roster needs a path")?,
|
||||
"--tables" => tables_dir = args.next().context("--tables needs a dir")?,
|
||||
"--emit-content" => emit_dir = Some(args.next().context("--emit-content needs a dir")?),
|
||||
"--defer-conflict" => {
|
||||
let rid = args.next().context("--defer-conflict needs a resourceId")?;
|
||||
approved.insert(
|
||||
rid.parse::<i64>()
|
||||
.with_context(|| format!("--defer-conflict {rid} is not an integer"))?,
|
||||
);
|
||||
}
|
||||
"--dry-run" => {}
|
||||
"--apply" => bail!(
|
||||
"--apply is not implemented in this phase (dry-run + identity import API only); refusing to write the Core DB or identity store"
|
||||
),
|
||||
"--emit-content" => bail!(
|
||||
"--emit-content is not implemented in this phase; refusing to write generated content"
|
||||
"--apply is not implemented in this phase; refusing to write the Core DB or identity store"
|
||||
),
|
||||
"-h" | "--help" => {
|
||||
print_help();
|
||||
@@ -56,13 +74,42 @@ fn run() -> Result<ExitCode> {
|
||||
}
|
||||
let profile_path = profile_path.context("--profile <path> is required (try --help)")?;
|
||||
|
||||
let profile = openfut_import_fifa17::load_profile(&profile_path)?;
|
||||
let roster = openfut_import_fifa17::load_roster(&roster_path)?;
|
||||
let report = openfut_import_fifa17::analyze(&profile, &roster);
|
||||
let raw =
|
||||
std::fs::read(&profile_path).with_context(|| format!("reading profile {profile_path}"))?;
|
||||
let profile = Profile::from_json_str(&String::from_utf8_lossy(&raw))?;
|
||||
let roster = load_roster(&roster_path)?;
|
||||
let entities = Entities::from_tables_dir(&tables_dir)?;
|
||||
|
||||
let report = analyze(&profile, &roster, &entities, &approved);
|
||||
print!("{report}");
|
||||
|
||||
if let Some(dir) = &emit_dir {
|
||||
if report.has_blockers() {
|
||||
bail!("not emitting content: unsafe blockers present (resolve them first)");
|
||||
}
|
||||
let fp = fingerprint(&raw);
|
||||
let sum = emit_content(&report, Path::new(dir), &fp)?;
|
||||
println!("\nEMITTED (fingerprint {fp})");
|
||||
println!(
|
||||
" content pack : {} ({} definitions)",
|
||||
sum.content_pack.display(),
|
||||
sum.definitions
|
||||
);
|
||||
println!(
|
||||
" host catalog : {} ({} entries)",
|
||||
sum.host_catalog.display(),
|
||||
sum.catalog_entries
|
||||
);
|
||||
println!(
|
||||
" manifest : {} (supported_instances={} deferred_instances={})",
|
||||
sum.manifest.display(),
|
||||
sum.supported_instances,
|
||||
sum.deferred_instances
|
||||
);
|
||||
}
|
||||
|
||||
if report.has_blockers() {
|
||||
eprintln!("import-fifa17: DRY-RUN FAILED — unsafe blockers present (see above)");
|
||||
eprintln!("import-fifa17: FAILED — unsafe blockers present (see above)");
|
||||
Ok(ExitCode::FAILURE)
|
||||
} else {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use model::Profile;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const VER5_176580: i64 = (5 << 24) | 176580; // versioned resourceId for asset 176580
|
||||
|
||||
@@ -14,12 +15,28 @@ fn roster() -> Roster {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn entities() -> Entities {
|
||||
Entities::from_maps(
|
||||
BTreeMap::from([(53, "LaLiga".to_string()), (16, "Serie A".to_string())]),
|
||||
BTreeMap::from([(38, "Portugal".to_string()), (45, "Spain".to_string())]),
|
||||
BTreeMap::from([
|
||||
(243, "Real Madrid".to_string()),
|
||||
(21, "Juventus".to_string()),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
fn none() -> BTreeSet<i64> {
|
||||
BTreeSet::new()
|
||||
}
|
||||
|
||||
fn attrs() -> String {
|
||||
r#"[{"index":0,"value":90},{"index":1,"value":91},{"index":2,"value":82},
|
||||
{"index":3,"value":88},{"index":4,"value":30},{"index":5,"value":78}]"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A resolvable player (nation 38, team 243, league 53 all in `entities()`).
|
||||
fn player(id: i64, resource: i64, asset: i64, rating: i64) -> String {
|
||||
format!(
|
||||
r#"{{"id":{id},"resourceId":{resource},"assetId":{asset},"itemType":"player",
|
||||
@@ -39,145 +56,169 @@ fn profile(items: &[String], squads: &str, next_item_id: i64) -> Profile {
|
||||
Profile::from_json_str(&json).unwrap()
|
||||
}
|
||||
|
||||
fn defs(items: &[String]) -> DefinitionPlan {
|
||||
plan_definitions(
|
||||
&profile(items, "[]", 100000500),
|
||||
&roster(),
|
||||
&entities(),
|
||||
&none(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_balances_across_disjoint_classes() {
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94), // base player
|
||||
player(100000002, VER5_176580, 176580, 92), // versioned player
|
||||
// consumable: player itemType, NO attributeList
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100000002, VER5_176580, 176580, 92),
|
||||
r#"{"id":100000239,"resourceId":5003012,"assetId":5003012,"itemType":"player","rating":85}"#.to_string(),
|
||||
// staff
|
||||
r#"{"id":100000427,"resourceId":3000083,"itemType":"staff"}"#.to_string(),
|
||||
];
|
||||
let p = profile(&items, "[]", 100000500);
|
||||
let c = count_items(&p);
|
||||
assert_eq!(c.total, 4);
|
||||
assert_eq!(c.player_cards, 2);
|
||||
assert_eq!(c.consumables, 1);
|
||||
assert_eq!(c.staff, 1);
|
||||
assert_eq!(c.other, 0);
|
||||
let c = count_items(&profile(&items, "[]", 100000500));
|
||||
assert_eq!(
|
||||
(c.total, c.player_cards, c.consumables, c.staff, c.other),
|
||||
(4, 2, 1, 1, 0)
|
||||
);
|
||||
assert!(c.balances());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_formula_and_card_id() {
|
||||
fn version_formula_card_id_and_resolved_names() {
|
||||
assert_eq!(version_of(20801), 0);
|
||||
assert_eq!(version_of(VER5_176580), 5);
|
||||
let p = profile(
|
||||
&[player(100000002, VER5_176580, 176580, 92)],
|
||||
"[]",
|
||||
100000500,
|
||||
);
|
||||
let plan = plan_definitions(&p, &roster());
|
||||
let plan = defs(&[player(100000002, VER5_176580, 176580, 92)]);
|
||||
assert_eq!(plan.supported.len(), 1);
|
||||
let d = &plan.supported[0];
|
||||
assert_eq!(d.card_id, format!("fifa17_{VER5_176580}"));
|
||||
assert_eq!(d.version, 5);
|
||||
assert_eq!(d.asset_id, 176580);
|
||||
assert_eq!(d.name, "Luis Suárez"); // base-asset name, not fabricated
|
||||
assert_eq!(d.name, "Luis Suárez"); // base-asset name, never fabricated
|
||||
assert_eq!(
|
||||
(d.nation.as_str(), d.league.as_str(), d.club.as_str()),
|
||||
("Portugal", "LaLiga", "Real Madrid")
|
||||
);
|
||||
assert_eq!(d.rarity, "gold"); // rating 92 -> tier, NOT a promo label
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_and_versioned_are_distinct_definitions() {
|
||||
// same base asset (176580), base + version 5 => two distinct card_ids
|
||||
let items = vec![
|
||||
player(100000010, 176580, 176580, 92), // base
|
||||
player(100000011, VER5_176580, 176580, 92), // versioned
|
||||
];
|
||||
let plan = plan_definitions(&profile(&items, "[]", 100000500), &roster());
|
||||
let plan = defs(&[
|
||||
player(100000010, 176580, 176580, 92),
|
||||
player(100000011, VER5_176580, 176580, 92),
|
||||
]);
|
||||
assert_eq!(plan.supported.len(), 2, "base and special never collapse");
|
||||
assert_eq!(plan.base_defs, 1);
|
||||
assert_eq!(plan.versioned_defs, 1);
|
||||
let ids: Vec<&str> = plan.supported.iter().map(|d| d.card_id.as_str()).collect();
|
||||
assert!(ids.contains(&"fifa17_176580"));
|
||||
assert!(ids.contains(&format!("fifa17_{VER5_176580}").as_str()));
|
||||
assert_eq!((plan.base_defs, plan.versioned_defs), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_copies_share_definition_but_preserve_distinct_wire_ids() {
|
||||
fn duplicate_copies_share_definition_but_keep_distinct_wire_ids() {
|
||||
let plan = defs(&[
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100000015, 20801, 20801, 94),
|
||||
]);
|
||||
assert_eq!(plan.supported.len(), 1);
|
||||
assert_eq!(plan.supported[0].wire_ids, vec![100000001, 100000015]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_conflict_blocks_but_approved_conflict_defers() {
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100000015, 20801, 20801, 94), // second owned copy
|
||||
player(100000002, 20801, 20801, 93), // disagree on rating
|
||||
];
|
||||
let p = profile(&items, "[]", 100000500);
|
||||
let plan = plan_definitions(&p, &roster());
|
||||
assert_eq!(
|
||||
plan.supported.len(),
|
||||
1,
|
||||
"one definition for the shared resource"
|
||||
// unapproved: a hard blocker
|
||||
let rep = analyze(
|
||||
&profile(&items, "[]", 100000500),
|
||||
&roster(),
|
||||
&entities(),
|
||||
&none(),
|
||||
);
|
||||
assert_eq!(plan.supported[0].owned_copies, 2);
|
||||
let idp = plan_identity(&p, &[20801]);
|
||||
assert_eq!(idp.import_wire_ids, vec![100000001, 100000015]);
|
||||
assert!(idp.duplicate_wire_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_same_resource_metadata_hard_fails() {
|
||||
// two copies of resourceId 20801 disagree on rating => conflict, no winner
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100000002, 20801, 20801, 93),
|
||||
];
|
||||
let p = profile(&items, "[]", 100000500);
|
||||
let plan = plan_definitions(&p, &roster());
|
||||
assert!(plan.supported.is_empty());
|
||||
assert_eq!(plan.conflicts.len(), 1);
|
||||
let rep = analyze(&p, &roster());
|
||||
assert!(rep.definitions.supported.is_empty());
|
||||
assert_eq!(rep.definitions.conflicts.len(), 1);
|
||||
assert!(!rep.definitions.conflicts[0].approved_defer);
|
||||
assert!(rep.has_blockers(), "a NEW conflict must block");
|
||||
// approved: deferred, not blocking
|
||||
let ok = analyze(
|
||||
&profile(&items, "[]", 100000500),
|
||||
&roster(),
|
||||
&entities(),
|
||||
&BTreeSet::from([20801]),
|
||||
);
|
||||
assert!(ok.definitions.conflicts[0].approved_defer);
|
||||
assert!(
|
||||
rep.has_blockers(),
|
||||
"a definition conflict is an unsafe blocker"
|
||||
!ok.has_blockers(),
|
||||
"an approved-deferred conflict does not block"
|
||||
);
|
||||
assert_eq!(
|
||||
ok.deferred_instances(),
|
||||
2,
|
||||
"both copies counted as deferred"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_formula_violation_is_unsupported_not_fabricated() {
|
||||
// resourceId != (version<<24)|assetId (version 0 but resource != asset)
|
||||
let items = vec![player(100000003, 999, 176580, 92)];
|
||||
let plan = plan_definitions(&profile(&items, "[]", 100000500), &roster());
|
||||
fn version_formula_violation_defers_not_fabricates() {
|
||||
let plan = defs(&[player(100000003, 999, 176580, 92)]); // 999 != (0<<24)|176580
|
||||
assert!(plan.supported.is_empty());
|
||||
assert!(matches!(
|
||||
plan.unsupported.first(),
|
||||
Some((_, _, Unsupported::VersionFormula { .. }))
|
||||
plan.deferred.first().map(|d| &d.reason),
|
||||
Some(Unsupported::VersionFormula { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_roster_name_is_unsupported_never_faked() {
|
||||
// asset 777777 is not in the roster => cannot honestly name it
|
||||
let items = vec![player(100000004, 777777, 777777, 80)];
|
||||
let plan = plan_definitions(&profile(&items, "[]", 100000500), &roster());
|
||||
fn missing_roster_name_defers_never_faked() {
|
||||
let plan = defs(&[player(100000004, 777777, 777777, 80)]); // 777777 not in roster
|
||||
assert!(plan.supported.is_empty());
|
||||
assert!(matches!(
|
||||
plan.unsupported.first(),
|
||||
Some((_, _, Unsupported::NoName { asset_id: 777777 }))
|
||||
plan.deferred.first().map(|d| &d.reason),
|
||||
Some(Unsupported::NoName { asset_id: 777777 })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_entity_defers() {
|
||||
// nation 999 not in entities()
|
||||
let item = format!(
|
||||
r#"{{"id":100000005,"resourceId":20801,"assetId":20801,"itemType":"player","rareflag":1,
|
||||
"rating":94,"preferredPosition":"ST","nation":999,"teamid":243,"leagueId":53,
|
||||
"attributeList":{}}}"#,
|
||||
attrs()
|
||||
);
|
||||
let plan = defs(&[item]);
|
||||
assert!(plan.supported.is_empty());
|
||||
assert!(matches!(
|
||||
plan.deferred.first().map(|d| &d.reason),
|
||||
Some(Unsupported::UnresolvedEntity { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_preserves_watermark_over_live_max() {
|
||||
// live ids up to 100004605, watermark (nextItemId) 100004617 above it
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100004605, 176580, 176580, 92),
|
||||
];
|
||||
let p = profile(&items, "[]", 100004617);
|
||||
let idp = plan_identity(&p, &[20801, 176580]);
|
||||
assert_eq!(idp.live_min, Some(100000001));
|
||||
assert_eq!(idp.live_max, Some(100004605));
|
||||
let idp = plan_identity(
|
||||
&profile(&items, "[]", 100004617),
|
||||
&BTreeSet::from([20801, 176580]),
|
||||
);
|
||||
assert_eq!(
|
||||
(idp.live_min, idp.live_max),
|
||||
(Some(100000001), Some(100004605))
|
||||
);
|
||||
assert_eq!(idp.source_watermark, 100004617);
|
||||
// next allocation is the source next-to-issue, NOT live_max+1 (=100004606)
|
||||
assert_eq!(idp.next_allocation, 100004617);
|
||||
assert_eq!(
|
||||
idp.next_allocation, 100004617,
|
||||
"next-to-issue, not live_max+1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn squad_coverage_flags_unsupported_starter_and_keeps_manager_ref() {
|
||||
fn squad_coverage_flags_unsupported_starter_and_keeps_manager() {
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100000002, 176580, 176580, 92),
|
||||
];
|
||||
// squad: slot0 supported, slot1 references an UNKNOWN wire id (unsupported),
|
||||
// an empty slot (id 0), a captain and a manager staff ref.
|
||||
let squads = r#"[{
|
||||
"formation":"f433","squadName":"OpenFUT","captain":100000001,
|
||||
"manager":[{"id":100000427,"dream":false}],
|
||||
@@ -185,19 +226,19 @@ fn squad_coverage_flags_unsupported_starter_and_keeps_manager_ref() {
|
||||
{"index":0,"itemData":{"id":100000001},"kitNumber":7},
|
||||
{"index":1,"itemData":{"id":999999999},"kitNumber":9},
|
||||
{"index":2,"itemData":{"id":0},"kitNumber":0}
|
||||
]
|
||||
}]"#;
|
||||
let p = profile(&items, squads, 100000500);
|
||||
let rep = analyze(&p, &roster());
|
||||
]}]"#;
|
||||
let rep = analyze(
|
||||
&profile(&items, squads, 100000500),
|
||||
&roster(),
|
||||
&entities(),
|
||||
&none(),
|
||||
);
|
||||
let sq = &rep.squad;
|
||||
assert!(sq.present);
|
||||
assert_eq!(sq.formation, "f433");
|
||||
assert_eq!(sq.occupied_slots, 2); // the id=0 slot is empty
|
||||
assert_eq!(sq.supported_slots, 1);
|
||||
assert_eq!((sq.occupied_slots, sq.supported_slots), (2, 1));
|
||||
assert_eq!(sq.unsupported_slots, vec![(1, 999999999)]);
|
||||
assert_eq!(sq.manager_wire_ids, vec![100000427]);
|
||||
assert_eq!(sq.captain_wire_id, Some(100000001));
|
||||
assert!(rep.has_blockers(), "an unsupported squad starter blocks");
|
||||
assert!(rep.has_blockers());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -207,14 +248,14 @@ fn analyze_is_deterministic() {
|
||||
player(100000001, 20801, 20801, 94),
|
||||
];
|
||||
let p = profile(&items, "[]", 100000500);
|
||||
let a = analyze(&p, &roster());
|
||||
let b = analyze(&p, &roster());
|
||||
let a = analyze(&p, &roster(), &entities(), &none());
|
||||
let b = analyze(&p, &roster(), &entities(), &none());
|
||||
assert_eq!(a.definitions.supported, b.definitions.supported);
|
||||
assert_eq!(a.identity.import_wire_ids, b.identity.import_wire_ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_profile_has_no_blockers_and_defers_non_players() {
|
||||
fn emit_content_writes_public_pack_catalog_and_private_manifest() {
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100000002, VER5_176580, 176580, 92),
|
||||
@@ -224,9 +265,56 @@ fn clean_profile_has_no_blockers_and_defers_non_players() {
|
||||
let squads = r#"[{"formation":"f433","squadName":"OpenFUT","captain":100000001,
|
||||
"manager":[{"id":100000427}],
|
||||
"players":[{"index":0,"itemData":{"id":100000001}},{"index":1,"itemData":{"id":100000002}}]}]"#;
|
||||
let rep = analyze(&profile(&items, squads, 100000500), &roster());
|
||||
let p = profile(&items, squads, 100000500);
|
||||
let rep = analyze(&p, &roster(), &entities(), &none());
|
||||
assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
|
||||
assert_eq!(rep.identity.import_wire_ids.len(), 2); // players only
|
||||
assert_eq!(rep.counts.consumables, 1);
|
||||
assert_eq!(rep.counts.staff, 1);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sum = emit_content(&rep, dir.path(), "deadbeef").unwrap();
|
||||
assert_eq!(sum.definitions, 2);
|
||||
assert_eq!(sum.supported_instances, 2);
|
||||
assert_eq!(sum.deferred_instances, 0);
|
||||
assert!(sum.content_pack.exists());
|
||||
assert!(sum.host_catalog.exists());
|
||||
assert!(sum.manifest.exists());
|
||||
|
||||
// content pack is a Core CardDefinition[] with our card_id + resolved names
|
||||
let pack: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&sum.content_pack).unwrap()).unwrap();
|
||||
let ids: Vec<&str> = pack
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c["id"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(ids.contains(&"fifa17_20801"));
|
||||
assert!(ids.contains(&format!("fifa17_{VER5_176580}").as_str()));
|
||||
|
||||
// catalog carries versioned entries
|
||||
let cat: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&sum.host_catalog).unwrap()).unwrap();
|
||||
assert_eq!(cat["cards"][format!("fifa17_{VER5_176580}")]["version"], 5);
|
||||
assert_eq!(cat["cards"]["fifa17_20801"]["version"], 0);
|
||||
|
||||
// manifest preserves wire ids and is separate from public content
|
||||
let man: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&sum.manifest).unwrap()).unwrap();
|
||||
assert_eq!(man["identity"]["source_watermark"], 100000500);
|
||||
assert_eq!(man["target"]["persona_id"], 33068179);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_refuses_when_blocked() {
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100000002, 20801, 20801, 93), // unapproved conflict -> blocker
|
||||
];
|
||||
let rep = analyze(
|
||||
&profile(&items, "[]", 100000500),
|
||||
&roster(),
|
||||
&entities(),
|
||||
&none(),
|
||||
);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(emit_content(&rep, dir.path(), "x").is_err());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user