Files
OpenFUT/openfut-adapter-fifa17/src/fut/content_taxonomy.rs
T
funman300 cd5983ecdd fifa17: cardtype 9 is unnameable -- measured, and the gap closes as a negative
Serving owned balls (subtype 30), league logos (31) and fcc_misccards
(231/232/233/236) was the last projection gap. The open guess was that their
caption would come from `localizedName` on the wire, "probably", and they were
withheld out of caution.

Measured against the running client instead (new
tools/cardtype_dispatch_probe.py, read-only, reproducible, every step with a
positive control). They cannot be named at all:

  1. The merge jump table at rva 0x141eb4 is indexed cardtype-1 with 10 entries.
     Cardtypes 1..5 and 10 each get a DB-merge arm; cardtypes 6,7,8,9 ALL land on
     one shared tail at 0x180141e8a that runs no query and writes no name.
  2. `cmp [reg+0x4c], 9` (cardtype): ZERO sites in .text. For contrast, cardtype
     1 has 13 and cardtype 7 has 6.
  3. `cmp [reg+0x50], 30` and `..., 31` (cardsubtypeid -- the field that actually
     selects a club-item caption): ZERO sites each, while kit 9, stadium 10 and
     badge 11 all appear, which is the control. The only cardtype-9 subtypes
     present anywhere are the four misccards ids, and all four are one boolean
     predicate near 0x1801a72da that returns FALSE for them: an exclusion, not a
     resolver. That predicate is NOT identified and is not claimed to be.
  4. The cardtype-7 resolver is gated `cmp [rax+0x4c], 7` at 0x1800f6f04, so a
     cardtype-9 item never reaches it. Its jne path formats AWARD_LABEL_%i --
     the trophy path, not a fallback that would name a ball.

Nothing reads a localizedName for these subtypes, so sending one cannot become a
caption. Withholding them is a measured limit of the client, not caution, and no
server change can lift it.

CORRECTION: FUN_180119bd0 was recorded as "zero refs in CardsDLL -> almost
certainly an export, its caller is in FIFA17.exe". It is not an export. Its
address occurs exactly once in the whole process, at 0x18021c738 in CardsDLL's
own .rdata, and nothing in FIFA17.exe references it. It is virtual: vtable base
0x18021c2a0, slot +0x498, index 147 -- independently reproducing the recorded
"manager vtable slot +0x498" by a different method. Finding the boundary needs
the constructor-LEA trick; walking back over .text-pointing qwords runs 826 slots
through several adjacent vtables.

Bonus: the shared tail cardtypes 6-9 fall into IS the discard level ladder
(movzx [rdi+0xb4]; cmp 0x4b; cmp 0x41; store [rdi+0x54]), confirming
discard::discard_level instruction for instruction against the live client.

Adapter 244 tests, fmt clean.
2026-08-21 23:10:44 +00:00

577 lines
24 KiB
Rust

