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:
funman300
2026-08-12 19:23:19 +00:00
parent 63f02c4fb1
commit a51947562c
8 changed files with 1380 additions and 9 deletions
Generated
+9
View File
@@ -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"
+1
View File
@@ -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
View File
@@ -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
);
}
}
+18
View File
@@ -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"
+698
View File
@@ -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;
+70
View File
@@ -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)
}
}
+99
View File
@@ -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,
}
+232
View File
@@ -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);
}