aa2abc2772
Four defects found by driving a real FIFA 17 client. Each was independently
sufficient to break listing, so all four had to go:
1. Every owned card was shaped `untradeable: true` (adapter item.rs), so the
client greyed out "Place/List on Transfer Market" for the whole club. Owned
and pack-pulled cards are TRADEABLE in FIFA 17; the oracle forces this off
for owned copies too (item_def keeps `true`; instances do not).
2. `POST /auctionhouse` required `itemData.resourceId`, which the client's
FutISStart body never sends (the oracle lists by wire id ALONE). Missing it,
the handler fail-closed and returned 200 while persisting NOTHING. It now
resolves server-side: wire id -> Core owned instance -> its card_id (minted on
a synthetic buy) + FIFA resourceId (the auction record). This also enforces
that a listing can only name a card the club actually owns.
3. An auction record's `itemData` was a 4-field STUB, so the Transfer List had a
row the client could not draw -> "1 item listed" but no visible sale. A
listing now persists a full shaped-card SNAPSHOT (new `listings.item_json`,
additive migration) built by the same `shape_item` shaper `/club` and the
squad projection use, so the auction card renders identically to the club
card. The seller's own pile stamps `itemState: listFS`; market search keeps
`forSale` (the oracle distinguishes these).
4. `/tradePile/counts` shared a handler with `/tradePile`. They are DIFFERENT
deserializers: `/counts` is FutGetAuctionCount, five scalar ints
(count/maxAuctionsAllowed/offered/selling/sold) that it reads and skips
everything else. Served the `auctionInfo` body it left every count at 0, so
the Transfer List screen showed no active sale while the hub tile showed one.
New Route::MarketCounts, classified BEFORE the base tradePile matcher (which
also accepts the /counts path).
Also: a listed card no longer appears in the club. `/club` and the hub's
`clubPlayers` now exclude the transfer pile. Pile membership is host-owned state
Core cannot filter on, so when anything is hidden `/club` reuses the existing
local-filter path (the one `rare=SP` already needed) and paginates the
club-visible set -- letting Core paginate would return short pages. With nothing
hidden the fast Core-paginated path is untouched, and only an EXPLICIT non-club
pile hides a card, so no-pile-row items still default to the club.
Fixed 5 pre-existing test fixtures across 4 targets that listed FABRICATED wire
ids -- only "valid" because the old handler skipped the ownership check.
Tests: 14 targets green + clippy clean, incl. new coverage for the 5-int tally
(asserting it must NOT carry auctionInfo), the full-card snapshot + listFS, and
club pile-exclusion with full-width pagination. The differential test against the
live Python oracle passes.
Verified live on prod: listed=true with a 21-field snapshot; counts
{count:1,selling:1,maxAuctionsAllowed:100}; tradePile renders the 94-rated card;
clubPlayers 1966 -> 1961 (exactly the 5 trade-pile items); listed wire absent
from the club page. Operator confirmed the card is visible in the Transfer List.
285 lines
11 KiB
Rust
285 lines
11 KiB
Rust
//! The shared FIFA 17 FUT **item-shaping primitive**.
|
|
//!
|
|
//! One function shapes one owned FUT item into the numeric card object the FIFA
|
|
//! 17 client renders, and **every** route that emits a player item goes through
|
|
//! it — `/club` (via [`crate::fut::club_response`]) and squad projection (via
|
|
//! [`crate::fut::squad_projection`]) alike. There is deliberately no second copy
|
|
//! of the field set: an item is shaped in exactly one place so the two routes
|
|
//! can never drift.
|
|
//!
|
|
//! ## The asset-id boundary (load-bearing, evidence-grounded)
|
|
//!
|
|
//! FIFA renders a card by resolving `resourceId & 0xffffff` against the client's
|
|
//! OWN local players table (proven live): a real id renders a real footballer,
|
|
//! an **invented id renders a blank generic card**. OpenFUT Core's catalogue is
|
|
//! synthetic string ids (`card_pl_001`) with no FIFA asset id. So an
|
|
//! [`ItemIdentityResolver`] is injected; when it cannot supply a **real** FIFA
|
|
//! asset id for an item, the item carries no fabricated identity — the caller
|
|
//! decides what that means (`/club` drops and counts it; a squad refuses to
|
|
//! project a starter it cannot render, never faking one).
|
|
//!
|
|
//! Entity ids (`leagueId`/`teamid`/`nation`) come from a reverse resolver; an
|
|
//! unresolved name yields a neutral `0` (a valid int — non-fatal; it only means
|
|
//! "no badge/flag"), because those are not the identity the renderer keys on.
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
use crate::fut::content_taxonomy::ContentKind;
|
|
use crate::fut::entities::ReverseEntityResolver;
|
|
|
|
/// One owned item in game-independent terms, as read from Core's inventory.
|
|
#[derive(Debug, Clone)]
|
|
pub struct CoreOwnedItem {
|
|
/// Core owned-instance id (string). The stable per-copy identity — two
|
|
/// copies of the same card definition have distinct `owned_card_id`s.
|
|
pub owned_card_id: String,
|
|
/// Core card-definition id (string), used for asset resolution.
|
|
pub card_id: String,
|
|
/// Effective overall rating.
|
|
pub rating: u8,
|
|
/// Effective position, e.g. "ST".
|
|
pub position: String,
|
|
pub nation: String,
|
|
pub league: String,
|
|
pub club: String,
|
|
/// [pace, shooting, passing, dribbling, defending, physical].
|
|
pub attributes: [u8; 6],
|
|
}
|
|
|
|
/// The FIFA-side numeric identity of an owned item. `asset_id` MUST be a real
|
|
/// FIFA player asset (low 24 bits the client resolves); `item_id` is the wire
|
|
/// instance id used for later item operations. Two owned copies of the same
|
|
/// definition share an `asset_id` but MUST have distinct `item_id`s.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Fifa17Identity {
|
|
pub item_id: u32,
|
|
/// Base FIFA player asset (low 24 bits the client resolves art/name from).
|
|
pub asset_id: u32,
|
|
/// Full versioned resource id = `(version << 24) | asset_id`. Equals
|
|
/// `asset_id` for a version-0 base card. This is the wire
|
|
/// `resourceId`/`definitionId`, kept DISTINCT from `asset_id` so a versioned
|
|
/// (special) card never collapses onto its base on the wire.
|
|
pub resource_id: u32,
|
|
/// FIFA wire `rareflag` — the card's rare/special TYPE (e.g. 3=inform,
|
|
/// 21..=24 = special programmes). Drives the client's special-card art;
|
|
/// carried from the catalog, never hardcoded, so specials render as specials.
|
|
pub rareflag: i64,
|
|
}
|
|
|
|
/// 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 {
|
|
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
|
|
|
|
/// Classify a Core item's definition as player/consumable/staff. 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
|
|
/// 0-rated player).
|
|
fn kind_of(&self, _item: &CoreOwnedItem) -> ContentKind {
|
|
ContentKind::Player
|
|
}
|
|
}
|
|
|
|
/// Diagnostics from shaping (safe to log — counts only).
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
|
pub struct ShapeStats {
|
|
pub emitted: usize,
|
|
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.
|
|
pub excluded_non_player: usize,
|
|
}
|
|
|
|
/// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table;
|
|
/// non-fatal display field).
|
|
fn discard_value(rating: u8) -> i64 {
|
|
match rating {
|
|
r if r >= 85 => 1500,
|
|
r if r >= 80 => 900,
|
|
r if r >= 75 => 600,
|
|
r if r >= 65 => 300,
|
|
_ => 150,
|
|
}
|
|
}
|
|
|
|
/// Build one FIFA `_item` object. `resourceId`/`definitionId` carry the full
|
|
/// versioned resource id; `assetId`/`cardassetid` carry the base asset. For a
|
|
/// version-0 base card these coincide; for a special they differ and MUST NOT
|
|
/// be collapsed.
|
|
///
|
|
/// This is the single source of truth for a player item's on-wire shape; the
|
|
/// `/club` envelope and squad projection both call it, so their items are
|
|
/// identical by construction. `id` is the owned instance's resolved FIFA
|
|
/// identity — pass the resolver's answer for *this* owned copy so two copies of
|
|
/// one definition stay distinct on the wire.
|
|
pub fn shape_item(
|
|
item: &CoreOwnedItem,
|
|
id: Fifa17Identity,
|
|
ent: &impl ReverseEntityResolver,
|
|
) -> Value {
|
|
let asset = id.asset_id;
|
|
let league_id = ent.league_id(&item.league).unwrap_or(0);
|
|
let team_id = ent.team_id(&item.club).unwrap_or(0);
|
|
let nation_id = ent.nation_id(&item.nation).unwrap_or(0);
|
|
let attribute_list: Vec<Value> = item
|
|
.attributes
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, v)| json!({ "index": i, "value": v }))
|
|
.collect();
|
|
json!({
|
|
"id": id.item_id,
|
|
"resourceId": id.resource_id,
|
|
"assetId": asset,
|
|
"cardassetid": asset,
|
|
"definitionId": id.resource_id,
|
|
"cardsubtypeid": 0,
|
|
"itemType": "player",
|
|
"rareflag": id.rareflag,
|
|
"rating": item.rating,
|
|
"preferredPosition": item.position,
|
|
"nation": nation_id,
|
|
"teamid": team_id,
|
|
"leagueId": league_id,
|
|
"playStyle": 250,
|
|
"attributeList": attribute_list,
|
|
"itemState": "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
|
|
// `true` greyed out "Place on Transfer Market" for every card — the same
|
|
// "our own data showing through" bug the Python oracle fixed by forcing
|
|
// this off for owned copies (item_def keeps `true`; instances do not).
|
|
"untradeable": false,
|
|
"contract": 7,
|
|
"fitness": 99,
|
|
"discardValue": discard_value(item.rating),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::fut::entities::Fifa17Entities;
|
|
use std::collections::HashMap;
|
|
|
|
fn entities() -> Fifa17Entities {
|
|
Fifa17Entities::from_maps(
|
|
HashMap::from([(13, "Premier League".to_string())]),
|
|
HashMap::from([(52, "Argentina".to_string())]),
|
|
HashMap::from([(5, "Chelsea".to_string())]),
|
|
)
|
|
}
|
|
|
|
fn item(owned: &str, card: &str, rating: u8, pos: &str) -> CoreOwnedItem {
|
|
CoreOwnedItem {
|
|
owned_card_id: owned.into(),
|
|
card_id: card.into(),
|
|
rating,
|
|
position: pos.into(),
|
|
nation: "Argentina".into(),
|
|
league: "Premier League".into(),
|
|
club: "Chelsea".into(),
|
|
attributes: [90, 88, 70, 85, 40, 78],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn shapes_real_identity_and_reverse_entity_ids() {
|
|
let ent = entities();
|
|
let it = shape_item(
|
|
&item("oc1", "card_ch_1", 86, "CDM"),
|
|
Fifa17Identity {
|
|
item_id: 100000001,
|
|
asset_id: 20801,
|
|
resource_id: 20801,
|
|
rareflag: 1,
|
|
},
|
|
&ent,
|
|
);
|
|
assert_eq!(it["id"], 100000001, "wire instance id");
|
|
assert_eq!(it["resourceId"], 20801);
|
|
assert_eq!(it["assetId"], 20801);
|
|
assert_eq!(
|
|
it["definitionId"], 20801,
|
|
"version byte 0 => resourceId==assetId==definitionId"
|
|
);
|
|
assert_eq!(it["rating"], 86);
|
|
assert_eq!(it["preferredPosition"], "CDM");
|
|
assert_eq!(it["leagueId"], 13);
|
|
assert_eq!(it["teamid"], 5);
|
|
assert_eq!(it["nation"], 52);
|
|
assert_eq!(it["itemType"], "player");
|
|
assert_eq!(it["attributeList"].as_array().unwrap().len(), 6);
|
|
assert_eq!(it["attributeList"][0], json!({"index":0,"value":90}));
|
|
}
|
|
|
|
#[test]
|
|
fn two_owned_copies_of_one_definition_stay_distinct_on_the_wire() {
|
|
// fifa17_101490 has two owned instances: same definition/asset, two
|
|
// distinct owned ids and two distinct wire ids. Shaping each from its
|
|
// own identity must NEVER collapse them.
|
|
let ent = entities();
|
|
let a = shape_item(
|
|
&item("oc-a", "fifa17_101490", 84, "ST"),
|
|
Fifa17Identity {
|
|
item_id: 100000030,
|
|
asset_id: 101490,
|
|
resource_id: 101490,
|
|
rareflag: 1,
|
|
},
|
|
&ent,
|
|
);
|
|
let b = shape_item(
|
|
&item("oc-b", "fifa17_101490", 84, "ST"),
|
|
Fifa17Identity {
|
|
item_id: 100000031,
|
|
asset_id: 101490,
|
|
resource_id: 101490,
|
|
rareflag: 1,
|
|
},
|
|
&ent,
|
|
);
|
|
assert_eq!(
|
|
a["resourceId"], b["resourceId"],
|
|
"same definition => same asset"
|
|
);
|
|
assert_ne!(
|
|
a["id"], b["id"],
|
|
"distinct owned copies keep distinct wire ids"
|
|
);
|
|
assert_eq!(a["id"], 100000030);
|
|
assert_eq!(b["id"], 100000031);
|
|
}
|
|
|
|
#[test]
|
|
fn versioned_special_keeps_resourceid_distinct_from_assetid() {
|
|
// A versioned (special) card: resourceId/definitionId carry the full
|
|
// versioned id; assetId/cardassetid stay the base asset. They MUST NOT
|
|
// collapse. (resource 117617092 = version 7 of asset 176580.)
|
|
let ent = entities();
|
|
let it = shape_item(
|
|
&item("oc-v", "fifa17_117617092", 92, "ST"),
|
|
Fifa17Identity {
|
|
item_id: 100000384,
|
|
asset_id: 176580,
|
|
resource_id: 117617092,
|
|
rareflag: 3,
|
|
},
|
|
&ent,
|
|
);
|
|
assert_eq!(
|
|
it["resourceId"], 117617092,
|
|
"versioned resource id on the wire"
|
|
);
|
|
assert_eq!(it["definitionId"], 117617092);
|
|
assert_eq!(it["assetId"], 176580, "base asset id preserved");
|
|
assert_eq!(it["cardassetid"], 176580);
|
|
assert_eq!(
|
|
it["rareflag"], 3,
|
|
"special rareflag carried, not hardcoded 1"
|
|
);
|
|
}
|
|
}
|