4 Commits

Author SHA1 Message Date
funman300 1a6355cad5 fix(staging): re-stamp the squad extension after seeding, and prove it
Filling the bench writes squad rows behind Core's back, which invalidates the
stored FIFA17 opaque extension: Core saw a canonical squad that no longer
matched the extension's fingerprint, the host refused to apply it
(`stale_integrity`), and the client got a squad with zero players and no
manager. Nothing failed loudly — the rig just came up empty.

The seeder now recomputes the fingerprint exactly as
`services::squad::squad_fingerprint` does, and bring-up asserts the squad really
projects (>= 18 occupied, a manager present) instead of trusting that it did.

Seeded owned rows also state their content_kind, so Core does not record a kit
or a manager as a player.
2026-08-21 19:50:21 +00:00
funman300 770029f207 fix(import-fifa17): carry the fields a consumable needs to exist
Two defects that together made the club's 17 owned consumables invisible while
club/stats still counted them — the count gate promised 17, the item route
served 0.

1. The catalog omitted `card_asset_id`, `amount`, `contract` and `rating` for
   non-player definitions. Without an art id the adapter refuses to emit the
   card (it would draw the notfound box), and the families that read
   `amount`/`contract` would render "-1" or grant nothing. All four values are
   present in the source wire and were simply dropped on the way out.

2. Owned rows were imported without a `content_kind`, and Core defaults an
   unstated row to `player` — durably recording a fitness coach and a contract
   card as players in the ownership authority, even though the catalog-driven
   wire looked right.

These are definition-level fields, so every owned copy must agree; a group that
disagrees is deferred rather than resolved by taking the first copy's value.
Measured on the real profile: observed `amount` equals the `fcc_*` table row for
every consumable that carries one (1, 2, 4, 5, 10, 15), and a wire omission
corresponds to a table amount of 0. It is therefore NOT a stack count — two
copies of 5003068 are two instances — so the import states no `quantity` at all.
2026-08-21 19:50:13 +00:00
funman300 802f0f580f feat(host): serve owned non-player content from Core's ownership truth
Follows Core's kit designations becoming generic active-item slots: the host
reads `GET /club/active-items` (five always-present slots) instead of the
removed `/club/kits`.

