feat(import): identity import API + FIFA17 real-profile dry-run importer
openfut-identity: - insert_existing_mapping(game,kind,core_id,external_id): preserve an existing external wire id instead of minting; idempotent for an identical mapping, rejects conflicting forward/reverse with IdError::Conflict, persists atomically. - persisted per-scope allocator watermark (set_watermark/watermark_for) so a future mint continues past the source high-water even across burned-id gaps; next id = max(base_floor, live_max+1, watermark). Backward-compatible on-disk format (legacy bare [Row] still loads). +4 tests (10 total). openfut-import-fifa17 (new): read-only dry-run analysis of a real FIFA17 Python profile for a faithful Core import. Enforces disjoint item-class balance; proposes profile-derived CardDefinitions keyed fifa17_<resourceId> (base vs versioned never collapse) with a resourceId-group consistency gate (hard-fail on disagreement, never pick a winner) and honest buildability (roster name + version formula + metadata, never fabricated); plans owned-instance identity (preserve Python wire ids, preserve nextItemId watermark); checks active-squad coverage. --apply/--emit-content refuse to write in this phase. 11 tests. Real profile (33068179/CAGE) dry-run: 1982 items balance (1962 players + 17 consumables + 3 staff); 1681 supported defs (155 base + 1535 versioned), 9 NoName unsupported, 1 hard conflict (resourceId 169193: one of 4 copies has a divergent nation/team/league); 1949 importable player instances, watermark 100004617 -> first new alloc 100004617; active squad f433 fully supported. fmt + clippy -D warnings clean.
This commit is contained in:
Generated
+9
@@ -3213,6 +3213,15 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-import-fifa17"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-launcher"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -12,6 +12,7 @@ members = [
|
||||
"openfut-roster-host",
|
||||
"openfut-utas-host",
|
||||
"openfut-identity",
|
||||
"openfut-import-fifa17",
|
||||
"openfut-bridge",
|
||||
"openfut-launcher",
|
||||
"openfut-launcher/openfut-hook",
|
||||
|
||||
+253
-9
@@ -37,6 +37,8 @@ use serde::{Deserialize, Serialize};
|
||||
pub enum IdError {
|
||||
Io(std::io::Error),
|
||||
Corrupt(String),
|
||||
/// A caller-supplied import mapping conflicts with an existing one.
|
||||
Conflict(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IdError {
|
||||
@@ -44,6 +46,7 @@ impl std::fmt::Display for IdError {
|
||||
match self {
|
||||
IdError::Io(e) => write!(f, "identity store io: {e}"),
|
||||
IdError::Corrupt(e) => write!(f, "identity store corrupt: {e}"),
|
||||
IdError::Conflict(e) => write!(f, "identity store conflict: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,12 +83,36 @@ struct Row {
|
||||
external_id: i64,
|
||||
}
|
||||
|
||||
/// A persisted per-scope allocator watermark: the next external id to issue,
|
||||
/// imported from a source system so historical allocation (including ids burned
|
||||
/// in gaps) is preserved, not just currently-live mappings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Watermark {
|
||||
game_id: String,
|
||||
entity_kind: String,
|
||||
next_id: i64,
|
||||
}
|
||||
|
||||
/// On-disk store file. Backward-compatible: a legacy bare `[Row, ...]` array is
|
||||
/// still accepted on load; new writes always use this object form so watermarks
|
||||
/// persist alongside the rows.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct StoreFile {
|
||||
#[serde(default)]
|
||||
rows: Vec<Row>,
|
||||
#[serde(default)]
|
||||
watermarks: Vec<Watermark>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Index {
|
||||
rows: Vec<Row>,
|
||||
fwd: HashMap<(String, String, String), i64>,
|
||||
rev: HashMap<(String, String, i64), String>,
|
||||
max: HashMap<(String, String), i64>,
|
||||
/// Per-scope imported allocation watermark (next id to issue): a floor that
|
||||
/// preserves source allocation history beyond the live `max`.
|
||||
watermarks: HashMap<(String, String), i64>,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
@@ -115,6 +142,21 @@ impl Index {
|
||||
}
|
||||
}
|
||||
|
||||
/// The next external id to issue in a scope: the maximum of the namespace floor,
|
||||
/// one past the highest live mapping, and any imported watermark. Taking the max
|
||||
/// of all three preserves source allocation history (burned-id gaps) while never
|
||||
/// reissuing a live id nor dropping below the adapter's policy floor.
|
||||
fn next_external(ix: &Index, game: &str, kind: &str, base_floor: i64) -> i64 {
|
||||
let scope = (game.to_string(), kind.to_string());
|
||||
let from_max = ix.max.get(&scope).map(|m| m + 1);
|
||||
let from_wm = ix.watermarks.get(&scope).copied();
|
||||
[Some(base_floor), from_max, from_wm]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.max()
|
||||
.unwrap_or(base_floor)
|
||||
}
|
||||
|
||||
/// JSON-file backed [`ExternalIdentityStore`]. Single-writer (one host process).
|
||||
pub struct JsonIdentityStore {
|
||||
path: PathBuf,
|
||||
@@ -129,9 +171,20 @@ impl JsonIdentityStore {
|
||||
if path.exists() {
|
||||
let raw = std::fs::read_to_string(&path).map_err(IdError::Io)?;
|
||||
if !raw.trim().is_empty() {
|
||||
let rows: Vec<Row> =
|
||||
serde_json::from_str(&raw).map_err(|e| IdError::Corrupt(e.to_string()))?;
|
||||
for row in rows {
|
||||
// Accept the modern object form; fall back to the legacy bare
|
||||
// `[Row, ...]` array so pre-watermark stores keep loading.
|
||||
let file: StoreFile = match serde_json::from_str::<StoreFile>(&raw) {
|
||||
Ok(f) => f,
|
||||
Err(_) => {
|
||||
let rows: Vec<Row> = serde_json::from_str(&raw)
|
||||
.map_err(|e| IdError::Corrupt(e.to_string()))?;
|
||||
StoreFile {
|
||||
rows,
|
||||
watermarks: Vec::new(),
|
||||
}
|
||||
}
|
||||
};
|
||||
for row in file.rows {
|
||||
// Reject a torn file that violates reverse-uniqueness rather
|
||||
// than silently serving an ambiguous reverse lookup.
|
||||
let rkey = (
|
||||
@@ -147,6 +200,11 @@ impl JsonIdentityStore {
|
||||
}
|
||||
index.insert(row);
|
||||
}
|
||||
for wm in file.watermarks {
|
||||
index
|
||||
.watermarks
|
||||
.insert((wm.game_id, wm.entity_kind), wm.next_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(JsonIdentityStore {
|
||||
@@ -157,13 +215,123 @@ impl JsonIdentityStore {
|
||||
|
||||
/// Atomically persist the full row set (temp + rename).
|
||||
fn persist(&self, index: &Index) -> Result<(), IdError> {
|
||||
let json =
|
||||
serde_json::to_vec_pretty(&index.rows).map_err(|e| IdError::Corrupt(e.to_string()))?;
|
||||
let file = StoreFile {
|
||||
rows: index.rows.clone(),
|
||||
watermarks: index
|
||||
.watermarks
|
||||
.iter()
|
||||
.map(|((g, k), &n)| Watermark {
|
||||
game_id: g.clone(),
|
||||
entity_kind: k.clone(),
|
||||
next_id: n,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let json = serde_json::to_vec_pretty(&file).map_err(|e| IdError::Corrupt(e.to_string()))?;
|
||||
let tmp = self.path.with_extension("tmp");
|
||||
std::fs::write(&tmp, &json).map_err(IdError::Io)?;
|
||||
std::fs::rename(&tmp, &self.path).map_err(IdError::Io)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register an EXISTING external id for `(game, kind, core_id)` instead of
|
||||
/// allocating one — the import path that preserves a source system's wire
|
||||
/// ids. Idempotent for an identical mapping; rejects a conflicting forward
|
||||
/// (core already mapped elsewhere) or reverse (external already owned)
|
||||
/// mapping with [`IdError::Conflict`]; persists both directions atomically.
|
||||
pub fn insert_existing_mapping(
|
||||
&self,
|
||||
game: &str,
|
||||
kind: &str,
|
||||
core_id: &str,
|
||||
external_id: i64,
|
||||
) -> Result<(), IdError> {
|
||||
let mut ix = self.inner.lock();
|
||||
let fkey = (game.to_string(), kind.to_string(), core_id.to_string());
|
||||
let rkey = (game.to_string(), kind.to_string(), external_id);
|
||||
let fwd_existing = ix.fwd.get(&fkey).copied();
|
||||
let rev_existing = ix.rev.get(&rkey).cloned();
|
||||
if let Some(e) = fwd_existing {
|
||||
if e == external_id && rev_existing.as_deref() == Some(core_id) {
|
||||
return Ok(()); // identical mapping — idempotent
|
||||
}
|
||||
return Err(IdError::Conflict(format!(
|
||||
"core '{core_id}' already maps to {e} in ({game}, {kind}), not {external_id}"
|
||||
)));
|
||||
}
|
||||
if let Some(c) = rev_existing {
|
||||
return Err(IdError::Conflict(format!(
|
||||
"external {external_id} already owned by core '{c}' in ({game}, {kind}), not '{core_id}'"
|
||||
)));
|
||||
}
|
||||
ix.insert(Row {
|
||||
game_id: game.to_string(),
|
||||
entity_kind: kind.to_string(),
|
||||
core_id: core_id.to_string(),
|
||||
external_id,
|
||||
});
|
||||
if let Err(e) = self.persist(&ix) {
|
||||
// roll the in-memory insert back so memory and disk never diverge
|
||||
ix.rows.pop();
|
||||
ix.fwd.remove(&fkey);
|
||||
ix.rev.remove(&rkey);
|
||||
let scope = (game.to_string(), kind.to_string());
|
||||
let new_max = ix
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|r| r.game_id == game && r.entity_kind == kind)
|
||||
.map(|r| r.external_id)
|
||||
.max();
|
||||
match new_max {
|
||||
Some(m) => {
|
||||
ix.max.insert(scope, m);
|
||||
}
|
||||
None => {
|
||||
ix.max.remove(&scope);
|
||||
}
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the per-scope allocator watermark (the next external id to issue),
|
||||
/// imported from the source system so future allocations continue past its
|
||||
/// historical high-water even where those ids left no live mapping (gaps).
|
||||
/// A floor: it never lowers the effective next id below the live max.
|
||||
pub fn set_watermark(&self, game: &str, kind: &str, next_id: i64) -> Result<(), IdError> {
|
||||
let mut ix = self.inner.lock();
|
||||
let scope = (game.to_string(), kind.to_string());
|
||||
let prev = ix.watermarks.insert(scope.clone(), next_id);
|
||||
if let Err(e) = self.persist(&ix) {
|
||||
match prev {
|
||||
Some(p) => {
|
||||
ix.watermarks.insert(scope, p);
|
||||
}
|
||||
None => {
|
||||
ix.watermarks.remove(&scope);
|
||||
}
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The imported watermark for a scope, if any.
|
||||
pub fn watermark_for(&self, game: &str, kind: &str) -> Option<i64> {
|
||||
self.inner
|
||||
.lock()
|
||||
.watermarks
|
||||
.get(&(game.to_string(), kind.to_string()))
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// The external id the next allocation in this scope WOULD receive, without
|
||||
/// allocating. For dry-run reporting.
|
||||
pub fn peek_next_external(&self, game: &str, kind: &str, base_floor: i64) -> i64 {
|
||||
let ix = self.inner.lock();
|
||||
next_external(&ix, game, kind, base_floor)
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalIdentityStore for JsonIdentityStore {
|
||||
@@ -180,10 +348,7 @@ impl ExternalIdentityStore for JsonIdentityStore {
|
||||
return Ok(ext);
|
||||
}
|
||||
let scope = (game.to_string(), kind.to_string());
|
||||
let next = match ix.max.get(&scope) {
|
||||
Some(&m) => (m + 1).max(base_floor),
|
||||
None => base_floor,
|
||||
};
|
||||
let next = next_external(&ix, game, kind, base_floor);
|
||||
let row = Row {
|
||||
game_id: game.to_string(),
|
||||
entity_kind: kind.to_string(),
|
||||
@@ -334,4 +499,83 @@ mod tests {
|
||||
"ambiguous reverse must not load"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_existing_mapping_preserves_and_is_idempotent() {
|
||||
let (s, _d) = store();
|
||||
s.insert_existing_mapping(G, K, "oc-A", 100_004_600)
|
||||
.unwrap();
|
||||
assert_eq!(s.external_for(G, K, "oc-A").unwrap(), Some(100_004_600));
|
||||
assert_eq!(
|
||||
s.core_for(G, K, 100_004_600).unwrap().as_deref(),
|
||||
Some("oc-A")
|
||||
);
|
||||
// identical re-insert is idempotent
|
||||
s.insert_existing_mapping(G, K, "oc-A", 100_004_600)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_existing_mapping_rejects_conflicts() {
|
||||
let (s, _d) = store();
|
||||
s.insert_existing_mapping(G, K, "oc-A", 100_000_010)
|
||||
.unwrap();
|
||||
// same core -> different external: reject
|
||||
assert!(matches!(
|
||||
s.insert_existing_mapping(G, K, "oc-A", 100_000_011),
|
||||
Err(IdError::Conflict(_))
|
||||
));
|
||||
// same external -> different core: reject
|
||||
assert!(matches!(
|
||||
s.insert_existing_mapping(G, K, "oc-B", 100_000_010),
|
||||
Err(IdError::Conflict(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watermark_preserves_allocation_history_past_gaps() {
|
||||
let (s, _d) = store();
|
||||
// a live mapping (highest surviving id) plus a source watermark that sits
|
||||
// ABOVE it because ids in between were issued then removed (burned gap).
|
||||
s.insert_existing_mapping(G, K, "oc-live", 100_004_605)
|
||||
.unwrap();
|
||||
s.set_watermark(G, K, 100_004_617).unwrap();
|
||||
assert_eq!(s.peek_next_external(G, K, BASE), 100_004_617);
|
||||
// first NEW allocation is the source next-to-issue, NOT live_max+1
|
||||
assert_eq!(
|
||||
s.resolve_or_allocate(G, K, "oc-new", BASE).unwrap(),
|
||||
100_004_617
|
||||
);
|
||||
// then continues monotonically
|
||||
assert_eq!(
|
||||
s.resolve_or_allocate(G, K, "oc-new2", BASE).unwrap(),
|
||||
100_004_618
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watermark_and_imported_ids_survive_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ids.json");
|
||||
{
|
||||
let s = JsonIdentityStore::open(&path).unwrap();
|
||||
s.insert_existing_mapping(G, K, "oc-A", 100_000_001)
|
||||
.unwrap();
|
||||
s.insert_existing_mapping(G, K, "oc-B", 100_004_605)
|
||||
.unwrap();
|
||||
s.set_watermark(G, K, 100_004_617).unwrap();
|
||||
}
|
||||
let s = JsonIdentityStore::open(&path).unwrap();
|
||||
assert_eq!(s.external_for(G, K, "oc-A").unwrap(), Some(100_000_001));
|
||||
assert_eq!(
|
||||
s.core_for(G, K, 100_004_605).unwrap().as_deref(),
|
||||
Some("oc-B")
|
||||
);
|
||||
assert_eq!(s.watermark_for(G, K), Some(100_004_617));
|
||||
// sparse import: next allocation respects the persisted watermark
|
||||
assert_eq!(
|
||||
s.resolve_or_allocate(G, K, "oc-C", BASE).unwrap(),
|
||||
100_004_617
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
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."
|
||||
|
||||
[[bin]]
|
||||
name = "openfut-import-fifa17"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "openfut_import_fifa17"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,698 @@
|
||||
//! 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:
|
||||
//!
|
||||
//! * 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;
|
||||
//! * 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.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
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.
|
||||
pub const OWNED_ITEM_BASE_FLOOR: i64 = 100_000_001;
|
||||
|
||||
pub mod model;
|
||||
|
||||
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).
|
||||
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)
|
||||
}
|
||||
|
||||
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 of these.
|
||||
#[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,
|
||||
}
|
||||
|
||||
pub fn classify(item: &Item) -> ItemClass {
|
||||
match item.item_type.as_str() {
|
||||
"staff" => ItemClass::Staff,
|
||||
"player" => {
|
||||
if item.attribute_list.is_some() {
|
||||
ItemClass::PlayerCard
|
||||
} else {
|
||||
ItemClass::Consumable
|
||||
}
|
||||
}
|
||||
_ => ItemClass::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Disjoint counts; `balances()` is the invariant the dry-run enforces.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ItemCounts {
|
||||
pub total: usize,
|
||||
pub player_cards: usize,
|
||||
pub consumables: usize,
|
||||
pub staff: usize,
|
||||
pub other: usize,
|
||||
}
|
||||
|
||||
impl ItemCounts {
|
||||
pub fn balances(&self) -> bool {
|
||||
self.player_cards + self.consumables + self.staff + 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::Other => c.other += 1,
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- 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.
|
||||
#[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>,
|
||||
/// 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.
|
||||
#[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> },
|
||||
}
|
||||
|
||||
/// 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_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,
|
||||
}
|
||||
|
||||
/// `version = resourceId >> 24`; base cards are version 0 (`resourceId == assetId`).
|
||||
pub fn version_of(resource_id: i64) -> i64 {
|
||||
resource_id >> 24
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
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 {
|
||||
// 1) consistency gate: all copies must share definition-level data.
|
||||
let first = def_fields(items[0]);
|
||||
let mut distinct = vec![first.clone()];
|
||||
for it in &items[1..] {
|
||||
let f = def_fields(it);
|
||||
if !distinct.contains(&f) {
|
||||
distinct.push(f);
|
||||
}
|
||||
}
|
||||
if distinct.len() > 1 {
|
||||
plan.conflicts.push(DefConflict {
|
||||
resource_id,
|
||||
wire_ids: items.iter().map(|i| i.id).collect(),
|
||||
distinct,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// 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 asset_id.is_none() {
|
||||
missing.push("assetId".into());
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
plan.unsupported.push((
|
||||
resource_id,
|
||||
owned_copies,
|
||||
Unsupported::MissingMetadata { fields: missing },
|
||||
));
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
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(),
|
||||
attrs: attrs6.unwrap(),
|
||||
rareflag: f.rareflag.unwrap_or(0),
|
||||
owned_copies,
|
||||
});
|
||||
}
|
||||
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
|
||||
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.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
|
||||
|
||||
/// 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();
|
||||
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; // empty slot
|
||||
}
|
||||
cov.occupied_slots += 1;
|
||||
if supported.contains(&wid) {
|
||||
cov.supported_slots += 1;
|
||||
} else {
|
||||
cov.unsupported_slots.push((slot.index, wid));
|
||||
}
|
||||
}
|
||||
cov
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- report
|
||||
|
||||
/// The full dry-run analysis.
|
||||
#[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,
|
||||
}
|
||||
|
||||
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.
|
||||
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.other,
|
||||
self.counts.total
|
||||
));
|
||||
}
|
||||
if self.counts.other > 0 {
|
||||
b.push(format!(
|
||||
"{} item(s) fall in no known class (itemType neither player nor staff)",
|
||||
self.counts.other
|
||||
));
|
||||
}
|
||||
if !self.definitions.conflicts.is_empty() {
|
||||
b.push(format!(
|
||||
"{} resourceId group(s) disagree on definition-level data (hard conflict)",
|
||||
self.definitions.conflicts.len()
|
||||
));
|
||||
}
|
||||
if !self.identity.duplicate_wire_ids.is_empty() {
|
||||
b.push(format!(
|
||||
"{} duplicate owned wire id(s) — instance identity is not unique",
|
||||
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) -> Report {
|
||||
let counts = count_items(profile);
|
||||
let definitions = plan_definitions(profile, roster);
|
||||
let supported_rids: Vec<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);
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
writeln!(
|
||||
f,
|
||||
"OpenFUT FIFA17 real-profile import — DRY RUN (no writes)"
|
||||
)?;
|
||||
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 (disjoint classes)")?;
|
||||
writeln!(
|
||||
f,
|
||||
" total={} player_cards={} consumables={} staff={} other={} balances={}",
|
||||
self.counts.total,
|
||||
self.counts.player_cards,
|
||||
self.counts.consumables,
|
||||
self.counts.staff,
|
||||
self.counts.other,
|
||||
self.counts.balances()
|
||||
)?;
|
||||
writeln!(f, "\nPLAYER DEFINITIONS (card_id = fifa17_<resourceId>)")?;
|
||||
writeln!(
|
||||
f,
|
||||
" supported={} (base={} versioned={}) unsupported={} conflicts={}",
|
||||
d.supported.len(),
|
||||
d.base_defs,
|
||||
d.versioned_defs,
|
||||
d.unsupported.len(),
|
||||
d.conflicts.len()
|
||||
)?;
|
||||
for (rid, copies, why) in &d.unsupported {
|
||||
writeln!(
|
||||
f,
|
||||
" UNSUPPORTED resourceId={rid} copies={copies}: {why:?}"
|
||||
)?;
|
||||
}
|
||||
for c in &d.conflicts {
|
||||
writeln!(
|
||||
f,
|
||||
" CONFLICT resourceId={} wire_ids={:?} distinct_defs={}",
|
||||
c.resource_id,
|
||||
c.wire_ids,
|
||||
c.distinct.len()
|
||||
)?;
|
||||
}
|
||||
writeln!(f, "\nIDENTITY (preserve existing Python wire ids)")?;
|
||||
writeln!(
|
||||
f,
|
||||
" import_instances={} live_min={:?} live_max={:?} duplicates={}",
|
||||
id.import_wire_ids.len(),
|
||||
id.live_min,
|
||||
id.live_max,
|
||||
id.duplicate_wire_ids.len()
|
||||
)?;
|
||||
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={:?}",
|
||||
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, " (no squad in profile)")?;
|
||||
}
|
||||
writeln!(f, "\nRESULT")?;
|
||||
writeln!(
|
||||
f,
|
||||
" would_import_players={} deferred_consumables={} deferred_staff={} unsupported_players(defs)={}",
|
||||
id.import_wire_ids.len(),
|
||||
self.counts.consumables,
|
||||
self.counts.staff,
|
||||
d.unsupported.len()
|
||||
)?;
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,70 @@
|
||||
//! `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.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(code) => code,
|
||||
Err(e) => {
|
||||
eprintln!("import-fifa17: {e:#}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
eprintln!(
|
||||
"openfut-import-fifa17 --profile <fifa17_profile.json> [--roster <roster.json>]\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\
|
||||
\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"
|
||||
);
|
||||
}
|
||||
|
||||
fn run() -> Result<ExitCode> {
|
||||
let mut profile_path: Option<String> = None;
|
||||
let mut roster_path = "fifa17-recon/data/roster.json".to_string();
|
||||
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")?,
|
||||
"--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"
|
||||
),
|
||||
"-h" | "--help" => {
|
||||
print_help();
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
other => bail!("unknown argument: {other} (try --help)"),
|
||||
}
|
||||
}
|
||||
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);
|
||||
print!("{report}");
|
||||
|
||||
if report.has_blockers() {
|
||||
eprintln!("import-fifa17: DRY-RUN FAILED — unsafe blockers present (see above)");
|
||||
Ok(ExitCode::FAILURE)
|
||||
} else {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Serde model of the real FIFA 17 Python profile (`fifa17_profile.json`).
|
||||
//! Only the fields the importer reasons about are modelled; every field is
|
||||
//! permissive (`Option`/`default`) because the source is external and unknown
|
||||
//! fields are ignored. Nothing here is written back.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Profile {
|
||||
#[serde(rename = "personaId", default)]
|
||||
pub persona_id: i64,
|
||||
#[serde(rename = "personaName", default)]
|
||||
pub persona_name: String,
|
||||
#[serde(rename = "clubName", default)]
|
||||
pub club_name: String,
|
||||
#[serde(rename = "clubAbbr", default)]
|
||||
pub club_abbr: String,
|
||||
#[serde(default)]
|
||||
pub coins: i64,
|
||||
/// Next wire id to issue (issue-then-increment: `fut_store.py` assigns this
|
||||
/// value then increments). The allocation watermark to preserve.
|
||||
#[serde(rename = "nextItemId", default)]
|
||||
pub next_item_id: i64,
|
||||
#[serde(default)]
|
||||
pub items: Vec<Item>,
|
||||
#[serde(default)]
|
||||
pub squads: Vec<Squad>,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn from_json_str(raw: &str) -> Result<Self> {
|
||||
serde_json::from_str(raw).context("parsing fifa17_profile.json")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Item {
|
||||
/// The per-INSTANCE wire id (preserved on import).
|
||||
pub id: i64,
|
||||
/// The per-DEFINITION versioned id: `resourceId = (version << 24) | assetId`.
|
||||
#[serde(rename = "resourceId", default)]
|
||||
pub resource_id: i64,
|
||||
#[serde(rename = "assetId", default)]
|
||||
pub asset_id: Option<i64>,
|
||||
#[serde(rename = "itemType", default)]
|
||||
pub item_type: String,
|
||||
#[serde(default)]
|
||||
pub rareflag: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub rating: Option<i64>,
|
||||
#[serde(rename = "preferredPosition", default)]
|
||||
pub preferred_position: Option<String>,
|
||||
#[serde(default)]
|
||||
pub nation: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub teamid: Option<i64>,
|
||||
#[serde(rename = "leagueId", default)]
|
||||
pub league_id: Option<i64>,
|
||||
#[serde(rename = "attributeList", default)]
|
||||
pub attribute_list: Option<Vec<Attr>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Attr {
|
||||
pub index: i64,
|
||||
pub value: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Squad {
|
||||
#[serde(default)]
|
||||
pub formation: String,
|
||||
#[serde(rename = "squadName", default)]
|
||||
pub squad_name: String,
|
||||
#[serde(default)]
|
||||
pub captain: Option<i64>,
|
||||
/// Opaque staff refs; each is `{ "id": <wire id>, ... }`.
|
||||
#[serde(default)]
|
||||
pub manager: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub players: Vec<SquadSlot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SquadSlot {
|
||||
pub index: i64,
|
||||
#[serde(rename = "itemData", default)]
|
||||
pub item_data: SlotItem,
|
||||
#[serde(rename = "kitNumber", default)]
|
||||
pub kit_number: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct SlotItem {
|
||||
/// Wire id of the owned instance in this slot; `0` = empty slot.
|
||||
#[serde(default)]
|
||||
pub id: i64,
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
use super::*;
|
||||
use model::Profile;
|
||||
|
||||
const VER5_176580: i64 = (5 << 24) | 176580; // versioned resourceId for asset 176580
|
||||
|
||||
fn roster() -> Roster {
|
||||
Roster::from_json_str(
|
||||
r#"[
|
||||
{"id":20801,"first":"Cristiano","last":"Ronaldo","common":""},
|
||||
{"id":176580,"first":"Luis","last":"Suárez","common":""},
|
||||
{"id":158023,"first":"Lionel","last":"Messi","common":""}
|
||||
]"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fn player(id: i64, resource: i64, asset: i64, rating: i64) -> String {
|
||||
format!(
|
||||
r#"{{"id":{id},"resourceId":{resource},"assetId":{asset},"itemType":"player",
|
||||
"rareflag":1,"rating":{rating},"preferredPosition":"ST","nation":38,
|
||||
"teamid":243,"leagueId":53,"attributeList":{}}}"#,
|
||||
attrs()
|
||||
)
|
||||
}
|
||||
|
||||
fn profile(items: &[String], squads: &str, next_item_id: i64) -> Profile {
|
||||
let json = format!(
|
||||
r#"{{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC",
|
||||
"coins":1000,"nextItemId":{next_item_id},"items":[{}],"squads":{}}}"#,
|
||||
items.join(","),
|
||||
squads
|
||||
);
|
||||
Profile::from_json_str(&json).unwrap()
|
||||
}
|
||||
|
||||
#[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
|
||||
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);
|
||||
assert!(c.balances());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_formula_and_card_id() {
|
||||
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());
|
||||
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
|
||||
}
|
||||
|
||||
#[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());
|
||||
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()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_copies_share_definition_but_preserve_distinct_wire_ids() {
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100000015, 20801, 20801, 94), // second owned copy
|
||||
];
|
||||
let p = profile(&items, "[]", 100000500);
|
||||
let plan = plan_definitions(&p, &roster());
|
||||
assert_eq!(
|
||||
plan.supported.len(),
|
||||
1,
|
||||
"one definition for the shared resource"
|
||||
);
|
||||
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.has_blockers(),
|
||||
"a definition conflict is an unsafe blocker"
|
||||
);
|
||||
}
|
||||
|
||||
#[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());
|
||||
assert!(plan.supported.is_empty());
|
||||
assert!(matches!(
|
||||
plan.unsupported.first(),
|
||||
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());
|
||||
assert!(plan.supported.is_empty());
|
||||
assert!(matches!(
|
||||
plan.unsupported.first(),
|
||||
Some((_, _, Unsupported::NoName { asset_id: 777777 }))
|
||||
));
|
||||
}
|
||||
|
||||
#[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));
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn squad_coverage_flags_unsupported_starter_and_keeps_manager_ref() {
|
||||
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}],
|
||||
"players":[
|
||||
{"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 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.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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_is_deterministic() {
|
||||
let items = vec![
|
||||
player(100000002, VER5_176580, 176580, 92),
|
||||
player(100000001, 20801, 20801, 94),
|
||||
];
|
||||
let p = profile(&items, "[]", 100000500);
|
||||
let a = analyze(&p, &roster());
|
||||
let b = analyze(&p, &roster());
|
||||
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() {
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100000002, VER5_176580, 176580, 92),
|
||||
r#"{"id":100000239,"resourceId":5003012,"assetId":5003012,"itemType":"player","rating":85}"#.to_string(),
|
||||
r#"{"id":100000427,"resourceId":3000083,"itemType":"staff"}"#.to_string(),
|
||||
];
|
||||
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());
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user