Files
OpenFUT/openfut-import-fifa17/src/tests.rs
T
funman300 755f237f17 fifa17: carry the staff rating the client re-rates to, verified live
Staff quick-sell could not be priced correctly: for cardtypes 2/3/4/5/10 the
client overwrites the rating and rare flag we send with values from its own card
database, and a staff wire record carries no rating, no rareflag and no
discardValue at all. The server had no way to know the displayed price from what
it sent, so pricing declined for staff and fell back to the placeholder ladder.

The missing input was read straight out of the running client (pid 6580), no UI
interaction required:

  * tools/coach_probe.py grades all four resident staff records HIT, which by
    construction requires record +0xb4 == the table's `value` and +0x58 == its
    `rare`. That settles `value`-is-the-rating, which was previously an inference
    and was deliberately not shipped on that basis.
  * tools/discard_probe.py (new) reads both discard slots -- +0x38, the value we
    sent, and +0x3c, the value the client computed for itself:

      1000509  sub 4  ct 2  rat 88  rare 1   sent 0   calc 282   predicted 282
      9000081  sub 6  ct 10 rat 66  rare 0   sent 0   calc  36   predicted  36
      3000083  sub 8  ct 4  rat 66  rare 0   sent 0   calc  36   predicted  36

    4 of 4 agree, 0 disagree. 36 on the value-66 GK coach was the exact falsifier
    written for this last commit.

Entities::enrich_staff now fills rating from `value` and rareflag from `rare` for
the five staff families, and the catalog emits the real rareflag instead of a
hardcoded 0 (it is not cosmetic -- it selects the discard price column, which is
why the rare-1 manager prices at 282 and a rare-0 coach at 36). Players and
consumables are untouched; their wire values are authoritative.

Verified on staging: a GK coach quick-sells for 36, not the 150 floor. The
catalog diff is exactly the two coach entries gaining rating 66; 1710 entries in
and out, nothing else changed.

The same probe shows what production does to PLAYERS today: every resident player
carries sent+38 = 1500, which suppresses the client's own computation, against a
real 688..752 for a gold rare and 72,800 / 74,400 for the two legends.

Still open, and not a discard problem: manager fifa17_1000509 is owned in Core
but has no catalog entry or definition (it reaches the client through the opaque
squad extension), so it declines to the ladder. That is definition coverage.

Importer 41 tests, fmt and clippy clean.
2026-08-21 22:51:16 +00:00

1144 lines
44 KiB
Rust

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<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",
"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_<resourceId>, 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<Option<i64>> = 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"
);
}