Compare commits
4 Commits
dcd470cddc
...
1a6355cad5
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a6355cad5 | |||
| 770029f207 | |||
| 802f0f580f | |||
| 6c7d0856b6 |
@@ -54,6 +54,22 @@ pub struct Fifa17CardIdentity {
|
||||
/// `manager.teamid` → `leagueteamlinks.leagueid`, because `managercards` has
|
||||
/// no league column. Lands in the equally untouched slot `rec+0xe0`.
|
||||
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.
|
||||
@@ -182,6 +198,15 @@ struct RawCard {
|
||||
/// Manager chemistry league; absent → `0`.
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -242,6 +267,9 @@ impl Fifa17CardCatalog {
|
||||
team_id: rc.team_id.unwrap_or(0),
|
||||
nation: rc.nation.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);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn two_cards_same_resource_is_a_conflict() {
|
||||
let err = Fifa17CardCatalog::from_json_str(
|
||||
|
||||
@@ -12,11 +12,12 @@ use serde_json::{json, Value};
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
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
|
||||
// are now defined once in `fut::item`.
|
||||
pub use crate::fut::item::{
|
||||
CoreOwnedItem, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver,
|
||||
ShapeStats,
|
||||
CoreOwnedItem, Fifa17ConsumableIdentity, Fifa17Identity, Fifa17KitIdentity,
|
||||
Fifa17StaffIdentity, ItemIdentityResolver, ShapeStats,
|
||||
};
|
||||
|
||||
/// 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.
|
||||
/// 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>(
|
||||
items: &[CoreOwnedItem],
|
||||
ent: &impl ReverseEntityResolver,
|
||||
@@ -56,28 +61,50 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
|
||||
},
|
||||
ContentKind::Kit => match ident.resolve_kit(item) {
|
||||
Some(id) => {
|
||||
let item_state = if active_kits.home == Some(item.owned_card_id.as_str()) {
|
||||
"activeHomeKit"
|
||||
let state = if active_kits.home == Some(item.owned_card_id.as_str()) {
|
||||
item_state::ACTIVE_HOME_KIT
|
||||
} else if active_kits.away == Some(item.owned_card_id.as_str()) {
|
||||
"activeAwayKit"
|
||||
item_state::ACTIVE_AWAY_KIT
|
||||
} else {
|
||||
"free"
|
||||
item_state::FREE
|
||||
};
|
||||
out.push(shape_kit_item(id, item_state));
|
||||
out.push(shape_kit_item(id, state));
|
||||
stats.emitted += 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) => {
|
||||
out.push(shape_staff_item(id, STAFF_CONTRACT));
|
||||
stats.emitted += 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 => {
|
||||
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)
|
||||
|
||||
@@ -65,6 +65,8 @@ const S_KITS: i64 = 0x28;
|
||||
const S_KITS_HOME: i64 = 0x29;
|
||||
const S_KITS_AWAY: i64 = 0x2A;
|
||||
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
|
||||
/// `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);
|
||||
}
|
||||
let mut staff_total = 0i64;
|
||||
for it in items
|
||||
.iter()
|
||||
.filter(|i| matches!(i.kind, ContentKind::Staff))
|
||||
{
|
||||
// A manager counts INSIDE the staff total (`staffManager` is a bucket within
|
||||
// it), so this selects the whole staff FAMILY, not `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) {
|
||||
*g.get_mut(&sid).unwrap() += 1;
|
||||
staff_total += 1;
|
||||
@@ -237,13 +239,23 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
|
||||
}
|
||||
g.insert(S_CONSUMABLES, cons_total);
|
||||
|
||||
// Club items: kits are Core-owned and counted (total plus the home/away
|
||||
// family split); unimplemented families stay honest zeros.
|
||||
for sid in [
|
||||
0x14, 0x1E, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
|
||||
] {
|
||||
// Club items. THESE COUNTS ARE THE GATE: the client does not ask for a
|
||||
// family's items until club/stats reports a non-zero count for it (proven by
|
||||
// the consumables round, where two rounds of item work sat unrequested
|
||||
// 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);
|
||||
}
|
||||
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
|
||||
.iter()
|
||||
.filter(|item| matches!(item.kind, ContentKind::Kit))
|
||||
@@ -414,6 +426,59 @@ mod tests {
|
||||
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]
|
||||
fn consumables_by_family() {
|
||||
// 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 default so a catalog authored before this taxonomy existed (no `kind`
|
||||
/// field) still classifies every entry as a player, unchanged.
|
||||
///
|
||||
/// The token set is OpenFUT Core's game-independent content vocabulary
|
||||
/// (`player | manager | staff | consumable | kit | badge | ball | stadium |
|
||||
/// misc`), so a Core owned row and a FIFA 17 catalog entry name the same class
|
||||
/// with the same string and the FIFA numerics (`cardsubtypeid`, resource ranges)
|
||||
/// never leak out of this crate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ContentKind {
|
||||
#[default]
|
||||
Player,
|
||||
Consumable,
|
||||
/// A MANAGER — its own Core kind, but on the FIFA 17 side it is a member of
|
||||
/// the STAFF family, never a class of its own: see
|
||||
/// [`ContentKind::is_staff_family`]. The wire discriminator is
|
||||
/// [`MANAGER_SUBTYPE`], not this token, so a catalog may classify a manager
|
||||
/// as either `manager` or `staff` + subtype 4 and every consumer here
|
||||
/// treats the two encodings identically.
|
||||
Manager,
|
||||
Staff,
|
||||
Consumable,
|
||||
Kit,
|
||||
Badge,
|
||||
Ball,
|
||||
Stadium,
|
||||
Misc,
|
||||
}
|
||||
|
||||
impl ContentKind {
|
||||
@@ -35,9 +52,14 @@ impl ContentKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ContentKind::Player => "player",
|
||||
ContentKind::Consumable => "consumable",
|
||||
ContentKind::Manager => "manager",
|
||||
ContentKind::Staff => "staff",
|
||||
ContentKind::Consumable => "consumable",
|
||||
ContentKind::Kit => "kit",
|
||||
ContentKind::Badge => "badge",
|
||||
ContentKind::Ball => "ball",
|
||||
ContentKind::Stadium => "stadium",
|
||||
ContentKind::Misc => "misc",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +71,30 @@ impl ContentKind {
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(s: &str) -> ContentKind {
|
||||
match s {
|
||||
"consumable" => ContentKind::Consumable,
|
||||
"manager" => ContentKind::Manager,
|
||||
"staff" => ContentKind::Staff,
|
||||
"consumable" => ContentKind::Consumable,
|
||||
"kit" => ContentKind::Kit,
|
||||
"badge" => ContentKind::Badge,
|
||||
"ball" => ContentKind::Ball,
|
||||
"stadium" => ContentKind::Stadium,
|
||||
"misc" => ContentKind::Misc,
|
||||
_ => ContentKind::Player,
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the two kinds that make up the FIFA 17 STAFF family.
|
||||
///
|
||||
/// A manager IS a staff card: the client's own club-stats model counts it
|
||||
/// inside the `staff` total with `staffManager` as a bucket within it, its
|
||||
/// STAFF tab asks for the whole family with `type=manager`, and one record
|
||||
/// shape ([`crate::fut::item::shape_staff_item`]) serves all five families.
|
||||
/// Every staff consumer MUST use this predicate rather than matching
|
||||
/// `Staff` alone, or a `manager`-classified row silently leaves the staff
|
||||
/// bucket and the STAFF tab.
|
||||
pub fn is_staff_family(&self) -> bool {
|
||||
matches!(self, ContentKind::Manager | ContentKind::Staff)
|
||||
}
|
||||
}
|
||||
|
||||
/// The functional family + honest display label for a consumable `cardsubtypeid`,
|
||||
@@ -104,6 +144,172 @@ pub fn staff_role(subtype: i64) -> Option<(&'static str, &'static str)> {
|
||||
Some(pair)
|
||||
}
|
||||
|
||||
/// The ONE extra wire key a consumable family needs, or [`ConsumableNeeds::None`].
|
||||
///
|
||||
/// Taken verbatim from `fifa17-recon/data/consumables.json`'s per-subtype `needs`
|
||||
/// (generated by `build_consumables.py` from `FUN_18013f4d0`), and independently
|
||||
/// confirmed by the real profile import, where `amount` is present on exactly the
|
||||
/// training/healing/fitness/play-style/league families and `contract` on exactly
|
||||
/// the two contract families.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConsumableNeeds {
|
||||
/// `amount` (atom 0x1b → `rec+0xbf`, or `+0xbe` for a play style) is
|
||||
/// MANDATORY: the parser initialises its temp to -1 and both accessors read
|
||||
/// it SIGNED, so omitting the key draws "-1" on the card, not "0".
|
||||
Amount,
|
||||
/// `contract` (atom 0xb8 → `rec+0x8c`) carries the number the card grants;
|
||||
/// the two contract families IGNORE `amount` entirely.
|
||||
Contract,
|
||||
/// Nothing beyond the common key set — the card's whole meaning comes from
|
||||
/// `cardsubtypeid` (formation and position modifiers).
|
||||
None,
|
||||
}
|
||||
|
||||
/// Which extra key a consumable family requires. An unknown family name is
|
||||
/// [`ConsumableNeeds::None`]; callers get families from [`consumable_family`],
|
||||
/// so an unknown one cannot arrive from the wire.
|
||||
pub fn consumable_needs(family: &str) -> ConsumableNeeds {
|
||||
match family {
|
||||
"gk_training" | "player_training" | "healing" | "player_fitness" | "squad_fitness"
|
||||
| "player_playstyle" | "gk_playstyle" | "manager_league" => ConsumableNeeds::Amount,
|
||||
"player_contract" | "manager_contract" => ConsumableNeeds::Contract,
|
||||
_ => ConsumableNeeds::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The consumable families one `GET club/consumables/<category>` segment asks
|
||||
/// for, or `None` for a segment outside the client's own group table.
|
||||
///
|
||||
/// **This route, not `club?type=`.** Consumables are NOT a `?type=` family: a
|
||||
/// previous round shipped four `?type=` arms for them and the screen stayed
|
||||
/// empty, because the client asks here (and only once
|
||||
/// `club/stats/consumables` reports a non-zero count — the counter is the gate
|
||||
/// and this route is the door).
|
||||
///
|
||||
/// The segment names are the consumable UI group table at `0x180203260` (seven
|
||||
/// codes: `training`, `contracts`, `fitness`, `healing`, `playStyle`,
|
||||
/// `managerLeagueModifier`, `position`); `training` and `contracts` are CONFIRMED
|
||||
/// on the wire and the singular `contract` is accepted because the client has
|
||||
/// used both spellings. Segments are matched lower-cased.
|
||||
///
|
||||
/// The family sets are the `FUN_18013f4d0` categories those codes name, and the
|
||||
/// correspondence is checkable against the panel: training→42, contracts→13,
|
||||
/// healing→21, fitness→6, position→20, chemistry style→24 items in the oracle's
|
||||
/// own shelf. NOTE the two formation-modifier families (categories 6 and 7) have
|
||||
/// NO group code, so no segment can reach them — that is the client's own gap,
|
||||
/// not an omission here.
|
||||
pub fn consumable_families_for_category(segment: &str) -> Option<&'static [&'static str]> {
|
||||
Some(match segment {
|
||||
"training" => &["gk_training", "player_training"],
|
||||
"contracts" | "contract" => &["player_contract", "manager_contract"],
|
||||
"fitness" => &["player_fitness", "squad_fitness"],
|
||||
"healing" => &["healing"],
|
||||
"position" => &["position_mod"],
|
||||
"playstyle" => &["player_playstyle", "gk_playstyle"],
|
||||
"managerleaguemodifier" => &["manager_league"],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The club-customisation `cardsubtypeid`s, SETTLED (supersedes
|
||||
/// `CARD_SYSTEM.md`'s "STILL UNKNOWN, AND NOT GUESSED" section, which is stale).
|
||||
///
|
||||
/// Kit 9, stadium 10 and badge 11 are cardtype **7** and resolve through
|
||||
/// `FUN_180119bd0` (the manager vtable slot `+0x498`, verified from disk and live
|
||||
/// memory); ball 30 (`0x1e`) and league logo 31 (`0x1f`) are cardtype 9, the
|
||||
/// latter by elimination over `FUN_1800d8330`'s cardtype-9 set. Four independent
|
||||
/// lines agree on kit = 9, including the deserializer's own `cardassetid` default
|
||||
/// of `0x23` = 35 for cardtype 7 / subtype 9 — exactly the `cardassetid` carried
|
||||
/// by all 1482 rows of `fcc_kitcards`.
|
||||
///
|
||||
/// `0x91..=0x96` are TROPHIES (tournament/season), not club items. The enum table
|
||||
/// at `0x180229ab0` (`badge=0xa kit=0xb leagueLogo=0xc … stadium=0x15 ball=0x16`)
|
||||
/// is the transfermarket `&cat=%s` vocabulary and NOT a subtype map: reading it as
|
||||
/// one swaps badge and kit and loses stadium.
|
||||
pub const KIT_SUBTYPE: i64 = 9;
|
||||
pub const STADIUM_SUBTYPE: i64 = 10;
|
||||
pub const BADGE_SUBTYPE: i64 = 11;
|
||||
pub const BALL_SUBTYPE: i64 = 30;
|
||||
pub const LEAGUE_LOGO_SUBTYPE: i64 = 31;
|
||||
|
||||
/// The club-customisation [`ContentKind`] for a `cardsubtypeid`, or `None` for a
|
||||
/// subtype outside the settled set above. A league logo has no Core kind of its
|
||||
/// own (it is not ownable club content in Core's vocabulary), so subtype 31
|
||||
/// deliberately maps to `None` rather than being folded into `Misc`.
|
||||
pub fn club_item_kind(subtype: i64) -> Option<ContentKind> {
|
||||
let kind = match subtype {
|
||||
KIT_SUBTYPE => ContentKind::Kit,
|
||||
STADIUM_SUBTYPE => ContentKind::Stadium,
|
||||
BADGE_SUBTYPE => ContentKind::Badge,
|
||||
BALL_SUBTYPE => ContentKind::Ball,
|
||||
_ => return None,
|
||||
};
|
||||
Some(kind)
|
||||
}
|
||||
|
||||
/// The three MY CLUB position tabs (`type=playerdefender|playermidfielder|
|
||||
/// playerforward`). `FUN_18012ddf0` remaps request field `*(req+0x14)` values
|
||||
/// `0x1c/0x1d/0x1e` onto type codes `0x1b/0x1c/0x1d` and SUPPRESSES `position=`,
|
||||
/// so a position tab arrives as one of those three tokens with no other filter.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PositionGroup {
|
||||
Defender,
|
||||
Midfielder,
|
||||
Forward,
|
||||
}
|
||||
|
||||
/// The FIFA 17 position ID for a FUT position label, from the client's OWN `pos`
|
||||
/// vocabulary — the NUL-terminated `{const char*, int}` table at `0x1802295c0`
|
||||
/// that it emits as the transfer-market `&pos=%s` parameter:
|
||||
/// `GK=0 RWB=2 RB=3 CB=5 LB=7 LWB=8 CDM=10 RM=12 CM=14 LM=16 CAM=18 RF=20 CF=21
|
||||
/// LF=22 RW=23 ST=25 LW=27`.
|
||||
///
|
||||
/// `None` = a label outside that table (never guessed): the item then belongs to
|
||||
/// no position tab rather than to an invented one.
|
||||
pub fn position_id(pos: &str) -> Option<i64> {
|
||||
let id = match pos {
|
||||
"GK" => 0,
|
||||
"RWB" => 2,
|
||||
"RB" => 3,
|
||||
"CB" => 5,
|
||||
"LB" => 7,
|
||||
"LWB" => 8,
|
||||
"CDM" => 10,
|
||||
"RM" => 12,
|
||||
"CM" => 14,
|
||||
"LM" => 16,
|
||||
"CAM" => 18,
|
||||
"RF" => 20,
|
||||
"CF" => 21,
|
||||
"LF" => 22,
|
||||
"RW" => 23,
|
||||
"ST" => 25,
|
||||
"LW" => 27,
|
||||
_ => return None,
|
||||
};
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// Which position tab a FUT position label belongs to, or `None` for a label
|
||||
/// outside the client's own `pos` table.
|
||||
///
|
||||
/// The ladder is the client's, not ours: `FUN_180135890` recomputes `rec+0x14c`
|
||||
/// from the position at `rec+0x146` as `0 → GK`, `1..=8 → DEF`, `9..=19 → MID`,
|
||||
/// `20..=27 → ATT`.
|
||||
///
|
||||
/// THE ONE GUESS, named: GK is folded into `Defender`, because the client has
|
||||
/// exactly three position tabs and no fourth, so a keeper must land in one of
|
||||
/// them or vanish from every drill-down. Falsifier: if the DEF tab renders
|
||||
/// without goalkeepers, move GK out (the group boundary becomes `1..=8`).
|
||||
pub fn position_group(pos: &str) -> Option<PositionGroup> {
|
||||
match position_id(pos)? {
|
||||
0..=8 => Some(PositionGroup::Defender),
|
||||
9..=19 => Some(PositionGroup::Midfielder),
|
||||
20..=27 => Some(PositionGroup::Forward),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -111,20 +317,171 @@ mod tests {
|
||||
#[test]
|
||||
fn content_kind_round_trips_and_defaults_to_player() {
|
||||
assert_eq!(ContentKind::default(), ContentKind::Player);
|
||||
for k in [
|
||||
// The FULL Core content vocabulary, every token round-tripping.
|
||||
let all = [
|
||||
ContentKind::Player,
|
||||
ContentKind::Consumable,
|
||||
ContentKind::Manager,
|
||||
ContentKind::Staff,
|
||||
ContentKind::Consumable,
|
||||
ContentKind::Kit,
|
||||
] {
|
||||
ContentKind::Badge,
|
||||
ContentKind::Ball,
|
||||
ContentKind::Stadium,
|
||||
ContentKind::Misc,
|
||||
];
|
||||
for k in all {
|
||||
assert_eq!(ContentKind::from_str(k.as_str()), k);
|
||||
}
|
||||
let tokens: Vec<&str> = all.iter().map(|k| k.as_str()).collect();
|
||||
assert_eq!(
|
||||
tokens,
|
||||
vec![
|
||||
"player",
|
||||
"manager",
|
||||
"staff",
|
||||
"consumable",
|
||||
"kit",
|
||||
"badge",
|
||||
"ball",
|
||||
"stadium",
|
||||
"misc"
|
||||
],
|
||||
"these exact strings are the cross-crate contract with Core"
|
||||
);
|
||||
// Unknown / absent tokens fall back to Player (backward compatible).
|
||||
assert_eq!(ContentKind::from_str(""), ContentKind::Player);
|
||||
assert_eq!(ContentKind::from_str("nonsense"), ContentKind::Player);
|
||||
assert_eq!(ContentKind::from_str("player"), ContentKind::Player);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_manager_and_staff_are_the_staff_family() {
|
||||
for k in [ContentKind::Manager, ContentKind::Staff] {
|
||||
assert!(k.is_staff_family(), "{} is a staff card", k.as_str());
|
||||
}
|
||||
for k in [
|
||||
ContentKind::Player,
|
||||
ContentKind::Consumable,
|
||||
ContentKind::Kit,
|
||||
ContentKind::Badge,
|
||||
ContentKind::Ball,
|
||||
ContentKind::Stadium,
|
||||
ContentKind::Misc,
|
||||
] {
|
||||
assert!(!k.is_staff_family(), "{} is not staff", k.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_consumable_family_needs_exactly_what_the_client_reads() {
|
||||
// Grouped from data/consumables.json's per-subtype `needs`, and observed
|
||||
// key-for-key in the real profile import.
|
||||
for f in [
|
||||
"gk_training",
|
||||
"player_training",
|
||||
"healing",
|
||||
"player_fitness",
|
||||
"squad_fitness",
|
||||
"player_playstyle",
|
||||
"gk_playstyle",
|
||||
"manager_league",
|
||||
] {
|
||||
assert_eq!(consumable_needs(f), ConsumableNeeds::Amount, "{f}");
|
||||
}
|
||||
for f in ["player_contract", "manager_contract"] {
|
||||
assert_eq!(consumable_needs(f), ConsumableNeeds::Contract, "{f}");
|
||||
}
|
||||
for f in ["manager_formation_mod", "formation_mod", "position_mod"] {
|
||||
assert_eq!(consumable_needs(f), ConsumableNeeds::None, "{f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_route_categories_partition_the_reachable_families() {
|
||||
// The seven group codes, plus the singular `contract` spelling.
|
||||
let segments = [
|
||||
"training",
|
||||
"contracts",
|
||||
"fitness",
|
||||
"healing",
|
||||
"position",
|
||||
"playstyle",
|
||||
"managerleaguemodifier",
|
||||
];
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
for seg in segments {
|
||||
for f in consumable_families_for_category(seg).unwrap() {
|
||||
assert!(!seen.contains(f), "{f} claimed by two categories");
|
||||
seen.push(f);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
consumable_families_for_category("contract"),
|
||||
consumable_families_for_category("contracts"),
|
||||
"both spellings the client has used mean the same set"
|
||||
);
|
||||
// Eleven of the thirteen families are reachable; the two formation
|
||||
// modifiers have no group code in the client's own table.
|
||||
assert_eq!(seen.len(), 11, "no duplicates: {seen:?}");
|
||||
for subtype in [51, 61, 91, 201, 202, 211, 219, 220, 250, 269, 300] {
|
||||
let (family, _) = consumable_family(subtype).unwrap();
|
||||
assert!(seen.contains(&family), "no category serves {family}");
|
||||
}
|
||||
for unreachable in [71, 121] {
|
||||
let (family, _) = consumable_family(unreachable).unwrap();
|
||||
assert!(
|
||||
!seen.contains(&family),
|
||||
"{family} has no group code; claiming it would invent a segment"
|
||||
);
|
||||
}
|
||||
// Not a consumables segment (and NOT a `?type=` token either).
|
||||
for s in ["", "player", "kit", "Training", "development"] {
|
||||
assert!(
|
||||
consumable_families_for_category(s).is_none(),
|
||||
"{s:?} is not a consumable category"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn club_item_subtypes_are_the_settled_five() {
|
||||
assert_eq!(club_item_kind(KIT_SUBTYPE), Some(ContentKind::Kit));
|
||||
assert_eq!(club_item_kind(STADIUM_SUBTYPE), Some(ContentKind::Stadium));
|
||||
assert_eq!(club_item_kind(BADGE_SUBTYPE), Some(ContentKind::Badge));
|
||||
assert_eq!(club_item_kind(BALL_SUBTYPE), Some(ContentKind::Ball));
|
||||
assert_eq!((KIT_SUBTYPE, STADIUM_SUBTYPE, BADGE_SUBTYPE), (9, 10, 11));
|
||||
assert_eq!((BALL_SUBTYPE, LEAGUE_LOGO_SUBTYPE), (30, 31));
|
||||
// A league logo is not ownable Core content, so it maps to no kind.
|
||||
assert_eq!(club_item_kind(LEAGUE_LOGO_SUBTYPE), None);
|
||||
// Trophies (0x91..0x96) are NOT club items, and staff/consumable
|
||||
// subtypes must never be mistaken for one.
|
||||
for s in [0, 4, 8, 0x91, 0x96, 201, 231] {
|
||||
assert_eq!(club_item_kind(s), None, "subtype {s} is not a club item");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_groups_follow_the_clients_own_ladder() {
|
||||
// Ids are the client's `pos` table; groups are its 0/1..8/9..19/20..27
|
||||
// recompute. GK folded into DEF is the one named guess.
|
||||
for p in ["GK", "CB", "LB", "RB", "LWB", "RWB"] {
|
||||
assert_eq!(position_group(p), Some(PositionGroup::Defender), "{p}");
|
||||
}
|
||||
for p in ["CDM", "CM", "CAM", "LM", "RM"] {
|
||||
assert_eq!(position_group(p), Some(PositionGroup::Midfielder), "{p}");
|
||||
}
|
||||
for p in ["RF", "CF", "LF", "RW", "ST", "LW"] {
|
||||
assert_eq!(position_group(p), Some(PositionGroup::Forward), "{p}");
|
||||
}
|
||||
assert_eq!(position_id("ST"), Some(25));
|
||||
assert_eq!(position_id("CDM"), Some(10));
|
||||
// Not in the client's table → no tab, never an invented one.
|
||||
for p in ["", "SW", "st", "MID", "SUB"] {
|
||||
assert_eq!(position_group(p), None, "{p:?}");
|
||||
assert_eq!(position_id(p), None, "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_family_range_boundaries() {
|
||||
// Each contiguous range: lower boundary, upper boundary, family + label.
|
||||
|
||||
@@ -24,8 +24,11 @@
|
||||
|
||||
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::item_state;
|
||||
|
||||
/// One owned item in game-independent terms, as read from Core's inventory.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -101,6 +104,85 @@ pub struct Fifa17StaffIdentity {
|
||||
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
|
||||
/// "no real FIFA asset id known" → the caller must not fabricate one.
|
||||
pub trait ItemIdentityResolver {
|
||||
@@ -119,7 +201,21 @@ pub trait ItemIdentityResolver {
|
||||
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
|
||||
/// catalog-backed resolver overrides this to consult its `kind_of`, letting
|
||||
/// `/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)]
|
||||
pub struct ShapeStats {
|
||||
pub emitted: usize,
|
||||
/// No real FIFA asset id for this definition — dropped, never faked.
|
||||
pub dropped_no_asset: usize,
|
||||
/// Consumable/staff items excluded from a player projection (they must never
|
||||
/// render as a 0-rated player). Counted, never emitted.
|
||||
/// The definition resolved but is INCOMPLETE or self-contradictory, so
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -192,7 +297,7 @@ pub fn shape_item(
|
||||
"leagueId": league_id,
|
||||
"playStyle": 250,
|
||||
"attributeList": attribute_list,
|
||||
"itemState": "free",
|
||||
"itemState": item_state::FREE,
|
||||
"owners": 1,
|
||||
// Owned/pack-pulled cards are TRADEABLE in FIFA 17 (untradeable is the
|
||||
// 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.
|
||||
"itemType": "staff",
|
||||
"contract": contract,
|
||||
"itemState": "free",
|
||||
"itemState": item_state::FREE,
|
||||
"owners": 1,
|
||||
"untradeable": false,
|
||||
});
|
||||
@@ -280,6 +385,92 @@ pub fn shape_staff_item(id: Fifa17StaffIdentity, contract: i64) -> Value {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -403,4 +594,144 @@ mod tests {
|
||||
"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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,13 @@
|
||||
pub mod catalog;
|
||||
pub mod club_response;
|
||||
pub mod club_stats;
|
||||
pub mod consumables;
|
||||
pub mod content_taxonomy;
|
||||
pub mod economy;
|
||||
pub mod economy_policy;
|
||||
pub mod entities;
|
||||
pub mod item;
|
||||
pub mod item_state;
|
||||
pub mod match_wire;
|
||||
pub mod non_economy;
|
||||
pub mod owned_query;
|
||||
|
||||
+1
-1
Submodule openfut-core updated: bae0a2bdaa...36bc594924
@@ -70,6 +70,11 @@ pub struct GenericClub {
|
||||
pub struct GenericOwned {
|
||||
pub owned_item_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)]
|
||||
@@ -174,6 +179,7 @@ pub fn plan_apply(
|
||||
owned.push(GenericOwned {
|
||||
owned_item_id: core_id.clone(),
|
||||
card_id: def.card_id.clone(),
|
||||
content_kind: "player",
|
||||
});
|
||||
mappings.push(IdentityMapping {
|
||||
core_id,
|
||||
@@ -191,6 +197,7 @@ pub fn plan_apply(
|
||||
owned.push(GenericOwned {
|
||||
owned_item_id: core_id.clone(),
|
||||
card_id: def.card_id.clone(),
|
||||
content_kind: def.kind.as_str(),
|
||||
});
|
||||
mappings.push(IdentityMapping {
|
||||
core_id,
|
||||
|
||||
@@ -546,6 +546,21 @@ pub struct NonPlayerDefinition {
|
||||
pub card_asset_id: Option<i64>,
|
||||
/// Source team id for a kit definition, when present.
|
||||
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").
|
||||
pub name: String,
|
||||
/// 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 team_id = items[0].teamid;
|
||||
if items
|
||||
.iter()
|
||||
.any(|item| item.cardassetid != card_asset_id || item.teamid != team_id)
|
||||
{
|
||||
// These are definition-level, so every owned copy must agree. Two copies
|
||||
// of one consumable that disagreed would mean the field is really
|
||||
// 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 {
|
||||
resource_id,
|
||||
subtype: Some(subtype),
|
||||
@@ -700,6 +725,9 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
||||
subtype,
|
||||
card_asset_id,
|
||||
team_id,
|
||||
amount,
|
||||
contract,
|
||||
rating,
|
||||
name: label.to_string(),
|
||||
wire_ids,
|
||||
});
|
||||
@@ -1184,6 +1212,14 @@ pub fn emit_content(
|
||||
serde_json::json!({
|
||||
"card_asset_id": d.card_asset_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),
|
||||
"version": 0,
|
||||
"rareflag": 0,
|
||||
|
||||
@@ -70,7 +70,9 @@ pub struct Item {
|
||||
/// Consumable ART id (small id), distinct from `resourceId`. Permissive.
|
||||
#[serde(default)]
|
||||
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)]
|
||||
pub amount: Option<i64>,
|
||||
/// Staff/contract `contract` count. Permissive.
|
||||
|
||||
@@ -714,6 +714,58 @@ fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
|
||||
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]
|
||||
fn kit_missing_render_metadata_defers() {
|
||||
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_3000083"), "staff 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.
|
||||
let m = plan
|
||||
.mappings
|
||||
|
||||
+350
-28
@@ -44,20 +44,24 @@ pub mod market_store;
|
||||
pub mod pile_store;
|
||||
pub mod sold_experiment;
|
||||
|
||||
use parking_lot::Mutex as PlMutex;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use parking_lot::Mutex as PlMutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy};
|
||||
use openfut_adapter_fifa17::fut::club_response::{
|
||||
shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17Identity,
|
||||
Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver,
|
||||
shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17ConsumableIdentity,
|
||||
Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver,
|
||||
};
|
||||
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::item::CONSUMABLE_UNTRADEABLE;
|
||||
use openfut_adapter_fifa17::fut::match_wire;
|
||||
use openfut_adapter_fifa17::fut::non_economy;
|
||||
use openfut_adapter_fifa17::fut::owned_query::{
|
||||
@@ -144,6 +148,11 @@ pub enum Route {
|
||||
/// Core-accurately in Rust (player tiers, staff/consumable families, nation
|
||||
/// buckets). club/stats/staff stays a separate empty-set route.
|
||||
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`,
|
||||
/// `…/tfa`, `…/livemessage`, `…/activeMessage` — Rust-owned UNCONDITIONAL
|
||||
/// 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("settings") if get => Route::Settings,
|
||||
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(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("store") => 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
|
||||
/// without the endpoint projects no active kits rather than fabricating one.
|
||||
/// Ownership-backed active club designations (`GET /club/active-items`). A
|
||||
/// Core without the endpoint projects no active items rather than
|
||||
/// fabricating one.
|
||||
fn get_active_kits(&self) -> Result<CoreKitAssignments, CoreError> {
|
||||
Ok(CoreKitAssignments::default())
|
||||
}
|
||||
@@ -942,9 +956,13 @@ impl CoreAccess for HttpCoreClient {
|
||||
}
|
||||
|
||||
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
|
||||
.client
|
||||
.get(format!("{}/club/kits", self.base_url))
|
||||
.get(format!("{}/club/active-items", self.base_url))
|
||||
.header("X-OpenFUT-Game", &self.game)
|
||||
.send()
|
||||
.map_err(|error| CoreError::Http(error.to_string()))?;
|
||||
@@ -955,15 +973,17 @@ impl CoreAccess for HttpCoreClient {
|
||||
let body: Value = response
|
||||
.json()
|
||||
.map_err(|error| CoreError::Parse(error.to_string()))?;
|
||||
let slots = body.get("active_items").unwrap_or(&body);
|
||||
let owned_id = |slot: &str| {
|
||||
body.get(slot)
|
||||
slots
|
||||
.get(slot)
|
||||
.and_then(|item| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
};
|
||||
Ok(CoreKitAssignments {
|
||||
home_owned_card_id: owned_id("home"),
|
||||
away_owned_card_id: owned_id("away"),
|
||||
home_owned_card_id: owned_id("home_kit"),
|
||||
away_owned_card_id: owned_id("away_kit"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1797,7 +1817,9 @@ impl ItemIdentityResolver for Fifa17IdentityResolver {
|
||||
|
||||
fn resolve_staff(&self, item: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
|
||||
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;
|
||||
}
|
||||
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
|
||||
/// retain the backward-compatible Player default but fail identity resolution.
|
||||
fn kind_of(&self, item: &CoreOwnedItem) -> ContentKind {
|
||||
@@ -1840,6 +1895,10 @@ pub struct ClubLog {
|
||||
pub total: i64,
|
||||
pub emitted: 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 limit: Option<i64>,
|
||||
}
|
||||
@@ -1856,6 +1915,168 @@ pub struct ClubDeps<'a> {
|
||||
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
|
||||
/// the filtered set locally. Returns `(page, total_specials)`. Pure — the whole
|
||||
/// 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).
|
||||
pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) {
|
||||
let raw = parse_club_query(query);
|
||||
let requested_kind = match raw.item_type.as_deref() {
|
||||
None | Some("player") => ContentKind::Player,
|
||||
Some("kit") => ContentKind::Kit,
|
||||
// The STAFF tab is the only staff request ever observed on the wire, and
|
||||
// it asked with `type=manager`. `type=staff` is accepted as the obvious
|
||||
// sibling token rather than betting the tab never sends it: both mean the
|
||||
// same owned set here, because managers and coaches are one content kind
|
||||
// (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) => {
|
||||
let filter_arm = match club_type_filter(raw.item_type.as_deref()) {
|
||||
Some(f) => f,
|
||||
// A token outside the client's own 30-arm taxonomy. Empty is the honest
|
||||
// answer AND the loud one: the log names the token so a new wire fact is
|
||||
// actionable instead of silently mapped onto the player set.
|
||||
None => {
|
||||
let other = raw.item_type.as_deref().unwrap_or("");
|
||||
return (
|
||||
json_response(&json!({ "itemData": [] })),
|
||||
ClubLog {
|
||||
@@ -1931,12 +2148,29 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
|
||||
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),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
// 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) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
@@ -1948,6 +2182,7 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
|
||||
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),
|
||||
},
|
||||
@@ -1962,7 +2197,7 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
|
||||
if !filter.is_empty() {
|
||||
filter.push(',');
|
||||
}
|
||||
filter.push_str(&format!("type={}", requested_kind.as_str()));
|
||||
filter.push_str(&format!("type={}", filter_arm.label));
|
||||
if core_q.special {
|
||||
filter.push_str(",rare=SP");
|
||||
}
|
||||
@@ -1978,7 +2213,13 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
|
||||
.items
|
||||
.into_iter()
|
||||
.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();
|
||||
let active = ActiveKitAssignments {
|
||||
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,
|
||||
emitted,
|
||||
dropped_no_asset: stats.dropped_no_asset,
|
||||
dropped_incomplete: stats.dropped_incomplete,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
@@ -2020,6 +2262,7 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
|
||||
total: 0,
|
||||
emitted: 0,
|
||||
dropped_no_asset: 0,
|
||||
dropped_incomplete: 0,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
@@ -3627,8 +3870,16 @@ impl Server {
|
||||
};
|
||||
let (resp, log) = handle_club(query, &deps);
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=club status={} outcome={} filter=[{}] total={} emitted={} dropped_no_asset={} offset={:?} limit={:?}",
|
||||
resp.status, log.outcome, log.filter, log.total, log.emitted, log.dropped_no_asset, log.offset, log.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.dropped_incomplete,
|
||||
log.offset,
|
||||
log.limit
|
||||
);
|
||||
resp
|
||||
}
|
||||
@@ -3690,6 +3941,7 @@ impl Server {
|
||||
}
|
||||
Route::Hub => self.handle_hub(),
|
||||
Route::ClubStats => self.handle_club_stats(path),
|
||||
Route::ClubConsumables => self.handle_club_consumables(path),
|
||||
Route::StaticAck => self.handle_static_ack(path),
|
||||
Route::WatchList => self.handle_watchlist(method),
|
||||
Route::User => self.handle_user(),
|
||||
@@ -4375,6 +4627,76 @@ impl Server {
|
||||
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).
|
||||
pub fn serve(&self, addr: &str) -> std::io::Result<()> {
|
||||
let listener = TcpListener::bind(addr)?;
|
||||
|
||||
@@ -28,6 +28,7 @@ use serde_json::{json, Value};
|
||||
|
||||
use openfut_adapter_fifa17::fut::entities::ReverseEntityResolver;
|
||||
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::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
|
||||
/// 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
|
||||
/// 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
|
||||
@@ -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
|
||||
/// is handed an unrecognised `CARD_OFFERSTATE`. Where the binary contradicts the
|
||||
/// oracle, the binary wins.
|
||||
fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
||||
auction_record_tuned(l, item_state, None, 0)
|
||||
fn auction_record_as(l: &Listing, state: &str) -> Value {
|
||||
auction_record_tuned(l, state, None, 0)
|
||||
}
|
||||
|
||||
/// [`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.
|
||||
fn auction_record_tuned(
|
||||
l: &Listing,
|
||||
item_state: &str,
|
||||
state: &str,
|
||||
sold_bid_state: Option<&str>,
|
||||
coins_processed: i64,
|
||||
) -> Value {
|
||||
@@ -152,7 +153,7 @@ fn auction_record_tuned(
|
||||
.map(|mut card| {
|
||||
// Keep the wire identity and presentation state authoritative here.
|
||||
card["id"] = json!(item_id);
|
||||
card["itemState"] = json!(item_state);
|
||||
card["itemState"] = json!(state);
|
||||
card["untradeable"] = json!(false);
|
||||
card
|
||||
})
|
||||
@@ -160,7 +161,7 @@ fn auction_record_tuned(
|
||||
json!({
|
||||
"id": item_id,
|
||||
"resourceId": resource,
|
||||
"itemState": item_state,
|
||||
"itemState": state,
|
||||
"untradeable": false,
|
||||
})
|
||||
});
|
||||
@@ -204,9 +205,9 @@ fn auction_record_tuned(
|
||||
/// closed/sold echoes the buy path returns.
|
||||
fn auction_record(l: &Listing) -> Value {
|
||||
let state = if l.state == "active" {
|
||||
"forSale"
|
||||
item_state::FOR_SALE
|
||||
} else {
|
||||
"free"
|
||||
item_state::FREE
|
||||
};
|
||||
auction_record_as(l, state)
|
||||
}
|
||||
@@ -444,7 +445,7 @@ pub async fn handle_market_query(
|
||||
};
|
||||
let mut auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record_as(l, "forSale"))
|
||||
.map(|l| auction_record_as(l, item_state::FOR_SALE))
|
||||
.collect();
|
||||
// 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
|
||||
@@ -456,7 +457,7 @@ pub async fn handle_market_query(
|
||||
for l in &sold {
|
||||
auctions.push(auction_record_tuned(
|
||||
l,
|
||||
"forSale",
|
||||
item_state::FOR_SALE,
|
||||
exp.bid_state,
|
||||
exp.coins_processed,
|
||||
));
|
||||
@@ -607,7 +608,7 @@ pub async fn handle_market_status(
|
||||
};
|
||||
let auctions: Vec<Value> = listings
|
||||
.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();
|
||||
eprintln!(
|
||||
"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["bidState"] = json!("highest");
|
||||
rec["currentBid"] = json!(price);
|
||||
rec["itemData"]["itemState"] = json!("free");
|
||||
rec["itemData"]["itemState"] = json!(item_state::FREE);
|
||||
ok_json(&json!({ "auctionInfo": [rec], "credits": new_balance }))
|
||||
}
|
||||
// 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 rec = parse(&pile)["auctionInfo"][0].clone();
|
||||
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"]["id"], 100004617i64);
|
||||
assert_eq!(rec["itemData"]["resourceId"], 169193);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -758,6 +758,53 @@ CLIENT_MIN_SQUAD = 18
|
||||
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:
|
||||
"""Top the squad up to the client's minimum with the club's best spare
|
||||
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"]
|
||||
# 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_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
|
||||
]
|
||||
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:
|
||||
owned = (
|
||||
[(item, SELLER_CLUB, card, TS) for item, card in SELLER_SQUAD_CARDS]
|
||||
+ [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, TS)]
|
||||
[(item, SELLER_CLUB, card, "player", TS) for item, card in SELLER_SQUAD_CARDS]
|
||||
+ [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, "player", TS)]
|
||||
+ owned
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, "
|
||||
"acquired_at) VALUES (?, ?, ?, 0, ?)",
|
||||
"content_kind, acquired_at) VALUES (?, ?, ?, 0, ?, ?)",
|
||||
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(
|
||||
"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 (?, ?, ?, ?)",
|
||||
[
|
||||
(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
|
||||
],
|
||||
)
|
||||
@@ -928,6 +987,10 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None:
|
||||
if real_club is not None
|
||||
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:
|
||||
conn.close()
|
||||
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} "
|
||||
"(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:
|
||||
@@ -1148,7 +1217,7 @@ def host_banner_line(lay: Layout) -> str:
|
||||
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)
|
||||
ok(f"host banner: {line}")
|
||||
if variant == "off":
|
||||
@@ -1167,6 +1236,26 @@ def verify(lay: Layout, variant: str) -> None:
|
||||
status, body = http_get(CORE_PORT, "/health")
|
||||
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.
|
||||
leaked = []
|
||||
for fd in os.listdir(f"/proc/{os.getpid()}/fd"):
|
||||
@@ -1395,7 +1484,7 @@ def main() -> int:
|
||||
ok(f"manifest written: {lay.manifest}")
|
||||
|
||||
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,
|
||||
args.roster_host, records, real_club)
|
||||
|
||||
Reference in New Issue
Block a user