Files
OpenFUT/openfut-import-fifa17/src/lib.rs
T
funman300 09db5413cd feat(import-fifa17): emit the Core reclassify request from the catalog
The importer already knows every definition's kind, so it writes the mapping
Core needs to correct a club imported before the taxonomy existed. Applied to
the real 1989-item club: 20 rows corrected (17 consumables + 3 staff), 1966
players already right, 0 unmatched definitions.
2026-08-21 19:55:46 +00:00

1397 lines
48 KiB
Rust

//! Evidence-driven import of a real FIFA 17 Python profile into OpenFUT Core.
//!
//! Read-only analysis (`analyze`) + public/private content emission
//! (`emit_content`). It proves — before anything is written — that the
//! migration can be done faithfully, and then generates exactly the supported
//! definitions. It never fabricates identity, never collapses a versioned card
//! onto its base, and never picks a winner for a conflicted definition.
//!
//! * disjoint item-type accounting must balance to the source total;
//! * every supported player card yields an HONEST [`ObservedDefinition`] keyed
//! by `fifa17_<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`, next-to-issue) is preserved so a future
//! Rust-minted id continues past historically burned ids.
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use anyhow::{Context, Result};
/// FIFA 17 owned-item wire-id floor (adapter `Fifa17WireItemIdPolicy`).
pub const OWNED_ITEM_BASE_FLOOR: i64 = 100_000_001;
pub mod apply;
pub mod model;
use model::{Item, Profile};
use openfut_adapter_fifa17::fut::content_taxonomy::{consumable_family, staff_role, ContentKind};
// ----------------------------------------------------------------- roster
/// Player names keyed by base asset id (`roster.json`). A card whose base asset
/// id is absent here cannot be honestly named and is DEFERRED, never fabricated.
pub struct Roster {
names: BTreeMap<i64, String>,
}
impl Roster {
pub fn from_json_str(raw: &str) -> Result<Self> {
#[derive(serde::Deserialize)]
struct Row {
id: i64,
#[serde(default)]
first: String,
#[serde(default)]
last: String,
#[serde(default)]
common: String,
}
let rows: Vec<Row> =
serde_json::from_str(raw).context("parsing roster.json (expected an array)")?;
let mut names = BTreeMap::new();
for r in rows {
let name = if !r.common.trim().is_empty() {
r.common.trim().to_string()
} else {
format!("{} {}", r.first.trim(), r.last.trim())
.trim()
.to_string()
};
if !name.is_empty() {
names.insert(r.id, name);
}
}
Ok(Roster { names })
}
pub fn name_for(&self, asset_id: i64) -> Option<&str> {
self.names.get(&asset_id).map(String::as_str)
}
}
pub fn load_roster(path: impl AsRef<Path>) -> Result<Roster> {
let raw = std::fs::read_to_string(path.as_ref())
.with_context(|| format!("reading roster {}", path.as_ref().display()))?;
Roster::from_json_str(&raw)
}
// --------------------------------------------------------------- entities
/// Forward numeric-id -> name resolution for the committed FIFA 17 tables
/// (`leagues.json`/`nations.json`/`teams.json`, `{schema, rows:[…]}` dump).
/// Mirrors `scripts/seed_fifa17_cards.py` (`_table_map`).
#[derive(Default)]
pub struct Entities {
leagues: BTreeMap<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()))?;
Profile::from_json_str(&raw)
}
// ------------------------------------------------------------- classification
/// The disjoint source item classes. Every source item is exactly one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemClass {
PlayerCard,
Consumable,
Staff,
Kit,
Other,
}
pub fn classify(item: &Item) -> ItemClass {
if item.cardsubtypeid == Some(9) && (6_300_000..=6_400_654).contains(&item.resource_id) {
return ItemClass::Kit;
}
match item.item_type.as_str() {
"staff" => ItemClass::Staff,
"player" => {
if item.attribute_list.is_some() {
ItemClass::PlayerCard
} else {
ItemClass::Consumable
}
}
_ => ItemClass::Other,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ItemCounts {
pub total: usize,
pub player_cards: usize,
pub consumables: usize,
pub staff: usize,
pub kits: usize,
pub other: usize,
}
impl ItemCounts {
pub fn balances(&self) -> bool {
self.player_cards + self.consumables + self.staff + self.kits + self.other == self.total
}
}
pub fn count_items(profile: &Profile) -> ItemCounts {
let mut c = ItemCounts {
total: profile.items.len(),
..Default::default()
};
for it in &profile.items {
match classify(it) {
ItemClass::PlayerCard => c.player_cards += 1,
ItemClass::Consumable => c.consumables += 1,
ItemClass::Staff => c.staff += 1,
ItemClass::Kit => c.kits += 1,
ItemClass::Other => c.other += 1,
}
}
c
}
// -------------------------------------------------------------- definitions
/// Definition-level fields carried per owned copy. The DEFINITION IDENTITY
/// subset (`asset_id`/`version`/`rating`/`position`/`attrs`/`rareflag`) MUST
/// agree across copies of one `resourceId`; `nation`/`league`/`team` are
/// observed INSTANCE metadata that MAY legitimately vary per owned copy (a
/// club-affiliation snapshot — evidence: `resourceId` 169193) and are EXCLUDED
/// from the identity gate. Instance fields (wire id, contract, fitness, …) are
/// not modelled here at all.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DefFields {
pub asset_id: Option<i64>,
pub version: i64,
pub rating: Option<i64>,
pub position: Option<String>,
pub nation: Option<i64>,
pub league: Option<i64>,
pub team: Option<i64>,
pub attrs: Option<Vec<i64>>,
pub rareflag: Option<i64>,
}
/// Why a `resourceId` cannot yield an honest CardDefinition (all DEFERRED).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Unsupported {
VersionFormula { resource_id: i64, asset_id: i64 },
NoName { asset_id: i64 },
MissingMetadata { fields: Vec<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
/// `fifa17_<resourceId>`. Carries provenance, not marketing taxonomy.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObservedDefinition {
pub card_id: String,
pub resource_id: i64,
pub asset_id: i64,
pub version: i64,
pub name: String,
pub rating: i64,
pub position: String,
pub nation: String,
pub league: String,
pub club: String,
pub nation_id: i64,
pub league_id: i64,
pub team_id: i64,
/// pace, shooting, passing, dribbling, defending, physical (index 0..=5).
pub attrs: [i64; 6],
pub rarity: &'static str,
/// Observed FIFA wire `rareflag` (the card's rare/special TYPE, e.g. 3=inform,
/// 21..=24 = special programmes). Carried verbatim from the profile — the raw
/// integer, never a guessed marketing label — so specials render as specials.
pub rareflag: i64,
/// Wire ids of the owned copies of this exact resourceId (preserved).
pub wire_ids: Vec<i64>,
}
/// 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 {
let attrs = item.attribute_list.as_ref().map(|list| {
let mut v: Vec<(i64, i64)> = list.iter().map(|a| (a.index, a.value)).collect();
v.sort_by_key(|(i, _)| *i);
v.into_iter().map(|(_, val)| val).collect::<Vec<i64>>()
});
DefFields {
asset_id: item.asset_id,
version: version_of(item.resource_id),
rating: item.rating,
position: item.preferred_position.clone(),
nation: item.nation,
league: item.league_id,
team: item.teamid,
attrs,
rareflag: item.rareflag,
}
}
/// The definition-IDENTITY projection of [`DefFields`]: the subset that MUST be
/// identical across every owned copy of one `resourceId`. Deliberately omits
/// `nation`/`league`/`team`, which are observed instance metadata (see
/// [`DefFields`]) — a club-only difference between two copies is NOT a conflict.
#[derive(Debug, Clone, PartialEq, Eq)]
struct DefIdentity {
asset_id: Option<i64>,
version: i64,
rating: Option<i64>,
position: Option<String>,
attrs: Option<Vec<i64>>,
rareflag: Option<i64>,
}
impl DefIdentity {
fn of(f: &DefFields) -> Self {
DefIdentity {
asset_id: f.asset_id,
version: f.version,
rating: f.rating,
position: f.position.clone(),
attrs: f.attrs.clone(),
rareflag: f.rareflag,
}
}
}
/// Build the definition proposal from player cards, applying the
/// resourceId-group consistency gate, entity resolution, and honest
/// buildability. `approved_conflicts` are the ONLY conflicts allowed to defer.
pub fn plan_definitions(
profile: &Profile,
roster: &Roster,
entities: &Entities,
approved_conflicts: &BTreeSet<i64>,
) -> DefinitionPlan {
let mut groups: BTreeMap<i64, Vec<&Item>> = BTreeMap::new();
for it in &profile.items {
if classify(it) == ItemClass::PlayerCard {
groups.entry(it.resource_id).or_default().push(it);
}
}
let mut plan = DefinitionPlan::default();
for (resource_id, items) in groups {
let wire_ids: Vec<i64> = items.iter().map(|i| i.id).collect();
// 1) consistency gate — compare DEFINITION IDENTITY only. A club-only
// difference (nation/league/team) between copies is observed instance
// metadata, never a conflict (evidence: resourceId 169193's 4 copies are
// identical but for club). `distinct` keeps only identity-distinct
// variants so a real conflict (differing rating/position/attrs/asset)
// still trips the gate.
let first = def_fields(items[0]);
let mut distinct = vec![first.clone()];
for it in &items[1..] {
let f = def_fields(it);
if !distinct
.iter()
.any(|d| DefIdentity::of(d) == DefIdentity::of(&f))
{
distinct.push(f);
}
}
if distinct.len() > 1 {
plan.conflicts.push(DefConflict {
resource_id,
wire_ids,
distinct,
approved_defer: approved_conflicts.contains(&resource_id),
});
continue; // never pick a winner
}
let f = &first;
// helper to record a deferral
macro_rules! defer {
($reason:expr) => {{
plan.deferred.push(DeferredDef {
resource_id,
asset_id: f.asset_id,
wire_ids: wire_ids.clone(),
reason: $reason,
});
continue;
}};
}
// 2) version formula.
let Some(asset_id) = f.asset_id else {
defer!(Unsupported::MissingMetadata {
fields: vec!["assetId".into()]
});
};
if resource_id != (f.version << 24) | asset_id {
defer!(Unsupported::VersionFormula {
resource_id,
asset_id
});
}
// 3) required metadata present?
let mut missing = Vec::new();
if f.rating.is_none() {
missing.push("rating".into());
}
if f.position.is_none() {
missing.push("position".into());
}
if f.nation.is_none() {
missing.push("nation".into());
}
if f.league.is_none() {
missing.push("league".into());
}
if f.team.is_none() {
missing.push("team".into());
}
let attrs6: Option<[i64; 6]> = f.attrs.as_ref().and_then(|a| {
if a.len() == 6 {
Some([a[0], a[1], a[2], a[3], a[4], a[5]])
} else {
None
}
});
if attrs6.is_none() {
missing.push("attributeList[6]".into());
}
if !missing.is_empty() {
defer!(Unsupported::MissingMetadata { fields: missing });
}
let (rating, position) = (f.rating.unwrap(), f.position.clone().unwrap());
let (nation_id, league_id, team_id) =
(f.nation.unwrap(), f.league.unwrap(), f.team.unwrap());
// 4) name (roster) — evidence, never fabricated.
let Some(name) = roster.name_for(asset_id) else {
defer!(Unsupported::NoName { asset_id });
};
// 5) entity resolution (nation/league/club names).
let (Some(nation), Some(league), Some(club)) = (
entities.nation(nation_id),
entities.league(league_id),
entities.club(team_id),
) else {
let mut fields = Vec::new();
if entities.nation(nation_id).is_none() {
fields.push(format!("nation:{nation_id}"));
}
if entities.league(league_id).is_none() {
fields.push(format!("league:{league_id}"));
}
if entities.club(team_id).is_none() {
fields.push(format!("team:{team_id}"));
}
defer!(Unsupported::UnresolvedEntity { fields });
};
plan.supported.push(ObservedDefinition {
card_id: format!("fifa17_{resource_id}"),
resource_id,
asset_id,
version: f.version,
name: name.to_string(),
rating,
position,
nation: nation.to_string(),
league: league.to_string(),
club: club.to_string(),
nation_id,
league_id,
team_id,
attrs: attrs6.unwrap(),
rarity: tier(rating),
rareflag: f.rareflag.unwrap_or(1),
wire_ids,
});
}
plan.base_defs = plan.supported.iter().filter(|d| d.version == 0).count();
plan.versioned_defs = plan.supported.len() - plan.base_defs;
plan
}
// ------------------------------------------------------- non-player content
/// An honest, profile-derived non-player CardDefinition proposal, keyed by
/// `fifa17_<resourceId>`. Neutral player fields are supplied at emit time; FIFA
/// render metadata stays here and in the adapter catalog, never generic Core.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NonPlayerDefinition {
pub card_id: String,
pub resource_id: i64,
/// Base asset id when the source carries one.
pub asset_id: Option<i64>,
pub kind: ContentKind,
/// FIFA `cardsubtypeid` (consumable family, staff role, or kit family).
pub subtype: i64,
/// Non-player card-art id (`35` for kits), when present.
pub card_asset_id: Option<i64>,
/// Source team id for a kit definition, when present.
pub team_id: Option<i64>,
/// Consumable effect magnitude (`amount`), when the source carries one.
///
/// Definition-level, and measured to be so: across every owned consumable in
/// the real profile the observed `amount` equals its `fcc_*` table row
/// (1, 2, 4, 5, 10, 15 — no disagreements), and a wire omission corresponds
/// to a table `amount` of 0. It is NOT a stack count: two copies of 5003068
/// arrive as two separate instances, each with the same amount.
pub amount: Option<i64>,
/// Contract-card payload (`contract`), when the source carries one. Present
/// on exactly the contract families (subtypes 201/202) and absent from every
/// other owned consumable, so it is the card's own field rather than the
/// generic per-item contract atom that players and staff carry.
pub contract: Option<i64>,
/// Card rating, when the source carries one (matches the `fcc_*` row).
pub rating: Option<i64>,
/// Honest functional label (e.g. "Player Contract", "GK Coach", "Kit").
pub name: String,
/// Wire ids of every owned copy of this resourceId (preserved).
pub wire_ids: Vec<i64>,
}
/// A non-player group that cannot be honestly classified (DEFERRED, never
/// fabricated). Mirrors the player NoName gate.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeferredNonPlayer {
pub resource_id: i64,
/// The agreed subtype when present; `None` when absent or in conflict.
pub subtype: Option<i64>,
pub wire_ids: Vec<i64>,
pub reason: String,
}
#[derive(Debug, Default)]
pub struct NonPlayerPlan {
pub supported: Vec<NonPlayerDefinition>,
pub deferred: Vec<DeferredNonPlayer>,
/// Count of SUPPORTED consumable definitions.
pub consumables: usize,
/// Count of SUPPORTED staff definitions.
pub staff: usize,
/// Count of SUPPORTED kit definitions.
pub kits: usize,
}
impl NonPlayerPlan {
/// Deferred non-player INSTANCES (owned copies) across all deferred groups.
pub fn deferred_instances(&self) -> usize {
self.deferred.iter().map(|d| d.wire_ids.len()).sum()
}
}
/// Plan non-player CardDefinitions. Consumable, Staff, and Kit groups must agree
/// on their definition-level metadata across every owned copy; disagreement or
/// missing required metadata defers the whole group, never fabricates a value.
pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
let mut groups: BTreeMap<i64, Vec<&Item>> = BTreeMap::new();
for it in &profile.items {
if matches!(
classify(it),
ItemClass::Consumable | ItemClass::Staff | ItemClass::Kit
) {
groups.entry(it.resource_id).or_default().push(it);
}
}
let mut plan = NonPlayerPlan::default();
for (resource_id, items) in groups {
let wire_ids: Vec<i64> = items.iter().map(|i| i.id).collect();
// Class agreement (a resourceId is either all-consumable or all-staff).
let class = classify(items[0]);
if items.iter().any(|i| classify(i) != class) {
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: None,
wire_ids,
reason: "class_conflict".to_string(),
});
continue;
}
// Subtype must agree across every owned copy (identity invariant).
let first_subtype = items[0].cardsubtypeid;
if items.iter().any(|i| i.cardsubtypeid != first_subtype) {
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: None,
wire_ids,
reason: "subtype_conflict".to_string(),
});
continue;
}
let Some(subtype) = first_subtype else {
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: None,
wire_ids,
reason: "missing_cardsubtypeid".to_string(),
});
continue;
};
let card_asset_id = items[0].cardassetid;
let team_id = items[0].teamid;
// These are definition-level, so every owned copy must agree. Two copies
// of one consumable that disagreed would mean the field is really
// per-instance, and silently taking the first copy's value would bake a
// guess into the catalog — so defer the whole group instead.
let amount = items[0].amount;
let contract = items[0].contract;
let rating = items[0].rating;
if items.iter().any(|item| {
item.cardassetid != card_asset_id
|| item.teamid != team_id
|| item.amount != amount
|| item.contract != contract
|| item.rating != rating
}) {
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: Some(subtype),
wire_ids,
reason: "render_metadata_conflict".to_string(),
});
continue;
}
let (kind, label) = match class {
ItemClass::Consumable => match consumable_family(subtype) {
Some((_family, label)) => (ContentKind::Consumable, label),
None => {
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: Some(subtype),
wire_ids,
reason: "unknown_subtype".to_string(),
});
continue;
}
},
ItemClass::Staff => match staff_role(subtype) {
Some((_role, label)) => (ContentKind::Staff, label),
None => {
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: Some(subtype),
wire_ids,
reason: "unknown_subtype".to_string(),
});
continue;
}
},
ItemClass::Kit if subtype == 9 => {
if card_asset_id != Some(35) || team_id.is_none() {
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: Some(subtype),
wire_ids,
reason: "missing_kit_render_metadata".to_string(),
});
continue;
}
(ContentKind::Kit, "Kit")
}
_ => unreachable!("only Consumable/Staff/Kit were grouped"),
};
plan.supported.push(NonPlayerDefinition {
card_id: format!("fifa17_{resource_id}"),
resource_id,
asset_id: if class == ItemClass::Kit {
Some(resource_id)
} else {
items[0].asset_id
},
kind,
subtype,
card_asset_id,
team_id,
amount,
contract,
rating,
name: label.to_string(),
wire_ids,
});
}
plan.consumables = plan
.supported
.iter()
.filter(|d| d.kind == ContentKind::Consumable)
.count();
plan.staff = plan
.supported
.iter()
.filter(|d| d.kind == ContentKind::Staff)
.count();
plan.kits = plan
.supported
.iter()
.filter(|definition| definition.kind == ContentKind::Kit)
.count();
plan
}
// --------------------------------------------------------------- identity
#[derive(Debug, Default)]
pub struct IdentityPlan {
pub import_wire_ids: Vec<i64>,
pub duplicate_wire_ids: Vec<i64>,
pub live_min: Option<i64>,
pub live_max: Option<i64>,
pub source_watermark: i64,
pub next_allocation: i64,
}
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 {
if !seen.insert(it.id) {
dups.insert(it.id);
}
}
let mut import_wire_ids: Vec<i64> = profile
.items
.iter()
.filter(|it| {
classify(it) == ItemClass::PlayerCard
&& supported_resource_ids.contains(&it.resource_id)
})
.map(|it| it.id)
.collect();
import_wire_ids.sort_unstable();
let live_min = profile.items.iter().map(|i| i.id).min();
let live_max = profile.items.iter().map(|i| i.id).max();
let watermark = profile.next_item_id;
let next_allocation = [
OWNED_ITEM_BASE_FLOOR,
live_max.map(|m| m + 1).unwrap_or(OWNED_ITEM_BASE_FLOOR),
watermark,
]
.into_iter()
.max()
.unwrap();
IdentityPlan {
import_wire_ids,
duplicate_wire_ids: dups.into_iter().collect(),
live_min,
live_max,
source_watermark: watermark,
next_allocation,
}
}
// ----------------------------------------------------------------- squad
#[derive(Debug, Default)]
pub struct SquadCoverage {
pub present: bool,
pub formation: String,
pub occupied_slots: usize,
pub supported_slots: usize,
pub unsupported_slots: Vec<(i64, i64)>,
pub manager_wire_ids: Vec<i64>,
pub captain_wire_id: Option<i64>,
}
pub fn plan_squad(profile: &Profile, supported_wire_ids: &BTreeSet<i64>) -> SquadCoverage {
let Some(sq) = profile.squads.first() else {
return SquadCoverage::default();
};
let mut cov = SquadCoverage {
present: true,
formation: sq.formation.clone(),
captain_wire_id: sq.captain.filter(|&c| c != 0),
manager_wire_ids: sq
.manager
.iter()
.filter_map(|m| m.get("id").and_then(|v| v.as_i64()))
.filter(|&id| id != 0)
.collect(),
..Default::default()
};
for slot in &sq.players {
let wid = slot.item_data.id;
if wid == 0 {
continue;
}
cov.occupied_slots += 1;
if supported_wire_ids.contains(&wid) {
cov.supported_slots += 1;
} else {
cov.unsupported_slots.push((slot.index, wid));
}
}
cov
}
// ----------------------------------------------------------------- report
#[derive(Debug)]
pub struct Report {
pub game: String,
pub persona_id: i64,
pub persona_name: String,
pub club_name: String,
pub club_abbr: String,
pub coins: i64,
pub counts: ItemCounts,
pub definitions: DefinitionPlan,
pub identity: IdentityPlan,
pub squad: SquadCoverage,
/// Consumable + staff content (supported definitions + deferred groups).
pub non_player: NonPlayerPlan,
/// Unconsumed pack entitlements to seed (from `unopenedPackIds`).
pub unopened_pack_ids: Vec<i64>,
}
impl Report {
/// Count of deferred player INSTANCES (owned copies), across unsupported
/// definitions and (approved-)deferred conflicts.
pub fn deferred_instances(&self) -> usize {
let d: usize = self
.definitions
.deferred
.iter()
.map(|d| d.wire_ids.len())
.sum();
let c: usize = self
.definitions
.conflicts
.iter()
.map(|c| c.wire_ids.len())
.sum();
d + c
}
/// Unsafe conditions that MUST fail (nonzero). Explicitly-deferred conflicts,
/// NoName/unresolved/version deferrals are NOT here — they are recorded.
pub fn blockers(&self) -> Vec<String> {
let mut b = Vec::new();
if !self.counts.balances() {
b.push(format!(
"item-type accounting does not balance ({}+{}+{}+{}+{} != {})",
self.counts.player_cards,
self.counts.consumables,
self.counts.staff,
self.counts.kits,
self.counts.other,
self.counts.total
));
}
if self.counts.other > 0 {
b.push(format!("{} item(s) in no known class", self.counts.other));
}
let new_conflicts = self
.definitions
.conflicts
.iter()
.filter(|c| !c.approved_defer)
.count();
if new_conflicts > 0 {
b.push(format!(
"{new_conflicts} NEW (unapproved) resourceId conflict(s) — pass --defer-conflict <rid> only for a reviewed one"
));
}
if !self.identity.duplicate_wire_ids.is_empty() {
b.push(format!(
"{} duplicate owned wire id(s)",
self.identity.duplicate_wire_ids.len()
));
}
if self.squad.present && !self.squad.unsupported_slots.is_empty() {
b.push(format!(
"{} active-squad slot(s) reference an unsupported/missing item",
self.squad.unsupported_slots.len()
));
}
b
}
pub fn has_blockers(&self) -> bool {
!self.blockers().is_empty()
}
}
/// Run the full read-only analysis. Pure: no I/O, no writes.
pub fn analyze(
profile: &Profile,
roster: &Roster,
entities: &Entities,
approved_conflicts: &BTreeSet<i64>,
) -> Report {
let counts = count_items(profile);
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 supported_wire: BTreeSet<i64> = identity.import_wire_ids.iter().copied().collect();
let squad = plan_squad(profile, &supported_wire);
let non_player = plan_non_player_definitions(profile);
Report {
game: "fifa17".to_string(),
persona_id: profile.persona_id,
persona_name: profile.persona_name.clone(),
club_name: profile.club_name.clone(),
club_abbr: profile.club_abbr.clone(),
coins: profile.coins,
counts,
definitions,
identity,
squad,
non_player,
unopened_pack_ids: profile.unopened_pack_ids.clone(),
}
}
impl std::fmt::Display for Report {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let d = &self.definitions;
let id = &self.identity;
let sq = &self.squad;
let np = &self.non_player;
writeln!(f, "OpenFUT FIFA17 real-profile import — analysis")?;
writeln!(f, "=============================================")?;
writeln!(
f,
"TARGET game={} persona={}/{} club={}/{} coins={}",
self.game,
self.persona_id,
self.persona_name,
self.club_name,
self.club_abbr,
self.coins
)?;
writeln!(
f,
"\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} kits={} other={} balances={}",
self.counts.total,
self.counts.player_cards,
self.counts.consumables,
self.counts.staff,
self.counts.kits,
self.counts.other,
self.counts.balances()
)?;
writeln!(
f,
"\nPLAYER DEFINITIONS (fifa17_<resourceId>) supported={} (base={} versioned={}) deferred_defs={} conflicts={}",
d.supported.len(),
d.base_defs,
d.versioned_defs,
d.deferred.len(),
d.conflicts.len()
)?;
for dd in &d.deferred {
writeln!(
f,
" DEFER resourceId={} copies={} {:?}",
dd.resource_id,
dd.wire_ids.len(),
dd.reason
)?;
}
for c in &d.conflicts {
writeln!(
f,
" CONFLICT resourceId={} copies={} distinct={} approved_defer={}",
c.resource_id,
c.wire_ids.len(),
c.distinct.len(),
c.approved_defer
)?;
}
writeln!(
f,
"\nIDENTITY import_instances={} live=[{:?}..{:?}] duplicates={} watermark={} next_alloc={}",
id.import_wire_ids.len(),
id.live_min,
id.live_max,
id.duplicate_wire_ids.len(),
id.source_watermark,
id.next_allocation
)?;
if sq.present {
writeln!(
f,
"\nACTIVE SQUAD formation={} occupied={} supported={} unsupported={} captain={:?} manager={:?}",
sq.formation,
sq.occupied_slots,
sq.supported_slots,
sq.unsupported_slots.len(),
sq.captain_wire_id,
sq.manager_wire_ids
)?;
for (idx, wid) in &sq.unsupported_slots {
writeln!(f, " UNSUPPORTED slot index={idx} wire_id={wid}")?;
}
} else {
writeln!(f, "\nACTIVE SQUAD (none)")?;
}
writeln!(
f,
"\nNON-PLAYER CONTENT (consumable/staff/kit) supported={} (consumables={} staff={} kits={}) deferred_groups={} deferred_instances={}",
np.supported.len(),
np.consumables,
np.staff,
np.kits,
np.deferred.len(),
np.deferred_instances()
)?;
for nd in &np.deferred {
writeln!(
f,
" DEFER resourceId={} subtype={:?} copies={} reason={}",
nd.resource_id,
nd.subtype,
nd.wire_ids.len(),
nd.reason
)?;
}
writeln!(
f,
"\nRESULT would_import_players={} would_import_non_players={} deferred_player_instances={} deferred_non_player_instances={}",
id.import_wire_ids.len(),
np.supported.iter().map(|d| d.wire_ids.len()).sum::<usize>(),
self.deferred_instances(),
np.deferred_instances()
)?;
let blockers = self.blockers();
if blockers.is_empty() {
writeln!(f, " BLOCKERS: none")?;
} else {
writeln!(f, " BLOCKERS ({}):", blockers.len())?;
for b in &blockers {
writeln!(f, " - {b}")?;
}
}
Ok(())
}
}
// ----------------------------------------------------------------- emit
/// Dependency-free 64-bit FNV-1a hex fingerprint of the source snapshot (a
/// provenance handle, not a cryptographic digest).
pub fn fingerprint(bytes: &[u8]) -> String {
let mut h: u64 = 0xcbf29ce484222325;
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
format!("{h:016x}")
}
/// What `emit_content` wrote.
#[derive(Debug)]
pub struct EmitSummary {
pub content_pack: std::path::PathBuf,
pub host_catalog: std::path::PathBuf,
pub manifest: std::path::PathBuf,
/// Core `reclassify` request correcting the content_kind of rows imported
/// before the taxonomy existed.
pub reclassify: std::path::PathBuf,
pub definitions: usize,
pub catalog_entries: usize,
pub supported_instances: usize,
pub deferred_instances: usize,
/// Non-player (consumable/staff) supported definitions written.
pub non_player_definitions: usize,
/// Non-player supported owned INSTANCES (owned copies across those defs).
pub non_player_instances: usize,
}
/// Emit the PUBLIC content pack + host catalog for supported definitions, and a
/// PRIVATE import manifest (wire ids + deferred set + watermark + target). Never
/// writes Core or the identity store. Refuses if the report has blockers.
pub fn emit_content(
report: &Report,
out_dir: &Path,
snapshot_fingerprint: &str,
) -> Result<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 mut 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();
// Non-player CardDefinitions use NEUTRAL player fields + the honest family/
// role name; Core stores them like any other definition (no FIFA concept).
for d in &report.non_player.supported {
defs.push(serde_json::json!({
"id": d.card_id,
"name": d.name,
"overall": 0,
"position": "",
"nation": "",
"league": "",
"club": "",
"pace": 0,
"shooting": 0,
"passing": 0,
"dribbling": 0,
"defending": 0,
"physical": 0,
"rarity": "bronze",
"image_path": serde_json::Value::Null,
}));
}
let content_pack = content_dir.join("fifa17-production-cards.json");
write_json_pretty(&content_pack, &defs)?;
// ---- PUBLIC: host identity catalog {card_id: {asset_id, version, rareflag}} ----
let mut cards = serde_json::Map::new();
for d in &report.definitions.supported {
// Players carry an explicit kind:"player" + subtype:0 so the adapter can
// classify EVERY catalogued card (not just non-players).
cards.insert(
d.card_id.clone(),
serde_json::json!({
"asset_id": d.asset_id,
"version": d.version,
"rareflag": d.rareflag,
"kind": "player",
"subtype": 0,
}),
);
}
for d in &report.non_player.supported {
// asset_id falls back to resource_id (staff carry no assetId); version 0,
// rareflag 0 — a consumable/staff never renders as a special card.
cards.insert(
d.card_id.clone(),
serde_json::json!({
"card_asset_id": d.card_asset_id,
"team_id": d.team_id,
// A consumable is unrenderable without these: the adapter refuses
// to emit a card whose art id it does not know, and the families
// that read `amount`/`contract` draw "-1" or grant nothing when
// the field is missing. Omitting them here is what kept every
// owned consumable off the wire.
"amount": d.amount,
"contract": d.contract,
"rating": d.rating,
"asset_id": d.asset_id.unwrap_or(d.resource_id),
"version": 0,
"rareflag": 0,
"kind": d.kind.as_str(),
"subtype": d.subtype,
}),
);
}
let catalog = serde_json::json!({
"schema_version": 1,
"game": "fifa17",
"_provenance": {
"generator": "openfut-import-fifa17",
"source_kind": "python-profile-observation",
"snapshot_fingerprint": snapshot_fingerprint,
"base_cards_only": false,
},
"cards": cards,
});
let host_catalog = content_dir.join("fifa17-production-catalog.json");
write_json_pretty(&host_catalog, &catalog)?;
// ---- Core reclassify request (content_kind of already-imported rows) ----
// A profile import is once-only, so a club imported before the taxonomy
// existed still records its coaches, kits and consumables as players. Core
// cannot derive the right kind (it is FIFA17 taxonomy), so hand it the
// mapping. Players are included too: a definition that was mis-set stays
// correctable, and a rerun changes nothing.
let assignments: Vec<serde_json::Value> = report
.definitions
.supported
.iter()
.map(|d| serde_json::json!({ "card_id": d.card_id, "content_kind": "player" }))
.chain(report.non_player.supported.iter().map(|d| {
serde_json::json!({ "card_id": d.card_id, "content_kind": d.kind.as_str() })
}))
.collect();
let reclassify = content_dir.join("fifa17-reclassify.json");
write_json_pretty(
&reclassify,
&serde_json::json!({ "game_id": "fifa17", "assignments": assignments }),
)?;
// ---- PRIVATE: import manifest (wire ids + deferred set) ----
let supported: Vec<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 non_player_supported: Vec<serde_json::Value> = report
.non_player
.supported
.iter()
.map(|d| {
serde_json::json!({
"card_asset_id": d.card_asset_id,
"team_id": d.team_id,
"card_id": d.card_id,
"resource_id": d.resource_id,
"asset_id": d.asset_id,
"kind": d.kind.as_str(),
"subtype": d.subtype,
"name": d.name,
"wire_ids": d.wire_ids,
})
})
.collect();
let non_player_deferred: Vec<serde_json::Value> = report
.non_player
.deferred
.iter()
.map(|dd| {
serde_json::json!({
"resource_id": dd.resource_id,
"subtype": dd.subtype,
"wire_ids": dd.wire_ids,
"reason": dd.reason,
})
})
.collect();
let supported_instances = report.identity.import_wire_ids.len();
let deferred_instances = report.deferred_instances();
let non_player_definitions = report.non_player.supported.len();
let non_player_instances: usize = report
.non_player
.supported
.iter()
.map(|d| d.wire_ids.len())
.sum();
let manifest = serde_json::json!({
"generator": "openfut-import-fifa17",
"source_kind": "python-profile-observation",
"snapshot_fingerprint": snapshot_fingerprint,
"target": {
"game": report.game,
"persona_id": report.persona_id,
"persona_name": report.persona_name,
"club_name": report.club_name,
"club_abbr": report.club_abbr,
},
"identity": {
"base_floor": OWNED_ITEM_BASE_FLOOR,
"source_watermark": report.identity.source_watermark,
"next_allocation": report.identity.next_allocation,
"supported_instances": supported_instances,
},
"supported_definitions": supported,
"deferred": deferred,
"non_player": {
"supported_definitions": non_player_supported,
"supported_instances": non_player_instances,
"deferred": non_player_deferred,
"deferred_instances": report.non_player.deferred_instances(),
},
});
let manifest_path = manifest_dir.join("fifa17-import-manifest.json");
write_json_pretty(&manifest_path, &manifest)?;
Ok(EmitSummary {
content_pack,
host_catalog,
reclassify,
manifest: manifest_path,
definitions: report.definitions.supported.len(),
catalog_entries: report.definitions.supported.len() + non_player_definitions,
supported_instances,
deferred_instances,
non_player_definitions,
non_player_instances,
})
}
fn write_json_pretty<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;