Adds the consumables route and widens the club families to every content kind,
all resolved from Core ownership + the FIFA catalog. An item the client sees is
now an item Core actually owns.
2026-08-21 19:49:59 +00:00
funman300 6c7d0856b6 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.
2026-08-21 19:49:52 +00:00
17 changed files with 2220 additions and 85 deletions
+74
View File
@@ -54,6 +54,22 @@ pub struct Fifa17CardIdentity {
/// `manager.teamid` → `leagueteamlinks.leagueid`, because `managercards` has /// `manager.teamid` → `leagueteamlinks.leagueid`, because `managercards` has
/// no league column. Lands in the equally untouched slot `rec+0xe0`. /// no league column. Lands in the equally untouched slot `rec+0xe0`.
pub league_id: i64, pub league_id: i64,
/// EA's authored `rating` for a NON-PLAYER definition (`fcc_*.rating`), which
/// Core does not model: an imported consumable's Core `overall` is 0, while
/// the client's own copies carry 55..95 and the value drives the card level
/// (`rec+0x54`) and therefore its quick-sell price. `None` → the caller falls
/// back to Core's rating, which stays authoritative for players.
pub rating: Option<u8>,
/// `amount` (atom 0x1b) for a consumable definition — the bonus magnitude EA
/// authored in the `fcc_*` row (+5 / +10 / +15 …). MANDATORY for the
/// training, healing, fitness, play-style and manager-league families:
/// omitting the key draws "-1" on the card, not "0".
pub amount: Option<i64>,
/// `contract` (atom 0xb8) for a contract-card definition (`cardsubtypeid`
/// 201/202) — the number of matches the card grants. `fcc_contractcards` has
/// no amount column, so this value comes from observed data; it is never
/// defaulted here.
pub contract: Option<i64>,
} }
/// The FIFA 17 numeric namespace policy for owned-item wire ids. /// The FIFA 17 numeric namespace policy for owned-item wire ids.
@@ -182,6 +198,15 @@ struct RawCard {
/// Manager chemistry league; absent → `0`. /// Manager chemistry league; absent → `0`.
#[serde(default)] #[serde(default)]
league_id: Option<i64>, league_id: Option<i64>,
/// EA-authored rating for a non-player definition; absent → Core's rating.
#[serde(default)]
rating: Option<u8>,
/// Consumable bonus magnitude (atom 0x1b); absent → key omitted.
#[serde(default)]
amount: Option<i64>,
/// Contract-card grant (atom 0xb8); absent → key omitted.
#[serde(default)]
contract: Option<i64>,
} }
fn default_rareflag() -> i64 { fn default_rareflag() -> i64 {
@@ -242,6 +267,9 @@ impl Fifa17CardCatalog {
team_id: rc.team_id.unwrap_or(0), team_id: rc.team_id.unwrap_or(0),
nation: rc.nation.unwrap_or(0), nation: rc.nation.unwrap_or(0),
league_id: rc.league_id.unwrap_or(0), league_id: rc.league_id.unwrap_or(0),
rating: rc.rating,
amount: rc.amount,
contract: rc.contract,
}, },
); );
} }
@@ -327,6 +355,52 @@ mod tests {
assert_eq!(cat.lookup("card_missing"), None); assert_eq!(cat.lookup("card_missing"), None);
} }
/// The non-player definition fields a consumable needs, and the ABSENCE that
/// must stay an absence: a defaulted `amount` would draw "-1" on the card and
/// a defaulted `contract` would invent the number of matches a card grants.
#[test]
fn consumable_definition_fields_are_carried_and_never_defaulted() {
let cat = Fifa17CardCatalog::from_json_str(
r#"{"schema_version":1,"game":"fifa17","cards":{
"fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54,
"card_asset_id":3,"rareflag":0,"rating":85,"amount":15},
"fifa17_5001004":{"asset_id":5001004,"kind":"consumable","subtype":201,
"card_asset_id":7,"rareflag":0,"rating":60,"contract":7},
"fifa17_5003059":{"asset_id":5003059,"kind":"consumable","subtype":91,
"card_asset_id":34,"rareflag":0,"rating":95},
"fifa17_20801":{"asset_id":20801}
}}"#,
)
.unwrap();
// A training card: art id 3 (NOT the carddbid), EA's rating, amount 15.
let training = cat.lookup("fifa17_5003012").unwrap();
assert_eq!(training.kind, ContentKind::Consumable);
assert_eq!(training.subtype, 54);
assert_eq!(training.card_asset_id, 3);
assert_eq!(training.rating, Some(85));
assert_eq!(training.amount, Some(15));
assert_eq!(training.contract, None);
// A contract card takes its number from `contract`, not `amount`.
let contract = cat.lookup("fifa17_5001004").unwrap();
assert_eq!(contract.contract, Some(7));
assert_eq!(contract.amount, None);
// A position modifier needs neither.
let position = cat.lookup("fifa17_5003059").unwrap();
assert_eq!(position.amount, None);
assert_eq!(position.contract, None);
assert_eq!(position.card_asset_id, 34);
// A player carries none of them and keeps Core's authoritative rating.
let player = cat.lookup("fifa17_20801").unwrap();
assert_eq!(player.kind, ContentKind::Player);
assert_eq!(player.rating, None);
assert_eq!(player.amount, None);
assert_eq!(player.contract, None);
assert_eq!(
player.card_asset_id, player.asset_id,
"a player's card art IS its asset id"
);
}
#[test] #[test]
fn two_cards_same_resource_is_a_conflict() { fn two_cards_same_resource_is_a_conflict() {
let err = Fifa17CardCatalog::from_json_str( let err = Fifa17CardCatalog::from_json_str(
@@ -12,11 +12,12 @@ use serde_json::{json, Value};
use crate::fut::content_taxonomy::ContentKind; use crate::fut::content_taxonomy::ContentKind;
use crate::fut::entities::ReverseEntityResolver; use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item::{shape_item, shape_kit_item, shape_staff_item, STAFF_CONTRACT}; use crate::fut::item::{shape_item, shape_kit_item, shape_staff_item, STAFF_CONTRACT};
use crate::fut::item_state;
// Re-exported so existing `club_response::{…}` callers keep working; the types // Re-exported so existing `club_response::{…}` callers keep working; the types
// are now defined once in `fut::item`. // are now defined once in `fut::item`.
pub use crate::fut::item::{ pub use crate::fut::item::{
CoreOwnedItem, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver, CoreOwnedItem, Fifa17ConsumableIdentity, Fifa17Identity, Fifa17KitIdentity,
ShapeStats, Fifa17StaffIdentity, ItemIdentityResolver, ShapeStats,
}; };
/// Active club-level kit roles, keyed by Core owned-instance id. /// Active club-level kit roles, keyed by Core owned-instance id.
@@ -36,7 +37,11 @@ pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
} }
/// Shape `/club` items, including ownership-backed active kit designations. /// Shape `/club` items, including ownership-backed active kit designations.
/// Consumables/staff remain excluded because they use separate wire envelopes. ///
/// This envelope carries the two families whose record shape it can carry:
/// players and kits, plus the staff family (manager + the four coach families).
/// Consumables have their own route and their own STACK envelope, and the
/// club-customisation families are counted and withheld — see each arm.
pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>( pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
items: &[CoreOwnedItem], items: &[CoreOwnedItem],
ent: &impl ReverseEntityResolver, ent: &impl ReverseEntityResolver,
@@ -56,28 +61,50 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
}, },
ContentKind::Kit => match ident.resolve_kit(item) { ContentKind::Kit => match ident.resolve_kit(item) {
Some(id) => { Some(id) => {
let item_state = if active_kits.home == Some(item.owned_card_id.as_str()) { let state = if active_kits.home == Some(item.owned_card_id.as_str()) {
"activeHomeKit" item_state::ACTIVE_HOME_KIT
} else if active_kits.away == Some(item.owned_card_id.as_str()) { } else if active_kits.away == Some(item.owned_card_id.as_str()) {
"activeAwayKit" item_state::ACTIVE_AWAY_KIT
} else { } else {
"free" item_state::FREE
}; };
out.push(shape_kit_item(id, item_state)); out.push(shape_kit_item(id, state));
stats.emitted += 1; stats.emitted += 1;
} }
None => stats.dropped_no_asset += 1, None => stats.dropped_no_asset += 1,
}, },
ContentKind::Staff => match ident.resolve_staff(item) { // A manager is a staff card: both Core kinds resolve through the one
// staff record shape, discriminated on the wire by `cardsubtypeid`
// (the same set as `ContentKind::is_staff_family`, spelled out here
// because a guard arm would not prove exhaustiveness).
ContentKind::Manager | ContentKind::Staff => match ident.resolve_staff(item) {
Some(id) => { Some(id) => {
out.push(shape_staff_item(id, STAFF_CONTRACT)); out.push(shape_staff_item(id, STAFF_CONTRACT));
stats.emitted += 1; stats.emitted += 1;
} }
None => stats.dropped_no_asset += 1, None => stats.dropped_no_asset += 1,
}, },
// Consumables have their OWN route and their own envelope:
// `GET club/consumables/<category>`, whose element is a stack
// wrapper, not an item (see [`crate::fut::consumables`]). A bare
// consumable item in THIS envelope is accepted by the client and
// silently discarded, so emitting one here would be a 200 that does
// nothing — the worst failure shape in this project. Counted.
ContentKind::Consumable => { ContentKind::Consumable => {
stats.excluded_non_player += 1; stats.excluded_non_player += 1;
} }
// Club customisation. The SUBTYPES are settled (kit 9, stadium 10,
// badge 11, ball 30, league logo 31), but the RECORD is not: a
// badge/stadium still needs the narrow `teamid`/`assetId` test that
// the 2026-08-05 crash denied us, and a ball has no display name at
// all except `localizedName`, which this project's own rule scores as
// "the parser reads it" and NOT "sending it is safe". Counted and
// withheld rather than guessed — ownership stays authoritative in
// Core either way, and club/stats still counts these families so the
// screen's own numbers are right.
ContentKind::Badge | ContentKind::Ball | ContentKind::Stadium | ContentKind::Misc => {
stats.excluded_non_player += 1;
}
} }
} }
(json!({ "itemData": out }), stats) (json!({ "itemData": out }), stats)
+74 -9
View File
@@ -65,6 +65,8 @@ const S_KITS: i64 = 0x28;
const S_KITS_HOME: i64 = 0x29; const S_KITS_HOME: i64 = 0x29;
const S_KITS_AWAY: i64 = 0x2A; const S_KITS_AWAY: i64 = 0x2A;
const S_BADGES: i64 = 0x2D; const S_BADGES: i64 = 0x2D;
const S_STADIA: i64 = 0x14;
const S_BALLS: i64 = 0x1E;
/// First `carddbid` of the AWAY kit family. `fcc_kitcards` is split into a /// First `carddbid` of the AWAY kit family. `fcc_kitcards` is split into a
/// `63xxxxx` home family and a `64xxxxx` away family, and the table's own /// `63xxxxx` home family and a `64xxxxx` away family, and the table's own
@@ -206,10 +208,10 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
g.insert(sid, 0); g.insert(sid, 0);
} }
let mut staff_total = 0i64; let mut staff_total = 0i64;
for it in items // A manager counts INSIDE the staff total (`staffManager` is a bucket within
.iter() // it), so this selects the whole staff FAMILY, not `ContentKind::Staff`
.filter(|i| matches!(i.kind, ContentKind::Staff)) // alone — a `manager`-classified row would otherwise vanish from the panel.
{ for it in items.iter().filter(|i| i.kind.is_staff_family()) {
if let Some(sid) = staff_stat(it.subtype) { if let Some(sid) = staff_stat(it.subtype) {
*g.get_mut(&sid).unwrap() += 1; *g.get_mut(&sid).unwrap() += 1;
staff_total += 1; staff_total += 1;
@@ -237,13 +239,23 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
} }
g.insert(S_CONSUMABLES, cons_total); g.insert(S_CONSUMABLES, cons_total);
// Club items: kits are Core-owned and counted (total plus the home/away // Club items. THESE COUNTS ARE THE GATE: the client does not ask for a
// family split); unimplemented families stay honest zeros. // family's items until club/stats reports a non-zero count for it (proven by
for sid in [ // the consumables round, where two rounds of item work sat unrequested
0x14, 0x1E, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, // because this panel answered zero). They are plain ints read by the same
] { // getter/publisher shape as the live-proven PLAYERS_EMPLOYED rows, so every
// family Core can own is counted here — including the ones whose ITEM record
// shape is still withheld, because a count cannot desync a parser and a zero
// guarantees the family is never even asked about. Unowned families stay
// honest zeros.
for sid in [0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38] {
g.entry(sid).or_insert(0); g.entry(sid).or_insert(0);
} }
let count_kind =
|want: ContentKind| items.iter().filter(|item| item.kind == want).count() as i64;
g.insert(S_STADIA, count_kind(ContentKind::Stadium));
g.insert(S_BALLS, count_kind(ContentKind::Ball));
g.insert(S_BADGES, count_kind(ContentKind::Badge));
let kits: Vec<&ClubStatInput> = items let kits: Vec<&ClubStatInput> = items
.iter() .iter()
.filter(|item| matches!(item.kind, ContentKind::Kit)) .filter(|item| matches!(item.kind, ContentKind::Kit))
@@ -414,6 +426,59 @@ mod tests {
assert_eq!(g["staffManager"], 0); assert_eq!(g["staffManager"], 0);
} }
/// A row Core classifies as `manager` must still land in the STAFF bucket and
/// in `staffManager`: the client's own model counts a manager inside its staff
/// total, and the two encodings (`manager`, or `staff` + subtype 4) are the
/// same card.
#[test]
fn a_manager_counts_inside_staff_under_either_kind_token() {
for kind in [ContentKind::Manager, ContentKind::Staff] {
let mut manager = staff(4);
manager.kind = kind;
let g = global(&club_stats_body(&[manager, staff(8)], ContextField::Nation));
assert_eq!(g["staffManager"], 1, "kind={}", kind.as_str());
assert_eq!(
g["staff"],
2,
"the manager is INSIDE the staff total (kind={})",
kind.as_str()
);
assert_eq!(g["staffFitnessCoach"], 1);
assert_eq!(g["players"], 0, "a manager is not a player");
}
}
/// The count is the GATE: the client will not ask for a family's items until
/// this panel reports a non-zero count for it, so an owned badge/ball/stadium
/// must be counted even while its item record is withheld.
#[test]
fn owned_club_items_are_counted_per_family() {
let club_item = |kind: ContentKind, subtype: i64| ClubStatInput {
kind,
subtype,
rating: 0,
rare: false,
asset_id: 0,
nation_id: None,
league_id: None,
team_id: None,
};
let items = vec![
club_item(ContentKind::Badge, 11),
club_item(ContentKind::Badge, 11),
club_item(ContentKind::Ball, 30),
club_item(ContentKind::Stadium, 10),
kit(),
];
let g = global(&club_stats_body(&items, ContextField::Nation));
assert_eq!(g["badges"], 2);
assert_eq!(g["balls"], 1);
assert_eq!(g["stadia"], 1);
assert_eq!(g["kits"], 1);
assert_eq!(g["players"], 0, "no club item is ever a player");
assert_eq!(g["leagueLogos"], 0, "not an ownable Core kind: honest zero");
}
#[test] #[test]
fn consumables_by_family() { fn consumables_by_family() {
// 54 gk_training, 201 player_contract, 217 healing, 258 player_playstyle // 54 gk_training, 201 player_contract, 217 healing, 258 player_playstyle
@@ -0,0 +1,230 @@
//! The FIFA 17 **consumables screen** response — `GET …/club/consumables/<category>`.
//!
//! Consumables are NOT a `club?type=` family. A previous round shipped four
//! `?type=` arms for them and the screen stayed empty, because the client asks
//! HERE — and it asks only once `club/stats/consumables` reports a non-zero count
//! for the family, so the counter in [`crate::fut::club_stats`] is the gate and
//! this route is the door. Before that was known, the path fell through the
//! generic `/club` PREFIX and the consumables screen was answered with the club's
//! player list.
//!
//! ## The element is a STACK WRAPPER, not an item
//!
//! Learned the hard way (live, 2026-08-05): bare items here were ACCEPTED and did
//! nothing — the client's card map afterwards held only the squad, and the screen
//! stayed empty with no error anywhere. `FutConsumablesSearchServerResponse`
//! (RS4 literal `0x1802222f8`, factory `0x180130a10`, vtable `0x180222200`,
//! deserializer `+0x08` = `0x180130d10`) reads `itemData` (atom 0x16b) at the root
//! like the club list, but its ELEMENT is a five-atom wrapper of which exactly one
//! atom carries the item:
//!
//! | atom | key | |
//! |---|---|---|
//! | 0xbc | `count` | copies in the stack |
//! | 0xd7 | `discardValue` | |
//! | 0x16a | `item` | → `FUN_18013fe00`, the item parser itself |
//! | 0x287 | `resourceId` | the stack's identity |
//! | 0x362 | `untradeableCount` | drives a UI flag as `untradeableCount < count` |
//!
//! Everything else falls to the value-SKIP handler, which is exactly why a bare
//! item was silently discarded. It is also why FUT draws consumables as one card
//! with a quantity badge rather than N cards: identical copies COLLAPSE by
//! `resourceId` here.
use serde_json::{json, Value};
use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeStats};
/// Build the consumables-screen body from the club's owned consumable copies.
///
/// Copies are collapsed by `resourceId` into one stack each, in first-seen order
/// (deterministic: Core's own owned order), with `count` and `untradeableCount`
/// counted over the copies. A copy whose definition is incomplete is DROPPED and
/// counted — see [`Fifa17ConsumableIdentity::is_renderable`]; drawing "-1" or a
/// different item than the club owns is worse than omitting the stack.
///
/// `discardValue` is `0`: the client computes a card's own quick-sell price from
/// `fcc_discardcoins` on `(cardtype 6, level, rare)`, and `0` is the value the
/// live-proven oracle sends on this route. Inventing a price from the player
/// quick-sell table would be a fabricated number the client does not need.
///
/// The stack's `item` is the FIRST copy, so its `id` is a real owned wire id — a
/// later item operation on the stack therefore addresses a card the club really
/// owns. (Which copy a quick-sell of a whole stack should consume is a lifecycle
/// question, not a projection one, and is not decided here.)
pub fn consumables_response(items: &[Fifa17ConsumableIdentity]) -> (Value, ShapeStats) {
let mut stats = ShapeStats::default();
// (resource_id, index into `stacks`) — a Vec keeps first-seen order without a
// second sort, and a club holds tens of stacks, not thousands.
let mut order: Vec<u32> = Vec::new();
let mut stacks: Vec<Value> = Vec::new();
for id in items {
if !id.is_renderable() {
stats.dropped_incomplete += 1;
continue;
}
stats.emitted += 1;
match order.iter().position(|r| *r == id.resource_id) {
Some(i) => {
let stack = stacks[i].as_object_mut().expect("stack is an object");
let count = stack["count"].as_i64().unwrap_or(0) + 1;
stack["count"] = json!(count);
if id.untradeable {
let untradeable = stack["untradeableCount"].as_i64().unwrap_or(0) + 1;
stack["untradeableCount"] = json!(untradeable);
}
}
None => {
order.push(id.resource_id);
stacks.push(json!({
"count": 1,
"discardValue": 0,
"item": shape_consumable_item(*id),
"resourceId": id.resource_id,
"untradeableCount": i64::from(id.untradeable),
}));
}
}
}
(json!({ "itemData": stacks }), stats)
}
#[cfg(test)]
mod tests {
use super::*;
/// A play-style card (category 9): `amount` mandatory, art id 50.
fn playstyle(item_id: u32, resource_id: u32) -> Fifa17ConsumableIdentity {
Fifa17ConsumableIdentity {
item_id,
resource_id,
asset_id: resource_id,
card_asset_id: 50,
subtype: 258,
rareflag: 0,
rating: 95,
amount: Some(2),
contract: None,
untradeable: true,
}
}
#[test]
fn identical_copies_collapse_into_one_counted_stack() {
// Two copies of 5003103 plus one of 5003112 → two stacks, counts 2 and 1.
let items = vec![
playstyle(100000293, 5_003_103),
playstyle(100000326, 5_003_112),
playstyle(100000294, 5_003_103),
];
let (body, stats) = consumables_response(&items);
assert_eq!(stats.emitted, 3, "every copy is accounted for");
let stacks = body["itemData"].as_array().unwrap();
assert_eq!(stacks.len(), 2, "collapsed by resourceId");
assert_eq!(stacks[0]["resourceId"], 5_003_103);
assert_eq!(stacks[0]["count"], 2);
assert_eq!(stacks[0]["untradeableCount"], 2);
assert_eq!(stacks[1]["resourceId"], 5_003_112);
assert_eq!(stacks[1]["count"], 1);
// The five wrapper atoms and nothing else: anything extra falls to the
// value-SKIP handler and only misleads the next reader.
let mut keys: Vec<&str> = stacks[0]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
vec![
"count",
"discardValue",
"item",
"resourceId",
"untradeableCount"
]
);
// The item rides inside the wrapper, not beside it.
assert_eq!(stacks[0]["item"]["id"], 100000293);
assert_eq!(stacks[0]["item"]["cardsubtypeid"], 258);
assert_eq!(stacks[0]["item"]["cardassetid"], 50);
}
#[test]
fn a_tradeable_copy_lowers_untradeable_count_below_the_stack_count() {
// The client's UI flag is `untradeableCount < count`, so the two numbers
// must be counted over the same copies.
let mut tradeable = playstyle(100000295, 5_003_103);
tradeable.untradeable = false;
let items = vec![playstyle(100000293, 5_003_103), tradeable];
let (body, _) = consumables_response(&items);
let stack = &body["itemData"][0];
assert_eq!(stack["count"], 2);
assert_eq!(stack["untradeableCount"], 1);
}
#[test]
fn incomplete_definitions_are_dropped_and_counted_never_drawn_wrong() {
// (a) a play style with no `amount` would draw "-1" on the card;
let mut no_amount = playstyle(100000293, 5_003_103);
no_amount.amount = None;
// (b) rareflag on 219 turns Player Fitness into SQUAD Fitness;
let trap = Fifa17ConsumableIdentity {
item_id: 100000300,
resource_id: 5_002_030,
asset_id: 5_002_030,
card_asset_id: 9,
subtype: 219,
rareflag: 1,
rating: 70,
amount: Some(10),
contract: None,
untradeable: true,
};
// (c) a subtype in no documented range renders as a plausible Squad
// Training (Pace) card with amount 0.
let mut dead_zone = playstyle(100000301, 5_003_999);
dead_zone.subtype = 137;
// (d) no `card_asset_id` in the catalog → the resolver defaulted it to
// the asset id and the client would draw the notfound.swf green box.
let mut no_art = playstyle(100000302, 5_003_104);
no_art.card_asset_id = no_art.asset_id;
let (body, stats) = consumables_response(&[no_amount, trap, dead_zone, no_art]);
assert_eq!(stats.emitted, 0);
assert_eq!(stats.dropped_incomplete, 4);
assert_eq!(body["itemData"].as_array().unwrap().len(), 0);
}
#[test]
fn a_contract_card_carries_contract_and_no_amount() {
let contract = Fifa17ConsumableIdentity {
item_id: 100000294,
resource_id: 5_001_004,
asset_id: 5_001_004,
card_asset_id: 7,
subtype: 201,
rareflag: 0,
rating: 60,
amount: None,
contract: Some(7),
untradeable: true,
};
let (body, stats) = consumables_response(&[contract]);
assert_eq!(stats.emitted, 1);
let item = &body["itemData"][0]["item"];
assert_eq!(item["contract"], 7);
assert!(
item.get("amount").is_none(),
"categories 2 and 3 ignore `amount` entirely"
);
}
#[test]
fn an_empty_club_is_an_empty_itemdata_not_a_missing_key() {
let (body, stats) = consumables_response(&[]);
assert_eq!(stats.emitted, 0);
assert!(body["itemData"].as_array().unwrap().is_empty());
assert_eq!(body.as_object().unwrap().len(), 1, "only itemData at root");
}
}
@@ -21,13 +21,30 @@
/// The disjoint content classes a FIFA 17 owned item can belong to. Player is /// 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` /// the default so a catalog authored before this taxonomy existed (no `kind`
/// field) still classifies every entry as a player, unchanged. /// 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ContentKind { pub enum ContentKind {
#[default] #[default]
Player, 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, Staff,
Consumable,
Kit, Kit,
Badge,
Ball,
Stadium,
Misc,
} }
impl ContentKind { impl ContentKind {
@@ -35,9 +52,14 @@ impl ContentKind {
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {
ContentKind::Player => "player", ContentKind::Player => "player",
ContentKind::Consumable => "consumable", ContentKind::Manager => "manager",
ContentKind::Staff => "staff", ContentKind::Staff => "staff",
ContentKind::Consumable => "consumable",
ContentKind::Kit => "kit", 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)] #[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> ContentKind { pub fn from_str(s: &str) -> ContentKind {
match s { match s {
"consumable" => ContentKind::Consumable, "manager" => ContentKind::Manager,
"staff" => ContentKind::Staff, "staff" => ContentKind::Staff,
"consumable" => ContentKind::Consumable,
"kit" => ContentKind::Kit, "kit" => ContentKind::Kit,
"badge" => ContentKind::Badge,
"ball" => ContentKind::Ball,
"stadium" => ContentKind::Stadium,
"misc" => ContentKind::Misc,
_ => ContentKind::Player, _ => 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`, /// 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) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -111,20 +317,171 @@ mod tests {
#[test] #[test]
fn content_kind_round_trips_and_defaults_to_player() { fn content_kind_round_trips_and_defaults_to_player() {
assert_eq!(ContentKind::default(), ContentKind::Player); assert_eq!(ContentKind::default(), ContentKind::Player);
for k in [ // The FULL Core content vocabulary, every token round-tripping.
let all = [
ContentKind::Player, ContentKind::Player,
ContentKind::Consumable, ContentKind::Manager,
ContentKind::Staff, ContentKind::Staff,
ContentKind::Consumable,
ContentKind::Kit, ContentKind::Kit,
] { ContentKind::Badge,
ContentKind::Ball,
ContentKind::Stadium,
ContentKind::Misc,
];
for k in all {
assert_eq!(ContentKind::from_str(k.as_str()), k); 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). // Unknown / absent tokens fall back to Player (backward compatible).
assert_eq!(ContentKind::from_str(""), ContentKind::Player); assert_eq!(ContentKind::from_str(""), ContentKind::Player);
assert_eq!(ContentKind::from_str("nonsense"), ContentKind::Player); assert_eq!(ContentKind::from_str("nonsense"), ContentKind::Player);
assert_eq!(ContentKind::from_str("player"), 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] #[test]
fn consumable_family_range_boundaries() { fn consumable_family_range_boundaries() {
// Each contiguous range: lower boundary, upper boundary, family + label. // Each contiguous range: lower boundary, upper boundary, family + label.
+337 -6
View File
@@ -24,8 +24,11 @@
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::fut::content_taxonomy::{ContentKind, MANAGER_SUBTYPE}; use crate::fut::content_taxonomy::{
consumable_family, consumable_needs, ConsumableNeeds, ContentKind, MANAGER_SUBTYPE,
};
use crate::fut::entities::ReverseEntityResolver; use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item_state;
/// One owned item in game-independent terms, as read from Core's inventory. /// One owned item in game-independent terms, as read from Core's inventory.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -101,6 +104,85 @@ pub struct Fifa17StaffIdentity {
pub team_id: i64, pub team_id: i64,
} }
/// FIFA-side identity + definition facts needed to render an owned consumable.
///
/// A consumable carries NO id space to discover: `FUN_18013f4d0` never touches a
/// DB handle, and category, artwork, name and both stat bytes all derive from
/// `cardsubtypeid` alone. What it does need is the fcc_* row's ART id and the one
/// extra key its family reads — see [`Fifa17ConsumableIdentity::is_renderable`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fifa17ConsumableIdentity {
pub item_id: u32,
/// `rec+0x18`. Bookkeeping only for a consumable (artwork is a client-side
/// constant, so this never reaches the screen), but kept as EA's own
/// `carddbid` so nothing drifts out of their space.
pub resource_id: u32,
pub asset_id: u32,
/// The fcc_* `cardassetid` — the ART id, NOT a copy of `resource_id`.
/// Observed values in the real profile: 3 (training), 7/8 (contracts),
/// 9 (healing), 34 (position), 50/51 (play style). Copying `resource_id`
/// here is right for players and wrong for every other family: the client
/// looks up art `5003001`, finds none, and draws the `notfound.swf` green
/// "NOT FOUND" box.
pub card_asset_id: u32,
/// `rec+0x50`. THE ONLY selector: category, artwork, name and both stat
/// bytes derive from it.
pub subtype: i64,
/// `rec+0x58`. Observed 0 on every owned consumable in the real profile.
pub rareflag: i64,
/// `rec+0xb4`. Drives the card level (`rec+0x54`) and therefore the
/// `fcc_discardcoins` price. Definition-level EA data (55..95 observed).
pub rating: u8,
/// `amount` (atom 0x1b) → `rec+0xbf`, or `+0xbe` for a play style.
/// `Some` exactly for the families [`ConsumableNeeds::Amount`] names.
pub amount: Option<i64>,
/// `contract` (atom 0xb8) → `rec+0x8c`. `Some` for the two contract
/// families only; they ignore `amount` entirely.
pub contract: Option<i64>,
/// `rec+0x49`. Per-INSTANCE in FIFA, unmodelled by Core, so the host passes
/// the observed constant [`CONSUMABLE_UNTRADEABLE`]. Carried per copy rather
/// than baked into the shaper because the consumables route's stack wrapper
/// reports `untradeableCount` over the copies in the stack.
pub untradeable: bool,
}
impl Fifa17ConsumableIdentity {
/// Whether this definition can be drawn HONESTLY. Three refusals, every one a
/// silent-failure guard rather than taste:
///
/// * the family's mandatory extra key is missing — the parser initialises
/// its `amount` temp to `-1` and both accessors read the byte SIGNED, so
/// an omission draws "-1" on the card, not "0" (and a contract card with
/// no `contract` grants nothing);
/// * `rareflag != 0` on subtype 219 — `FUN_1801bfac0` case 5 renders a RARE
/// Player Fitness card as a SQUAD Fitness card, i.e. a different item
/// entirely, with no error anywhere;
/// * `card_asset_id == asset_id` — a consumable's art id is a SMALL `fcc_`
/// art id (3, 7, 8, 9, 34, 50, 51 observed) and never its own `carddbid`,
/// so this means the catalog carried no `card_asset_id` and the client
/// would draw `notfound.swf`, the green "NOT FOUND" box.
///
/// A subtype outside every documented range is also refused: it falls to
/// `FUN_18013f4d0`'s bottom default and renders as a perfectly ordinary
/// Squad Training (Pace) card with amount 0 — plausible and wrong.
pub fn is_renderable(&self) -> bool {
if self.subtype == SQUAD_FITNESS_TRAP_SUBTYPE && self.rareflag != 0 {
return false;
}
if self.card_asset_id == self.asset_id {
return false;
}
match consumable_family(self.subtype) {
None => false,
Some((family, _)) => match consumable_needs(family) {
ConsumableNeeds::Amount => self.amount.is_some(),
ConsumableNeeds::Contract => self.contract.is_some(),
ConsumableNeeds::None => true,
},
}
}
}
/// Supplies the FIFA numeric identity for a Core item. Returning `None` means /// Supplies the FIFA numeric identity for a Core item. Returning `None` means
/// "no real FIFA asset id known" → the caller must not fabricate one. /// "no real FIFA asset id known" → the caller must not fabricate one.
pub trait ItemIdentityResolver { pub trait ItemIdentityResolver {
@@ -119,7 +201,21 @@ pub trait ItemIdentityResolver {
None None
} }
/// Classify a Core item's definition as player/consumable/staff. Defaults to /// Resolve one owned consumable definition. Default `None` preserves
/// existing resolvers; the catalog-backed FIFA17 resolver overrides it.
fn resolve_consumable(&self, _item: &CoreOwnedItem) -> Option<Fifa17ConsumableIdentity> {
None
}
/// The FIFA `cardsubtypeid` of a Core item's definition, or `0` when unknown
/// or a player. NON-MINTING by contract: `/club`'s per-family filters call it
/// for every owned row, so allocating a wire id here would pollute the
/// identity store on a read.
fn subtype_of(&self, _item: &CoreOwnedItem) -> i64 {
0
}
/// Classify a Core item's definition into the content vocabulary. Defaults to
/// [`ContentKind::Player`] so existing resolvers keep their behaviour; a /// [`ContentKind::Player`] so existing resolvers keep their behaviour; a
/// catalog-backed resolver overrides this to consult its `kind_of`, letting /// catalog-backed resolver overrides this to consult its `kind_of`, letting
/// `/club` exclude non-player content (which must never render as a /// `/club` exclude non-player content (which must never render as a
@@ -133,9 +229,18 @@ pub trait ItemIdentityResolver {
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ShapeStats { pub struct ShapeStats {
pub emitted: usize, pub emitted: usize,
/// No real FIFA asset id for this definition — dropped, never faked.
pub dropped_no_asset: usize, pub dropped_no_asset: usize,
/// Consumable/staff items excluded from a player projection (they must never /// The definition resolved but is INCOMPLETE or self-contradictory, so
/// render as a 0-rated player). Counted, never emitted. /// drawing it would be a lie the client cannot detect (a consumable missing
/// the mandatory `amount`/`contract`, or the subtype-219 rareflag trap).
/// Dropped and counted separately, because the fix is a catalog re-emit, not
/// an identity mapping.
pub dropped_incomplete: usize,
/// Owned content this envelope deliberately does not carry: a CONSUMABLE
/// (its own route serves it as a stack), or a club-customisation family
/// whose record shape is not yet verified (badge, ball, stadium, misc).
/// Core owns the row; the projection is withheld, never guessed.
pub excluded_non_player: usize, pub excluded_non_player: usize,
} }
@@ -192,7 +297,7 @@ pub fn shape_item(
"leagueId": league_id, "leagueId": league_id,
"playStyle": 250, "playStyle": 250,
"attributeList": attribute_list, "attributeList": attribute_list,
"itemState": "free", "itemState": item_state::FREE,
"owners": 1, "owners": 1,
// Owned/pack-pulled cards are TRADEABLE in FIFA 17 (untradeable is the // Owned/pack-pulled cards are TRADEABLE in FIFA 17 (untradeable is the
// exception for SBC/promo rewards, which Core does not model). Emitting // exception for SBC/promo rewards, which Core does not model). Emitting
@@ -267,7 +372,7 @@ pub fn shape_staff_item(id: Fifa17StaffIdentity, contract: i64) -> Value {
// readers use it to tell a staff card from a footballer at a glance. // readers use it to tell a staff card from a footballer at a glance.
"itemType": "staff", "itemType": "staff",
"contract": contract, "contract": contract,
"itemState": "free", "itemState": item_state::FREE,
"owners": 1, "owners": 1,
"untradeable": false, "untradeable": false,
}); });
@@ -280,6 +385,92 @@ pub fn shape_staff_item(id: Fifa17StaffIdentity, contract: i64) -> Value {
item item
} }
/// Build one FIFA 17 consumable item.
///
/// The key set is EXACTLY what the real profile import holds for its 17 owned
/// consumables — i.e. what the client itself stored — and every key is a key the
/// live player path already proves, so this introduces NO new wire shape:
///
/// * `id` → `rec+0x08`, `resourceId` → `rec+0x18`, `assetId`, `cardassetid` (the
/// ART id, see [`Fifa17ConsumableIdentity::card_asset_id`]),
/// `cardsubtypeid` → `rec+0x50`, `rareflag` → `rec+0x58`,
/// `rating` → `rec+0xb4`, `itemState` → `rec+0x5c`, `owners` → `rec+0x48`,
/// `untradeable` → `rec+0x49`.
/// * `amount` → `rec+0xbf` / `+0xbe` and `contract` → `rec+0x8c`, each emitted
/// only for the families that read it (the caller has already gated on
/// [`Fifa17ConsumableIdentity::is_renderable`]).
///
/// `itemType` is `"player"`, which is not a mislabel: it is the ONLY value this
/// client has ever been sent, it is what the real profile stores on all 17, and
/// `cardtype` is derived from `cardsubtypeid` alone (`FUN_18013fe00`), so the
/// string cannot affect the render. A consumable is discriminated by its subtype
/// plus the ABSENCE of `attributeList`; inventing `"consumable"` here would be a
/// fabricated token.
///
/// `untradeable` is carried per copy from
/// [`Fifa17ConsumableIdentity::untradeable`] (the host supplies the observed
/// [`CONSUMABLE_UNTRADEABLE`]), because the consumables route reports
/// `untradeableCount` over a stack and the two must agree.
///
/// DELIBERATELY ABSENT, each for a named reason:
/// * `teamid`, `leagueid` and `value` — the three "extras" copied out of an fcc
/// row that CRASHED the client on 2026-08-05. `value` is the established
/// culprit (it is an OBJECT member elsewhere, and a scalar where an object is
/// expected is the type-desync busy loop at `0x1801c7f1a`); none of the three
/// is needed to draw a card.
/// * `preferredPosition`, `nation`, `playStyle`, `attributeList`, `fitness` —
/// player-only, and `attributeList` is the very thing that distinguishes a
/// footballer from a consumable.
/// * `definitionId` — not an atom at all; the parser has always skipped it.
/// * `discardValue` — the client computes it from `fcc_discardcoins` on
/// `(cardtype 6, level, rare)`, and real rows exist for both rare values.
/// * `pile` — Core/host state (the transfer pile), not a wire atom: the
/// live-proven player path does not send it either.
pub fn shape_consumable_item(id: Fifa17ConsumableIdentity) -> Value {
let mut item = json!({
"id": id.item_id,
"resourceId": id.resource_id,
"assetId": id.asset_id,
"cardassetid": id.card_asset_id,
"cardsubtypeid": id.subtype,
"itemType": "player",
"rareflag": id.rareflag,
"rating": id.rating,
"itemState": item_state::FREE,
"owners": 1,
"untradeable": id.untradeable,
});
let obj = item.as_object_mut().expect("json! built an object");
if let Some(amount) = id.amount {
obj.insert("amount".to_string(), json!(amount));
}
if let Some(contract) = id.contract {
obj.insert("contract".to_string(), json!(contract));
}
item
}
/// `cardsubtypeid` of the PLAYER FITNESS card, and the one subtype where
/// `rareflag` is load-bearing rather than cosmetic: `FUN_1801bfac0` case 5 reads
/// it as the squad-fitness selector, so a rare Player Fitness card silently
/// becomes a SQUAD Fitness card — a different item, with no error anywhere.
pub const SQUAD_FITNESS_TRAP_SUBTYPE: i64 = 219;
/// Tradeability of an owned consumable.
///
/// FIFA models this per INSTANCE (`rec+0x49`) and Core does not model it at all,
/// so this is the observed value, not a policy: all 17 owned consumables in the
/// real profile import carry `untradeable: true`, and it is also the oracle's own
/// default for the family. When Core models per-instance tradeability, this
/// constant is what it replaces.
///
/// Note the lever it controls on screen: the consumables deserializer sets a UI
/// flag from `untradeableCount < count`, so an all-untradeable stack draws the
/// untradeable badge. That is correct for genuinely untradeable copies; it was
/// only wrong for the oracle's SYNTHETIC shelf, where the badge was its own data
/// showing through.
pub const CONSUMABLE_UNTRADEABLE: bool = true;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -403,4 +594,144 @@ mod tests {
"special rareflag carried, not hardcoded 1" "special rareflag carried, not hardcoded 1"
); );
} }
/// The GK-training card the real profile owns: `5003012`, art 3, subtype 54,
/// rating 85, amount 15. Its key set is the acceptance criterion.
fn training_consumable() -> Fifa17ConsumableIdentity {
Fifa17ConsumableIdentity {
item_id: 100000239,
resource_id: 5_003_012,
asset_id: 5_003_012,
card_asset_id: 3,
subtype: 54,
rareflag: 0,
rating: 85,
amount: Some(15),
contract: None,
untradeable: CONSUMABLE_UNTRADEABLE,
}
}
#[test]
fn consumable_emits_exactly_the_keys_the_client_itself_stored() {
let it = shape_consumable_item(training_consumable());
// Verbatim from the real profile import (persona 33068179):
// {"id":100000239,"resourceId":5003012,"assetId":5003012,"cardassetid":3,
// "cardsubtypeid":54,"itemType":"player","rareflag":0,"rating":85,
// "itemState":"free","owners":1,"untradeable":true,"amount":15}
assert_eq!(
it,
json!({
"id": 100000239,
"resourceId": 5_003_012,
"assetId": 5_003_012,
"cardassetid": 3,
"cardsubtypeid": 54,
"itemType": "player",
"rareflag": 0,
"rating": 85,
"itemState": "free",
"owners": 1,
"untradeable": true,
"amount": 15,
})
);
// The three "extras" that crashed the client on 2026-08-05, and the
// player-only keys that would make a consumable look like a footballer.
for forbidden in [
"teamid",
"leagueid",
"leagueId",
"value",
"attributeList",
"preferredPosition",
"nation",
"playStyle",
"fitness",
"definitionId",
"discardValue",
"pile",
] {
assert!(
it.get(forbidden).is_none(),
"a consumable must not carry `{forbidden}`"
);
}
}
#[test]
fn consumable_art_id_is_never_the_resource_id() {
// The green "NOT FOUND" box: the client resolves artwork by cardassetid,
// which is a SMALL fcc_ art id, not the carddbid.
let it = shape_consumable_item(training_consumable());
assert_eq!(it["cardassetid"], 3);
assert_ne!(it["cardassetid"], it["resourceId"]);
}
/// EVERY `itemState` this crate can put on the wire must be one of the twelve
/// tokens recovered from the client's own table. An unrecovered token decodes
/// to `0xffffffff` through `FUN_180166660` and the client then acts on an
/// unrecognised state.
#[test]
fn every_emitted_item_state_is_in_the_recovered_table() {
let ent = entities();
let mut emitted: Vec<String> = Vec::new();
let player = shape_item(
&item("oc1", "card_ch_1", 86, "CDM"),
Fifa17Identity {
item_id: 1,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
&ent,
);
emitted.push(player["itemState"].as_str().unwrap().to_string());
let staff = shape_staff_item(
Fifa17StaffIdentity {
item_id: 2,
resource_id: 1_000_509,
subtype: MANAGER_SUBTYPE,
nation: 45,
league_id: 53,
team_id: 241,
},
STAFF_CONTRACT,
);
emitted.push(staff["itemState"].as_str().unwrap().to_string());
emitted.push(
shape_consumable_item(training_consumable())["itemState"]
.as_str()
.unwrap()
.to_string(),
);
// Every state `/club` can hand a kit, including both equipped roles.
let kit = Fifa17KitIdentity {
item_id: 3,
asset_id: 6_300_006,
resource_id: 6_300_006,
card_asset_id: 35,
subtype: 9,
team_id: 21,
};
for state in [
item_state::FREE,
item_state::ACTIVE_HOME_KIT,
item_state::ACTIVE_AWAY_KIT,
] {
let it = shape_kit_item(kit, state);
emitted.push(it["itemState"].as_str().unwrap().to_string());
}
for state in &emitted {
assert!(
item_state::is_recovered(state),
"{state:?} is not one of the twelve recovered itemState tokens"
);
}
assert!(
!emitted.iter().any(|s| s == item_state::INVALID),
"omitting itemState yields `invalid` (0) and fails the squad builder; \
no shaper may emit it deliberately either"
);
}
} }
@@ -0,0 +1,99 @@
//! The FIFA 17 **`itemState` vocabulary** — the complete recovered set, and the
//! only place these strings are written down.
//!
//! Twelve entries in one NUL-terminated `{const char* name, u32 value}` table at
//! `0x180229cc0` (stride 0x10), walked in full from both disk and live memory.
//! `FUN_180166660` is a linear walk over that table and returns `0xffffffff` for
//! anything not in it, so an invented token is not a cosmetic slip: it decodes to
//! "unrecognised state" and the client acts on garbage. Every shaper in this
//! crate therefore takes its `itemState` from a constant here, and
//! [`is_recovered`] is asserted over every emitted value by the tests.
//!
//! **Omitting `itemState` is NOT the same as sending [`FREE`].** The record
//! constructor zero-initialises `+0x50..+0x5f` from `_DAT_1801f66a0`, so an
//! absent key leaves `0` = [`INVALID`], and an item left at `0` fails the squad
//! builder's `state == 1 || state == 2` acceptance test. Always send it.
//!
//! (Source: `fifa17-recon/docs/plan-2026-08-06-card-subsystem.md` §4, which also
//! corrects `CARD_SYSTEM.md`'s earlier ten-row reading — that one started at
//! `0x180229d20`, the MIDDLE of the table, and so missed `invalid`, `free`,
//! `WAITING_FOR_GAME`, `inGame`, `forSale` and `offered`.)
/// `0` — what an item gets when `itemState` is OMITTED. No consumer found; it
/// fails the squad builder. Never emit it deliberately.
pub const INVALID: &str = "invalid";
/// `1` — the normal owned state: accepted by the squad builder, and what the
/// unequip path writes back.
pub const FREE: &str = "free";
/// `2` — alias of [`IN_GAME`] (both decode to 2).
pub const WAITING_FOR_GAME: &str = "WAITING_FOR_GAME";
/// `2` — accepted by the squad builder.
pub const IN_GAME: &str = "inGame";
/// `5` — an item offered for sale. Never TESTED anywhere in CardsDLL, but it is
/// in the table, so it decodes; the transfer market emits it.
pub const FOR_SALE: &str = "forSale";
/// `6` — never tested anywhere in CardsDLL.
pub const OFFERED: &str = "offered";
/// `100` — equipped badge; drives the `IS_ACTIVE` tick.
pub const ACTIVE_BADGE: &str = "activeBadge";
/// `101` — equipped home kit.
pub const ACTIVE_HOME_KIT: &str = "activeHomeKit";
/// `102` — equipped away kit.
pub const ACTIVE_AWAY_KIT: &str = "activeAwayKit";
/// `103` — equipped ball; the unequip path writes [`FREE`] back over it.
pub const ACTIVE_BALL: &str = "activeBall";
/// `104` — equipped stadium.
pub const ACTIVE_STADIUM: &str = "activeStadium";
/// `255` — no consumer found.
pub const ACTIVE: &str = "active";
/// The complete recovered vocabulary, in table order.
pub const ALL: [&str; 12] = [
INVALID,
FREE,
WAITING_FOR_GAME,
IN_GAME,
FOR_SALE,
OFFERED,
ACTIVE_BADGE,
ACTIVE_HOME_KIT,
ACTIVE_AWAY_KIT,
ACTIVE_BALL,
ACTIVE_STADIUM,
ACTIVE,
];
/// Whether `state` is one of the twelve recovered tokens. Case-sensitive, as the
/// client's own lookup is a `strcmp` walk.
pub fn is_recovered(state: &str) -> bool {
ALL.contains(&state)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_table_is_the_twelve_recovered_rows_and_nothing_else() {
assert_eq!(ALL.len(), 12);
for s in ALL {
assert!(is_recovered(s), "{s} must be in its own table");
}
// Tokens this project has actually seen invented or mis-cased. `listFS`
// in particular is the Python oracle's own token and appears NOWHERE in
// the client (zero occurrences in the DLL and in 4.26 GiB of live
// process memory), so it decodes to -1.
for s in [
"listFS",
"free ",
"Free",
"activehomekit",
"sold",
"won",
"equipped",
"",
] {
assert!(!is_recovered(s), "{s:?} is not a FIFA 17 itemState");
}
}
}
+2
View File
@@ -7,11 +7,13 @@
pub mod catalog; pub mod catalog;
pub mod club_response; pub mod club_response;
pub mod club_stats; pub mod club_stats;
pub mod consumables;
pub mod content_taxonomy; pub mod content_taxonomy;
pub mod economy; pub mod economy;
pub mod economy_policy; pub mod economy_policy;
pub mod entities; pub mod entities;
pub mod item; pub mod item;
pub mod item_state;
pub mod match_wire; pub mod match_wire;
pub mod non_economy; pub mod non_economy;
pub mod owned_query; pub mod owned_query;
+7
View File
@@ -70,6 +70,11 @@ pub struct GenericClub {
pub struct GenericOwned { pub struct GenericOwned {
pub owned_item_id: String, pub owned_item_id: String,
pub card_id: String, pub card_id: String,
/// Generic classification for Core's ownership row. FIFA17 is the only side
/// that can map `cardsubtypeid` onto this, and Core defaults to `player`, so
/// leaving it off would durably record a coach or a contract card as a
/// player — wrong in the ownership authority even when the wire looks right.
pub content_kind: &'static str,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -174,6 +179,7 @@ pub fn plan_apply(
owned.push(GenericOwned { owned.push(GenericOwned {
owned_item_id: core_id.clone(), owned_item_id: core_id.clone(),
card_id: def.card_id.clone(), card_id: def.card_id.clone(),
content_kind: "player",
}); });
mappings.push(IdentityMapping { mappings.push(IdentityMapping {
core_id, core_id,
@@ -191,6 +197,7 @@ pub fn plan_apply(
owned.push(GenericOwned { owned.push(GenericOwned {
owned_item_id: core_id.clone(), owned_item_id: core_id.clone(),
card_id: def.card_id.clone(), card_id: def.card_id.clone(),
content_kind: def.kind.as_str(),
}); });
mappings.push(IdentityMapping { mappings.push(IdentityMapping {
core_id, core_id,
+40 -4
View File
@@ -546,6 +546,21 @@ pub struct NonPlayerDefinition {
pub card_asset_id: Option<i64>, pub card_asset_id: Option<i64>,
/// Source team id for a kit definition, when present. /// Source team id for a kit definition, when present.
pub team_id: Option<i64>, pub team_id: Option<i64>,
/// Consumable effect magnitude (`amount`), when the source carries one.
///
/// Definition-level, and measured to be so: across every owned consumable in
/// the real profile the observed `amount` equals its `fcc_*` table row
/// (1, 2, 4, 5, 10, 15 — no disagreements), and a wire omission corresponds
/// to a table `amount` of 0. It is NOT a stack count: two copies of 5003068
/// arrive as two separate instances, each with the same amount.
pub amount: Option<i64>,
/// Contract-card payload (`contract`), when the source carries one. Present
/// on exactly the contract families (subtypes 201/202) and absent from every
/// other owned consumable, so it is the card's own field rather than the
/// generic per-item contract atom that players and staff carry.
pub contract: Option<i64>,
/// Card rating, when the source carries one (matches the `fcc_*` row).
pub rating: Option<i64>,
/// Honest functional label (e.g. "Player Contract", "GK Coach", "Kit"). /// Honest functional label (e.g. "Player Contract", "GK Coach", "Kit").
pub name: String, pub name: String,
/// Wire ids of every owned copy of this resourceId (preserved). /// Wire ids of every owned copy of this resourceId (preserved).
@@ -635,10 +650,20 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
let card_asset_id = items[0].cardassetid; let card_asset_id = items[0].cardassetid;
let team_id = items[0].teamid; let team_id = items[0].teamid;
if items // These are definition-level, so every owned copy must agree. Two copies
.iter() // of one consumable that disagreed would mean the field is really
.any(|item| item.cardassetid != card_asset_id || item.teamid != team_id) // per-instance, and silently taking the first copy's value would bake a
{ // guess into the catalog — so defer the whole group instead.
let amount = items[0].amount;
let contract = items[0].contract;
let rating = items[0].rating;
if items.iter().any(|item| {
item.cardassetid != card_asset_id
|| item.teamid != team_id
|| item.amount != amount
|| item.contract != contract
|| item.rating != rating
}) {
plan.deferred.push(DeferredNonPlayer { plan.deferred.push(DeferredNonPlayer {
resource_id, resource_id,
subtype: Some(subtype), subtype: Some(subtype),
@@ -700,6 +725,9 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
subtype, subtype,
card_asset_id, card_asset_id,
team_id, team_id,
amount,
contract,
rating,
name: label.to_string(), name: label.to_string(),
wire_ids, wire_ids,
}); });
@@ -1184,6 +1212,14 @@ pub fn emit_content(
serde_json::json!({ serde_json::json!({
"card_asset_id": d.card_asset_id, "card_asset_id": d.card_asset_id,
"team_id": d.team_id, "team_id": d.team_id,
// A consumable is unrenderable without these: the adapter refuses
// to emit a card whose art id it does not know, and the families
// that read `amount`/`contract` draw "-1" or grant nothing when
// the field is missing. Omitting them here is what kept every
// owned consumable off the wire.
"amount": d.amount,
"contract": d.contract,
"rating": d.rating,
"asset_id": d.asset_id.unwrap_or(d.resource_id), "asset_id": d.asset_id.unwrap_or(d.resource_id),
"version": 0, "version": 0,
"rareflag": 0, "rareflag": 0,
+3 -1
View File
@@ -70,7 +70,9 @@ pub struct Item {
/// Consumable ART id (small id), distinct from `resourceId`. Permissive. /// Consumable ART id (small id), distinct from `resourceId`. Permissive.
#[serde(default)] #[serde(default)]
pub cardassetid: Option<i64>, pub cardassetid: Option<i64>,
/// Consumable stack size (`amount`). Permissive. /// Consumable effect magnitude (`amount`) — NOT a stack size: every owned
/// copy is its own instance and carries its definition's value, which is
/// exactly the `fcc_*` table's `amount` column. Permissive.
#[serde(default)] #[serde(default)]
pub amount: Option<i64>, pub amount: Option<i64>,
/// Staff/contract `contract` count. Permissive. /// Staff/contract `contract` count. Permissive.
+76
View File
@@ -714,6 +714,58 @@ fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
assert_eq!(by_id("fifa17_3000003").name, "GK Coach"); assert_eq!(by_id("fifa17_3000003").name, "GK Coach");
} }
/// A consumable the adapter cannot render is a consumable the club cannot use:
/// the shaper drops any card whose art id it does not know, and the families
/// that read `amount`/`contract` would draw "-1" or grant nothing. Emitting the
/// definition without these fields is exactly what kept all 17 owned
/// consumables off the wire while club/stats still counted them.
#[test]
fn consumable_definitions_carry_the_fields_the_client_renders() {
let items = vec![
// training +15: carries `amount`, no `contract`
r#"{"id":100000201,"resourceId":5003012,"assetId":5003012,"itemType":"player",
"cardsubtypeid":54,"cardassetid":3,"amount":15,"rating":85,"rareflag":0}"#
.to_string(),
// player contract: carries `contract`, no `amount`
r#"{"id":100000202,"resourceId":5001004,"assetId":5001004,"itemType":"player",
"cardsubtypeid":201,"cardassetid":7,"contract":7,"rating":60,"rareflag":0}"#
.to_string(),
];
let plan = plan_non_player_definitions(&profile(&items, "[]", 100000500));
assert!(plan.deferred.is_empty(), "{:?}", plan.deferred);
let by_id = |cid: &str| plan.supported.iter().find(|d| d.card_id == cid).unwrap();
let training = by_id("fifa17_5003012");
assert_eq!(training.card_asset_id, Some(3), "card art id");
assert_eq!(training.amount, Some(15), "effect magnitude");
assert_eq!(training.contract, None);
assert_eq!(training.rating, Some(85));
let contract = by_id("fifa17_5001004");
assert_eq!(contract.card_asset_id, Some(7));
assert_eq!(contract.contract, Some(7));
assert_eq!(contract.amount, None, "contract families ignore amount");
}
/// `amount` is definition-level (every owned copy of a card carries the same
/// value), so two copies that DISAGREE mean the field is really per-instance.
/// Taking the first copy's value would silently bake a guess into the catalog.
#[test]
fn disagreeing_render_metadata_defers_rather_than_guessing() {
let items = vec![
r#"{"id":100000201,"resourceId":5003012,"assetId":5003012,"itemType":"player",
"cardsubtypeid":54,"cardassetid":3,"amount":15,"rareflag":0}"#
.to_string(),
r#"{"id":100000202,"resourceId":5003012,"assetId":5003012,"itemType":"player",
"cardsubtypeid":54,"cardassetid":3,"amount":10,"rareflag":0}"#
.to_string(),
];
let plan = plan_non_player_definitions(&profile(&items, "[]", 100000500));
assert!(plan.supported.is_empty(), "must not pick a winner");
assert_eq!(plan.deferred.len(), 1);
assert_eq!(plan.deferred[0].reason, "render_metadata_conflict");
}
#[test] #[test]
fn kit_missing_render_metadata_defers() { fn kit_missing_render_metadata_defers() {
let item = r#"{"id":100000501,"resourceId":6300006,"assetId":6300006, let item = r#"{"id":100000501,"resourceId":6300006,"assetId":6300006,
@@ -893,6 +945,30 @@ fn plan_apply_mints_non_player_owned_instances() {
assert!(cards.contains("fifa17_5003012"), "consumable minted"); assert!(cards.contains("fifa17_5003012"), "consumable minted");
assert!(cards.contains("fifa17_3000083"), "staff minted"); assert!(cards.contains("fifa17_3000083"), "staff minted");
assert!(cards.contains("fifa17_6300006"), "kit minted"); assert!(cards.contains("fifa17_6300006"), "kit minted");
// Core is the ownership authority, and it defaults an unstated row to
// `player`. A coach or a contract card durably recorded as a player is wrong
// in the authority even while the catalog-driven wire still looks right.
let kind_of = |card: &str| {
plan.request
.owned
.iter()
.find(|o| o.card_id == card)
.unwrap()
.content_kind
};
assert_eq!(kind_of("fifa17_20801"), "player");
assert_eq!(kind_of("fifa17_5003012"), "consumable");
assert_eq!(kind_of("fifa17_3000083"), "staff");
assert_eq!(kind_of("fifa17_6300006"), "kit");
// `amount` is an effect magnitude, not a stack count: two copies of one
// consumable are two rows, never one row of quantity 2. The importer states
// no quantity at all, and Core's default for an absent quantity is "not a
// stack" — so the guarantee is that the key never appears in the request.
let wire = serde_json::to_string(&plan.request).unwrap();
assert!(
!wire.contains("quantity"),
"no instance may claim to be a stack: {wire}"
);
// Deterministic OwnedItemId per (persona, wire) — same rule as players. // Deterministic OwnedItemId per (persona, wire) — same rule as players.
let m = plan let m = plan
.mappings .mappings
+350 -28
View File
@@ -44,20 +44,24 @@ pub mod market_store;
pub mod pile_store; pub mod pile_store;
pub mod sold_experiment; pub mod sold_experiment;
use parking_lot::Mutex as PlMutex;
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream}; use std::net::{TcpListener, TcpStream};
use parking_lot::Mutex as PlMutex;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Instant, SystemTime, UNIX_EPOCH}; use std::time::{Instant, SystemTime, UNIX_EPOCH};
use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy}; use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy};
use openfut_adapter_fifa17::fut::club_response::{ use openfut_adapter_fifa17::fut::club_response::{
shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17Identity, shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17ConsumableIdentity,
Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver,
}; };
use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput, ContextField}; use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput, ContextField};
use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind; use openfut_adapter_fifa17::fut::consumables::consumables_response;
use openfut_adapter_fifa17::fut::content_taxonomy::{
consumable_families_for_category, consumable_family, position_group, ContentKind, PositionGroup,
};
use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver}; use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver};
use openfut_adapter_fifa17::fut::item::CONSUMABLE_UNTRADEABLE;
use openfut_adapter_fifa17::fut::match_wire; use openfut_adapter_fifa17::fut::match_wire;
use openfut_adapter_fifa17::fut::non_economy; use openfut_adapter_fifa17::fut::non_economy;
use openfut_adapter_fifa17::fut::owned_query::{ use openfut_adapter_fifa17::fut::owned_query::{
@@ -144,6 +148,11 @@ pub enum Route {
/// Core-accurately in Rust (player tiers, staff/consumable families, nation /// Core-accurately in Rust (player tiers, staff/consumable families, nation
/// buckets). club/stats/staff stays a separate empty-set route. /// buckets). club/stats/staff stays a separate empty-set route.
ClubStats, ClubStats,
/// `GET …/club/consumables/<category>` — the consumables ITEM screen, served
/// from Core as the STACK-wrapper envelope this response class actually reads.
/// A `/club` PREFIX, so it MUST be classified before the generic club arms or
/// the screen is answered with the club's player list.
ClubConsumables,
/// `GET …/store` (eligibility gate), `…/match/keepalive`, `…/captcha`, /// `GET …/store` (eligibility gate), `…/match/keepalive`, `…/captcha`,
/// `…/tfa`, `…/livemessage`, `…/activeMessage` — Rust-owned UNCONDITIONAL /// `…/tfa`, `…/livemessage`, `…/activeMessage` — Rust-owned UNCONDITIONAL
/// static acks, byte-identical to the Python oracle's constant responses /// static acks, byte-identical to the Python oracle's constant responses
@@ -222,10 +231,14 @@ pub fn classify(method: &str, path: &str) -> Route {
Some("user") if get => Route::User, Some("user") if get => Route::User,
Some("settings") if get => Route::Settings, Some("settings") if get => Route::Settings,
Some("leaderboards/options") if get => Route::LeaderboardOptions, Some("leaderboards/options") if get => Route::LeaderboardOptions,
Some("match/reset") if put => Route::MatchReset,
Some(tail) if tail.starts_with("phishing/") => Route::SecurityQuestion,
Some("club/stats/staff") if get => Route::ClubStatsStaff, Some("club/stats/staff") if get => Route::ClubStatsStaff,
Some(t) if get && t.starts_with("club/stats/") => Route::ClubStats, Some(t) if get && t.starts_with("club/stats/") => Route::ClubStats,
// Before any other `club/` arm: this is a /club PREFIX, and letting it
// fall through is what once answered the consumables screen with the
// club's 194-card player list.
Some(t) if get && t.starts_with("club/consumables") => Route::ClubConsumables,
Some("match/reset") if put => Route::MatchReset,
Some(tail) if tail.starts_with("phishing/") => Route::SecurityQuestion,
Some("hub") if get => Route::Hub, Some("hub") if get => Route::Hub,
Some("store") => Route::StaticAck, Some("store") => Route::StaticAck,
Some("match/keepalive") => Route::StaticAck, Some("match/keepalive") => Route::StaticAck,
@@ -749,8 +762,9 @@ pub trait CoreAccess: Send + Sync {
)) ))
} }
/// Ownership-backed active home/away kit ids (`GET /club/kits`). A Core /// Ownership-backed active club designations (`GET /club/active-items`). A
/// without the endpoint projects no active kits rather than fabricating one. /// Core without the endpoint projects no active items rather than
/// fabricating one.
fn get_active_kits(&self) -> Result<CoreKitAssignments, CoreError> { fn get_active_kits(&self) -> Result<CoreKitAssignments, CoreError> {
Ok(CoreKitAssignments::default()) Ok(CoreKitAssignments::default())
} }
@@ -942,9 +956,13 @@ impl CoreAccess for HttpCoreClient {
} }
fn get_active_kits(&self) -> Result<CoreKitAssignments, CoreError> { fn get_active_kits(&self) -> Result<CoreKitAssignments, CoreError> {
// Core generalised the two-slot kit table into slot-keyed active club
// designations, so the kit ids now arrive under `home_kit`/`away_kit`
// inside an `active_items` object. Every slot key is always present and
// an empty slot is JSON null.
let response = self let response = self
.client .client
.get(format!("{}/club/kits", self.base_url)) .get(format!("{}/club/active-items", self.base_url))
.header("X-OpenFUT-Game", &self.game) .header("X-OpenFUT-Game", &self.game)
.send() .send()
.map_err(|error| CoreError::Http(error.to_string()))?; .map_err(|error| CoreError::Http(error.to_string()))?;
@@ -955,15 +973,17 @@ impl CoreAccess for HttpCoreClient {
let body: Value = response let body: Value = response
.json() .json()
.map_err(|error| CoreError::Parse(error.to_string()))?; .map_err(|error| CoreError::Parse(error.to_string()))?;
let slots = body.get("active_items").unwrap_or(&body);
let owned_id = |slot: &str| { let owned_id = |slot: &str| {
body.get(slot) slots
.get(slot)
.and_then(|item| item.get("id")) .and_then(|item| item.get("id"))
.and_then(Value::as_str) .and_then(Value::as_str)
.map(str::to_string) .map(str::to_string)
}; };
Ok(CoreKitAssignments { Ok(CoreKitAssignments {
home_owned_card_id: owned_id("home"), home_owned_card_id: owned_id("home_kit"),
away_owned_card_id: owned_id("away"), away_owned_card_id: owned_id("away_kit"),
}) })
} }
@@ -1797,7 +1817,9 @@ impl ItemIdentityResolver for Fifa17IdentityResolver {
fn resolve_staff(&self, item: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> { fn resolve_staff(&self, item: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
let ident = self.catalog.lookup(&item.card_id)?; let ident = self.catalog.lookup(&item.card_id)?;
if ident.kind != ContentKind::Staff { // The whole staff FAMILY: a catalog may classify a manager as either
// `manager` or `staff` + subtype 4, and one record shape serves both.
if !ident.kind.is_staff_family() {
return None; return None;
} }
Some(Fifa17StaffIdentity { Some(Fifa17StaffIdentity {
@@ -1812,6 +1834,39 @@ impl ItemIdentityResolver for Fifa17IdentityResolver {
}) })
} }
/// Compose an owned consumable's wire identity from the catalog.
///
/// `rating`, `amount` and `contract` are EA's authored definition data, which
/// generic Core does not model (an imported consumable's Core `overall` is
/// 0), so they come from the FIFA catalog; `rating` falls back to Core's value
/// rather than being invented, and the two mandatory keys are simply absent
/// when the catalog has none, which
/// [`Fifa17ConsumableIdentity::is_renderable`] then refuses.
fn resolve_consumable(&self, item: &CoreOwnedItem) -> Option<Fifa17ConsumableIdentity> {
let ident = self.catalog.lookup(&item.card_id)?;
if ident.kind != ContentKind::Consumable {
return None;
}
Some(Fifa17ConsumableIdentity {
item_id: self.wire_for(item)?,
resource_id: ident.resource_id,
asset_id: ident.asset_id,
card_asset_id: ident.card_asset_id,
subtype: ident.subtype,
rareflag: ident.rareflag,
rating: ident.rating.unwrap_or(item.rating),
amount: ident.amount,
contract: ident.contract,
untradeable: CONSUMABLE_UNTRADEABLE,
})
}
/// The catalog `cardsubtypeid`, NON-MINTING (see the trait's contract): the
/// `/club` per-family filters call this for every owned row on every request.
fn subtype_of(&self, item: &CoreOwnedItem) -> i64 {
self.catalog.subtype_of(&item.card_id)
}
/// Delegate content classification to the catalog. Unknown definitions /// Delegate content classification to the catalog. Unknown definitions
/// retain the backward-compatible Player default but fail identity resolution. /// retain the backward-compatible Player default but fail identity resolution.
fn kind_of(&self, item: &CoreOwnedItem) -> ContentKind { fn kind_of(&self, item: &CoreOwnedItem) -> ContentKind {
@@ -1840,6 +1895,10 @@ pub struct ClubLog {
pub total: i64, pub total: i64,
pub emitted: usize, pub emitted: usize,
pub dropped_no_asset: usize, pub dropped_no_asset: usize,
/// Rows whose definition resolved but is incomplete, so the card would draw
/// a wrong value. Logged separately from `dropped_no_asset` because the fix
/// is a CATALOG re-emit, not an identity mapping.
pub dropped_incomplete: usize,
pub offset: Option<i64>, pub offset: Option<i64>,
pub limit: Option<i64>, pub limit: Option<i64>,
} }
@@ -1856,6 +1915,168 @@ pub struct ClubDeps<'a> {
pub active_kits: &'a CoreKitAssignments, pub active_kits: &'a CoreKitAssignments,
} }
/// Which owned rows one `?type=` arm of the club query selects.
///
/// The client's taxonomy is `FUN_18012ec50`: 30 arms plus a default that returns
/// `any`. Every arm is named here, because the alternative — a narrow allow-list
/// with an "unsupported" catch-all — is how an owned manager became unreachable
/// once already. An arm either selects a real set, or is DELIBERATELY empty with
/// its reason recorded ([`ClubSelector::Withheld`]); nothing silently falls
/// through, and no arm ever answers with a family it was not asked for (that
/// mirror filter is what stops footballers appearing in the coaching staff).
enum ClubSelector {
/// Every owned row of this kind (staff arms take the whole staff family).
Kind(ContentKind),
/// One staff family, by `cardsubtypeid` (5 headcoach, 6 gkcoach, 7 physio,
/// 8 fitnesscoach).
StaffRole(i64),
/// Players in one MY CLUB position tab.
PlayerPositions(PositionGroup),
/// Answered empty on purpose; the string is why.
Withheld(&'static str),
}
/// One resolved `?type=` arm: what it selects, plus the canonical label logged
/// for it.
struct ClubTypeFilter {
label: &'static str,
selector: ClubSelector,
}
impl ClubTypeFilter {
fn kind(label: &'static str, kind: ContentKind) -> Self {
ClubTypeFilter {
label,
selector: ClubSelector::Kind(kind),
}
}
/// Whether one owned row belongs in this arm's answer.
fn matches(&self, kind: ContentKind, subtype: i64, position: &str) -> bool {
match self.selector {
ClubSelector::Kind(want) if want.is_staff_family() => kind.is_staff_family(),
ClubSelector::Kind(want) => kind == want,
ClubSelector::StaffRole(role) => kind.is_staff_family() && subtype == role,
ClubSelector::PlayerPositions(group) => {
kind == ContentKind::Player && position_group(position) == Some(group)
}
ClubSelector::Withheld(_) => false,
}
}
}
/// Resolve a `?type=` token (or its absence) to an arm, or `None` for a token
/// outside the client's own 30-arm vocabulary.
fn club_type_filter(token: Option<&str>) -> Option<ClubTypeFilter> {
let filter = match token {
// An untyped fetch is the main club screen and is live-proven to be the
// player set. `any` is the taxonomy's own arm 0 (and its default): it is
// answered with the SAME set, because a genuinely mixed multi-family
// response is exactly what crashed the client on 2026-08-05, and `custom`
// is the observed companion of the league/team drill-downs.
None | Some("player") | Some("any") | Some("custom") => {
ClubTypeFilter::kind("player", ContentKind::Player)
}
// The three MY CLUB position tabs (`FUN_18012ddf0` suppresses `position=`
// and sends these tokens instead).
Some("playerdefender") => ClubTypeFilter {
label: "playerdefender",
selector: ClubSelector::PlayerPositions(PositionGroup::Defender),
},
Some("playermidfielder") => ClubTypeFilter {
label: "playermidfielder",
selector: ClubSelector::PlayerPositions(PositionGroup::Midfielder),
},
Some("playerforward") => ClubTypeFilter {
label: "playerforward",
selector: ClubSelector::PlayerPositions(PositionGroup::Forward),
},
// The STAFF tab is the only staff request ever observed on the wire, and
// it asked with `type=manager` (count=200) for the WHOLE family — the
// client's own club-stats model likewise counts a manager inside its
// `staff` total with `staffManager` as a bucket within it. Narrowing this
// arm to subtype 4 would empty the staff tab of a club that owns coaches.
Some("staff") | Some("manager") => ClubTypeFilter::kind("staff", ContentKind::Staff),
// The four per-family coach arms, from the same taxonomy (and the
// oracle's own `CLUB_TYPES`). Each answers ONE `cardsubtypeid`.
Some("headcoach") => ClubTypeFilter {
label: "headcoach",
selector: ClubSelector::StaffRole(5),
},
Some("gkcoach") => ClubTypeFilter {
label: "gkcoach",
selector: ClubSelector::StaffRole(6),
},
Some("physio") => ClubTypeFilter {
label: "physio",
selector: ClubSelector::StaffRole(7),
},
Some("fitnesscoach") => ClubTypeFilter {
label: "fitnesscoach",
selector: ClubSelector::StaffRole(8),
},
// Club customisation, singular names, all observed live. The kind mapping
// is settled (kit 9, stadium 10, badge 11, ball 30), so each arm asks Core
// for the right rows; the item record for the three non-kit families is
// still withheld inside the shaper, which counts them.
Some("kit") => ClubTypeFilter::kind("kit", ContentKind::Kit),
Some("badge") => ClubTypeFilter::kind("badge", ContentKind::Badge),
Some("stadium") => ClubTypeFilter::kind("stadium", ContentKind::Stadium),
Some("ball") => ClubTypeFilter::kind("ball", ContentKind::Ball),
Some("misc") => ClubTypeFilter::kind("misc", ContentKind::Misc),
// WITHHELD, each for a recorded reason.
Some("equippables") => ClubTypeFilter {
label: "equippables",
// The combined customisation view, and the one response that has ever
// crashed this client: 30 items across five families at once
// (2026-08-05). A single-family answer here is not available either —
// the view is by definition multi-family — and the kit swap it feeds
// needs `item+0x60 == 4`, which a server can never produce (it can
// only ever produce 1 = club and 6 = purchased). So this stays empty
// until that is a client-side change, not a wire one.
selector: ClubSelector::Withheld("multi_family_crash_2026_08_05"),
},
Some("leaguelogos") => ClubTypeFilter {
label: "leaguelogos",
// Subtype 31 is by elimination and unprobed, there is no
// `FUT_UC_LEAGUELOGO` caption anywhere in the DLL, and the family's
// only display name would be `localizedName` — "the parser reads it"
// is not "sending it is safe". Also not an ownable Core content kind.
selector: ClubSelector::Withheld("subtype_by_elimination_unprobed"),
},
Some("healing") | Some("contract") | Some("training") | Some("development") => {
ClubTypeFilter {
label: "consumable_arm",
// Consumables are NOT served through `club?type=`. A previous
// round shipped four `?type=` arms for exactly these tokens and
// the screen stayed empty: the client asks
// `GET club/consumables/<category>`, whose element is a STACK
// wrapper, and a bare item in this envelope is accepted and
// silently discarded.
selector: ClubSelector::Withheld("served_by_club_consumables_route"),
}
}
Some("unlocks") => ClubTypeFilter {
label: "unlocks",
selector: ClubSelector::Withheld("not_owned_inventory"),
},
Some("offlinetrophy")
| Some("onlinetrophy")
| Some("featuredofflinetrophy")
| Some("featuredonlinetrophy")
| Some("allofflinetrophy")
| Some("allonlinetrophy") => ClubTypeFilter {
label: "trophy",
// Trophies are the `0x91..=0x96` tournament/season records, not owned
// club items, and Core models no trophy ownership. The club/stats
// trophy rows stay honest zeros for the same reason.
selector: ClubSelector::Withheld("no_trophy_ownership_in_core"),
},
Some(_) => return None,
};
Some(filter)
}
/// Filter already-shaped `/club` items to SPECIALS (`rareflag > 1`) and paginate /// Filter already-shaped `/club` items to SPECIALS (`rareflag > 1`) and paginate
/// the filtered set locally. Returns `(page, total_specials)`. Pure — the whole /// the filtered set locally. Returns `(page, total_specials)`. Pure — the whole
/// point is that "special" pagination is over the filtered set, never Core's /// point is that "special" pagination is over the filtered set, never Core's
@@ -1912,17 +2133,13 @@ fn paginate_items(items: &[Value], offset: Option<i64>, limit: Option<i64>) -> (
/// JSON body (UTAS must never 401/403; an empty result is the safe degrade). /// JSON body (UTAS must never 401/403; an empty result is the safe degrade).
pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) { pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) {
let raw = parse_club_query(query); let raw = parse_club_query(query);
let requested_kind = match raw.item_type.as_deref() { let filter_arm = match club_type_filter(raw.item_type.as_deref()) {
None | Some("player") => ContentKind::Player, Some(f) => f,
Some("kit") => ContentKind::Kit, // A token outside the client's own 30-arm taxonomy. Empty is the honest
// The STAFF tab is the only staff request ever observed on the wire, and // answer AND the loud one: the log names the token so a new wire fact is
// it asked with `type=manager`. `type=staff` is accepted as the obvious // actionable instead of silently mapped onto the player set.
// sibling token rather than betting the tab never sends it: both mean the None => {
// same owned set here, because managers and coaches are one content kind let other = raw.item_type.as_deref().unwrap_or("");
// (the client's own club-stats model likewise counts a manager inside its
// `staff` total, with `staffManager` as a bucket within it).
Some("staff") | Some("manager") => ContentKind::Staff,
Some(other) => {
return ( return (
json_response(&json!({ "itemData": [] })), json_response(&json!({ "itemData": [] })),
ClubLog { ClubLog {
@@ -1931,12 +2148,29 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
total: 0, total: 0,
emitted: 0, emitted: 0,
dropped_no_asset: 0, dropped_no_asset: 0,
dropped_incomplete: 0,
offset: raw.start.map(|value| value as i64), offset: raw.start.map(|value| value as i64),
limit: raw.count.map(|value| value as i64), limit: raw.count.map(|value| value as i64),
}, },
); );
} }
}; };
// A withheld arm never reaches Core: the reason, not a query, is the answer.
if let ClubSelector::Withheld(reason) = filter_arm.selector {
return (
json_response(&json!({ "itemData": [] })),
ClubLog {
outcome: "withheld",
filter: format!("type={},reason={reason}", filter_arm.label),
total: 0,
emitted: 0,
dropped_no_asset: 0,
dropped_incomplete: 0,
offset: raw.start.map(|value| value as i64),
limit: raw.count.map(|value| value as i64),
},
);
}
let core_q = match map_to_core(&raw, deps.entities) { let core_q = match map_to_core(&raw, deps.entities) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
@@ -1948,6 +2182,7 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
total: 0, total: 0,
emitted: 0, emitted: 0,
dropped_no_asset: 0, dropped_no_asset: 0,
dropped_incomplete: 0,
offset: raw.start.map(|value| value as i64), offset: raw.start.map(|value| value as i64),
limit: raw.count.map(|value| value as i64), limit: raw.count.map(|value| value as i64),
}, },
@@ -1962,7 +2197,7 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
if !filter.is_empty() { if !filter.is_empty() {
filter.push(','); filter.push(',');
} }
filter.push_str(&format!("type={}", requested_kind.as_str())); filter.push_str(&format!("type={}", filter_arm.label));
if core_q.special { if core_q.special {
filter.push_str(",rare=SP"); filter.push_str(",rare=SP");
} }
@@ -1978,7 +2213,13 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
.items .items
.into_iter() .into_iter()
.filter(|item| !deps.hidden.contains(&item.owned_card_id)) .filter(|item| !deps.hidden.contains(&item.owned_card_id))
.filter(|item| deps.assets.kind_of(item) == requested_kind) .filter(|item| {
filter_arm.matches(
deps.assets.kind_of(item),
deps.assets.subtype_of(item),
&item.position,
)
})
.collect(); .collect();
let active = ActiveKitAssignments { let active = ActiveKitAssignments {
home: deps.active_kits.home_owned_card_id.as_deref(), home: deps.active_kits.home_owned_card_id.as_deref(),
@@ -2005,6 +2246,7 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
total, total,
emitted, emitted,
dropped_no_asset: stats.dropped_no_asset, dropped_no_asset: stats.dropped_no_asset,
dropped_incomplete: stats.dropped_incomplete,
offset, offset,
limit, limit,
}, },
@@ -2020,6 +2262,7 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
total: 0, total: 0,
emitted: 0, emitted: 0,
dropped_no_asset: 0, dropped_no_asset: 0,
dropped_incomplete: 0,
offset, offset,
limit, limit,
}, },
@@ -3627,8 +3870,16 @@ impl Server {
}; };
let (resp, log) = handle_club(query, &deps); let (resp, log) = handle_club(query, &deps);
eprintln!( eprintln!(
"utas-host owner=RUST route=club status={} outcome={} filter=[{}] total={} emitted={} dropped_no_asset={} offset={:?} limit={:?}", "utas-host owner=RUST route=club status={} outcome={} filter=[{}] total={} emitted={} dropped_no_asset={} dropped_incomplete={} offset={:?} limit={:?}",
resp.status, log.outcome, log.filter, log.total, log.emitted, log.dropped_no_asset, log.offset, log.limit resp.status,
log.outcome,
log.filter,
log.total,
log.emitted,
log.dropped_no_asset,
log.dropped_incomplete,
log.offset,
log.limit
); );
resp resp
} }
@@ -3690,6 +3941,7 @@ impl Server {
} }
Route::Hub => self.handle_hub(), Route::Hub => self.handle_hub(),
Route::ClubStats => self.handle_club_stats(path), Route::ClubStats => self.handle_club_stats(path),
Route::ClubConsumables => self.handle_club_consumables(path),
Route::StaticAck => self.handle_static_ack(path), Route::StaticAck => self.handle_static_ack(path),
Route::WatchList => self.handle_watchlist(method), Route::WatchList => self.handle_watchlist(method),
Route::User => self.handle_user(), Route::User => self.handle_user(),
@@ -4375,6 +4627,76 @@ impl Server {
json_status(200, &club_stats_body(&items, ctx)) json_status(200, &club_stats_body(&items, ctx))
} }
/// `GET …/club/consumables/<category>` — the consumables ITEM screen, served
/// from Core's authoritative inventory.
///
/// The category segment is the client's own consumable UI group name
/// (`training`, `contracts`, `fitness`, `healing`, `position`, `playStyle`,
/// `managerLeagueModifier`), matched lower-cased. An UNKNOWN segment is
/// answered EMPTY and logged loudly: serving the whole shelf instead would
/// put the wrong families in a named tab, which is the same class of bug as
/// answering a drill-down with the entire club.
///
/// Fail-closed 503 on a Core error, like club/stats: an empty list here is a
/// meaningful answer ("the club owns none of these"), so it must never double
/// as "Core is down".
///
/// Cards with an ACTIVE market listing are excluded, exactly as on `/club`: a
/// listed card has LEFT the club and must not appear in both places.
fn handle_club_consumables(&self, path: &str) -> WireResponse {
let segment = ut_tail(path)
.and_then(|t| t.strip_prefix("club/consumables"))
.map(|rest| rest.trim_matches('/').to_ascii_lowercase())
.unwrap_or_default();
let families = match consumable_families_for_category(&segment) {
Some(f) => f,
None => {
eprintln!(
"utas-host owner=RUST route=club-consumables status=200 \
category={segment:?} outcome=unknown_category emitted=0 \
(add it to consumable_families_for_category if the client really asks)"
);
return json_status(200, &json!({ "itemData": [] }));
}
};
let owned = match self.core.all_owned() {
Ok(o) => o,
Err(e) => {
eprintln!("utas-host owner=RUST route=club-consumables status=503 error=core:{e}");
return error_response(503, "core_unavailable");
}
};
let hidden = self.club_hidden_ids();
let mut resolved: Vec<Fifa17ConsumableIdentity> = Vec::new();
let mut unresolved = 0usize;
for item in owned
.iter()
.filter(|it| !hidden.contains(&it.owned_card_id))
.filter(|it| self.resolver.kind_of(it) == ContentKind::Consumable)
.filter(|it| {
consumable_family(self.resolver.subtype_of(it))
.map(|(family, _)| families.contains(&family))
.unwrap_or(false)
})
{
match self.resolver.resolve_consumable(item) {
Some(id) => resolved.push(id),
None => unresolved += 1,
}
}
let (body, stats) = consumables_response(&resolved);
eprintln!(
"utas-host owner=RUST route=club-consumables status=200 category={} \
copies={} stacks={} dropped_no_asset={} dropped_incomplete={}",
segment,
stats.emitted,
body["itemData"].as_array().map(Vec::len).unwrap_or(0),
unresolved,
stats.dropped_incomplete
);
json_status(200, &body)
}
/// Serve forever on `addr` (thread-per-connection, HTTP/1.1 keep-alive). /// Serve forever on `addr` (thread-per-connection, HTTP/1.1 keep-alive).
pub fn serve(&self, addr: &str) -> std::io::Result<()> { pub fn serve(&self, addr: &str) -> std::io::Result<()> {
let listener = TcpListener::bind(addr)?; let listener = TcpListener::bind(addr)?;
+17 -12
View File
@@ -28,6 +28,7 @@ use serde_json::{json, Value};
use openfut_adapter_fifa17::fut::entities::ReverseEntityResolver; use openfut_adapter_fifa17::fut::entities::ReverseEntityResolver;
use openfut_adapter_fifa17::fut::item::{shape_item, ItemIdentityResolver}; use openfut_adapter_fifa17::fut::item::{shape_item, ItemIdentityResolver};
use openfut_adapter_fifa17::fut::item_state;
use openfut_adapter_fifa17::fut::non_economy; use openfut_adapter_fifa17::fut::non_economy;
use openfut_adapter_fifa17::fut::squad::SquadWireResolver; use openfut_adapter_fifa17::fut::squad::SquadWireResolver;
@@ -81,7 +82,7 @@ fn trade_id_from_path(path: &str) -> Option<String> {
/// snapshot persisted at listing time; a row written before snapshots existed /// snapshot persisted at listing time; a row written before snapshots existed
/// degrades to the stub (honest, not fabricated). /// degrades to the stub (honest, not fabricated).
/// ///
/// `item_state` overrides the card's `itemState`. FIFA 17's vocabulary is the /// `state` overrides the card's `itemState`. FIFA 17's vocabulary is the
/// 12-row `{const char*, int}` table at `0x180229cc0`, and `forSale` (5) is its /// 12-row `{const char*, int}` table at `0x180229cc0`, and `forSale` (5) is its
/// value for an item offered for sale. The Python oracle stamps `listFS` on the /// value for an item offered for sale. The Python oracle stamps `listFS` on the
/// seller's own pile instead — a token that does NOT EXIST in FIFA 17 (zero /// seller's own pile instead — a token that does NOT EXIST in FIFA 17 (zero
@@ -89,8 +90,8 @@ fn trade_id_from_path(path: &str) -> Option<String> {
/// memory) and therefore decodes to `-1` through `FUN_180166660`, i.e. the client /// memory) and therefore decodes to `-1` through `FUN_180166660`, i.e. the client
/// is handed an unrecognised `CARD_OFFERSTATE`. Where the binary contradicts the /// is handed an unrecognised `CARD_OFFERSTATE`. Where the binary contradicts the
/// oracle, the binary wins. /// oracle, the binary wins.
fn auction_record_as(l: &Listing, item_state: &str) -> Value { fn auction_record_as(l: &Listing, state: &str) -> Value {
auction_record_tuned(l, item_state, None, 0) auction_record_tuned(l, state, None, 0)
} }
/// [`auction_record_as`] with the two fields the staging sold experiment varies. /// [`auction_record_as`] with the two fields the staging sold experiment varies.
@@ -105,7 +106,7 @@ fn auction_record_as(l: &Listing, item_state: &str) -> Value {
/// what makes the client's reaction attributable to the token. /// what makes the client's reaction attributable to the token.
fn auction_record_tuned( fn auction_record_tuned(
l: &Listing, l: &Listing,
item_state: &str, state: &str,
sold_bid_state: Option<&str>, sold_bid_state: Option<&str>,
coins_processed: i64, coins_processed: i64,
) -> Value { ) -> Value {
@@ -152,7 +153,7 @@ fn auction_record_tuned(
.map(|mut card| { .map(|mut card| {
// Keep the wire identity and presentation state authoritative here. // Keep the wire identity and presentation state authoritative here.
card["id"] = json!(item_id); card["id"] = json!(item_id);
card["itemState"] = json!(item_state); card["itemState"] = json!(state);
card["untradeable"] = json!(false); card["untradeable"] = json!(false);
card card
}) })
@@ -160,7 +161,7 @@ fn auction_record_tuned(
json!({ json!({
"id": item_id, "id": item_id,
"resourceId": resource, "resourceId": resource,
"itemState": item_state, "itemState": state,
"untradeable": false, "untradeable": false,
}) })
}); });
@@ -204,9 +205,9 @@ fn auction_record_tuned(
/// closed/sold echoes the buy path returns. /// closed/sold echoes the buy path returns.
fn auction_record(l: &Listing) -> Value { fn auction_record(l: &Listing) -> Value {
let state = if l.state == "active" { let state = if l.state == "active" {
"forSale" item_state::FOR_SALE
} else { } else {
"free" item_state::FREE
}; };
auction_record_as(l, state) auction_record_as(l, state)
} }
@@ -444,7 +445,7 @@ pub async fn handle_market_query(
}; };
let mut auctions: Vec<Value> = listings let mut auctions: Vec<Value> = listings
.iter() .iter()
.map(|l| auction_record_as(l, "forSale")) .map(|l| auction_record_as(l, item_state::FOR_SALE))
.collect(); .collect();
// STAGING ONLY. FIFA 17's bulk `DELETE …/trade/sold` verb only makes sense if // STAGING ONLY. FIFA 17's bulk `DELETE …/trade/sold` verb only makes sense if
// sold rows persist in the seller's pile until acknowledged, so the experiment // sold rows persist in the seller's pile until acknowledged, so the experiment
@@ -456,7 +457,7 @@ pub async fn handle_market_query(
for l in &sold { for l in &sold {
auctions.push(auction_record_tuned( auctions.push(auction_record_tuned(
l, l,
"forSale", item_state::FOR_SALE,
exp.bid_state, exp.bid_state,
exp.coins_processed, exp.coins_processed,
)); ));
@@ -607,7 +608,7 @@ pub async fn handle_market_status(
}; };
let auctions: Vec<Value> = listings let auctions: Vec<Value> = listings
.iter() .iter()
.map(|l| auction_record_tuned(l, "forSale", exp.bid_state, exp.coins_processed)) .map(|l| auction_record_tuned(l, item_state::FOR_SALE, exp.bid_state, exp.coins_processed))
.collect(); .collect();
eprintln!( eprintln!(
"utas-host owner=RUST route=market-status requested={} returned={} query={}", "utas-host owner=RUST route=market-status requested={} returned={} query={}",
@@ -723,7 +724,7 @@ pub async fn handle_market_buy(
rec["tradeState"] = json!("closed"); rec["tradeState"] = json!("closed");
rec["bidState"] = json!("highest"); rec["bidState"] = json!("highest");
rec["currentBid"] = json!(price); rec["currentBid"] = json!(price);
rec["itemData"]["itemState"] = json!("free"); rec["itemData"]["itemState"] = json!(item_state::FREE);
ok_json(&json!({ "auctionInfo": [rec], "credits": new_balance })) ok_json(&json!({ "auctionInfo": [rec], "credits": new_balance }))
} }
// Insufficient funds surfaced by Core (concurrent debit) -> 461. // Insufficient funds surfaced by Core (concurrent debit) -> 461.
@@ -1155,6 +1156,10 @@ mod tests {
let pile = handle_market_query("active", &econ, &store, SoldExperiment::OFF).await; let pile = handle_market_query("active", &econ, &store, SoldExperiment::OFF).await;
let rec = parse(&pile)["auctionInfo"][0].clone(); let rec = parse(&pile)["auctionInfo"][0].clone();
assert_eq!(rec["itemData"]["itemState"], "forSale"); assert_eq!(rec["itemData"]["itemState"], "forSale");
assert!(
item_state::is_recovered(rec["itemData"]["itemState"].as_str().unwrap()),
"every emitted itemState must be in FIFA 17's own 12-row table"
);
assert_eq!(rec["itemData"]["rating"], 84); assert_eq!(rec["itemData"]["rating"], 84);
assert_eq!(rec["itemData"]["id"], 100004617i64); assert_eq!(rec["itemData"]["id"], 100004617i64);
assert_eq!(rec["itemData"]["resourceId"], 169193); assert_eq!(rec["itemData"]["resourceId"], 169193);
+413
View File
@@ -2028,3 +2028,416 @@ fn watchlist_is_rust_owned_and_never_passed_through() {
); );
} }
} }
// ── /club ?type= completeness, and the consumables route ────────────────────
/// A club holding one of everything the taxonomy can ask about: two players (a
/// forward and a defender), a manager, a GK coach, a fitness coach, two kits and
/// one consumable — the same mix the real profile has, at fixture scale.
fn mixed_club_catalog() -> &'static str {
"\"fifa17_20801\":{\"asset_id\":20801},\
\"fifa17_158023\":{\"asset_id\":158023},\
\"fifa17_1000509\":{\"asset_id\":1000509,\"kind\":\"staff\",\"subtype\":4,\
\"nation\":45,\"league_id\":53,\"team_id\":241},\
\"fifa17_9000081\":{\"asset_id\":9000081,\"kind\":\"staff\",\"subtype\":6},\
\"fifa17_3000083\":{\"asset_id\":3000083,\"kind\":\"staff\",\"subtype\":8},\
\"fifa17_6300006\":{\"asset_id\":6300006,\"kind\":\"kit\",\"subtype\":9,\
\"card_asset_id\":35,\"team_id\":21},\
\"fifa17_6400003\":{\"asset_id\":6400003,\"kind\":\"kit\",\"subtype\":9,\
\"card_asset_id\":35,\"team_id\":21},\
\"fifa17_5003012\":{\"asset_id\":5003012,\"kind\":\"consumable\",\"subtype\":54,\
\"card_asset_id\":3,\"rareflag\":0,\"rating\":85,\"amount\":15}"
}
fn mixed_club_items() -> Vec<CoreOwnedItem> {
vec![
item(
"oc-st",
"fifa17_20801",
94,
"ST",
"Argentina",
"Premier League",
"Chelsea",
),
item(
"oc-cb",
"fifa17_158023",
88,
"CB",
"Argentina",
"Premier League",
"Chelsea",
),
item("oc-mgr", "fifa17_1000509", 0, "", "", "", ""),
item("oc-gk-coach", "fifa17_9000081", 0, "", "", "", ""),
item("oc-fit-coach", "fifa17_3000083", 0, "", "", "", ""),
item("oc-kit-home", "fifa17_6300006", 0, "", "", "", ""),
item("oc-kit-away", "fifa17_6400003", 0, "", "", "", ""),
item("oc-consumable", "fifa17_5003012", 0, "", "", "", ""),
]
}
/// Run one `?type=` query against the mixed club.
fn club_query(query: &str) -> (Vec<Value>, String, &'static str) {
let core = Arc::new(FakeCore::new(mixed_club_items(), 8));
let resolver = resolver_for_catalog(mixed_club_catalog());
let ents = entities();
let hidden = std::collections::HashSet::new();
let kits = CoreKitAssignments {
home_owned_card_id: Some("oc-kit-home".into()),
away_owned_card_id: Some("oc-kit-away".into()),
};
let deps = ClubDeps {
core: core.as_ref(),
entities: &ents,
assets: resolver.as_ref(),
hidden: &hidden,
active_kits: &kits,
};
let (resp, log) = handle_club(query, &deps);
assert_eq!(resp.status, 200, "UTAS must never fail a club query");
let body: Value = serde_json::from_slice(&resp.body).unwrap();
let items = body["itemData"].as_array().cloned().unwrap_or_default();
(items, log.filter, log.outcome)
}
/// Every `?type=` arm that claims a family must serve THAT family and nothing
/// else. The mirror filter is the point: a manager carries nation/leagueId/teamid,
/// so leaking one into a player query would put a coach in the by-league and
/// by-team drill-downs — the exact regression the filter exists to prevent.
#[test]
fn club_type_arms_serve_their_own_family_and_never_leak_another() {
// Players: the untyped fetch, `player`, and the taxonomy's `any`/`custom`
// arms all mean the club's footballers.
for query in ["", "type=player", "type=any", "type=custom"] {
let (items, _, outcome) = club_query(query);
assert_eq!(outcome, "ok", "{query}");
assert_eq!(items.len(), 2, "{query}: two footballers");
for it in &items {
assert_eq!(it["itemType"], "player", "{query}");
assert_eq!(it["cardsubtypeid"], 0, "{query}: no staff/kit subtype");
assert!(it["attributeList"].is_array(), "{query}");
}
}
// The whole staff family, under either observed token.
for query in ["type=staff", "type=manager"] {
let (items, _, outcome) = club_query(query);
assert_eq!(outcome, "ok", "{query}");
let mut subtypes: Vec<i64> = items
.iter()
.map(|i| i["cardsubtypeid"].as_i64().unwrap())
.collect();
subtypes.sort_unstable();
assert_eq!(
subtypes,
vec![4, 6, 8],
"{query}: manager + both coaches, no footballer and no kit"
);
for it in &items {
assert_eq!(it["itemType"], "staff", "{query}");
assert!(it.get("attributeList").is_none(), "{query}");
}
}
// One arm per coach family, by cardsubtypeid.
for (query, subtype) in [
("type=gkcoach", 6),
("type=fitnesscoach", 8),
("type=headcoach", 5),
("type=physio", 7),
] {
let (items, _, outcome) = club_query(query);
assert_eq!(outcome, "ok", "{query}");
let owned: Vec<i64> = items
.iter()
.map(|i| i["cardsubtypeid"].as_i64().unwrap())
.collect();
// The club owns a GK coach and a fitness coach only, so the other two
// arms are legitimately empty — never "everything" to fill the tab.
let expected: Vec<i64> = if [6, 8].contains(&subtype) {
vec![subtype]
} else {
vec![]
};
assert_eq!(owned, expected, "{query}");
assert!(
!owned.contains(&4),
"{query}: the MANAGER is not a coach family"
);
}
// Kits, with their ownership-backed active designations.
let (kits, _, outcome) = club_query("type=kit");
assert_eq!(outcome, "ok");
assert_eq!(kits.len(), 2);
assert_eq!(kits[0]["itemState"], "activeHomeKit");
assert_eq!(kits[1]["itemState"], "activeAwayKit");
for kit in &kits {
assert_eq!(kit["cardsubtypeid"], 9);
assert!(kit.get("attributeList").is_none());
}
// The consumable is in NEITHER: it has its own route and its own envelope.
for query in ["", "type=player", "type=staff", "type=kit"] {
let (items, _, _) = club_query(query);
assert!(
!items
.iter()
.any(|i| i["cardsubtypeid"].as_i64() == Some(54)),
"{query}: a consumable must never appear in a /club item list"
);
}
}
/// The three MY CLUB position tabs answer their own group, from the client's own
/// position ladder — never the whole club.
#[test]
fn position_tabs_serve_only_their_own_position_group() {
let (forwards, _, outcome) = club_query("type=playerforward");
assert_eq!(outcome, "ok");
assert_eq!(forwards.len(), 1);
assert_eq!(forwards[0]["preferredPosition"], "ST");
let (defenders, _, _) = club_query("type=playerdefender");
assert_eq!(defenders.len(), 1);
assert_eq!(defenders[0]["preferredPosition"], "CB");
// No midfielder is owned → an empty tab, not the other five cards.
let (mids, _, outcome) = club_query("type=playermidfielder");
assert_eq!(outcome, "ok");
assert!(mids.is_empty());
// And no position tab ever contains staff or a kit.
for query in [
"type=playerforward",
"type=playerdefender",
"type=playermidfielder",
] {
let (items, _, _) = club_query(query);
for it in &items {
assert_eq!(it["itemType"], "player", "{query}");
}
}
}
/// The arms that are DELIBERATELY empty answer 200 with an empty list and record
/// WHY — distinguishable in the log from a token we do not know.
#[test]
fn withheld_club_type_arms_are_empty_with_a_recorded_reason() {
for (query, reason) in [
("type=equippables", "multi_family_crash_2026_08_05"),
("type=leaguelogos", "subtype_by_elimination_unprobed"),
("type=healing", "served_by_club_consumables_route"),
("type=contract", "served_by_club_consumables_route"),
("type=training", "served_by_club_consumables_route"),
("type=development", "served_by_club_consumables_route"),
("type=unlocks", "not_owned_inventory"),
("type=offlinetrophy", "no_trophy_ownership_in_core"),
("type=onlinetrophy", "no_trophy_ownership_in_core"),
("type=featuredofflinetrophy", "no_trophy_ownership_in_core"),
("type=featuredonlinetrophy", "no_trophy_ownership_in_core"),
("type=allofflinetrophy", "no_trophy_ownership_in_core"),
("type=allonlinetrophy", "no_trophy_ownership_in_core"),
] {
let (items, filter, outcome) = club_query(query);
assert_eq!(outcome, "withheld", "{query}");
assert!(items.is_empty(), "{query} must serve nothing");
assert!(
filter.contains(reason),
"{query}: log must carry the reason, got [{filter}]"
);
}
}
/// The three club-customisation families Core can own are MAPPED (so the arm asks
/// Core for the right rows) but their item record is still withheld, so the answer
/// is an empty list rather than a guessed record — and never another family's.
#[test]
fn club_item_arms_are_mapped_but_withhold_the_unverified_record() {
for query in ["type=badge", "type=stadium", "type=ball", "type=misc"] {
let (items, filter, outcome) = club_query(query);
assert_eq!(outcome, "ok", "{query}");
assert!(
items.is_empty(),
"{query}: the club owns none, and no other family may fill the tab"
);
let token = query.trim_start_matches("type=");
assert!(filter.contains(&format!("type={token}")), "{filter}");
}
}
/// A token outside the client's 30-arm taxonomy stays `unsupported_type`: empty,
/// and loud in the log, never silently mapped onto the player set.
#[test]
fn unknown_club_type_is_unsupported_not_silently_mapped() {
for query in ["type=nonsense", "type=PLAYER", "type=kits"] {
let (items, filter, outcome) = club_query(query);
assert_eq!(outcome, "unsupported_type", "{query}");
assert!(items.is_empty(), "{query}");
assert!(filter.starts_with("type="), "{filter}");
}
}
fn build_server_with_catalog(core: Arc<FakeCore>, upstream: &str, cards_json: &str) -> Server {
let doc = format!("{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{cards_json}}}}}");
let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap();
let store = JsonIdentityStore::open(unique_store_path()).unwrap();
let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
Server::new(
core,
Arc::new(entities()),
resolver,
Arc::new(PassClient::new(upstream)),
33_068_179,
)
}
/// `GET club/consumables/<category>` is Rust-owned, served from Core, and answers
/// with the STACK wrapper this response class reads — not bare items, which the
/// client accepts and silently discards.
#[test]
fn consumables_route_serves_core_owned_stacks_per_category() {
// Two copies of one training card + one contract card + a footballer.
let items = vec![
item("oc-c1", "fifa17_5003012", 0, "", "", "", ""),
item("oc-c2", "fifa17_5003012", 0, "", "", "", ""),
item("oc-c3", "fifa17_5001004", 0, "", "", "", ""),
item(
"oc-p",
"fifa17_20801",
94,
"ST",
"Argentina",
"Premier League",
"Chelsea",
),
];
let core = Arc::new(FakeCore::new(items, 4));
let (py_url, rec) = spawn_mock_python();
let server = build_server_with_catalog(
core,
&py_url,
"\"fifa17_20801\":{\"asset_id\":20801},\
\"fifa17_5003012\":{\"asset_id\":5003012,\"kind\":\"consumable\",\"subtype\":54,\
\"card_asset_id\":3,\"rareflag\":0,\"rating\":85,\"amount\":15},\
\"fifa17_5001004\":{\"asset_id\":5001004,\"kind\":\"consumable\",\"subtype\":201,\
\"card_asset_id\":7,\"rareflag\":0,\"rating\":60,\"contract\":7}",
);
assert_eq!(
classify("GET", "/ut/game/fifa17/club/consumables/training"),
Route::ClubConsumables,
"a /club PREFIX must not fall through to the generic club route"
);
let resp = server.handle("GET", "/ut/game/fifa17/club/consumables/training", &[], b"");
assert_eq!(resp.status, 200);
let body: Value = serde_json::from_slice(&resp.body).unwrap();
let stacks = body["itemData"].as_array().unwrap();
assert_eq!(stacks.len(), 1, "one stack for the two identical copies");
assert_eq!(stacks[0]["count"], 2);
assert_eq!(stacks[0]["resourceId"], 5_003_012);
assert_eq!(stacks[0]["item"]["cardsubtypeid"], 54);
assert_eq!(
stacks[0]["item"]["cardassetid"], 3,
"the ART id, not the id"
);
assert_eq!(stacks[0]["item"]["amount"], 15, "mandatory for category 0");
assert_eq!(stacks[0]["item"]["rating"], 85, "EA's definition rating");
assert!(
stacks[0]["item"].get("attributeList").is_none(),
"a consumable has no attributes — that is what makes it not a player"
);
// The contracts category serves the OTHER card, and never the training one.
let resp = server.handle(
"GET",
"/ut/game/fifa17/club/consumables/contracts",
&[],
b"",
);
let body: Value = serde_json::from_slice(&resp.body).unwrap();
let stacks = body["itemData"].as_array().unwrap();
assert_eq!(stacks.len(), 1);
assert_eq!(stacks[0]["resourceId"], 5_001_004);
assert_eq!(stacks[0]["item"]["contract"], 7);
assert!(
stacks[0]["item"].get("amount").is_none(),
"categories 2 and 3 ignore `amount`"
);
// A category the club owns nothing in is empty — and the footballer never
// appears in any of them.
for category in ["healing", "fitness", "position", "playstyle"] {
let path = format!("/ut/game/fifa17/club/consumables/{category}");
let resp = server.handle("GET", &path, &[], b"");
let body: Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
body["itemData"].as_array().unwrap().is_empty(),
"{category} must not be filled with another family"
);
}
assert_eq!(rec.lock().len(), 0, "consumables never reach Python");
}
/// An UNKNOWN category segment is empty, not the whole shelf: filling a named tab
/// with every family is the same bug class as answering a drill-down with the
/// entire club.
#[test]
fn consumables_route_unknown_category_serves_nothing() {
let core = Arc::new(FakeCore::new(
vec![item("oc-c1", "fifa17_5003012", 0, "", "", "", "")],
1,
));
let (py_url, rec) = spawn_mock_python();
let server = build_server_with_catalog(
core,
&py_url,
"\"fifa17_5003012\":{\"asset_id\":5003012,\"kind\":\"consumable\",\"subtype\":54,\
\"card_asset_id\":3,\"rareflag\":0,\"rating\":85,\"amount\":15}",
);
for path in [
"/ut/game/fifa17/club/consumables/somethingelse",
"/ut/game/fifa17/club/consumables",
] {
let resp = server.handle("GET", path, &[], b"");
assert_eq!(resp.status, 200, "{path}");
let body: Value = serde_json::from_slice(&resp.body).unwrap();
assert!(body["itemData"].as_array().unwrap().is_empty(), "{path}");
}
assert_eq!(rec.lock().len(), 0, "still never Python");
}
/// A consumable whose catalog entry is INCOMPLETE is dropped, not drawn wrong:
/// without `amount` the client renders "-1" (its parser initialises the temp to
/// -1 and sign-extends), and without a real `cardassetid` it draws the
/// `notfound.swf` green box.
#[test]
fn incomplete_consumable_definitions_are_dropped_not_drawn_wrong() {
let core = Arc::new(FakeCore::new(
vec![
item("oc-no-amount", "fifa17_5003011", 0, "", "", "", ""),
item("oc-no-art", "fifa17_5003013", 0, "", "", "", ""),
],
2,
));
let (py_url, _rec) = spawn_mock_python();
let server = build_server_with_catalog(
core,
&py_url,
// (a) art id present, `amount` missing; (b) `amount` present, art missing.
"\"fifa17_5003011\":{\"asset_id\":5003011,\"kind\":\"consumable\",\"subtype\":54,\
\"card_asset_id\":3,\"rareflag\":0,\"rating\":65},\
\"fifa17_5003013\":{\"asset_id\":5003013,\"kind\":\"consumable\",\"subtype\":54,\
\"rareflag\":0,\"rating\":85,\"amount\":15}",
);
let resp = server.handle("GET", "/ut/game/fifa17/club/consumables/training", &[], b"");
assert_eq!(resp.status, 200);
let body: Value = serde_json::from_slice(&resp.body).unwrap();
assert!(
body["itemData"].as_array().unwrap().is_empty(),
"neither definition can be drawn honestly"
);
}
+98 -9
View File
@@ -758,6 +758,53 @@ CLIENT_MIN_SQUAD = 18
SQUAD_SLOTS = 23 SQUAD_SLOTS = 23
def _squad_fingerprint(squad_id: str, formation: str, slots) -> str:
"""Recompute Core's canonical squad fingerprint.
Mirrors `openfut-core/src/services/squad.rs::squad_fingerprint` exactly:
`v1|<squad>|<formation>|` plus SORTED `slot:owned:captain:bench` items joined
by `;`, hashed with FNV-1a-64 and printed as 16 lowercase hex digits.
Core stamps this whenever the canonical squad changes, and the FIFA17 opaque
extension is only applied when its stored fingerprint still matches. Writing
squad rows directly therefore INVALIDATES the extension, and the host then
projects `stale_integrity` — no players and no manager at all. Re-stamping is
what a squad replace through Core would have done.
"""
items = sorted(
f"{slot}:{owned}:{int(captain)}:{int(bench)}" for slot, owned, captain, bench in slots
)
canon = f"v1|{squad_id}|{formation}|" + ";".join(items)
h = 0xCBF29CE484222325
for b in canon.encode():
h = ((h ^ b) * 0x00000100000001B3) & 0xFFFFFFFFFFFFFFFF
return f"{h:016x}"
def restamp_squad_extension(conn, squad_id: str) -> bool:
"""Bring the stored FIFA17 extension back in step with the canonical squad.
Returns False when the squad has no extension row (nothing to re-stamp),
which is the fixture club's normal state.
"""
row = conn.execute(
"SELECT formation FROM squads WHERE id = ?", (squad_id,)
).fetchone()
if row is None:
return False
slots = conn.execute(
"SELECT position_index, owned_card_id, is_captain, is_on_bench "
"FROM squad_players WHERE squad_id = ?", (squad_id,)
).fetchall()
fingerprint = _squad_fingerprint(squad_id, row[0], slots)
changed = conn.execute(
"UPDATE game_entity_ext SET canonical_fingerprint = ? "
"WHERE entity_kind = 'squad' AND entity_id = ?",
(fingerprint, squad_id),
).rowcount
return changed > 0
def fill_bench_to_minimum(conn, club_id: str, squad_id: str, lay: Layout) -> int: def fill_bench_to_minimum(conn, club_id: str, squad_id: str, lay: Layout) -> int:
"""Top the squad up to the client's minimum with the club's best spare """Top the squad up to the client's minimum with the club's best spare
players, filling empty bench slots from index 11 upward. players, filling empty bench slots from index 11 upward.
@@ -858,22 +905,31 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
) )
seller_club = SELLER_CLUB if real_club is None else real_club["club_id"] seller_club = SELLER_CLUB if real_club is None else real_club["club_id"]
# Core classifies ownership generically (migration 0025), so every
# seeded row states what it IS. Getting this wrong would leave Core
# calling a kit a player, and Core is the ownership authority.
owned = [ owned = [
(owned_id, seller_club, card_id, TS) (owned_id, seller_club, card_id, "kit", TS)
for _slot, owned_id, card_id, _resource_id in STAGING_KITS for _slot, owned_id, card_id, _resource_id in STAGING_KITS
] ]
owned.append( owned.append(
(STAGING_MANAGER["owned_id"], seller_club, STAGING_MANAGER["card_id"], TS) (
STAGING_MANAGER["owned_id"],
seller_club,
STAGING_MANAGER["card_id"],
"manager",
TS,
)
) )
if real_club is None: if real_club is None:
owned = ( owned = (
[(item, SELLER_CLUB, card, TS) for item, card in SELLER_SQUAD_CARDS] [(item, SELLER_CLUB, card, "player", TS) for item, card in SELLER_SQUAD_CARDS]
+ [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, TS)] + [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, "player", TS)]
+ owned + owned
) )
conn.executemany( conn.executemany(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, " "INSERT INTO owned_cards (id, club_id, card_id, is_loan, "
"acquired_at) VALUES (?, ?, ?, 0, ?)", "content_kind, acquired_at) VALUES (?, ?, ?, 0, ?, ?)",
owned, owned,
) )
@@ -892,11 +948,14 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
], ],
) )
# Core generalised the two-slot kit table into slot-keyed active club
# designations (migration 0026), so the fixture writes 'home_kit' /
# 'away_kit' rather than 'home' / 'away'.
conn.executemany( conn.executemany(
"INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) " "INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at) "
"VALUES (?, ?, ?, ?)", "VALUES (?, ?, ?, ?)",
[ [
(seller_club, slot, owned_id, TS) (seller_club, f"{slot}_kit", owned_id, TS)
for slot, owned_id, _card_id, _resource_id in STAGING_KITS for slot, owned_id, _card_id, _resource_id in STAGING_KITS
], ],
) )
@@ -928,6 +987,10 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
if real_club is not None if real_club is not None
else 0 else 0
) )
# Squad rows were just written behind Core's back (bench fill, and
# the manager designation above), so the opaque FIFA17 extension is
# now stale and the host would drop it — projecting an EMPTY squad.
restamped = restamp_squad_extension(conn, squad_row[0])
finally: finally:
conn.close() conn.close()
if real_club is None: if real_club is None:
@@ -950,6 +1013,12 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
f"players: FIFA refuses to kick off below {CLIENT_MIN_SQUAD} " f"players: FIFA refuses to kick off below {CLIENT_MIN_SQUAD} "
"(11 starters + 7 subs), and the imported squad carries only its XI" "(11 starters + 7 subs), and the imported squad carries only its XI"
) )
if restamped:
ok(
"re-stamped the squad's FIFA17 extension fingerprint so Core still "
"considers it fresh after the squad changed (a stale extension "
"projects as an empty squad with no manager)"
)
def db_summary(lay: Layout) -> str: def db_summary(lay: Layout) -> str:
@@ -1148,7 +1217,7 @@ def host_banner_line(lay: Layout) -> str:
return hits[-1] return hits[-1]
def verify(lay: Layout, variant: str) -> None: def verify(lay: Layout, variant: str, real_club_expected: bool) -> None:
line = host_banner_line(lay) line = host_banner_line(lay)
ok(f"host banner: {line}") ok(f"host banner: {line}")
if variant == "off": if variant == "off":
@@ -1167,6 +1236,26 @@ def verify(lay: Layout, variant: str) -> None:
status, body = http_get(CORE_PORT, "/health") status, body = http_get(CORE_PORT, "/health")
ok(f"GET 127.0.0.1:{CORE_PORT}/health -> HTTP {status} {body}") ok(f"GET 127.0.0.1:{CORE_PORT}/health -> HTTP {status} {body}")
# A stale opaque extension does not fail anything loudly — the host just
# drops it and the client sees an empty squad with no manager. Prove the
# real club actually projects before handing the rig over.
if real_club_expected:
status, body = http_get(HOST_PORT, f"/ut/game/{GAME}/squad/0")
squad = json.loads(body) if status == 200 else {}
occupied = sum(1 for p in squad.get("players", []) if p.get("itemData", {}).get("id"))
manager = squad.get("manager") or []
ok(
f"GET /ut/game/{GAME}/squad/0 -> HTTP {status} "
f"{occupied} occupied slot(s), {len(manager)} manager"
)
if occupied < CLIENT_MIN_SQUAD or not manager:
raise Fatal(
f"the squad projected {occupied} player(s) and {len(manager)} "
f"manager(s); expected at least {CLIENT_MIN_SQUAD} and one "
"manager. A 'stale_integrity' line in the host log means the "
"squad changed without re-stamping its extension fingerprint"
)
# Nothing this process ever opened may live under the production state dir. # Nothing this process ever opened may live under the production state dir.
leaked = [] leaked = []
for fd in os.listdir(f"/proc/{os.getpid()}/fd"): for fd in os.listdir(f"/proc/{os.getpid()}/fd"):
@@ -1395,7 +1484,7 @@ def main() -> int:
ok(f"manifest written: {lay.manifest}") ok(f"manifest written: {lay.manifest}")
banner("VERIFY ISOLATION") banner("VERIFY ISOLATION")
verify(lay, args.variant) verify(lay, args.variant, real_club is not None)
print_summary(lay, args.variant, args.coins_processed, args.count_mode, print_summary(lay, args.variant, args.coins_processed, args.count_mode,
args.roster_host, records, real_club) args.roster_host, records, real_club)