fix(market): make the transfer market work end-to-end (live-verified)
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.
This commit is contained in:
@@ -423,20 +423,19 @@ fn case_c_dup_quicksell(h: &Harness) -> String {
|
||||
/// exactly one debit; exactly one mint.
|
||||
fn case_d_two_market_buyers(h: &Harness) -> String {
|
||||
let mut wins = 0u32;
|
||||
for i in 0..ITERS {
|
||||
for _ in 0..ITERS {
|
||||
// List a genuinely-owned card: the server resolves the Core card_id +
|
||||
// resourceId from inventory via the wire id (you can only list what you own).
|
||||
let (item_id, _core) = mint_one(h);
|
||||
set_balance(&h.client, 50_000);
|
||||
let item_id = 500_000 + i as i64; // unique listing per iteration
|
||||
let list = h
|
||||
.server
|
||||
.try_handle_economy(
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
&[],
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
||||
h.sample_resource
|
||||
)
|
||||
.as_bytes(),
|
||||
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||
.as_bytes(),
|
||||
None,
|
||||
)
|
||||
.expect("list routed");
|
||||
|
||||
@@ -407,7 +407,7 @@ fn pack_ids(pg: &Value) -> Vec<u64> {
|
||||
fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
wait_ready(core_base);
|
||||
let http = reqwest::blocking::Client::new();
|
||||
let (server, client, sample_resource) = build_econ_server(core_base, dir);
|
||||
let (server, client, _sample_resource) = build_econ_server(core_base, dir);
|
||||
|
||||
// ── Fixture alignment: both sides own exactly one pack-70 entitlement. ──
|
||||
// Oracle: fresh profile already owns pack 70. Core: grant the "70" entitlement
|
||||
@@ -847,7 +847,20 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
None,
|
||||
);
|
||||
let o_trade_id = o_list["id"].as_i64().expect("oracle trade id");
|
||||
let (r_ls, r_list) = rust(&server, "POST", "/ut/game/fifa17/auctionhouse", format!(r#"{{"itemData":{{"id":777,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"#).as_bytes(), None);
|
||||
// Same body shape as the oracle: the wire id ALONE (the server resolves the
|
||||
// owned card's card_id + resourceId from inventory). `r_wire[1]` is the card
|
||||
// moved to the trade pile in OP 9 — the Rust parallel of the oracle's o_wire[1].
|
||||
let (r_ls, r_list) = rust(
|
||||
&server,
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
||||
r_wire[1]
|
||||
)
|
||||
.as_bytes(),
|
||||
None,
|
||||
);
|
||||
let r_trade_id = r_list["id"].as_i64().expect("rust trade id");
|
||||
assert_eq!(o_ls, 200);
|
||||
assert_eq!(r_ls, 200, "market list status parity");
|
||||
@@ -992,7 +1005,19 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
"oracle cancelled listing gone from tradePile"
|
||||
);
|
||||
// Rust: list a fresh item, cancel it, then a buy is a 0-delta empty auction.
|
||||
let clist = rust(&server, "POST", "/ut/game/fifa17/auctionhouse", format!(r#"{{"itemData":{{"id":888,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"#).as_bytes(), None).1;
|
||||
// A still-owned card (r_wire[0]/[3] were quick-sold, [1] is listed above).
|
||||
let clist = rust(
|
||||
&server,
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
||||
r_wire[2]
|
||||
)
|
||||
.as_bytes(),
|
||||
None,
|
||||
)
|
||||
.1;
|
||||
let r_cancel_id = clist["id"].as_i64().unwrap();
|
||||
let (r_cs, _) = rust(
|
||||
&server,
|
||||
|
||||
@@ -257,7 +257,6 @@ struct FailHarness {
|
||||
bridge: Arc<AsyncBridge>,
|
||||
core: Arc<dyn CoreAccess>,
|
||||
entities: Arc<Fifa17Entities>,
|
||||
sample_resource: i64,
|
||||
}
|
||||
|
||||
fn catalog_from_core(core: &dyn CoreAccess) -> Fifa17CardCatalog {
|
||||
@@ -341,7 +340,6 @@ fn build_fail_harness(base: &str, dir: &std::path::Path) -> FailHarness {
|
||||
bridge,
|
||||
core,
|
||||
entities,
|
||||
sample_resource: 20000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,16 +674,15 @@ fn case_move_pile_failure(h: &FailHarness) -> String {
|
||||
|
||||
/// MARKET RESERVE failure → no debit, no grant, listing stays legal (active).
|
||||
fn case_market_reserve_failure(h: &FailHarness) -> String {
|
||||
// List a genuinely-owned card: the server resolves card_id + resourceId from
|
||||
// Core inventory via the wire id (you can only list what you own).
|
||||
let (item_id, _core) = h.mint_one();
|
||||
set_balance(&h.client, 50_000);
|
||||
let item_id = 700_001i64;
|
||||
let list = h.dispatch(
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
||||
h.sample_resource
|
||||
)
|
||||
.as_bytes(),
|
||||
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||
.as_bytes(),
|
||||
);
|
||||
let trade_id = bj(&list)["id"].as_i64().expect("trade id");
|
||||
let before = h.client.balance().unwrap();
|
||||
@@ -712,16 +709,13 @@ fn case_market_reserve_failure(h: &FailHarness) -> String {
|
||||
/// MARKET Core purchase_item failure AFTER reserve → reservation rolls back to
|
||||
/// active, no debit, no mint.
|
||||
fn case_market_purchase_failure(h: &FailHarness) -> String {
|
||||
let (item_id, _core) = h.mint_one();
|
||||
set_balance(&h.client, 50_000);
|
||||
let item_id = 700_002i64;
|
||||
let list = h.dispatch(
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
||||
h.sample_resource
|
||||
)
|
||||
.as_bytes(),
|
||||
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||
.as_bytes(),
|
||||
);
|
||||
let trade_id = bj(&list)["id"].as_i64().expect("trade id");
|
||||
let before = h.client.balance().unwrap();
|
||||
@@ -754,16 +748,13 @@ fn case_market_purchase_failure(h: &FailHarness) -> String {
|
||||
/// active), so no further `active -> reserved` CAS can succeed → not buyable,
|
||||
/// with exactly one debit + one mint. Returns ("SAFE"|"E3", detail).
|
||||
fn case_market_complete_sale_failure(h: &FailHarness) -> (String, String) {
|
||||
let (item_id, _core) = h.mint_one();
|
||||
set_balance(&h.client, 50_000);
|
||||
let item_id = 700_003i64;
|
||||
let list = h.dispatch(
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
||||
h.sample_resource
|
||||
)
|
||||
.as_bytes(),
|
||||
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||
.as_bytes(),
|
||||
);
|
||||
let trade_id = bj(&list)["id"].as_i64().expect("trade id");
|
||||
let mint_id = format!("market-buy:{trade_id}");
|
||||
|
||||
@@ -350,7 +350,7 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
wait_ready(base);
|
||||
// Core is seeded (start_core_seeded): a fifa17 profile with 100k coins + one
|
||||
// owned instance per definition. No /auth/local — the profile already exists.
|
||||
let (server, client, resolver, sample_resource) =
|
||||
let (server, client, resolver, _sample_resource) =
|
||||
build_econ_server(base, dir, "http://127.0.0.1:9");
|
||||
let start = client.balance().unwrap();
|
||||
assert!(start >= 5000, "seeded dev balance present ({start})");
|
||||
@@ -440,13 +440,17 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
|
||||
// 5) MARKET buy-now (async handlers via the bridge): list -> query -> buy ->
|
||||
// query -> second buy fails, exactly one debit + one sale.
|
||||
// List a still-owned minted card (items[0] was quick-sold, items[1] is moved
|
||||
// below). The body carries the wire id ALONE — the server resolves the owned
|
||||
// card's Core card_id + FIFA resourceId from inventory.
|
||||
let list_wire = items[2]["id"].as_i64().unwrap();
|
||||
let list = server
|
||||
.try_handle_economy(
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
&[],
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":777,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"#
|
||||
r#"{{"itemData":{{"id":{list_wire}}},"buyNowPrice":1000,"startingBid":500}}"#
|
||||
)
|
||||
.as_bytes(),
|
||||
None,
|
||||
@@ -504,13 +508,14 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
);
|
||||
|
||||
// 6) MARKET cancel: a cancelled listing cannot be bought.
|
||||
let cancel_wire = items[3]["id"].as_i64().unwrap();
|
||||
let clist = server
|
||||
.try_handle_economy(
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
&[],
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":888,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"#
|
||||
r#"{{"itemData":{{"id":{cancel_wire}}},"buyNowPrice":1000,"startingBid":500}}"#
|
||||
)
|
||||
.as_bytes(),
|
||||
None,
|
||||
@@ -796,6 +801,11 @@ fn exercise_from_config(base: &str, dir: &std::path::Path) -> i64 {
|
||||
)
|
||||
.expect("buy routed");
|
||||
assert_eq!(buy.status, 200);
|
||||
// A listing must name a card the club actually owns, so take one the BUY minted.
|
||||
let list_wire = serde_json::from_slice::<Value>(&buy.body).unwrap()["createPackResponse"]
|
||||
["itemList"][0]["id"]
|
||||
.as_i64()
|
||||
.expect("minted wire id");
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
start - 400,
|
||||
@@ -808,7 +818,8 @@ fn exercise_from_config(base: &str, dir: &std::path::Path) -> i64 {
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
&[],
|
||||
br#"{"itemData":{"id":555,"resourceId":20000},"buyNowPrice":1000,"startingBid":500}"#,
|
||||
format!(r#"{{"itemData":{{"id":{list_wire}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||
.as_bytes(),
|
||||
None,
|
||||
)
|
||||
.expect("list routed");
|
||||
|
||||
@@ -13,10 +13,10 @@ use openfut_adapter_fifa17::fut::club_response::{CoreOwnedItem, ItemIdentityReso
|
||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||
use openfut_identity::JsonIdentityStore;
|
||||
use openfut_utas_host::{
|
||||
classify, handle_put_squad, handle_squad_active, handle_squad_list, handle_user_mass_info,
|
||||
read_request, CoreAccess, CoreError, CoreExtState, CorePage, CoreReplaceRequest,
|
||||
CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient,
|
||||
PassClient, Route, Server, SquadDeps,
|
||||
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
||||
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, CorePage,
|
||||
CoreReplaceRequest, CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver,
|
||||
HttpCoreClient, PassClient, Route, Server, SquadDeps,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
@@ -279,6 +279,86 @@ fn build_server(
|
||||
)
|
||||
}
|
||||
|
||||
/// A real identity resolver over a `card_id -> asset_id` map (same construction
|
||||
/// `build_server` uses), for tests that call `handle_club` directly.
|
||||
fn resolver_for(cards: &[(&str, u32)]) -> Arc<Fifa17IdentityResolver> {
|
||||
let entries: Vec<String> = cards
|
||||
.iter()
|
||||
.map(|(id, asset)| format!("\"{id}\":{{\"asset_id\":{asset}}}"))
|
||||
.collect();
|
||||
let doc = format!(
|
||||
"{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{}}}}}",
|
||||
entries.join(",")
|
||||
);
|
||||
let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap();
|
||||
let store = JsonIdentityStore::open(unique_store_path()).unwrap();
|
||||
Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)))
|
||||
}
|
||||
|
||||
/// A card on the transfer list has LEFT the club: `/club` must not show it, and
|
||||
/// pagination must run over the CLUB-VISIBLE set (never Core's unfiltered page,
|
||||
/// which would hand back short pages).
|
||||
#[test]
|
||||
fn club_excludes_transfer_pile_items_and_paginates_the_visible_set() {
|
||||
let core = Arc::new(FakeCore::new(
|
||||
vec![
|
||||
item("oc1", "card_a", 86, "CDM", "Argentina", "Premier League", "Chelsea"),
|
||||
item("oc2", "card_b", 85, "ST", "Argentina", "Premier League", "Chelsea"),
|
||||
item("oc3", "card_c", 84, "CB", "Argentina", "Premier League", "Chelsea"),
|
||||
],
|
||||
3,
|
||||
));
|
||||
let resolver = resolver_for(&[("card_a", 20801), ("card_b", 20802), ("card_c", 20803)]);
|
||||
let ents = entities();
|
||||
|
||||
// oc2 is listed on the transfer market.
|
||||
let hidden: std::collections::HashSet<String> = ["oc2".to_string()].into_iter().collect();
|
||||
let deps = ClubDeps {
|
||||
core: core.as_ref(),
|
||||
entities: &ents,
|
||||
assets: resolver.as_ref(),
|
||||
hidden: &hidden,
|
||||
};
|
||||
let (resp, log) = handle_club("", &deps);
|
||||
let v: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
let assets: Vec<i64> = v["itemData"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|i| i["assetId"].as_i64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
assets,
|
||||
vec![20801, 20803],
|
||||
"the transfer-pile card is not in the club"
|
||||
);
|
||||
assert_eq!(log.total, 2, "total is the club-visible count");
|
||||
|
||||
// A page of 2 over a 2-item visible set is FULL — not short because a hidden
|
||||
// item consumed a slot.
|
||||
let (resp2, log2) = handle_club("count=2", &deps);
|
||||
let v2: Value = serde_json::from_slice(&resp2.body).unwrap();
|
||||
assert_eq!(
|
||||
v2["itemData"].as_array().unwrap().len(),
|
||||
2,
|
||||
"full-width page from the visible set"
|
||||
);
|
||||
assert_eq!(log2.total, 2);
|
||||
|
||||
// Nothing hidden → the fast Core-paginated path, all three visible.
|
||||
let none: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let deps_all = ClubDeps {
|
||||
core: core.as_ref(),
|
||||
entities: &ents,
|
||||
assets: resolver.as_ref(),
|
||||
hidden: &none,
|
||||
};
|
||||
let (resp3, log3) = handle_club("", &deps_all);
|
||||
let v3: Value = serde_json::from_slice(&resp3.body).unwrap();
|
||||
assert_eq!(v3["itemData"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(log3.total, 3);
|
||||
}
|
||||
|
||||
// ── /club served from Core ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user