diff --git a/openfut-adapter-fifa17/src/fut/non_economy.rs b/openfut-adapter-fifa17/src/fut/non_economy.rs index 9bbc589..9e457c4 100644 --- a/openfut-adapter-fifa17/src/fut/non_economy.rs +++ b/openfut-adapter-fifa17/src/fut/non_economy.rs @@ -232,6 +232,17 @@ pub struct AccountSyncRequest { pub account_funds_cap: i64, } +/// The FIFA persona display name for this emulator's single account. +/// +/// The oracle sources this from the shared account (`fut_account.py`, default +/// `"CAGE"`) and documents the property as "Blaze PDTL.DSNM / LSX +/// GetProfileResponse Persona / **UTAS sellerName**". That last role is +/// load-bearing: the client decides whether a transfer-market listing is the +/// player's OWN — and therefore whether to offer Remove / Re-list at all — from +/// the seller identity on the auction record. Stamping EA's house name there +/// makes the player's own listing un-actionable (pressing it opens no dialog). +pub const PERSONA_DISPLAY_NAME: &str = "CAGE"; + /// Parse the `account/sync` request body, applying every default. `default_persona` /// is the host's configured persona id (used when `personaId` is absent). pub fn parse_account_sync(body: &[u8], default_persona: i64) -> AccountSyncRequest { @@ -240,7 +251,7 @@ pub fn parse_account_sync(body: &[u8], default_persona: i64) -> AccountSyncReque let persona_name = v .get("personaName") .and_then(Value::as_str) - .unwrap_or("CAGE") + .unwrap_or(PERSONA_DISPLAY_NAME) .to_string(); AccountSyncRequest { persona_id: int("personaId", default_persona), diff --git a/openfut-utas-host/src/market.rs b/openfut-utas-host/src/market.rs index 5290538..f4e516f 100644 --- a/openfut-utas-host/src/market.rs +++ b/openfut-utas-host/src/market.rs @@ -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::non_economy; use openfut_adapter_fifa17::fut::squad::SquadWireResolver; use crate::economy_store::OwnedItemLookup; @@ -120,7 +121,15 @@ fn auction_record_as(l: &Listing, item_state: &str) -> Value { "currentBid": current_bid, "bidState": bid_state, "expires": 3600, - "sellerName": l.owner.clone().unwrap_or_else(|| "EASFC".to_string()), + // Every listing in this store is the player's OWN (there is no NPC seller + // in a single-account emulator), so the seller defaults to the player's + // persona name exactly as the oracle stamps it. This is what lets the + // client offer Remove / Re-list on a transfer-pile row; EA's house name + // here silently makes the player's own listing un-actionable. + "sellerName": l + .owner + .clone() + .unwrap_or_else(|| non_economy::PERSONA_DISPLAY_NAME.to_string()), "sellerEstablished": 1, "watched": false, "coinsProcessed": 0, diff --git a/openfut-utas-host/tests/economy_differential.rs b/openfut-utas-host/tests/economy_differential.rs index fb4cf1f..ee94e93 100644 --- a/openfut-utas-host/tests/economy_differential.rs +++ b/openfut-utas-host/tests/economy_differential.rs @@ -92,6 +92,7 @@ use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog; use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_adapter_fifa17::fut::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID}; +use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME; use openfut_identity::JsonIdentityStore; use openfut_utas_host::async_bridge::AsyncBridge; use openfut_utas_host::market_store::MarketStore; @@ -891,6 +892,53 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { r_tp["auctionInfo"][0]["tradeState"], "active", "rust listing active" ); + // Compare the record FIELD-FOR-FIELD, not merely its length and trade state. + // The client reads the seller identity to decide whether a transfer-pile row is + // the player's OWN — and therefore whether Remove / Re-list exist at all — and + // a len+tradeState check is blind to that. A real client silently offered NO + // action on the player's own listing (pressing it opened no dialog) because we + // stamped EA's house name as the seller while the oracle stamps the persona. + let o_rec = &o_tp["auctionInfo"][0]; + let r_rec = &r_tp["auctionInfo"][0]; + let keys = |v: &Value| { + let mut k: Vec = v + .as_object() + .expect("auction record is an object") + .keys() + .cloned() + .collect(); + k.sort(); + k + }; + assert_eq!( + keys(o_rec), + keys(r_rec), + "tradePile auction-record key set parity" + ); + for f in [ + "sellerName", + "bidState", + "currentBid", + "expires", + "sellerEstablished", + "watched", + "coinsProcessed", + ] { + assert_eq!(o_rec[f], r_rec[f], "tradePile record field `{f}` parity"); + } + assert_eq!( + r_rec["sellerName"], PERSONA_DISPLAY_NAME, + "the player's own listing is sold BY the player, never by EA" + ); + // itemData must be the full shaped card on both sides; a stub cannot render. + assert_eq!( + o_rec["itemData"]["itemState"], r_rec["itemData"]["itemState"], + "own-pile itemState parity (listFS)" + ); + assert!( + r_rec["itemData"]["rating"].is_i64() && r_rec["itemData"]["attributeList"].is_array(), + "rust tradePile itemData is the full card, not a stub" + ); matrix.push(("market query tradePile", "PARITY")); // ── OP 13: market buy (POST /trade/) — DIFFERENT-BY-DESIGN ─────────