97498c560e
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.
642 lines
27 KiB
Rust
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");
|
|
}
|
|
}
|
|
}
|