//! 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 item 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, Kit, } 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", ContentKind::Kit => "kit", } } /// 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, "kit" => ContentKind::Kit, _ => 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) } /// `cardsubtypeid` of a MANAGER staff card. This value alone selects the /// `managercards` merge in the client (`FUN_1800d8330` → cardtype 2 → /// `FUN_1801356c0`), and it is what distinguishes a manager from the four coach /// families inside [`ContentKind::Staff`]. pub const MANAGER_SUBTYPE: i64 = 4; /// 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, ContentKind::Kit, ] { 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"); } } }