9026220533
ROOT CAUSE, one line. openfut-import-fifa17 emitted `"overall": 0` for every non-player Core definition while `d.rating` already held EA's authoritative `value` -- and the very next block wrote that same number correctly to the adapter catalog. So the tier existed host-side but never reached Core: Core overall 0 -> /collection effective_overall 0 -> CoreOwnedItem.rating 0 -> tier_for_rating(0) = Bronze for a Gold (88) manager. That silent mis-grant is exactly what the 409 was protecting against, so the refusal was correct. The emitter now also writes `source_rating`, keeping `overall` at 0. Regenerating the production pack changes exactly 18 entries and exactly one field each (source_rating None -> value); same 1710 ids, same fingerprint 28c333f1e833338a. WHY value IS the tier source, and why the thresholds are the player ladder: LIVE_PROVEN, not inferred. The client re-rates staff from its own managercards/*coachcards/physiocards by carddbid and applies discard_level's 65/75 ladder; coach_probe/discard_probe agree 4/4 (manager value 88 -> level 3, coaches 66 -> level 2). The shipped coach tables corroborate: each family has exactly 3 tiers x 2 rarities, and only 65/75 splits them 2/2/2. Manager contracts stop refusing and now resolve the TARGET's tier from Core-owned state. Still fail-closed everywhere it matters: a coach or physio is `contract_target_not_a_manager` (only cardsubtypeid 4 is a manager), and a manager Core carries no source_rating for is `manager_tier_unknown` rather than a guessed tier. Core's own content_kind token is sent as target_kind, because Core calls the squad manager `manager` while the catalog classifies it `staff`+subtype 4. NOT implemented, unchanged: STORED_MANAGER_BONUS and MATCH_CONTRACT_DECREMENT.
1207 lines
46 KiB
Rust
1207 lines
46 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());
|
|
// `source_rating` mirrors the wire verbatim: this consumable really sends
|
|
// `"rating":0`, so 0 is the authored value, not a substituted default.
|
|
assert_eq!(cons["source_rating"], 0, "{cons}");
|
|
// This fixture's `entities()` carries no staff table, so the coach's value is
|
|
// UNKNOWN — it must stay `null`, never a fabricated 0 a tier rule reads as
|
|
// bronze.
|
|
let coach = arr.iter().find(|c| c["id"] == "fifa17_3000083").unwrap();
|
|
assert!(
|
|
coach["source_rating"].is_null(),
|
|
"an unknown authored value must stay null: {coach}"
|
|
);
|
|
|
|
// 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"
|
|
);
|
|
}
|
|
|
|
/// A MANAGER CONTRACT grant needs the target's TIER, and the only authoritative
|
|
/// source is EA's authored `value` from the staff family table. Emit must carry
|
|
/// it into Core's content pack as `source_rating` — the SAME number the host
|
|
/// catalog carries as `rating`, so the two artifacts can never disagree — while
|
|
/// `overall` stays 0, because `overall` is what Core prices and projects from.
|
|
///
|
|
/// Before this, a non-player reached Core with `overall: 0` and nothing else, so
|
|
/// `/collection` reported `effective_overall: 0` and a gold (76) manager graded
|
|
/// bronze.
|
|
#[test]
|
|
fn emit_content_carries_ea_authored_value_as_source_rating() {
|
|
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![
|
|
staff(100000427, 1000001, 4), // manager — managercards.value = 76 (gold)
|
|
staff(100000280, 9000081, 6), // GK coach — gkcoachcards.value = 66 (silver)
|
|
];
|
|
let rep = analyze(&profile(&items, "[]", 100000500), &roster(), &ent, &none());
|
|
assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
|
|
|
|
let out = tempfile::tempdir().unwrap();
|
|
let sum = emit_content(&rep, out.path(), "fp").unwrap();
|
|
let pack: serde_json::Value =
|
|
serde_json::from_str(&std::fs::read_to_string(&sum.content_pack).unwrap()).unwrap();
|
|
let cat: serde_json::Value =
|
|
serde_json::from_str(&std::fs::read_to_string(&sum.host_catalog).unwrap()).unwrap();
|
|
|
|
for (card_id, value) in [("fifa17_1000001", 76), ("fifa17_9000081", 66)] {
|
|
let def = pack
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|c| c["id"] == card_id)
|
|
.unwrap_or_else(|| panic!("{card_id} must be in the content pack"));
|
|
assert_eq!(
|
|
def["source_rating"],
|
|
serde_json::json!(value),
|
|
"EA's authored value must reach Core: {def}"
|
|
);
|
|
assert_eq!(
|
|
def["overall"],
|
|
serde_json::json!(0),
|
|
"overall stays 0 for a non-player: it feeds pricing and projection"
|
|
);
|
|
assert_eq!(
|
|
def["source_rating"], cat["cards"][card_id]["rating"],
|
|
"one source, two artifacts — they must never disagree on a tier"
|
|
);
|
|
}
|
|
}
|