//! 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 `fifa17-recon/docs/plan-2026-08-06-card-subsystem.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.
///
/// The token set is OpenFUT Core's game-independent content vocabulary
/// (`player | manager | staff | consumable | kit | badge | ball | stadium |
/// misc`), so a Core owned row and a FIFA 17 catalog entry name the same class
/// with the same string and the FIFA numerics (`cardsubtypeid`, resource ranges)
/// never leak out of this crate.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ContentKind {
#[default]
Player,
/// A MANAGER — its own Core kind, but on the FIFA 17 side it is a member of
/// the STAFF family, never a class of its own: see
/// [`ContentKind::is_staff_family`]. The wire discriminator is
/// [`MANAGER_SUBTYPE`], not this token, so a catalog may classify a manager
/// as either `manager` or `staff` + subtype 4 and every consumer here
/// treats the two encodings identically.
Manager,
Staff,
Consumable,
Kit,
Badge,
Ball,
Stadium,
Misc,
}
impl ContentKind {
/// The stable wire/catalog token for this kind.
pub fn as_str(&self) -> &'static str {
match self {
ContentKind::Player => "player",
ContentKind::Manager => "manager",
ContentKind::Staff => "staff",
ContentKind::Consumable => "consumable",
ContentKind::Kit => "kit",
ContentKind::Badge => "badge",
ContentKind::Ball => "ball",
ContentKind::Stadium => "stadium",
ContentKind::Misc => "misc",
}
}
/// 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 {
"manager" => ContentKind::Manager,
"staff" => ContentKind::Staff,
"consumable" => ContentKind::Consumable,
"kit" => ContentKind::Kit,
"badge" => ContentKind::Badge,
"ball" => ContentKind::Ball,
"stadium" => ContentKind::Stadium,
"misc" => ContentKind::Misc,
_ => ContentKind::Player,
}
}
/// True for the two kinds that make up the FIFA 17 STAFF family.
///
/// A manager IS a staff card: the client's own club-stats model counts it
/// inside the `staff` total with `staffManager` as a bucket within it, its
/// STAFF tab asks for the whole family with `type=manager`, and one record
/// shape ([`crate::fut::item::shape_staff_item`]) serves all five families.
/// Every staff consumer MUST use this predicate rather than matching
/// `Staff` alone, or a `manager`-classified row silently leaves the staff
/// bucket and the STAFF tab.
pub fn is_staff_family(&self) -> bool {
matches!(self, ContentKind::Manager | ContentKind::Staff)
}
/// True for the three club-customisation kinds that share the **cardtype-7**
/// record: kit (9), stadium (10) and badge (11).
///
/// `FUN_1800d8330` maps all three subtypes to cardtype 7, and one client-side
/// resolver (`FUN_180119bd0`, dispatched on `item+0x4c == 7`) captions all
/// three. They therefore share ONE wire record
/// ([`crate::fut::item::shape_club_item`]) and one identity resolver.
///
/// Ball (30) and league logo (31) are cardtype 9 and are deliberately NOT in
/// this family. They are not merely unproven — they are UNNAMEABLE, measured
/// against the running client on 2026-08-21
/// (`fifa17-recon/tools/cardtype_dispatch_probe.py`):
///
/// * the merge switch's jump table (rva `0x141eb4`, indexed `cardtype - 1`)
/// sends cardtypes 6/7/8/9 to a shared tail that runs no query and writes
/// no name;
/// * `cmp [reg+0x4c], 9` occurs ZERO times in `.text`;
/// * `cmp [reg+0x50], 30` and `… , 31` occur ZERO times, while kit 9,
/// stadium 10 and badge 11 all appear (the positive control);
/// * the cardtype-7 resolver is gated `cmp [rax+0x4c], 7`, so a cardtype-9
/// item can never reach it.
///
/// So no `localizedName` we send could become a caption: nothing reads one
/// for these subtypes. Serving them would draw unnamed cards, and no
/// server-side change can fix that.
pub fn is_cardtype7_club_item(&self) -> bool {
matches!(
self,
ContentKind::Kit | ContentKind::Stadium | ContentKind::Badge
)
}
}
/// 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)
}
/// The ONE extra wire key a consumable family needs, or [`ConsumableNeeds::None`].
///
/// Taken verbatim from `fifa17-recon/data/consumables.json`'s per-subtype `needs`
/// (generated by `build_consumables.py` from `FUN_18013f4d0`), and independently
/// confirmed by the real profile import, where `amount` is present on exactly the
/// training/healing/fitness/play-style/league families and `contract` on exactly
/// the two contract families.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsumableNeeds {
/// `amount` (atom 0x1b → `rec+0xbf`, or `+0xbe` for a play style) is
/// MANDATORY: the parser initialises its temp to -1 and both accessors read
/// it SIGNED, so omitting the key draws "-1" on the card, not "0".
Amount,
/// `contract` (atom 0xb8 → `rec+0x8c`) carries the number the card grants;
/// the two contract families IGNORE `amount` entirely.
Contract,
/// Nothing beyond the common key set — the card's whole meaning comes from
/// `cardsubtypeid` (formation and position modifiers).
None,
}
/// Which extra key a consumable family requires. An unknown family name is
/// [`ConsumableNeeds::None`]; callers get families from [`consumable_family`],
/// so an unknown one cannot arrive from the wire.
pub fn consumable_needs(family: &str) -> ConsumableNeeds {
match family {
"gk_training" | "player_training" | "healing" | "player_fitness" | "squad_fitness"
| "player_playstyle" | "gk_playstyle" | "manager_league" => ConsumableNeeds::Amount,
"player_contract" | "manager_contract" => ConsumableNeeds::Contract,
_ => ConsumableNeeds::None,
}
}
/// The consumable families one `GET club/consumables/<category>` segment asks
/// for, or `None` for a segment outside the client's own group table.
///
/// **This route, not `club?type=`.** Consumables are NOT a `?type=` family: a
/// previous round shipped four `?type=` arms for them and the screen stayed
/// empty, because the client asks here (and only once
/// `club/stats/consumables` reports a non-zero count — the counter is the gate
/// and this route is the door).
///
/// The segment names are the consumable UI group table at `0x180203260` (seven
/// codes: `training`, `contracts`, `fitness`, `healing`, `playStyle`,
/// `managerLeagueModifier`, `position`); `training` and `contracts` are CONFIRMED
/// on the wire and the singular `contract` is accepted because the client has
/// used both spellings. Segments are matched lower-cased.
///
/// The family sets are the `FUN_18013f4d0` categories those codes name, and the
/// correspondence is checkable against the panel: training→42, contracts→13,
/// healing→21, fitness→6, position→20, chemistry style→24 items in the oracle's
/// own shelf. NOTE the two formation-modifier families (categories 6 and 7) have
/// NO group code, so no segment can reach them — that is the client's own gap,
/// not an omission here.
pub fn consumable_families_for_category(segment: &str) -> Option<&'static [&'static str]> {
Some(match segment {
"training" => &["gk_training", "player_training"],
"contracts" | "contract" => &["player_contract", "manager_contract"],
"fitness" => &["player_fitness", "squad_fitness"],
"healing" => &["healing"],
"position" => &["position_mod"],
"playstyle" => &["player_playstyle", "gk_playstyle"],
"managerleaguemodifier" => &["manager_league"],
_ => return None,
})
}
/// The club-customisation `cardsubtypeid`s, SETTLED (supersedes
/// `CARD_SYSTEM.md`'s "STILL UNKNOWN, AND NOT GUESSED" section, which is stale).
///
/// Kit 9, stadium 10 and badge 11 are cardtype **7** and resolve through
/// `FUN_180119bd0` (the manager vtable slot `+0x498`, verified from disk and live
/// memory); ball 30 (`0x1e`) and league logo 31 (`0x1f`) are cardtype 9, the
/// latter by elimination over `FUN_1800d8330`'s cardtype-9 set. Four independent
/// lines agree on kit = 9, including the deserializer's own `cardassetid` default
/// of `0x23` = 35 for cardtype 7 / subtype 9 — exactly the `cardassetid` carried
/// by all 1482 rows of `fcc_kitcards`.
///
/// `0x91..=0x96` are TROPHIES (tournament/season), not club items. The enum table
/// at `0x180229ab0` (`badge=0xa kit=0xb leagueLogo=0xc … stadium=0x15 ball=0x16`)
/// is the transfermarket `&cat=%s` vocabulary and NOT a subtype map: reading it as
/// one swaps badge and kit and loses stadium.
pub const KIT_SUBTYPE: i64 = 9;
pub const STADIUM_SUBTYPE: i64 = 10;
pub const BADGE_SUBTYPE: i64 = 11;
pub const BALL_SUBTYPE: i64 = 30;
pub const LEAGUE_LOGO_SUBTYPE: i64 = 31;
/// The club-customisation [`ContentKind`] for a `cardsubtypeid`, or `None` for a
/// subtype outside the settled set above. A league logo has no Core kind of its
/// own (it is not ownable club content in Core's vocabulary), so subtype 31
/// deliberately maps to `None` rather than being folded into `Misc`.
pub fn club_item_kind(subtype: i64) -> Option<ContentKind> {
let kind = match subtype {
KIT_SUBTYPE => ContentKind::Kit,
STADIUM_SUBTYPE => ContentKind::Stadium,
BADGE_SUBTYPE => ContentKind::Badge,
BALL_SUBTYPE => ContentKind::Ball,
_ => return None,
};
Some(kind)
}
/// The three MY CLUB position tabs (`type=playerdefender|playermidfielder|
/// playerforward`). `FUN_18012ddf0` remaps request field `*(req+0x14)` values
/// `0x1c/0x1d/0x1e` onto type codes `0x1b/0x1c/0x1d` and SUPPRESSES `position=`,
/// so a position tab arrives as one of those three tokens with no other filter.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PositionGroup {
Defender,
Midfielder,
Forward,
}
/// The FIFA 17 position ID for a FUT position label, from the client's OWN `pos`
/// vocabulary — the NUL-terminated `{const char*, int}` table at `0x1802295c0`
/// that it emits as the transfer-market `&pos=%s` parameter:
/// `GK=0 RWB=2 RB=3 CB=5 LB=7 LWB=8 CDM=10 RM=12 CM=14 LM=16 CAM=18 RF=20 CF=21
/// LF=22 RW=23 ST=25 LW=27`.
///
/// `None` = a label outside that table (never guessed): the item then belongs to
/// no position tab rather than to an invented one.
pub fn position_id(pos: &str) -> Option<i64> {
let id = match pos {
"GK" => 0,
"RWB" => 2,
"RB" => 3,
"CB" => 5,
"LB" => 7,
"LWB" => 8,
"CDM" => 10,
"RM" => 12,
"CM" => 14,
"LM" => 16,
"CAM" => 18,
"RF" => 20,
"CF" => 21,
"LF" => 22,
"RW" => 23,
"ST" => 25,
"LW" => 27,
_ => return None,
};
Some(id)
}
/// Which position tab a FUT position label belongs to, or `None` for a label
/// outside the client's own `pos` table.
///
/// The ladder is the client's, not ours: `FUN_180135890` recomputes `rec+0x14c`
/// from the position at `rec+0x146` as `0 → GK`, `1..=8 → DEF`, `9..=19 → MID`,
/// `20..=27 → ATT`.
///
/// THE ONE GUESS, named: GK is folded into `Defender`, because the client has
/// exactly three position tabs and no fourth, so a keeper must land in one of
/// them or vanish from every drill-down. Falsifier: if the DEF tab renders
/// without goalkeepers, move GK out (the group boundary becomes `1..=8`).
pub fn position_group(pos: &str) -> Option<PositionGroup> {
match position_id(pos)? {
0..=8 => Some(PositionGroup::Defender),
9..=19 => Some(PositionGroup::Midfielder),
20..=27 => Some(PositionGroup::Forward),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn content_kind_round_trips_and_defaults_to_player() {
assert_eq!(ContentKind::default(), ContentKind::Player);
// The FULL Core content vocabulary, every token round-tripping.
let all = [
ContentKind::Player,
ContentKind::Manager,
ContentKind::Staff,
ContentKind::Consumable,
ContentKind::Kit,
ContentKind::Badge,
ContentKind::Ball,
ContentKind::Stadium,
ContentKind::Misc,
];
for k in all {
assert_eq!(ContentKind::from_str(k.as_str()), k);
}
let tokens: Vec<&str> = all.iter().map(|k| k.as_str()).collect();
assert_eq!(
tokens,
vec![
"player",
"manager",
"staff",
"consumable",
"kit",
"badge",
"ball",
"stadium",
"misc"
],
"these exact strings are the cross-crate contract with Core"
);
// 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 only_manager_and_staff_are_the_staff_family() {
for k in [ContentKind::Manager, ContentKind::Staff] {
assert!(k.is_staff_family(), "{} is a staff card", k.as_str());
}
for k in [
ContentKind::Player,
ContentKind::Consumable,
ContentKind::Kit,
ContentKind::Badge,
ContentKind::Ball,
ContentKind::Stadium,
ContentKind::Misc,
] {
assert!(!k.is_staff_family(), "{} is not staff", k.as_str());
}
}
#[test]
fn every_consumable_family_needs_exactly_what_the_client_reads() {
// Grouped from data/consumables.json's per-subtype `needs`, and observed
// key-for-key in the real profile import.
for f in [
"gk_training",
"player_training",
"healing",
"player_fitness",
"squad_fitness",
"player_playstyle",
"gk_playstyle",
"manager_league",
] {
assert_eq!(consumable_needs(f), ConsumableNeeds::Amount, "{f}");
}
for f in ["player_contract", "manager_contract"] {
assert_eq!(consumable_needs(f), ConsumableNeeds::Contract, "{f}");
}
for f in ["manager_formation_mod", "formation_mod", "position_mod"] {
assert_eq!(consumable_needs(f), ConsumableNeeds::None, "{f}");
}
}
#[test]
fn consumable_route_categories_partition_the_reachable_families() {
// The seven group codes, plus the singular `contract` spelling.
let segments = [
"training",
"contracts",
"fitness",
"healing",
"position",
"playstyle",
"managerleaguemodifier",
];
let mut seen: Vec<&str> = Vec::new();
for seg in segments {
for f in consumable_families_for_category(seg).unwrap() {
assert!(!seen.contains(f), "{f} claimed by two categories");
seen.push(f);
}
}
assert_eq!(
consumable_families_for_category("contract"),
consumable_families_for_category("contracts"),
"both spellings the client has used mean the same set"
);
// Eleven of the thirteen families are reachable; the two formation
// modifiers have no group code in the client's own table.
assert_eq!(seen.len(), 11, "no duplicates: {seen:?}");
for subtype in [51, 61, 91, 201, 202, 211, 219, 220, 250, 269, 300] {
let (family, _) = consumable_family(subtype).unwrap();
assert!(seen.contains(&family), "no category serves {family}");
}
for unreachable in [71, 121] {
let (family, _) = consumable_family(unreachable).unwrap();
assert!(
!seen.contains(&family),
"{family} has no group code; claiming it would invent a segment"
);
}
// Not a consumables segment (and NOT a `?type=` token either).
for s in ["", "player", "kit", "Training", "development"] {
assert!(
consumable_families_for_category(s).is_none(),
"{s:?} is not a consumable category"
);
}
}
#[test]
fn club_item_subtypes_are_the_settled_five() {
assert_eq!(club_item_kind(KIT_SUBTYPE), Some(ContentKind::Kit));
assert_eq!(club_item_kind(STADIUM_SUBTYPE), Some(ContentKind::Stadium));
assert_eq!(club_item_kind(BADGE_SUBTYPE), Some(ContentKind::Badge));
assert_eq!(club_item_kind(BALL_SUBTYPE), Some(ContentKind::Ball));
assert_eq!((KIT_SUBTYPE, STADIUM_SUBTYPE, BADGE_SUBTYPE), (9, 10, 11));
assert_eq!((BALL_SUBTYPE, LEAGUE_LOGO_SUBTYPE), (30, 31));
// A league logo is not ownable Core content, so it maps to no kind.
assert_eq!(club_item_kind(LEAGUE_LOGO_SUBTYPE), None);
// Trophies (0x91..0x96) are NOT club items, and staff/consumable
// subtypes must never be mistaken for one.
for s in [0, 4, 8, 0x91, 0x96, 201, 231] {
assert_eq!(club_item_kind(s), None, "subtype {s} is not a club item");
}
}
#[test]
fn position_groups_follow_the_clients_own_ladder() {
// Ids are the client's `pos` table; groups are its 0/1..8/9..19/20..27
// recompute. GK folded into DEF is the one named guess.
for p in ["GK", "CB", "LB", "RB", "LWB", "RWB"] {
assert_eq!(position_group(p), Some(PositionGroup::Defender), "{p}");
}
for p in ["CDM", "CM", "CAM", "LM", "RM"] {
assert_eq!(position_group(p), Some(PositionGroup::Midfielder), "{p}");
}
for p in ["RF", "CF", "LF", "RW", "ST", "LW"] {
assert_eq!(position_group(p), Some(PositionGroup::Forward), "{p}");
}
assert_eq!(position_id("ST"), Some(25));
assert_eq!(position_id("CDM"), Some(10));
// Not in the client's table → no tab, never an invented one.
for p in ["", "SW", "st", "MID", "SUB"] {
assert_eq!(position_group(p), None, "{p:?}");
assert_eq!(position_id(p), None, "{p:?}");
}
}
#[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");
}
}
}