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
+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);
}