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:
@@ -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