Files
OpenFUT/openfut-utas-host
funman300 468bc0fba9 feat(market): isolated two-identity SOLD-row A/B harness (staging only, not promoted)
Static RE exhausted CardsDLL on the one open question: for a closed row
IS_GLOW = (bidState != none) and INBOX = (bidState in {highest, buyNow}), so
closed/highest and closed/buyNow are BIT-IDENTICAL natively. But bidState is
published to the movie verbatim as YOURBID, so the FUT ActionScript CAN separate
them. This builds the controlled experiment that asks the client which one it
treats as the seller's sale.

PRODUCTION SAFETY IS THE FIRST CONCERN
New module openfut-utas-host/src/sold_experiment.rs. Every knob is OFF unless its
env var is set, an unrecognised value is OFF rather than a default token (silently
picking one would fabricate the answer being measured), and the host logs a startup
banner naming the active variant so a staging capture can never be mistaken for a
production one. With no env set, /tradePile and /trade/status emit only real active
auctions (the Fix A invariant) and counts still report sold: 0. The entire existing
test suite now passes SoldExperiment::OFF explicitly, making it a regression guard.

  OPENFUT_FIFA17_SOLD_EXPERIMENT      = highest | buyNow   (else OFF)
  OPENFUT_FIFA17_SOLD_COINS_PROCESSED = 1                  (else 0)
  OPENFUT_FIFA17_SOLD_COUNT_MODE      = active_plus_sold    (else active)

WHAT THE EXPERIMENT PROJECTS
Uncleared sold listings appear in /tradePile and /trade/status as tradeState
"closed" with the token under test and currentBid = the sale price; counts report
the real sold tally. There is ONE record builder, so the A/B changes only what is
passed into it, and a test asserts that EXACTLY ONE field differs between the two
variants -- without that control the client's reaction is not attributable to the
token and the whole experiment is void. coinsProcessed (Flash COINS_AWARDED) varies
independently so the third pass cannot be confounded with the first.

CLEAR-SOLD, PE-PROVEN
New EconomyRoute::MarketClearSold for DELETE .../trade/sold, classified BEFORE the
generic trade cancel arm -- a `sold` tail carries no id, so the cancel handler would
have parsed nothing and acked while clearing nothing. Builder 0x1801647c0 emits
"/sold" when the tradeId field is zero and "/%lld" otherwise; the client calls it
RemoveAllSoldFromTradePile. New market-store column cleared_at records the seller's
acknowledgement SEPARATELY from the sale, so clearing can never be mistaken for
re-settling: it is presentation only, moves no coins and no ownership, and is
idempotent for client retries.

FOUND AND FIXED A LATENT STORE BUG
Adding a column via the additive ALTER path immediately after CREATE TABLE in the
same open() desynced sqlx's per-connection schema cache: a fresh store then read a
12-column row while metadata said 13, panicking a pool worker with an index
out-of-bounds and silently returning zero listings. Declaring cleared_at in
CREATE_LISTINGS fixes it; the ALTER now only serves pre-existing stores. This would
have bitten the next column too.

STAGING, WITHOUT TOUCHING PRODUCTION
The client learns the UTAS base from BLAZE (blaze_responder_v3b.py:646 hardcodes
:8099), and it dials that port directly, so redirecting UTAS means changing Blaze or
port 8099 -- both production. 10.10.0.121 is unreachable. The compliant path is a
parallel stack on spare ports plus a one-line change to the CLIENT's own config:
  * scripts/sold-staging-up.py / sold-staging-down.py -- staging Core 18081,
    utas-host 8299, Blaze 42327/42330/42331 advertising :8299, two seeded identities,
    own DBs under /home/alex/openfut-sold-staging/. Patches a COPY of the Blaze
    responder and asserts every substitution applied, so a silent no-op cannot leave
    it pointing at production. Kills only recorded pids whose cmdline contains the
    staging dir (openfut-utas-host matches BOTH, so pkill-by-pattern is banned).
  * docs/SOLD_STAGING_RUNBOOK.md -- the exact client change and its revert.
  * src/bin/staging_sell.rs -- the synthetic Buyer B, running the REAL settlement
    (CoreEconomy::settle_sale) then mark_sold. Settle-first ordering: a failure
    leaves the listing live with nothing moved. Refuses any path containing
    openfut-promotion or the production ports.
  * scripts/sold-wire-check.py -- proves the whole flow headless before any operator
    time is spent.

WIRE CHECK: 35/35 PASS on the canonical 150-coin sale. Seller 1,000 -> 1,143 (fee 7,
proceeds 143), buyer 20,000 -> 19,850, ownership transferred, exactly ONE
authoritative instance, economy shrank by exactly the fee. Sold row: closed,
currentBid 150, expires 0, twelve atoms, counts sold 1 / selling 0, /trade/status
agreeing. Variant B differs only in bidState and coinsProcessed. Clear: 200 {}, row
gone, counts.sold 0, no coins moved, buyer keeps the item, second clear a safe no-op.

Gates: 104 host lib tests (+9), all 7 host targets green, clippy clean, zero fmt
diffs in the new code. Settlement candidate unchanged. NOT PROMOTED.

Production untouched: prod-host pid 3631953 uptime 2h44m restarts=0, coins and
/tradePile unchanged, nothing under /home/alex/openfut-promotion/state/ opened.

The A/B itself is NOT yet run: it needs a real FIFA client, which is operator work.
2026-08-18 02:14:18 +00:00
..

openfut-utas-host

