use super::*; use model::Profile; use std::collections::{BTreeMap, BTreeSet}; 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 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 { 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", "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() } 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), 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(), r#"{"id":100000500,"resourceId":6300006,"assetId":6300006, "cardsubtypeid":9,"cardassetid":35,"teamid":21,"itemState":"activeHomeKit"}"# .to_string(), ]; let c = count_items(&profile(&items, "[]", 100000501)); assert_eq!( ( c.total, c.player_cards, c.consumables, c.staff, c.club_items, c.other ), (5, 2, 1, 1, 1, 0) ); assert!(c.balances()); } #[test] fn version_formula_card_id_and_resolved_names() { assert_eq!(version_of(20801), 0); assert_eq!(version_of(VER5_176580), 5); 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, 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() { 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, plan.versioned_defs), (1, 1)); } #[test] 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(100000002, 20801, 20801, 93), // disagree on rating ]; // unapproved: a hard blocker let rep = analyze( &profile(&items, "[]", 100000500), &roster(), &entities(), &none(), ); 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!( !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_defers_not_fabricates() { let plan = defs(&[player(100000003, 999, 176580, 92)]); // 999 != (0<<24)|176580 assert!(plan.supported.is_empty()); assert!(matches!( plan.deferred.first().map(|d| &d.reason), Some(Unsupported::VersionFormula { .. }) )); } #[test] 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.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() { let items = vec![ player(100000001, 20801, 20801, 94), player(100004605, 176580, 176580, 92), ]; 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); assert_eq!( idp.next_allocation, 100004617, "next-to-issue, not live_max+1" ); } #[test] fn squad_coverage_flags_unsupported_starter_and_keeps_manager() { let items = vec![ player(100000001, 20801, 20801, 94), player(100000002, 176580, 176580, 92), ]; 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 rep = analyze( &profile(&items, squads, 100000500), &roster(), &entities(), &none(), ); let sq = &rep.squad; 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()); } #[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(), &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 emit_content_writes_public_pack_catalog_and_private_manifest() { 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 p = profile(&items, squads, 100000500); let rep = analyze(&p, &roster(), &entities(), &none()); assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers()); 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()); } #[test] fn club_only_difference_across_copies_is_not_a_conflict() { // Two owned copies of one resourceId, identical in asset/rating/position/ // attributes but DIFFERENT nation/team/league (a club-affiliation snapshot). // Evidence (resourceId 169193) says this is NOT a conflict: one definition, // both preserved wire ids, display club from the first-observed copy. let a = format!( r#"{{"id":100000001,"resourceId":20801,"assetId":20801,"itemType":"player","rareflag":1, "rating":94,"preferredPosition":"ST","nation":38,"teamid":243,"leagueId":53,"attributeList":{}}}"#, attrs() ); let b = format!( r#"{{"id":100000002,"resourceId":20801,"assetId":20801,"itemType":"player","rareflag":1, "rating":94,"preferredPosition":"ST","nation":45,"teamid":21,"leagueId":16,"attributeList":{}}}"#, attrs() ); let plan = defs(&[a, b]); assert!( plan.conflicts.is_empty(), "a club-only difference must NOT be a conflict" ); assert_eq!(plan.supported.len(), 1, "one definition"); let d = &plan.supported[0]; assert_eq!( d.wire_ids, vec![100000001, 100000002], "both copies preserved" ); assert_eq!(d.nation, "Portugal", "display club = first observed copy"); assert_eq!(d.club, "Real Madrid"); assert_eq!(d.league, "LaLiga"); } #[test] fn identity_difference_across_copies_is_still_a_conflict() { // A differing rating is a real definition-identity disagreement -> conflict, // never silently merged. let plan = defs(&[ player(100000001, 20801, 20801, 94), player(100000002, 20801, 20801, 90), ]); assert!(plan.supported.is_empty()); assert_eq!(plan.conflicts.len(), 1); } // ------------------------------------------------------------------ apply use crate::apply::{ apply as apply_import, content_card_ids, gate_staging, identity_dry_preflight, local_core_preflight, owned_item_id, plan_apply, post_validate_identity, seed_identity, }; use openfut_identity::{ExternalIdentityStore, JsonIdentityStore}; const SQUAD_F433: &str = r#"[{"formation":"f433","squadName":"OpenFUT","captain":100000001, "squadType":"REGULAR_SQUAD","custom":"[0,0,0]", "players":[{"index":0,"itemData":{"id":100000001},"kitNumber":7}, {"index":1,"itemData":{"id":100000002},"kitNumber":9}], "kicktakers":[{"index":0,"id":100000001}],"manager":[{"id":100000427}]}]"#; /// Build a report + raw profile Value from the same JSON (typed profile for /// analysis, raw Value for the squad wire). fn report_and_raw( items: &[String], squads: &str, next_item_id: i64, ) -> (Report, serde_json::Value) { let json = format!( r#"{{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC", "coins":1000,"nextItemId":{next_item_id},"items":[{}],"squads":{}}}"#, items.join(","), squads ); let prof = Profile::from_json_str(&json).unwrap(); let report = analyze(&prof, &roster(), &entities(), &none()); let raw: serde_json::Value = serde_json::from_str(&json).unwrap(); (report, raw) } #[test] fn owned_item_id_is_deterministic_and_distinct() { let a = owned_item_id(33068179, 100000001); assert_eq!(a, owned_item_id(33068179, 100000001), "stable across calls"); assert_ne!( a, owned_item_id(33068179, 100000002), "distinct per wire id" ); assert_ne!( a, owned_item_id(90909090, 100000001), "distinct per account" ); } #[test] fn plan_apply_builds_generic_request_mappings_and_squad() { let items = vec![ player(100000001, 20801, 20801, 94), player(100000002, VER5_176580, 176580, 92), ]; let (report, raw) = report_and_raw(&items, SQUAD_F433, 100000500); let plan = plan_apply(&report, &raw, "fp-test").unwrap(); assert_eq!(plan.request.owned.len(), 2); assert_eq!(plan.mappings.len(), 2); assert_eq!(plan.watermark, 100000500); assert_eq!(plan.supported_instances, 2); assert_eq!(plan.deferred_instances, 0); assert_eq!(plan.source_fingerprint, "fp-test"); // card ids are fifa17_, base and versioned distinct. let mut cards: Vec<&str> = plan .request .owned .iter() .map(|o| o.card_id.as_str()) .collect(); cards.sort_unstable(); assert_eq!( cards, vec!["fifa17_20801", &format!("fifa17_{VER5_176580}")[..]] ); // mapping core_id == the owned_item_id for that wire (both stores agree). for m in &plan.mappings { assert_eq!(m.core_id, owned_item_id(33068179, m.wire_id)); } // squad + opaque extension. let sq = plan.request.squad.as_ref().expect("squad present"); assert_eq!(sq.formation, "f433"); assert_eq!(sq.slots.len(), 2); assert_eq!(sq.extension.namespace, "fifa17.squad"); assert_eq!(sq.extension.schema_version, 1); assert!( sq.extension.payload.contains("[0,0,0]"), "custom carried verbatim" ); // captain flag follows wire 100000001, keyed by its OwnedItemId. let cap_owned = owned_item_id(33068179, 100000001); assert!(sq .slots .iter() .any(|s| s.owned_item_id == cap_owned && s.is_captain)); } #[test] fn plan_apply_seeds_entitlements_from_unopened_packs() { let items = [player(100000001, 20801, 20801, 94)]; let json = format!( r#"{{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC", "coins":1000,"nextItemId":100000500,"items":[{}],"squads":[],"unopenedPackIds":[70,70,5]}}"#, items.join(",") ); let prof = Profile::from_json_str(&json).unwrap(); let report = analyze(&prof, &roster(), &entities(), &none()); assert_eq!(report.unopened_pack_ids, [70, 70, 5]); let raw: serde_json::Value = serde_json::from_str(&json).unwrap(); let plan = plan_apply(&report, &raw, "fp-ent").unwrap(); let defs: Vec<&str> = plan .request .entitlements .iter() .map(|e| e.definition_id.as_str()) .collect(); // One entitlement per unopened pack instance, order preserved (dup 70 kept). assert_eq!(defs, ["70", "70", "5"]); } #[test] fn plan_apply_refuses_when_blockers_present() { // unapproved same-resourceId conflict -> blocker. let items = vec![ player(100000003, 20801, 20801, 94), player(100000004, 20801, 20801, 90), ]; let (report, raw) = report_and_raw(&items, "[]", 100000500); assert!(plan_apply(&report, &raw, "fp").is_err()); } #[test] fn gate_staging_requires_explicit_optin_for_deferred() { // one supported + one NoName (unresolvable asset) deferred instance. let items = vec![ player(100000001, 20801, 20801, 94), format!( r#"{{"id":100000009,"resourceId":999999,"assetId":999999,"itemType":"player","rating":80, "preferredPosition":"ST","nation":38,"teamid":243,"leagueId":53,"attributeList":{}}}"#, attrs() ), ]; let (report, raw) = report_and_raw(&items, "[]", 100000500); let plan = plan_apply(&report, &raw, "fp").unwrap(); assert_eq!(plan.deferred_instances, 1); assert!(gate_staging(&plan, false).is_err(), "deferred needs opt-in"); assert!(gate_staging(&plan, true).unwrap(), "staging with opt-in"); } #[test] fn gate_staging_production_complete_needs_no_flag() { let items = vec![player(100000001, 20801, 20801, 94)]; let (report, raw) = report_and_raw(&items, "[]", 100000500); let plan = plan_apply(&report, &raw, "fp").unwrap(); assert_eq!(plan.deferred_instances, 0); assert!(!gate_staging(&plan, false).unwrap()); } #[test] fn local_preflight_rejects_owned_card_absent_from_content() { let items = vec![player(100000001, 20801, 20801, 94)]; let (report, raw) = report_and_raw(&items, "[]", 100000500); let plan = plan_apply(&report, &raw, "fp").unwrap(); let mut present = BTreeSet::new(); present.insert("fifa17_20801".to_string()); assert!(local_core_preflight(&plan, &present).is_ok()); assert!(local_core_preflight(&plan, &BTreeSet::new()).is_err()); } #[test] fn identity_seed_dry_postvalidate_and_idempotent_rerun() { let items = vec![ player(100000001, 20801, 20801, 94), player(100000002, VER5_176580, 176580, 92), ]; let (report, raw) = report_and_raw(&items, SQUAD_F433, 100004617); let plan = plan_apply(&report, &raw, "fp").unwrap(); let dir = tempfile::tempdir().unwrap(); let store = JsonIdentityStore::open(dir.path().join("ids.json")).unwrap(); identity_dry_preflight(&store, &plan).unwrap(); seed_identity(&store, &plan).unwrap(); // every preserved wire id resolves exactly; watermark set. for m in &plan.mappings { assert_eq!( store .external_for("fifa17", "owned-item", &m.core_id) .unwrap(), Some(m.wire_id) ); } assert_eq!(store.watermark_for("fifa17", "owned-item"), Some(100004617)); post_validate_identity(&store, &plan).unwrap(); // re-seeding the SAME plan is an idempotent no-op (crash recovery). seed_identity(&store, &plan).unwrap(); post_validate_identity(&store, &plan).unwrap(); } #[test] fn identity_dry_preflight_detects_conflicting_existing_mapping() { let items = vec![player(100000001, 20801, 20801, 94)]; let (report, raw) = report_and_raw(&items, "[]", 100000500); let plan = plan_apply(&report, &raw, "fp").unwrap(); let dir = tempfile::tempdir().unwrap(); let store = JsonIdentityStore::open(dir.path().join("ids.json")).unwrap(); // wire 100000001 already owned by a DIFFERENT core id. store .insert_existing_mapping("fifa17", "owned-item", "someone-else", 100000001) .unwrap(); assert!(identity_dry_preflight(&store, &plan).is_err()); } #[test] fn content_card_ids_reads_emitted_pack() { let items = vec![player(100000001, 20801, 20801, 94)]; let rep = analyze( &profile(&items, "[]", 100000500), &roster(), &entities(), &none(), ); let dir = tempfile::tempdir().unwrap(); let sum = emit_content(&rep, dir.path(), "fp").unwrap(); let ids = content_card_ids(&sum.content_pack).unwrap(); assert!(ids.contains("fifa17_20801")); } /// The full apply spawns the Core binary; that end-to-end path is exercised by /// the staged runtime gate, not here. This asserts the orchestration refuses /// cleanly (no panic, no partial identity seed) when the Core binary is absent /// AFTER the two local gates pass — proving ordering: gates first, spawn last. #[test] fn apply_fails_gracefully_when_core_binary_missing() { use crate::apply::ApplyPaths; let items = vec![player(100000001, 20801, 20801, 94)]; let (report, raw) = report_and_raw(&items, "[]", 100000500); let plan = plan_apply(&report, &raw, "fp").unwrap(); let dir = tempfile::tempdir().unwrap(); // emit a real content pack so local preflight passes. let rep2 = analyze( &profile(&items, "[]", 100000500), &roster(), &entities(), &none(), ); let sum = emit_content(&rep2, dir.path(), "fp").unwrap(); let paths = ApplyPaths { core_bin: dir.path().join("no-such-core-binary"), core_db_url: "sqlite::memory:".to_string(), content_pack: sum.content_pack, data_dir: "data".to_string(), identity_store: dir.path().join("ids.json"), request_out: dir.path().join("req.json"), completion_out: dir.path().join("done.json"), }; let err = apply_import(&plan, &paths, false).unwrap_err(); assert!(format!("{err:#}").contains("spawn core import"), "{err:#}"); } // -------------------------------------------------- non-player content use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog; use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind; /// A consumable owned item: itemType="player" but NO attributeList; identity is /// carried entirely by resourceId (== assetId == carddbid) + cardsubtypeid. fn consumable(id: i64, resource: i64, subtype: i64) -> String { format!( r#"{{"id":{id},"resourceId":{resource},"assetId":{resource},"itemType":"player", "cardsubtypeid":{subtype},"cardassetid":3,"amount":1,"rating":0,"rareflag":0}}"# ) } /// A staff owned item: itemType="staff", resourceId only (NO assetId), keyed by /// cardsubtypeid. fn staff(id: i64, resource: i64, subtype: i64) -> String { format!( r#"{{"id":{id},"resourceId":{resource},"itemType":"staff","cardsubtypeid":{subtype},"contract":10}}"# ) } fn kit(id: i64, resource: i64, team_id: i64, item_state: &str) -> String { format!( r#"{{"id":{id},"resourceId":{resource},"assetId":{resource}, "cardsubtypeid":9,"cardassetid":35,"teamid":{team_id}, "itemState":"{item_state}","owners":1,"untradeable":false}}"# ) } #[test] fn plan_non_player_supports_seventeen_consumables_and_three_staff() { // The exact record set from the ticket: distinct resourceIds, so each is its // own definition even where two copies share a subtype (54,54 / 100,100 / // 202,202 / staff 8,8) — subtype duplication across DISTINCT definitions is // not a conflict. let consumable_subtypes = [ 54, 54, 52, 91, 92, 97, 98, 100, 100, 258, 267, 271, 201, 202, 202, 217, 213, ]; let staff_subtypes = [8i64, 8, 6]; let mut items = Vec::new(); for (i, &st) in consumable_subtypes.iter().enumerate() { let i = i as i64; items.push(consumable(100_000_200 + i, 5_003_001 + i, st)); } for (i, &st) in staff_subtypes.iter().enumerate() { let i = i as i64; items.push(staff(100_000_300 + i, 3_000_001 + i, st)); } let plan = plan_non_player_definitions(&profile(&items, "[]", 100000500)); assert_eq!( plan.supported.len(), 20, "17 consumable + 3 staff definitions" ); assert_eq!(plan.consumables, 17); assert_eq!(plan.staff, 3); assert_eq!(plan.kits, 0); assert!(plan.deferred.is_empty(), "0 deferred: {:?}", plan.deferred); // Honest labels + kinds resolve from the taxonomy (spot checks). let by_id = |cid: &str| plan.supported.iter().find(|d| d.card_id == cid).unwrap(); // subtype 201 -> Player Contract (13th consumable, resource 5003013) let contract = by_id("fifa17_5003013"); assert_eq!(contract.name, "Player Contract"); assert_eq!(contract.kind, ContentKind::Consumable); assert_eq!(contract.subtype, 201); // subtype 258 -> Player Chemistry Style (10th consumable, resource 5003010) assert_eq!(by_id("fifa17_5003010").name, "Player Chemistry Style"); // staff subtype 8 -> Fitness Coach; subtype 6 -> GK Coach let fitness = by_id("fifa17_3000001"); assert_eq!(fitness.name, "Fitness Coach"); assert_eq!(fitness.kind, ContentKind::Staff); assert_eq!(fitness.asset_id, None, "staff carry no assetId"); assert_eq!(by_id("fifa17_3000003").name, "GK Coach"); } /// A consumable the adapter cannot render is a consumable the club cannot use: /// the shaper drops any card whose art id it does not know, and the families /// that read `amount`/`contract` would draw "-1" or grant nothing. Emitting the /// definition without these fields is exactly what kept all 17 owned /// consumables off the wire while club/stats still counted them. #[test] fn consumable_definitions_carry_the_fields_the_client_renders() { let items = vec![ // training +15: carries `amount`, no `contract` r#"{"id":100000201,"resourceId":5003012,"assetId":5003012,"itemType":"player", "cardsubtypeid":54,"cardassetid":3,"amount":15,"rating":85,"rareflag":0}"# .to_string(), // player contract: carries `contract`, no `amount` r#"{"id":100000202,"resourceId":5001004,"assetId":5001004,"itemType":"player", "cardsubtypeid":201,"cardassetid":7,"contract":7,"rating":60,"rareflag":0}"# .to_string(), ]; let plan = plan_non_player_definitions(&profile(&items, "[]", 100000500)); assert!(plan.deferred.is_empty(), "{:?}", plan.deferred); let by_id = |cid: &str| plan.supported.iter().find(|d| d.card_id == cid).unwrap(); let training = by_id("fifa17_5003012"); assert_eq!(training.card_asset_id, Some(3), "card art id"); assert_eq!(training.amount, Some(15), "effect magnitude"); assert_eq!(training.contract, None); assert_eq!(training.rating, Some(85)); let contract = by_id("fifa17_5001004"); assert_eq!(contract.card_asset_id, Some(7)); assert_eq!(contract.contract, Some(7)); assert_eq!(contract.amount, None, "contract families ignore amount"); } /// `amount` is definition-level (every owned copy of a card carries the same /// value), so two copies that DISAGREE mean the field is really per-instance. /// Taking the first copy's value would silently bake a guess into the catalog. #[test] fn disagreeing_render_metadata_defers_rather_than_guessing() { let items = vec![ r#"{"id":100000201,"resourceId":5003012,"assetId":5003012,"itemType":"player", "cardsubtypeid":54,"cardassetid":3,"amount":15,"rareflag":0}"# .to_string(), r#"{"id":100000202,"resourceId":5003012,"assetId":5003012,"itemType":"player", "cardsubtypeid":54,"cardassetid":3,"amount":10,"rareflag":0}"# .to_string(), ]; let plan = plan_non_player_definitions(&profile(&items, "[]", 100000500)); assert!(plan.supported.is_empty(), "must not pick a winner"); assert_eq!(plan.deferred.len(), 1); assert_eq!(plan.deferred[0].reason, "render_metadata_conflict"); } #[test] fn club_item_missing_render_metadata_defers() { // No cardassetid at all: it cannot be drawn, whatever family it claims. let item = r#"{"id":100000501,"resourceId":6300006,"assetId":6300006, "cardsubtypeid":9,"itemState":"activeHomeKit"}"# .to_string(); let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000600)); assert!(plan.supported.is_empty()); assert_eq!(plan.deferred.len(), 1); assert_eq!(plan.deferred[0].reason, "missing_club_item_render_metadata"); // Correct kit art, but no team: the kit identity resolver keys on teamid. let item = r#"{"id":100000502,"resourceId":6300007,"assetId":6300007, "cardsubtypeid":9,"cardassetid":35,"itemState":"activeHomeKit"}"# .to_string(); let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000600)); assert!(plan.supported.is_empty()); assert_eq!(plan.deferred[0].reason, "missing_kit_render_metadata"); // A kit carrying another family's art is not the item it claims to be. let item = r#"{"id":100000503,"resourceId":6300008,"assetId":6300008, "cardsubtypeid":9,"cardassetid":39,"teamid":21}"# .to_string(); let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000600)); assert!(plan.supported.is_empty()); assert_eq!(plan.deferred[0].reason, "missing_club_item_render_metadata"); } /// Club items are settled by `cardsubtypeid`, not by the id range they occupy. /// Keying kits off 6_300_000..=6_400_654 classified every OTHER club family as /// `Other`, so a badge, ball, stadium or league logo was silently dropped from /// the import even though its definition table ships with the game. #[test] fn every_club_family_classifies_and_imports() { let club = |id: i64, resource: i64, subtype: i64, art: i64| { format!( r#"{{"id":{id},"resourceId":{resource},"assetId":{resource}, "cardsubtypeid":{subtype},"cardassetid":{art},"teamid":21}}"# ) }; let items = vec![ club(100000501, 6_300_006, 9, 35), // kit club(100000502, 6_200_001, 10, 36), // stadium club(100000503, 6_000_012, 11, 39), // badge club(100000504, 8_120_194, 30, 37), // ball club(100000505, 8_010_001, 31, 40), // league logo ]; let counts = count_items(&profile(&items, "[]", 100000600)); assert_eq!( counts.club_items, 5, "no club family falls through to Other" ); assert_eq!(counts.other, 0); let plan = plan_non_player_definitions(&profile(&items, "[]", 100000600)); assert!(plan.deferred.is_empty(), "{:?}", plan.deferred); let kind = |cid: &str| { plan.supported .iter() .find(|d| d.card_id == cid) .unwrap_or_else(|| panic!("{cid} not imported")) .kind }; assert_eq!(kind("fifa17_6300006"), ContentKind::Kit); assert_eq!(kind("fifa17_6200001"), ContentKind::Stadium); assert_eq!(kind("fifa17_6000012"), ContentKind::Badge); assert_eq!(kind("fifa17_8120194"), ContentKind::Ball); // A league logo has no equipped slot, so it is generic owned content. assert_eq!(kind("fifa17_8010001"), ContentKind::Misc); // A club item's carddbid IS its asset id. assert_eq!( plan.supported .iter() .find(|d| d.card_id == "fifa17_6000012") .unwrap() .asset_id, Some(6_000_012) ); } #[test] fn unknown_subtype_consumable_defers_never_fabricated() { let plan = plan_non_player_definitions(&profile( &[consumable(100000300, 5009999, 999)], "[]", 100000500, )); assert!(plan.supported.is_empty()); assert_eq!(plan.deferred.len(), 1); assert_eq!(plan.deferred[0].reason, "unknown_subtype"); assert_eq!(plan.deferred[0].subtype, Some(999)); assert_eq!(plan.deferred[0].wire_ids, vec![100000300]); } #[test] fn missing_cardsubtypeid_defers() { // itemType player, no attributeList, no cardsubtypeid -> consumable w/o a // resolvable family -> DEFER (never a placeholder). let item = r#"{"id":100000301,"resourceId":5003050,"assetId":5003050,"itemType":"player","rating":0}"# .to_string(); let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000500)); assert!(plan.supported.is_empty()); assert_eq!(plan.deferred.len(), 1); assert_eq!(plan.deferred[0].reason, "missing_cardsubtypeid"); } #[test] fn conflicting_subtype_across_copies_defers() { // Two copies of one resourceId that disagree on subtype -> defer, never a // silent winner. let plan = plan_non_player_definitions(&profile( &[ consumable(100000302, 5003060, 201), consumable(100000303, 5003060, 202), ], "[]", 100000500, )); assert!(plan.supported.is_empty()); assert_eq!(plan.deferred.len(), 1); assert_eq!(plan.deferred[0].reason, "subtype_conflict"); assert_eq!(plan.deferred[0].wire_ids, vec![100000302, 100000303]); } #[test] fn non_player_deferral_is_not_a_blocker() { // A non-player deferral (like a player NoName deferral) must NOT block emit. let items = vec![ player(100000001, 20801, 20801, 94), consumable(100000300, 5009999, 999), // unknown subtype -> deferred ]; let rep = analyze( &profile(&items, "[]", 100000500), &roster(), &entities(), &none(), ); assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers()); assert_eq!(rep.non_player.deferred.len(), 1); } #[test] fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() { let items = vec![ player(100000001, 20801, 20801, 94), consumable(100000201, 5003012, 201), // Player Contract staff(100000427, 3000083, 8), // Fitness Coach kit(100000500, 6300006, 21, "activeHomeKit"), ]; let rep = analyze( &profile(&items, "[]", 100000500), &roster(), &entities(), &none(), ); assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers()); assert_eq!(rep.non_player.supported.len(), 3); let dir = tempfile::tempdir().unwrap(); let sum = emit_content(&rep, dir.path(), "fp").unwrap(); assert_eq!(sum.definitions, 1, "one player definition"); assert_eq!(sum.non_player_definitions, 3); assert_eq!(sum.non_player_instances, 3); assert_eq!( sum.catalog_entries, 4, "player + 3 non-player catalog entries" ); // Content pack: neutral non-player CardDefinition with honest name. let pack: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&sum.content_pack).unwrap()).unwrap(); let arr = pack.as_array().unwrap(); let cons = arr.iter().find(|c| c["id"] == "fifa17_5003012").unwrap(); assert_eq!(cons["name"], "Player Contract"); assert_eq!(cons["overall"], 0); assert_eq!(cons["position"], ""); assert_eq!(cons["nation"], ""); assert_eq!(cons["rarity"], "bronze"); assert!(cons["image_path"].is_null()); // Catalog: kind+subtype on player AND non-player; staff asset falls back to // resourceId; and the emitted catalog LOADS in the adapter with kind_of. let cat: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&sum.host_catalog).unwrap()).unwrap(); assert_eq!(cat["cards"]["fifa17_20801"]["kind"], "player"); assert_eq!(cat["cards"]["fifa17_20801"]["subtype"], 0); assert_eq!(cat["cards"]["fifa17_5003012"]["kind"], "consumable"); assert_eq!(cat["cards"]["fifa17_5003012"]["subtype"], 201); assert_eq!(cat["cards"]["fifa17_5003012"]["rareflag"], 0); assert_eq!(cat["cards"]["fifa17_3000083"]["kind"], "staff"); assert_eq!(cat["cards"]["fifa17_3000083"]["subtype"], 8); assert_eq!(cat["cards"]["fifa17_3000083"]["asset_id"], 3000083); assert_eq!(cat["cards"]["fifa17_6300006"]["kind"], "kit"); assert_eq!(cat["cards"]["fifa17_6300006"]["subtype"], 9); assert_eq!(cat["cards"]["fifa17_6300006"]["card_asset_id"], 35); assert_eq!(cat["cards"]["fifa17_6300006"]["team_id"], 21); let loaded = Fifa17CardCatalog::from_file(&sum.host_catalog).unwrap(); assert_eq!(loaded.kind_of("fifa17_20801"), ContentKind::Player); assert_eq!(loaded.kind_of("fifa17_5003012"), ContentKind::Consumable); assert_eq!(loaded.subtype_of("fifa17_5003012"), 201); assert_eq!(loaded.kind_of("fifa17_3000083"), ContentKind::Staff); assert_eq!(loaded.kind_of("fifa17_6300006"), ContentKind::Kit); let loaded_kit = loaded.lookup("fifa17_6300006").unwrap(); assert_eq!(loaded_kit.card_asset_id, 35); assert_eq!(loaded_kit.team_id, 21); // Manifest: private non_player section with preserved wire ids. let man: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&sum.manifest).unwrap()).unwrap(); assert_eq!(man["non_player"]["supported_instances"], 3); let np = man["non_player"]["supported_definitions"] .as_array() .unwrap(); assert_eq!(np.len(), 3); let cons_man = np .iter() .find(|d| d["card_id"] == "fifa17_5003012") .unwrap(); assert_eq!(cons_man["wire_ids"], serde_json::json!([100000201])); assert_eq!(cons_man["kind"], "consumable"); } #[test] fn plan_apply_mints_non_player_owned_instances() { let items = vec![ player(100000001, 20801, 20801, 94), consumable(100000201, 5003012, 201), staff(100000427, 3000083, 8), kit(100000500, 6300006, 21, "activeHomeKit"), ]; let (report, raw) = report_and_raw(&items, "[]", 100000500); let plan = plan_apply(&report, &raw, "fp").unwrap(); // Player, consumable, staff, and kit instances mint through one generic path. assert_eq!(plan.request.owned.len(), 4); assert_eq!(plan.mappings.len(), 4); assert_eq!(plan.supported_instances, 4); assert_eq!(plan.deferred_instances, 0); let cards: BTreeSet<&str> = plan .request .owned .iter() .map(|o| o.card_id.as_str()) .collect(); assert!(cards.contains("fifa17_5003012"), "consumable minted"); assert!(cards.contains("fifa17_3000083"), "staff minted"); assert!(cards.contains("fifa17_6300006"), "kit minted"); // Core is the ownership authority, and it defaults an unstated row to // `player`. A coach or a contract card durably recorded as a player is wrong // in the authority even while the catalog-driven wire still looks right. let kind_of = |card: &str| { plan.request .owned .iter() .find(|o| o.card_id == card) .unwrap() .content_kind }; assert_eq!(kind_of("fifa17_20801"), "player"); assert_eq!(kind_of("fifa17_5003012"), "consumable"); assert_eq!(kind_of("fifa17_3000083"), "staff"); assert_eq!(kind_of("fifa17_6300006"), "kit"); // `amount` is an effect magnitude, not a stack count: two copies of one // consumable are two rows, never one row of quantity 2. The importer states // no quantity at all, and Core's default for an absent quantity is "not a // stack" — so the guarantee is that the key never appears in the request. let wire = serde_json::to_string(&plan.request).unwrap(); assert!( !wire.contains("quantity"), "no instance may claim to be a stack: {wire}" ); // Deterministic OwnedItemId per (persona, wire) — same rule as players. let m = plan .mappings .iter() .find(|m| m.wire_id == 100000201) .unwrap(); assert_eq!(m.core_id, owned_item_id(33068179, 100000201)); // Local preflight passes because the emitted content pack contains the // non-player card_ids too. let dir = tempfile::tempdir().unwrap(); let sum = emit_content(&report, dir.path(), "fp").unwrap(); let ids = content_card_ids(&sum.content_pack).unwrap(); local_core_preflight(&plan, &ids).unwrap(); } #[test] fn deferred_non_player_instances_gate_a_production_apply() { // A supported player + a deferred (unknown-subtype) consumable: the deferred // non-player instance blocks a production apply, allowed only for staging. let items = vec![ player(100000001, 20801, 20801, 94), consumable(100000300, 5009999, 999), ]; let (report, raw) = report_and_raw(&items, "[]", 100000500); let plan = plan_apply(&report, &raw, "fp").unwrap(); assert_eq!(plan.deferred_instances, 1, "the deferred consumable counts"); assert!(gate_staging(&plan, false).is_err(), "production blocks"); assert!(gate_staging(&plan, true).unwrap(), "staging opt-in allows"); } /// The staff rating and rare flag come from the client's OWN tables, and these /// exact values were read back out of the RUNNING client's memory: /// `tools/coach_probe.py` graded all four resident staff records HIT (record /// `+0xb4` == `value`, `+0x58` == `rare`), and `tools/discard_probe.py` read the /// discard value the client computed for itself at record `+0x3c` — 36 for both /// `value`-66 coaches and 282 for the `rare`-1, `value`-88 manager. /// /// So this is not a table-parsing test. It pins the importer to numbers the live /// client demonstrably uses. #[test] fn staff_stats_are_the_values_the_live_client_re_rates_to() { let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/data/tables"); let ent = Entities::from_tables_dir(dir).expect("committed tables load"); // (subtype, carddbid) -> (value, rare), verified live. assert_eq!(ent.staff_stats(6, 9000081), Some((66, 0)), "GK coach"); assert_eq!(ent.staff_stats(8, 3000083), Some((66, 0)), "fitness coach"); assert_eq!(ent.staff_stats(4, 1000509), Some((88, 1)), "manager"); // Keyed per family: a coach id must not resolve through another's table. assert_eq!( ent.staff_stats(4, 9000081), None, "gkcoach id is not a manager" ); assert_eq!(ent.staff_stats(6, 12345678), None, "absent id stays absent"); } /// `enrich_staff` fills ONLY staff, and only where the wire left a gap. #[test] fn enrich_staff_fills_staff_and_leaves_everything_else_alone() { let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/data/tables"); let ent = Entities::from_tables_dir(dir).expect("committed tables load"); let items = vec![ // A GK coach (subtype 6) and a contract consumable, which carries its own // rating on the wire and must not be touched. staff(100000280, 9000081, 6), consumable(100000300, 5001004, 201), ]; let mut plan = plan_non_player_definitions(&profile(&items, "[]", 100000500)); let before: Vec> = plan.supported.iter().map(|d| d.rating).collect(); ent.enrich_staff(&mut plan); let coach = plan .supported .iter() .find(|d| d.resource_id == 9000081) .expect("coach planned"); assert_eq!( coach.rating, Some(66), "rating filled from gkcoachcards.value" ); assert_eq!(coach.rareflag, Some(0), "rare filled from the same row"); let cons = plan .supported .iter() .find(|d| d.resource_id == 5001004) .expect("consumable planned"); assert_eq!(cons.rareflag, None, "a consumable gets no staff rare flag"); let cons_before = before[plan .supported .iter() .position(|d| d.resource_id == 5001004) .unwrap()]; assert_eq!( cons.rating, cons_before, "the wire rating is left untouched" ); }