feat(fifa17): import consumable + staff content as first-class Core content
Close 20 of the 33-record content gap (17 consumables + 3 staff; 13 Legends are unrecoverable from PC data). Verdict A (no Core change): consumables/staff become ordinary Core CardDefinitions (neutral player fields + honest family/role names) and owned instances via the SAME generic import path; a catalog kind lets the adapter exclude them from the player-only /club projection. - adapter fut::content_taxonomy: evidence-based cardsubtypeid->family/label (Ghidra-derived ranges) + staff role map; unknown subtype => defer, never fabricate. - adapter catalog: Fifa17CardIdentity/RawCard gain optional kind+subtype (backward-compat: legacy catalogs load as player); kind_of/subtype_of lookups. - adapter item/club_response: shape_club_response excludes non-player kinds (ShapeStats.excluded_non_player); ItemIdentityResolver::kind_of default=Player. - host Fifa17IdentityResolver overrides kind_of to delegate to the catalog so /club excludes consumables/staff in production. - import: Item gains cardsubtypeid/cardassetid/amount/contract; plan_non_player_definitions (resourceId-grouped, subtype-consistency gated); emit_content writes non-player defs + catalog kind + manifest; apply mints owned instances via owned_item_id. Real profile 33068179: 1962 players + 20 non-player = 1982 owned; 18 non-player defs (16 consumable + 2 staff, dup resourceIds shared); 0 deferred non-player; 0 blockers.
This commit is contained in:
@@ -626,3 +626,259 @@ fn apply_fails_gracefully_when_core_binary_missing() {
|
||||
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}}"#
|
||||
)
|
||||
}
|
||||
|
||||
#[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!(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");
|
||||
}
|
||||
|
||||
#[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
|
||||
];
|
||||
let rep = analyze(
|
||||
&profile(&items, "[]", 100000500),
|
||||
&roster(),
|
||||
&entities(),
|
||||
&none(),
|
||||
);
|
||||
assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
|
||||
assert_eq!(rep.non_player.supported.len(), 2);
|
||||
|
||||
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, 2);
|
||||
assert_eq!(sum.non_player_instances, 2);
|
||||
assert_eq!(
|
||||
sum.catalog_entries, 3,
|
||||
"player + 2 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);
|
||||
|
||||
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);
|
||||
|
||||
// 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"], 2);
|
||||
let np = man["non_player"]["supported_definitions"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
assert_eq!(np.len(), 2);
|
||||
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),
|
||||
];
|
||||
let (report, raw) = report_and_raw(&items, "[]", 100000500);
|
||||
let plan = plan_apply(&report, &raw, "fp").unwrap();
|
||||
// 1 player + 2 non-player owned instances, minted via the identical path.
|
||||
assert_eq!(plan.request.owned.len(), 3);
|
||||
assert_eq!(plan.mappings.len(), 3);
|
||||
assert_eq!(plan.supported_instances, 3);
|
||||
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");
|
||||
// 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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user