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:
@@ -20,6 +20,8 @@ use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
|
||||
/// The FIFA 17 render identity of a card definition. `version` is the high byte
|
||||
/// of `resource_id`; `asset_id` (the low 24 bits) is the real FIFA player id.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -30,6 +32,12 @@ pub struct Fifa17CardIdentity {
|
||||
/// FIFA wire `rareflag` (rare/special card TYPE). Carried so specials render
|
||||
/// as specials; observed metadata, not a guessed label.
|
||||
pub rareflag: i64,
|
||||
/// Content class of this definition. A catalog authored before this field
|
||||
/// existed defaults to [`ContentKind::Player`] (backward compatible).
|
||||
pub kind: ContentKind,
|
||||
/// FIFA `cardsubtypeid` for a non-player definition (consumable family /
|
||||
/// staff role), `0` for a player or when absent.
|
||||
pub subtype: i64,
|
||||
}
|
||||
|
||||
/// The FIFA 17 numeric namespace policy for owned-item wire ids.
|
||||
@@ -115,6 +123,14 @@ struct RawCard {
|
||||
/// behaviour; the production catalog carries the observed value.
|
||||
#[serde(default = "default_rareflag")]
|
||||
rareflag: i64,
|
||||
/// Content class token ("player"|"consumable"|"staff"). Absent → default
|
||||
/// (empty) → [`ContentKind::Player`], so existing player-only catalogs load
|
||||
/// unchanged.
|
||||
#[serde(default)]
|
||||
kind: String,
|
||||
/// FIFA `cardsubtypeid` for a non-player entry; absent → `0`.
|
||||
#[serde(default)]
|
||||
subtype: i64,
|
||||
}
|
||||
|
||||
fn default_rareflag() -> i64 {
|
||||
@@ -169,6 +185,8 @@ impl Fifa17CardCatalog {
|
||||
version: rc.version,
|
||||
resource_id,
|
||||
rareflag: rc.rareflag,
|
||||
kind: ContentKind::from_str(&rc.kind),
|
||||
subtype: rc.subtype,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -197,6 +215,21 @@ impl Fifa17CardCatalog {
|
||||
self.by_resource.get(&resource_id).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Classify a `card_id` as player/consumable/staff. An unknown definition is
|
||||
/// [`ContentKind::Player`] — the neutral, backward-compatible default (an
|
||||
/// un-catalogued id was always treated as a player-shaped card).
|
||||
pub fn kind_of(&self, card_id: &str) -> ContentKind {
|
||||
self.by_card
|
||||
.get(card_id)
|
||||
.map(|c| c.kind)
|
||||
.unwrap_or(ContentKind::Player)
|
||||
}
|
||||
|
||||
/// The FIFA `cardsubtypeid` for a definition, or `0` if unknown / a player.
|
||||
pub fn subtype_of(&self, card_id: &str) -> i64 {
|
||||
self.by_card.get(card_id).map(|c| c.subtype).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.by_card.len()
|
||||
}
|
||||
@@ -335,4 +368,44 @@ mod tests {
|
||||
assert_eq!(ron.version, 0);
|
||||
assert_eq!(ron.resource_id, 20801);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_catalog_without_kind_loads_as_player() {
|
||||
// A pre-taxonomy catalog (no `kind`/`subtype`) must load unchanged and
|
||||
// classify every entry as a player, with subtype 0.
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"fifa17_20801":{"asset_id":20801},
|
||||
"fifa17_176580":{"asset_id":176580,"version":5,"rareflag":3}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let base = cat.lookup("fifa17_20801").unwrap();
|
||||
assert_eq!(base.kind, ContentKind::Player);
|
||||
assert_eq!(base.subtype, 0);
|
||||
assert_eq!(base.rareflag, 1, "absent rareflag still defaults to 1");
|
||||
assert_eq!(cat.kind_of("fifa17_20801"), ContentKind::Player);
|
||||
assert_eq!(cat.kind_of("fifa17_176580"), ContentKind::Player);
|
||||
// Unknown id -> neutral Player default.
|
||||
assert_eq!(cat.kind_of("fifa17_missing"), ContentKind::Player);
|
||||
assert_eq!(cat.subtype_of("fifa17_missing"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_and_subtype_are_parsed_for_non_player_entries() {
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"fifa17_20801":{"asset_id":20801,"kind":"player","subtype":0},
|
||||
"fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54,"rareflag":0},
|
||||
"fifa17_3000083":{"asset_id":3000083,"kind":"staff","subtype":8,"rareflag":0}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cat.kind_of("fifa17_20801"), ContentKind::Player);
|
||||
assert_eq!(cat.kind_of("fifa17_5003012"), ContentKind::Consumable);
|
||||
assert_eq!(cat.subtype_of("fifa17_5003012"), 54);
|
||||
assert_eq!(cat.kind_of("fifa17_3000083"), ContentKind::Staff);
|
||||
assert_eq!(cat.subtype_of("fifa17_3000083"), 8);
|
||||
assert_eq!(cat.lookup("fifa17_5003012").unwrap().rareflag, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item::shape_item;
|
||||
// Re-exported so existing `club_response::{…}` callers keep working; the types
|
||||
@@ -25,6 +26,12 @@ pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
let mut stats = ShapeStats::default();
|
||||
for item in items {
|
||||
// Exclude non-player content (consumables/staff): a `/club` player list
|
||||
// must never render them as 0-rated players. Counted, never emitted.
|
||||
if ident.kind_of(item) != ContentKind::Player {
|
||||
stats.excluded_non_player += 1;
|
||||
continue;
|
||||
}
|
||||
match ident.resolve(item) {
|
||||
Some(id) => {
|
||||
out.push(shape_item(item, id, ent));
|
||||
@@ -193,4 +200,66 @@ mod tests {
|
||||
"only itemData at top level"
|
||||
);
|
||||
}
|
||||
|
||||
/// A resolver that resolves an asset id for EVERY item (so exclusion is not
|
||||
/// an artifact of a missing asset) but classifies some card_ids as non-player
|
||||
/// via an explicit kind table.
|
||||
struct KindMapIdentity {
|
||||
ids: HashMap<String, Fifa17Identity>,
|
||||
kinds: HashMap<String, ContentKind>,
|
||||
}
|
||||
impl ItemIdentityResolver for KindMapIdentity {
|
||||
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
self.ids.get(&it.card_id).copied()
|
||||
}
|
||||
fn kind_of(&self, it: &CoreOwnedItem) -> ContentKind {
|
||||
self.kinds
|
||||
.get(&it.card_id)
|
||||
.copied()
|
||||
.unwrap_or(ContentKind::Player)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_and_staff_are_excluded_from_club_players() {
|
||||
let ent = entities();
|
||||
let id = |item_id: u32, asset: u32| Fifa17Identity {
|
||||
item_id,
|
||||
asset_id: asset,
|
||||
resource_id: asset,
|
||||
rareflag: 1,
|
||||
};
|
||||
let ident = KindMapIdentity {
|
||||
ids: HashMap::from([
|
||||
("card_player".to_string(), id(100000001, 20801)),
|
||||
("card_consumable".to_string(), id(100000002, 5003012)),
|
||||
("card_staff".to_string(), id(100000003, 3000083)),
|
||||
]),
|
||||
kinds: HashMap::from([
|
||||
("card_consumable".to_string(), ContentKind::Consumable),
|
||||
("card_staff".to_string(), ContentKind::Staff),
|
||||
]),
|
||||
};
|
||||
let items = vec![
|
||||
item(
|
||||
"oc1",
|
||||
"card_player",
|
||||
86,
|
||||
"ST",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
),
|
||||
item("oc2", "card_consumable", 0, "", "", "", ""),
|
||||
item("oc3", "card_staff", 0, "", "", "", ""),
|
||||
];
|
||||
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
||||
assert_eq!(stats.emitted, 1, "only the player is emitted");
|
||||
assert_eq!(stats.excluded_non_player, 2, "consumable + staff excluded");
|
||||
assert_eq!(stats.dropped_no_asset, 0);
|
||||
let arr = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["id"], 100000001, "the player survives");
|
||||
assert_eq!(arr[0]["itemType"], "player");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! FIFA 17 **non-player content taxonomy** — the evidence-based map from a card
|
||||
//! `cardsubtypeid` to its functional family (consumables) or role (staff).
|
||||
//!
|
||||
//! This is the ONLY place the FIFA-specific `cardsubtypeid` vocabulary lives; it
|
||||
//! keeps that game concept out of generic Core, exactly as the player-side
|
||||
//! catalog keeps `resourceId`/`rareflag` out of Core. Nothing here is guessed:
|
||||
//!
|
||||
//! * Consumable families and their contiguous `cardsubtypeid` ranges are taken
|
||||
//! verbatim from `fifa17-recon/tools/fut_consumables.py`
|
||||
//! (`BY_SUBTYPE`/`CORE_KINDS`, Ghidra-derived from `FUN_18013f4d0` /
|
||||
//! `FUN_1801bfac0`) and `docs/CARD_TAXONOMY.md` (verified against the `.105`
|
||||
//! `fcc_*.json` tables).
|
||||
//! * Staff roles are the `FUN_1800d8330` family selector: 4=manager, 5=headcoach,
|
||||
//! 6=gkcoach, 7=physio, 8=fitnesscoach.
|
||||
//!
|
||||
//! Display **labels are functional, never marketing** (e.g. "Player Chemistry
|
||||
//! Style", not a promo name). A `cardsubtypeid` outside every documented range
|
||||
//! resolves to `None` — the caller DEFERS it (mirroring the player NoName gate),
|
||||
//! never fabricating a family.
|
||||
|
||||
/// The disjoint content classes a FIFA 17 owned card can belong to. Player is
|
||||
/// the default so a catalog authored before this taxonomy existed (no `kind`
|
||||
/// field) still classifies every entry as a player, unchanged.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ContentKind {
|
||||
#[default]
|
||||
Player,
|
||||
Consumable,
|
||||
Staff,
|
||||
}
|
||||
|
||||
impl ContentKind {
|
||||
/// The stable wire/catalog token for this kind.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ContentKind::Player => "player",
|
||||
ContentKind::Consumable => "consumable",
|
||||
ContentKind::Staff => "staff",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a catalog `kind` token. Unknown or "player" (or an absent field that
|
||||
/// deserializes to the default) is `Player` — backward compatible.
|
||||
// Intentionally infallible (every input maps to a kind, unknown → Player), so
|
||||
// it is NOT `std::str::FromStr` (which is fallible); the name mirrors the
|
||||
// catalog token vocabulary.
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(s: &str) -> ContentKind {
|
||||
match s {
|
||||
"consumable" => ContentKind::Consumable,
|
||||
"staff" => ContentKind::Staff,
|
||||
_ => ContentKind::Player,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The functional family + honest display label for a consumable `cardsubtypeid`,
|
||||
/// or `None` if the subtype is outside every documented range (→ DEFER).
|
||||
///
|
||||
/// Returns `(family, label)`, both `'static`. `family` is the neutral machine
|
||||
/// name stored as the CardDefinition family; `label` is the functional
|
||||
/// human-readable name.
|
||||
pub fn consumable_family(subtype: i64) -> Option<(&'static str, &'static str)> {
|
||||
let pair = match subtype {
|
||||
51..=57 => ("gk_training", "GK Training"),
|
||||
61..=67 => ("player_training", "Player Training"),
|
||||
71..=86 => ("manager_formation_mod", "Manager Formation"),
|
||||
91..=110 => ("position_mod", "Position Modifier"),
|
||||
121..=136 => ("formation_mod", "Formation Modifier"),
|
||||
201 => ("player_contract", "Player Contract"),
|
||||
202 => ("manager_contract", "Manager Contract"),
|
||||
211..=218 => ("healing", "Healing"),
|
||||
219 => ("player_fitness", "Player Fitness"),
|
||||
220 => ("squad_fitness", "Squad Fitness"),
|
||||
250..=268 => ("player_playstyle", "Player Chemistry Style"),
|
||||
269..=273 => ("gk_playstyle", "GK Chemistry Style"),
|
||||
300..=341 => ("manager_league", "Manager League Modifier"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(pair)
|
||||
}
|
||||
|
||||
/// The staff role + honest display label for a staff `cardsubtypeid` (4..=8), or
|
||||
/// `None` for any other subtype (→ DEFER). Grounded in the `FUN_1800d8330`
|
||||
/// family selector.
|
||||
pub fn staff_role(subtype: i64) -> Option<(&'static str, &'static str)> {
|
||||
let pair = match subtype {
|
||||
4 => ("manager", "Manager"),
|
||||
5 => ("headcoach", "Head Coach"),
|
||||
6 => ("gkcoach", "GK Coach"),
|
||||
7 => ("physio", "Physio"),
|
||||
8 => ("fitnesscoach", "Fitness Coach"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(pair)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn content_kind_round_trips_and_defaults_to_player() {
|
||||
assert_eq!(ContentKind::default(), ContentKind::Player);
|
||||
for k in [
|
||||
ContentKind::Player,
|
||||
ContentKind::Consumable,
|
||||
ContentKind::Staff,
|
||||
] {
|
||||
assert_eq!(ContentKind::from_str(k.as_str()), k);
|
||||
}
|
||||
// Unknown / absent tokens fall back to Player (backward compatible).
|
||||
assert_eq!(ContentKind::from_str(""), ContentKind::Player);
|
||||
assert_eq!(ContentKind::from_str("nonsense"), ContentKind::Player);
|
||||
assert_eq!(ContentKind::from_str("player"), ContentKind::Player);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_family_range_boundaries() {
|
||||
// Each contiguous range: lower boundary, upper boundary, family + label.
|
||||
let cases: &[(i64, i64, &str, &str)] = &[
|
||||
(51, 57, "gk_training", "GK Training"),
|
||||
(61, 67, "player_training", "Player Training"),
|
||||
(71, 86, "manager_formation_mod", "Manager Formation"),
|
||||
(91, 110, "position_mod", "Position Modifier"),
|
||||
(121, 136, "formation_mod", "Formation Modifier"),
|
||||
(211, 218, "healing", "Healing"),
|
||||
(250, 268, "player_playstyle", "Player Chemistry Style"),
|
||||
(269, 273, "gk_playstyle", "GK Chemistry Style"),
|
||||
(300, 341, "manager_league", "Manager League Modifier"),
|
||||
];
|
||||
for &(lo, hi, family, label) in cases {
|
||||
assert_eq!(consumable_family(lo), Some((family, label)), "lo {lo}");
|
||||
assert_eq!(consumable_family(hi), Some((family, label)), "hi {hi}");
|
||||
}
|
||||
// Singleton subtypes.
|
||||
assert_eq!(
|
||||
consumable_family(201),
|
||||
Some(("player_contract", "Player Contract"))
|
||||
);
|
||||
assert_eq!(
|
||||
consumable_family(202),
|
||||
Some(("manager_contract", "Manager Contract"))
|
||||
);
|
||||
assert_eq!(
|
||||
consumable_family(219),
|
||||
Some(("player_fitness", "Player Fitness"))
|
||||
);
|
||||
assert_eq!(
|
||||
consumable_family(220),
|
||||
Some(("squad_fitness", "Squad Fitness"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_family_gaps_and_out_of_range_are_none() {
|
||||
// Just outside range edges, and in documented gaps between ranges.
|
||||
for s in [
|
||||
0, 50, 58, 60, 68, 70, 87, 90, 111, 120, 137, 200, 203, 210, 221, 249, 274, 299, 342,
|
||||
999,
|
||||
] {
|
||||
assert_eq!(consumable_family(s), None, "subtype {s} must be unknown");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staff_role_each_role_and_unknown_is_none() {
|
||||
assert_eq!(staff_role(4), Some(("manager", "Manager")));
|
||||
assert_eq!(staff_role(5), Some(("headcoach", "Head Coach")));
|
||||
assert_eq!(staff_role(6), Some(("gkcoach", "GK Coach")));
|
||||
assert_eq!(staff_role(7), Some(("physio", "Physio")));
|
||||
assert_eq!(staff_role(8), Some(("fitnesscoach", "Fitness Coach")));
|
||||
for s in [0, 1, 2, 3, 9, 10, 201, 300] {
|
||||
assert_eq!(staff_role(s), None, "staff subtype {s} must be unknown");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
|
||||
/// One owned item in game-independent terms, as read from Core's inventory.
|
||||
@@ -69,6 +70,15 @@ pub struct Fifa17Identity {
|
||||
/// "no real FIFA asset id known" → the caller must not fabricate one.
|
||||
pub trait ItemIdentityResolver {
|
||||
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
|
||||
|
||||
/// Classify a Core item's definition as player/consumable/staff. Defaults to
|
||||
/// [`ContentKind::Player`] so existing resolvers keep their behaviour; a
|
||||
/// catalog-backed resolver overrides this to consult its `kind_of`, letting
|
||||
/// `/club` exclude non-player content (which must never render as a
|
||||
/// 0-rated player).
|
||||
fn kind_of(&self, _item: &CoreOwnedItem) -> ContentKind {
|
||||
ContentKind::Player
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnostics from shaping (safe to log — counts only).
|
||||
@@ -76,6 +86,9 @@ pub trait ItemIdentityResolver {
|
||||
pub struct ShapeStats {
|
||||
pub emitted: usize,
|
||||
pub dropped_no_asset: usize,
|
||||
/// Consumable/staff items excluded from a player projection (they must never
|
||||
/// render as a 0-rated player). Counted, never emitted.
|
||||
pub excluded_non_player: usize,
|
||||
}
|
||||
|
||||
/// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! socket — a Rust UTAS host wires it to Core later.
|
||||
pub mod catalog;
|
||||
pub mod club_response;
|
||||
pub mod content_taxonomy;
|
||||
pub mod economy;
|
||||
pub mod economy_policy;
|
||||
pub mod entities;
|
||||
|
||||
Reference in New Issue
Block a user