feat(fifa17): project every owned content kind, from one recovered vocabulary
Extends the FIFA17 adapter past players so the wire can carry the rest of a real club's inventory. itemState: the recovered 12-row table at 0x180229cc0 becomes the single source (`fut::item_state`), replacing scattered literals. Every shaper draws from it and the tests assert no shaper can emit a state the client does not know. CARD_SYSTEM.md's 0x180229d20 is the middle of that table, not its start. ContentKind covers all nine tokens. Managers stay inside the staff family for counting, because the client's own club-stats model puts a manager INSIDE the staff total with staffManager as a sub-bucket — a parallel Manager kind would silently under-count. Consumables get their own route (`club/consumables/<category>`) and a stack-wrapper envelope, classified BEFORE the other club/ arms; they are not a `?type=` family. This path previously fell through to Python, so owned inventory was being served by the oracle. The shaper refuses to emit a card it cannot render: no known art id, or a missing `amount`/`contract` for the families that read them, or the subtype-219 rareflag trap that silently turns Player Fitness into Squad Fitness. A dropped card is counted and logged, never faked.
This commit is contained in:
@@ -21,13 +21,30 @@
|
||||
/// 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,
|
||||
Consumable,
|
||||
/// 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 {
|
||||
@@ -35,9 +52,14 @@ impl ContentKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ContentKind::Player => "player",
|
||||
ContentKind::Consumable => "consumable",
|
||||
ContentKind::Manager => "manager",
|
||||
ContentKind::Staff => "staff",
|
||||
ContentKind::Consumable => "consumable",
|
||||
ContentKind::Kit => "kit",
|
||||
ContentKind::Badge => "badge",
|
||||
ContentKind::Ball => "ball",
|
||||
ContentKind::Stadium => "stadium",
|
||||
ContentKind::Misc => "misc",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +71,30 @@ impl ContentKind {
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(s: &str) -> ContentKind {
|
||||
match s {
|
||||
"consumable" => ContentKind::Consumable,
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
/// The functional family + honest display label for a consumable `cardsubtypeid`,
|
||||
@@ -104,6 +144,172 @@ pub fn staff_role(subtype: i64) -> Option<(&'static str, &'static str)> {
|
||||
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::*;
|
||||
@@ -111,20 +317,171 @@ mod tests {
|
||||
#[test]
|
||||
fn content_kind_round_trips_and_defaults_to_player() {
|
||||
assert_eq!(ContentKind::default(), ContentKind::Player);
|
||||
for k in [
|
||||
// The FULL Core content vocabulary, every token round-tripping.
|
||||
let all = [
|
||||
ContentKind::Player,
|
||||
ContentKind::Consumable,
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user