Files
OpenFUT/openfut-adapter-fifa17/src/fut/content_taxonomy.rs
T
funman300 97498c560e docs(re): refute the contract:7 effect source; record the competing development reading
Two corrections found while trying to close the effect boundary statically.

1. `contract: 7` IS OUR OWN PLACEHOLDER. fut_store.py:232's generic _item()
   factory -- which builds every item the oracle serves -- hardcodes
   playStyle 250 / contract 7 / fitness 99 on players and consumables alike. The
   staging GK reads back exactly those three constants. So the production
   catalog's contract:7 for resource 5001004 is an oracle placeholder
   round-tripped through an observed profile, not an EA value. Its status is not
   INFERRED, it is KNOWN-BOGUS as a source. Had the effect been implemented on
   it, it would have been a fabricated game rule wearing observed-data clothing.

2. fcc_contractcards is NOT amount-less. An earlier note here claimed it "has no
   amount column, so this value comes from observed data". It has 13 rows with
   gold/silver/bronze/rating, 6 player + 6 manager paired by rating plus a
   99/99/99 special. The sibling fcc_healingcards shares every column except
   that it carries a single `amount`, which argues the differing columns ARE the
   effect payload (per target tier). Against that: the values are non-monotonic
   across tiers, which suits weights better than amounts; and no column of
   5001004 is 7, so neither reading explains the placeholder.

   The reader that would settle amount-vs-weight is in FIFA17.exe, not CardsDLL
   (the table and column literals are absent from the DLL), so this stays
   EFFECT_UNKNOWN rather than being guessed.

Also records, in content_taxonomy.rs, the competing reading of `development`:
fut_consumables.py's TYPE_CATEGORIES groups it as card-categories {6,7,8,9,10}
(modifiers only), explicitly flagged there as inferred from UI-bucket names and
never observed on the wire. Different enum space from the CONSUMABLE_TYPE switch
that actually emits the segment, and the switch gives formation/position/
playStyle/managerLeagueModifier their own segments rather than folding them into
development -- so the unfiltered reading is better supported, but it is still a
reading and the doc now says so instead of sounding settled.

248 adapter tests, fmt clean. No behaviour change.
2026-08-22 01:01:47 +00:00

642 lines
27 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 client's own CONSUMABLE_TYPE→segment switch,
/// recovered live 2026-08-22 from CardsDLL: the literal table at `0x1801f5a38`
/// (under `MyClubAdapterClass`/`CONSUMABLE_TYPE`) and the jump table at
/// `0x180048820`, which indexes by `enum + 1` through the byte table at
/// `0x180048a90`. Nine segments, not seven:
///
/// | enum | segment |
/// |------|---------|
/// | -1 (unset) | `development` |
/// | 1, 2 | `contracts` |
/// | 3 | `healing` |
/// | 4 | `fitness` |
/// | 16 | `formation` |
/// | 17 | `position` |
/// | 23 | `playStyle` |
/// | 24 | `managerLeagueModifier` |
/// | 0, 5..15, 18..22 | `training` (the switch default) |
///
/// This CORRECTS the previous note here, which read the seven-code UI group
/// table at `0x180203260` and concluded the two formation-modifier families
/// "have NO group code, so no segment can reach them — the client's own gap".
/// The client does have a `formation` segment (enum 16), and it asked for
/// `development` live, so both were server-side gaps, not client ones.
///
/// `development` is the **type-unset** bucket: index 0 of a table indexed by
/// `enum + 1`, i.e. no type filter was set. It is therefore the unfiltered view
/// and maps to every family — which is consistent, since the eight TYPED
/// segments already reach all thirteen families exactly once.
///
/// COMPETING INFERENCE, recorded rather than buried. `fut_consumables.py`'s
/// `TYPE_CATEGORIES` maps `development` to card-categories `{6,7,8,9,10}`
/// (formation/position/playstyle/manager-league) — i.e. the modifier families
/// only, not everything. That grouping is explicitly flagged there as INFERRED
/// from `FUN_180048780`'s UI-bucket names, with "the tab-to-arm binding has
/// NEVER been observed on the wire".
///
/// They are not the same enum: the oracle's is the 0..10 CARD-category space of
/// `FUN_18013f4d0`, this is the 0..24 CONSUMABLE_TYPE space that actually
/// produces the URL segment. The tiebreaker is the switch itself — it gives
/// formation (16), position (17), playStyle (23) and managerLeagueModifier (24)
/// their OWN segment strings, so those types are not folded into `development`,
/// which is what the oracle's grouping would require. The unfiltered reading is
/// therefore the better-supported one, but it is still a reading: what the
/// SCREEN expects to list has not been observed, and one live capture of the
/// development tab would settle it.
///
/// `training` and `contracts` are CONFIRMED on the wire, `development` was
/// observed live, and the singular `contract` is accepted because the client has
/// used both spellings. Segments are matched lower-cased.
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"],
"formation" => &["manager_formation_mod", "formation_mod"],
"development" => ALL_CONSUMABLE_FAMILIES,
_ => return None,
})
}
/// Every consumable family, i.e. the `development` (type-unset) view. Kept as one
/// list so a new family cannot be added to the taxonomy and silently omitted from
/// the unfiltered screen.
pub const ALL_CONSUMABLE_FAMILIES: &[&str] = &[
"gk_training",
"player_training",
"player_contract",
"manager_contract",
"player_fitness",
"squad_fitness",
"healing",
"position_mod",
"player_playstyle",
"gk_playstyle",
"manager_league",
"manager_formation_mod",
"formation_mod",
];
/// 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 eight TYPED segments of the client's own switch (enum 1,2,3,4,16,
// 17,23,24 plus the default), and the singular `contract` spelling.
let segments = [
"training",
"contracts",
"fitness",
"healing",
"position",
"playstyle",
"managerleaguemodifier",
"formation",
];
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"
);
// All THIRTEEN families are reachable: the client does have a `formation`
// segment (enum 16), so the two formation modifiers were a server-side
// gap, not the client gap this test used to assert.
assert_eq!(seen.len(), 13, "no duplicates: {seen:?}");
for subtype in [51, 61, 71, 91, 121, 201, 202, 211, 219, 220, 250, 269, 300] {
let (family, _) = consumable_family(subtype).unwrap();
assert!(seen.contains(&family), "no category serves {family}");
}
// `development` is the type-UNSET bucket (index 0 of an `enum + 1` table),
// i.e. the unfiltered view. It deliberately overlaps the typed segments,
// and must stay exactly the union of them so a new family cannot be added
// to the taxonomy and silently vanish from the unfiltered screen.
let mut dev = consumable_families_for_category("development")
.unwrap()
.to_vec();
dev.sort_unstable();
let mut all = seen.clone();
all.sort_unstable();
assert_eq!(dev, all, "development must be exactly the unfiltered set");
// Not a consumables segment (and NOT a `?type=` token either).
for s in ["", "player", "kit", "Training"] {
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");
}
}
}