The first live FIFA 17 UTAS migration host. It fronts the client-visible UTAS port and migrates one route at a time to OpenFUT Core, proxying everything else to the Python UTAS oracle so the rest of FUT keeps working unchanged.

FIFA 17 ──HTTP──▶ openfut-utas-host
                    ├── GET …/club  ──▶ FIFA17 adapter ──▶ OpenFUT Core (/collection)
                    └── everything else ──▶ Python UTAS oracle (verbatim reverse proxy)

What it owns / does not own

Owns: socket + HTTP/1.1 keep-alive transport, route classification, the Core access client, the Python passthrough, and diagnostics. It owns no game domain state — filtering/pagination is Core's; wire parsing/shaping is the adapter's. The adapter never learns how Core is reached (the architecture rule): the host holds the [CoreAccess] boundary (GET {core_url}/collection?… today).

Safety model

  • Classification happens once, before execution. Exact GET /ut/game/<title>/club → Rust; everything else → Python. No shared path, no "try Rust then Python".
  • A Core failure on /club degrades to a valid empty {"itemData":[]} and logs an error — it never falls back to Python (which could double-apply a mutation on other routes). /club is read-only, but the rule is absolute.
  • Mutating routes (PUT/POST, /squad, /purchased, quick-sell, market, auth, SBC, /club/stats/*, /clubUser) all classify to passthrough and are untouched.

Configuration (env)

Var Required Default Meaning
OPENFUT_UTAS_HOST_ADDR yes where this host listens (client-visible UTAS addr)
OPENFUT_UTAS_PYTHON_URL yes Python UTAS oracle base URL for fallback (must differ from this host)
OPENFUT_FIFA17_CATALOG yes FIFA 17 card-definition identity catalog (Fifa17CardCatalog JSON: card id → asset id)
OPENFUT_IDENTITY_STORE yes persistent external-identity store file (owned instance → stable wire id)
OPENFUT_PERSONA_ID yes FIFA persona id stamped on GET /squad/active (must match the persona LSX/Blaze/POW/UTAS agree on)
OPENFUT_CORE_URL no http://127.0.0.1:8080 OpenFUT Core base
OPENFUT_FIFA17_TABLES_DIR no fifa17-recon/data/tables leagues/nations/teams.json for id⇄name

Startup fails clearly if the catalog or identity store cannot be loaded — there is no placeholder fallback (exactly one production identity path).

Identity model (resolved)

FIFA renders an owned card by resolving resourceId & 0xffffff against the client's own local players table; an invented id renders a blank generic card (proven live — fut_cards.py:11-21). Two distinct identities, never conflated, are resolved by [Fifa17IdentityResolver] (the single production path):

  • Definition identity (resourceId/assetId) — the card's real FIFA asset id, from the versioned OPENFUT_FIFA17_CATALOG. An unmapped definition is dropped and counted, never faked.
  • Instance identity (id) — a stable, persistent, reversible wire integer from the generic openfut-identity store under the FIFA 17 wire-id policy (monotonic from 100_000_001). The same owned instance keeps its id across restart and reverses exactly; two copies of one definition share a resourceId but get distinct ids. The namespace is globally monotonic within (fifa17, owned-item) — no per-account column is needed because Core owned-instance ids are globally-unique UUIDs.

Remaining prerequisite for a rendering retail /club: Core inventory must reference cards that exist in the catalog. The catalog + store + resolver are built and tested; wiring a controlled real FIFA 17 dev-content inventory (the curated per-game dev pack) is the next slice. rare=SP ("Special") stays UNSUPPORTED (semantics unproven; parsed, reported, never guessed).

Retail A/B runbook (first /club gate)

Change only the UTAS routing layer; keep the validated Rust Redirector/Roster and the current Blaze path. Python remains the rollback oracle — do not modify it.

Preconditions (mirror the proven blaze/roster switch discipline):

  1. cargo test -p openfut-utas-host -p openfut-adapter-fifa17 green; clippy -D warnings clean; fmt --check clean.
  2. Built binary identity == HEAD (scripts/verify-build-identity.sh); no dirty tree.
  3. Python UTAS directly reachable; the Rust host directly probeable; no stale NAT/switch rules; FIFA fully closed.

Bring-up:

  1. Move Python UTAS to an alternate port (FUT_PORT=8199 in the container/openfut-fut.sh); it keeps serving there.
  2. Start this host on the client-visible UTAS addr: OPENFUT_UTAS_HOST_ADDR=<lan>:8099 OPENFUT_UTAS_PYTHON_URL=http://127.0.0.1:8199 OPENFUT_CORE_URL=http://127.0.0.1:8080 OPENFUT_FIFA17_CATALOG=<catalog.json> OPENFUT_IDENTITY_STORE=<store.json> OPENFUT_PERSONA_ID=33068179 openfut-utas-host
  3. Launch FIFA → FUT → My Squad player picker and exercise: no-filter, position, nation, league, league+team, Gold+position, then scroll beyond page one.

Evidence to capture (all six):

  • Switch: client traffic hits the Rust host.
  • Rust positive: host log owner=RUST route=club … for the client IP.
  • Python negative for /club: Python logs no /club request in the window.
  • Python positive for other UTAS: unimplemented routes still reach Python.
  • Core positive: Core logs the /collection query and returns the expected set.
  • Application + pagination: the UI shows filtered results; later pages differ from page one (no repeated-first-page amplification).

Rollback: point the UTAS addr back at Python directly; confirm FUT still usable; then re-enable the host and confirm /club again (proves reversibility).

Logs are safe by construction: no auth/session/device/token material — only owner, route, filter summary, counts, status.