09db5413cd
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.
1397 lines
48 KiB
Rust
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;
|