Files
OpenFUT/openfut-utas-host/src/clientdata_store.rs
T
funman300 f9ca901a50 market: stop advertising unlisted pile members as tradeState:"inactive"
RE of the FUT front-end closed the question the Actions-panel investigation left
open, and the answer retracts Q2 rather than completing it.

`tradeState` reaches exactly ONE native branch in CardsDLL — `cmp …,0x4` at
`0x18013e619`, "is it closed?" — and `inactive`(2) and `expired`(3) take the same
edge, producing bit-identical `flagA`/`flagB` (exhaustive 22-site census of
`[reg+0x88]` reads across the PE; confirmed live, both classes read glow=0
inbox=0). The value is then handed to the movie verbatim as the Flash property
`STATE`, and the action gate lives in the APT/ActionScript FUT front-end: the
trade-pile class partitions rows with `getCardsInAuction`/`isInActiveAuction`
(traces `initPile() - IN AUCTION:` / `- NOT IN AUCTION:`) and only auction rows
reach `PreCheckCardOptions` -> `handleTradeCardAction`. A non-auction row renders
and can never be acted on, which is exactly what the operator saw.

So the rows were never usable. "LIVE-CONFIRMED" established that they RENDER,
which is not the same claim, and I treated it as if it were.

The corpus said this before any of it was built —
`plan-2026-08-06-transfer-market.md:731-733`: "`inactive` decodes but no client
path treats it specially; do not emit it." The earlier note explaining that the
warning "was written about the PRESENTATION function" was motivated reasoning.
This also fires the corpus's own pre-registered falsifier E3 (:368-373).

Removed: the `inactive` projection from `GET …/tradePile` and `…/trade/status`,
`UnlistedCandidate`, `resolve_unlisted_pile`, `unlisted_record`,
`Server::resolve_trade_pile`, and the two helpers that existed only to feed them
(`MarketStore::blocking_core_items`, `Fifa17IdentityResolver::wire_for_owned_id`).
Unlisted trade-pile membership is now internal state with no wire expression.

Nothing is stranded: `/club` excludes only items with an ACTIVE listing, so an
unlisted pile member stays visible in the club, which is where the client can act
on it. Verified live after deploy — `/tradePile` total 7 -> 1 with zero `inactive`
rows, `/trade/status` resolving only the real auction, coins unchanged at
29,843,976, and all six former rows present in `/club` (1965 items).

Tests: 126 pass, fmt + clippy clean. Two guards replace the three tests that
pinned the old behaviour: `the_trade_pile_advertises_only_real_auctions` and
`trade_status_answers_only_about_real_auctions`.

NOT fixed here, deliberately: `itemData.itemState: "listFS"` is not a FIFA 17
token (0 occurrences in CardsDLL md5 4de3493131d7d2ff7f8b360c5ac9b655, 0 in
4.26 GiB of process memory, decodes to -1; the real value is `forSale` = 5, and
the Python oracle emits `listFS` too — which is why the differential never caught
it). `CARD_OFFERSTATE` is one of three unresolved action-gate candidates and
every actionable row observed carried -1, so that change ships alone with its own
live A/B.
2026-08-17 23:14:35 +00:00

106 lines
3.6 KiB
Rust

//! Durable FIFA 17 **client-data blob** store (`clientdata` / `userHubData`).
//!
//! FIFA persists opaque per-user client blobs via `PUT/POST …/clientdata/<key>`
//! and reads them back via `GET …/clientdata/<key>`. The blobs are entirely
//! client-defined (UI/hub state) — the server only round-trips them and never
//! interprets their contents. This store keeps them in memory keyed by
//! `<persona>:<key>` and persists the whole map to a JSON file on every write, so
//! the client's saved state survives a host restart.
//!
//! The blobs are non-authoritative client presentation state (NOT economy or
//! ownership), so a missing/unreadable backing file starts empty rather than
//! being a hard failure.
use std::collections::HashMap;
use std::path::PathBuf;
use parking_lot::Mutex;
use serde_json::Value;
/// In-memory client-data blobs, persisted to a JSON file on write.
pub struct ClientDataStore {
path: PathBuf,
map: Mutex<HashMap<String, Value>>,
}
impl ClientDataStore {
/// Open the store, loading any previously-persisted blobs. A missing or
/// unreadable file starts empty.
pub fn open(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let map = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice::<HashMap<String, Value>>(&b).ok())
.unwrap_or_default();
ClientDataStore {
path,
map: Mutex::new(map),
}
}
fn compound_key(persona: i64, key: &str) -> String {
format!("{persona}:{key}")
}
/// The stored blob for `<persona>:<key>`, or `None` if never written.
pub fn get(&self, persona: i64, key: &str) -> Option<Value> {
self.map
.lock()
.get(&Self::compound_key(persona, key))
.cloned()
}
/// Store `value` under `<persona>:<key>` and persist the whole map to disk.
/// The serialized snapshot is taken under the lock; the file write happens
/// after the lock is released.
pub fn put(&self, persona: i64, key: &str, value: Value) {
let snapshot = {
let mut map = self.map.lock();
map.insert(Self::compound_key(persona, key), value);
serde_json::to_vec(&*map).unwrap_or_default()
};
if let Some(parent) = self.path.parent() {
if !parent.as_os_str().is_empty() {
let _ = std::fs::create_dir_all(parent);
}
}
let _ = std::fs::write(&self.path, snapshot);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn temp_path(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"openfut-clientdata-test-{}-{}.json",
std::process::id(),
tag
))
}
#[test]
fn round_trips_and_persists_across_reopen() {
let path = temp_path("roundtrip");
let _ = std::fs::remove_file(&path);
let store = ClientDataStore::open(&path);
assert_eq!(store.get(33_068_179, "userHubData"), None);
store.put(33_068_179, "userHubData", json!({"tiles": [1, 2, 3]}));
assert_eq!(
store.get(33_068_179, "userHubData"),
Some(json!({"tiles": [1, 2, 3]}))
);
// A different persona under the same key is isolated.
assert_eq!(store.get(1, "userHubData"), None);
// Reopening reads the persisted blob back.
let reopened = ClientDataStore::open(&path);
assert_eq!(
reopened.get(33_068_179, "userHubData"),
Some(json!({"tiles": [1, 2, 3]}))
);
let _ = std::fs::remove_file(&path);
}
}