Commit Graph

3 Commits

Author SHA1 Message Date
funman300 f6606accb3 feat(market): FIFA 5% transfer fee policy, host settle_sale capability, isolated staging harness
Core gains the generic settlement (gitlink 31ab4a6); the FIFA-specific parts live
here.

FEE (openfut-adapter-fifa17/src/fut/economy_policy.rs), beside pack_price and
match_reward_total because 5% is a game policy constant and Core must stay
game-neutral — Core only validates 0 <= fee <= gross and never computes a rate:

  TRANSFER_MARKET_FEE_PERCENT = 5
  transfer_market_fee(gross)  = floor(gross * 5 / 100), i128 intermediate
  seller_proceeds(gross)      = gross - fee

Integer only. Floating point is never used for coin settlement: 0.05 is not
representable in binary and a f64 round trip can create or destroy a coin at large
prices. Widening to i128 makes overflow unreachable for any i64 price, so no price
ceiling has to be assumed.

ROUNDING IS A CHOICE AND IT IS NOT CONFIRMED. The fee is floored, so the seller
keeps the fractional coin, chosen because it makes fee + proceeds == gross hold
exactly at every input — the property the accounting invariant rests on. The
discriminating case against flooring the seller's 95% instead is a gross of 150:
this rule pays 143, the alternative 142. Nothing in the corpus or the client binary
settles which the real server did (the client is only ever told the gross; no
tax/netPrice/sellerProceeds wire field exists). Pinned at 0/1/19/20/21/39/40/100/
150/200/1_000/15_000/15_000_000/i64::MAX plus a fee+proceeds==gross sweep.

HOST: CoreEconomy gains settle_sale + EconomySale/EconomySaleReceipt, implemented on
HttpCoreClient as POST /economy/settle-sale. Request field names were checked
against Core's actual SettleSaleRequest/SaleReceipt rather than assumed. Absent club
ids are OMITTED from the body (not null), which is what Core's Outside/active-club
defaults depend on, so a unit test pins that body shape. handle_market_buy is
deliberately untouched: the synthetic buy path has no counterparty, so minting there
is correct.

HARNESS: scripts/settlement-staging.py, stdlib only, drives a REAL Core over real
HTTP on an ephemeral port against a throwaway DB (production 8099/8199/18080 in a
hard deny-list checked in three places), seeds the canonical two-party fixture,
prints BEFORE/PURCHASE/AFTER with PASS-FAIL lines, cleans up in a finally. 31/31
pass. It found the rejection-precedence bug fixed in Core, and that Core's content
preflight aborts startup on an owned card whose CardDefinitionId no pack defines.

Gates: Core 194, adapter 217, host 127, harness 31/31, clippy clean, new code
fmt-clean. Nothing deployed; no production process, port or database was touched.
2026-08-18 00:51:37 +00:00
funman300 aa2abc2772 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.
2026-08-17 18:03:06 +00:00
OpenFUT Agent b1643309f6 test(fifa17): prove host economy concurrency and failure rollback
Two real host-dispatch test files (no fakes) driving Server::try_handle_economy
against a live in-process Core over the real blocking client + durable
MarketStore/PileStore + JsonIdentityStore, each racer its own OS thread
(off-runtime pattern).

economy_concurrency.rs — 8 races x 50 iterations:
  A two BUYs (coins for one) -> exactly one 200 + one 461, final 0, one debit.
  B duplicate owned-pack open -> one redemption, +11 once, entitlement once.
  C duplicate quick-sell -> one sell + one credit + one removal.
  D two market buyers -> one win, one debit, one mint, sold once.
  E reward+BUY -> no lost update (Core relative UPDATE under BEGIN IMMEDIATE).
  F move+quick-sell / G list+quick-sell -> one coherent transition.
  H 1000 concurrent mints -> unique + reversible wire ids, monotonic watermark.

economy_failure.rs — 10 fault-injection sub-cases, all fail-closed:
  BUY/open-redeem/generator/pile/identity, quick-sell, move, market
  reserve/purchase/complete. CRITICAL complete-sale-after-commit = SAFE: the
  listing is left `reserved` (not active), so the active->reserved reserve CAS
  can never win again -> not buyable, exactly one debit + one mint. No E3.

Fault injection uses test-file CoreEconomy/ExternalIdentityStore doubles plus a
NARROW, inert-by-default `StoreFault` seam in market_store.rs + pile_store.rs
(the concrete stores have no trait boundary; 3 `tripped()` checks + a field,
zero behaviour unless a test arms it). `parking_lot` promoted to a normal dep
(the seam's Mutex is used at lib scope). Classifier/ROUTE_AUTHORITY/Python
untouched. host lib 71/71; both new tests pass.
2026-08-13 22:30:35 +00:00