feat(import): --emit-content (production pack + host catalog + private manifest)
Fold entity resolution (nation/league/club id->name via committed tables,
mirroring seed_fifa17_cards.py) and quality-tier rarity into the analysis, so
the supported set is honest about unresolved entities too. Add an explicit
--defer-conflict <rid> allowlist: a reviewed conflict (169193) defers, any NEW
conflict still hard-fails (defer never becomes a silent conflict suppressor).
--emit-content writes three files, PUBLIC content separated from PRIVATE account
state: fifa17-production-cards.json (Core CardDefinition[] keyed fifa17_<resourceId>,
base+versioned, tier rarity, profile-derived, no promo labels), a versioned host
identity catalog {card_id:{asset_id,version}}, and a private import manifest
(supported instances' wire ids + deferred set with reasons + preserved watermark
+ target profile + snapshot fingerprint). Emit refuses while blockers exist.
Real profile (33068179/CAGE): 1681 supported defs (150 base + 1531 versioned),
9 NoName deferred, 1 approved-deferred conflict (169193, 4 copies), 1949
importable instances, watermark 100004617 -> next 100004617, active squad f433
11/11 supported. fmt + clippy -D warnings clean; 13 tests.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use model::Profile;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const VER5_176580: i64 = (5 << 24) | 176580; // versioned resourceId for asset 176580
|
||||
|
||||
@@ -14,12 +15,28 @@ fn roster() -> Roster {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn entities() -> Entities {
|
||||
Entities::from_maps(
|
||||
BTreeMap::from([(53, "LaLiga".to_string()), (16, "Serie A".to_string())]),
|
||||
BTreeMap::from([(38, "Portugal".to_string()), (45, "Spain".to_string())]),
|
||||
BTreeMap::from([
|
||||
(243, "Real Madrid".to_string()),
|
||||
(21, "Juventus".to_string()),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
fn none() -> BTreeSet<i64> {
|
||||
BTreeSet::new()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// A resolvable player (nation 38, team 243, league 53 all in `entities()`).
|
||||
fn player(id: i64, resource: i64, asset: i64, rating: i64) -> String {
|
||||
format!(
|
||||
r#"{{"id":{id},"resourceId":{resource},"assetId":{asset},"itemType":"player",
|
||||
@@ -39,145 +56,169 @@ fn profile(items: &[String], squads: &str, next_item_id: i64) -> Profile {
|
||||
Profile::from_json_str(&json).unwrap()
|
||||
}
|
||||
|
||||
fn defs(items: &[String]) -> DefinitionPlan {
|
||||
plan_definitions(
|
||||
&profile(items, "[]", 100000500),
|
||||
&roster(),
|
||||
&entities(),
|
||||
&none(),
|
||||
)
|
||||
}
|
||||
|
||||
#[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
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100000002, VER5_176580, 176580, 92),
|
||||
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);
|
||||
let c = count_items(&profile(&items, "[]", 100000500));
|
||||
assert_eq!(
|
||||
(c.total, c.player_cards, c.consumables, c.staff, c.other),
|
||||
(4, 2, 1, 1, 0)
|
||||
);
|
||||
assert!(c.balances());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_formula_and_card_id() {
|
||||
fn version_formula_card_id_and_resolved_names() {
|
||||
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());
|
||||
let plan = defs(&[player(100000002, VER5_176580, 176580, 92)]);
|
||||
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
|
||||
assert_eq!(d.name, "Luis Suárez"); // base-asset name, never fabricated
|
||||
assert_eq!(
|
||||
(d.nation.as_str(), d.league.as_str(), d.club.as_str()),
|
||||
("Portugal", "LaLiga", "Real Madrid")
|
||||
);
|
||||
assert_eq!(d.rarity, "gold"); // rating 92 -> tier, NOT a promo label
|
||||
}
|
||||
|
||||
#[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());
|
||||
let plan = defs(&[
|
||||
player(100000010, 176580, 176580, 92),
|
||||
player(100000011, VER5_176580, 176580, 92),
|
||||
]);
|
||||
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()));
|
||||
assert_eq!((plan.base_defs, plan.versioned_defs), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_copies_share_definition_but_preserve_distinct_wire_ids() {
|
||||
fn duplicate_copies_share_definition_but_keep_distinct_wire_ids() {
|
||||
let plan = defs(&[
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100000015, 20801, 20801, 94),
|
||||
]);
|
||||
assert_eq!(plan.supported.len(), 1);
|
||||
assert_eq!(plan.supported[0].wire_ids, vec![100000001, 100000015]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_conflict_blocks_but_approved_conflict_defers() {
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100000015, 20801, 20801, 94), // second owned copy
|
||||
player(100000002, 20801, 20801, 93), // disagree on rating
|
||||
];
|
||||
let p = profile(&items, "[]", 100000500);
|
||||
let plan = plan_definitions(&p, &roster());
|
||||
assert_eq!(
|
||||
plan.supported.len(),
|
||||
1,
|
||||
"one definition for the shared resource"
|
||||
// unapproved: a hard blocker
|
||||
let rep = analyze(
|
||||
&profile(&items, "[]", 100000500),
|
||||
&roster(),
|
||||
&entities(),
|
||||
&none(),
|
||||
);
|
||||
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.definitions.supported.is_empty());
|
||||
assert_eq!(rep.definitions.conflicts.len(), 1);
|
||||
assert!(!rep.definitions.conflicts[0].approved_defer);
|
||||
assert!(rep.has_blockers(), "a NEW conflict must block");
|
||||
// approved: deferred, not blocking
|
||||
let ok = analyze(
|
||||
&profile(&items, "[]", 100000500),
|
||||
&roster(),
|
||||
&entities(),
|
||||
&BTreeSet::from([20801]),
|
||||
);
|
||||
assert!(ok.definitions.conflicts[0].approved_defer);
|
||||
assert!(
|
||||
rep.has_blockers(),
|
||||
"a definition conflict is an unsafe blocker"
|
||||
!ok.has_blockers(),
|
||||
"an approved-deferred conflict does not block"
|
||||
);
|
||||
assert_eq!(
|
||||
ok.deferred_instances(),
|
||||
2,
|
||||
"both copies counted as deferred"
|
||||
);
|
||||
}
|
||||
|
||||
#[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());
|
||||
fn version_formula_violation_defers_not_fabricates() {
|
||||
let plan = defs(&[player(100000003, 999, 176580, 92)]); // 999 != (0<<24)|176580
|
||||
assert!(plan.supported.is_empty());
|
||||
assert!(matches!(
|
||||
plan.unsupported.first(),
|
||||
Some((_, _, Unsupported::VersionFormula { .. }))
|
||||
plan.deferred.first().map(|d| &d.reason),
|
||||
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());
|
||||
fn missing_roster_name_defers_never_faked() {
|
||||
let plan = defs(&[player(100000004, 777777, 777777, 80)]); // 777777 not in roster
|
||||
assert!(plan.supported.is_empty());
|
||||
assert!(matches!(
|
||||
plan.unsupported.first(),
|
||||
Some((_, _, Unsupported::NoName { asset_id: 777777 }))
|
||||
plan.deferred.first().map(|d| &d.reason),
|
||||
Some(Unsupported::NoName { asset_id: 777777 })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_entity_defers() {
|
||||
// nation 999 not in entities()
|
||||
let item = format!(
|
||||
r#"{{"id":100000005,"resourceId":20801,"assetId":20801,"itemType":"player","rareflag":1,
|
||||
"rating":94,"preferredPosition":"ST","nation":999,"teamid":243,"leagueId":53,
|
||||
"attributeList":{}}}"#,
|
||||
attrs()
|
||||
);
|
||||
let plan = defs(&[item]);
|
||||
assert!(plan.supported.is_empty());
|
||||
assert!(matches!(
|
||||
plan.deferred.first().map(|d| &d.reason),
|
||||
Some(Unsupported::UnresolvedEntity { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[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));
|
||||
let idp = plan_identity(
|
||||
&profile(&items, "[]", 100004617),
|
||||
&BTreeSet::from([20801, 176580]),
|
||||
);
|
||||
assert_eq!(
|
||||
(idp.live_min, idp.live_max),
|
||||
(Some(100000001), 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);
|
||||
assert_eq!(
|
||||
idp.next_allocation, 100004617,
|
||||
"next-to-issue, not live_max+1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn squad_coverage_flags_unsupported_starter_and_keeps_manager_ref() {
|
||||
fn squad_coverage_flags_unsupported_starter_and_keeps_manager() {
|
||||
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}],
|
||||
@@ -185,19 +226,19 @@ fn squad_coverage_flags_unsupported_starter_and_keeps_manager_ref() {
|
||||
{"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 rep = analyze(
|
||||
&profile(&items, squads, 100000500),
|
||||
&roster(),
|
||||
&entities(),
|
||||
&none(),
|
||||
);
|
||||
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.occupied_slots, sq.supported_slots), (2, 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");
|
||||
assert!(rep.has_blockers());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -207,14 +248,14 @@ fn analyze_is_deterministic() {
|
||||
player(100000001, 20801, 20801, 94),
|
||||
];
|
||||
let p = profile(&items, "[]", 100000500);
|
||||
let a = analyze(&p, &roster());
|
||||
let b = analyze(&p, &roster());
|
||||
let a = analyze(&p, &roster(), &entities(), &none());
|
||||
let b = analyze(&p, &roster(), &entities(), &none());
|
||||
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() {
|
||||
fn emit_content_writes_public_pack_catalog_and_private_manifest() {
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100000002, VER5_176580, 176580, 92),
|
||||
@@ -224,9 +265,56 @@ fn clean_profile_has_no_blockers_and_defers_non_players() {
|
||||
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());
|
||||
let p = profile(&items, squads, 100000500);
|
||||
let rep = analyze(&p, &roster(), &entities(), &none());
|
||||
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);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sum = emit_content(&rep, dir.path(), "deadbeef").unwrap();
|
||||
assert_eq!(sum.definitions, 2);
|
||||
assert_eq!(sum.supported_instances, 2);
|
||||
assert_eq!(sum.deferred_instances, 0);
|
||||
assert!(sum.content_pack.exists());
|
||||
assert!(sum.host_catalog.exists());
|
||||
assert!(sum.manifest.exists());
|
||||
|
||||
// content pack is a Core CardDefinition[] with our card_id + resolved names
|
||||
let pack: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&sum.content_pack).unwrap()).unwrap();
|
||||
let ids: Vec<&str> = pack
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c["id"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(ids.contains(&"fifa17_20801"));
|
||||
assert!(ids.contains(&format!("fifa17_{VER5_176580}").as_str()));
|
||||
|
||||
// catalog carries versioned entries
|
||||
let cat: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&sum.host_catalog).unwrap()).unwrap();
|
||||
assert_eq!(cat["cards"][format!("fifa17_{VER5_176580}")]["version"], 5);
|
||||
assert_eq!(cat["cards"]["fifa17_20801"]["version"], 0);
|
||||
|
||||
// manifest preserves wire ids and is separate from public content
|
||||
let man: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&sum.manifest).unwrap()).unwrap();
|
||||
assert_eq!(man["identity"]["source_watermark"], 100000500);
|
||||
assert_eq!(man["target"]["persona_id"], 33068179);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_refuses_when_blocked() {
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100000002, 20801, 20801, 93), // unapproved conflict -> blocker
|
||||
];
|
||||
let rep = analyze(
|
||||
&profile(&items, "[]", 100000500),
|
||||
&roster(),
|
||||
&entities(),
|
||||
&none(),
|
||||
);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(emit_content(&rep, dir.path(), "x").is_err());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user