Found while implementing sale settlement, and reproduced before fixing:
sell_item(pool, "club", "item-x", 250)
-> Database(SqliteError { code: 787, message: "FOREIGN KEY constraint failed" })
squad_players.owned_card_id is a FK onto owned_cards(id) and db.rs enables
foreign_keys, so DELETEing an item that sits in any lineup is refused outright.
services::economy::sell_item never cleared the lineup, and this is the LIVE FIFA 17
quick-sell path (host economy_store -> econ.sell_item -> POST /economy/sell-item).
Selling a card that happens to be in your squad is ordinary, not an edge case.
sell_item now evicts the item from every lineup first, inside its existing
transaction, so a wrong-owner sale rolls the eviction back with everything else
(asserted, not assumed).
Also folds routes/cards.rs::delete_owned_card into the same authority. It
hand-rolled DELETE + club::add_coins directly on the pool, which had BOTH defects:
no transaction, so a failed credit destroyed the card for nothing, and the same
missing squad eviction. Its response shape is unchanged and the pre-existing route
test still passes.
Tests: selling_a_squadded_item_succeeds_and_frees_the_slot (the repro) and
a_rejected_quick_sell_leaves_the_lineup_intact. Core 55 lib + 123 integration pass,
clippy clean.
Not deployed — production is mid live-test.
Core had no way to settle a real sale. Every "buy" MINTED a new owned_cards row
(services/economy.rs::purchase_item), so a sold card existed twice; the seller was
never credited; no fee arithmetic existed anywhere in the project; and no
/economy/* route could even name a counterparty, since all eight resolve one club
from the X-OpenFUT-Game active profile.
Adds settle_sale() beside the existing tx-scoped primitives, so it inherits the
module's proven atomicity (pool.acquire + BEGIN IMMEDIATE + finish) rather than
re-deriving it: debit buyer gross -> evict from squads -> transfer the EXISTING row
-> credit seller gross-fee. The fee is simply never credited anywhere, which is
what destroys it. Exposed as POST /economy/settle-sale, the one economy route that
names clubs explicitly because a sale has two sides; an omitted buyer means a
counterparty OUTSIDE the modelled economy, never a silent fallback to the active
club (which would settle a club against itself).
Ownership moves by UPDATE ... WHERE id = ? AND club_id = ?, an ownership CAS. No
INSERT and no DELETE on the two-party path, so duplication is ruled out
structurally, not by an assertion.
Two bugs found by writing the tests rather than by reading the code:
1. Deriving the seller from CURRENT ownership let two racing buyers BOTH succeed —
after the first sale the item belonged to B, so the second call read B as the
seller and chain-sold it B -> C. Core has no listing concept and could not
notice the replay. The seller is now the caller's EXPECTED owner and every
ownership statement is predicated on it, which makes the CAS authoritative about
"already sold" independently of caller-side listing state.
2. Debiting before transferring made a replay fail as "insufficient balance" (the
buyer had spent the coins on the sale that succeeded), so the ownership guard was
shadowed and settling_the_same_sale_twice_pays_once passed WITHOUT exercising
the guard it named. Ownership is now judged first; the test asserts NotFound
specifically and adds a cheap affordable replay that only ownership can refuse.
Squad eviction is mandatory, not cosmetic: squad_players.owned_card_id is a FK and
the pool enables foreign_keys, so an Outside sale of a squadded card would fail
outright, and a transfer preserves the row id so a stale lineup row would leave the
PREVIOUS owner fielding a card they no longer own.
Tests: 21 service (canonical 15,000/750/14,250 fixture, conservation, squad
eviction, Outside retirement, 8 invalid paths, zero-price, replay, two-buyer race)
+ 6 route-level over HTTP with two parties. Core 194 pass.
Deliberately NOT done: no wire/route output change, no deployment, and nothing yet
decides that a player's listing has sold — the seller-facing sold wire state needs
live client evidence and must not be guessed.
Correctness fixes from docs/CORE_CORRECTNESS_ISSUES.md:
- Issue 3 (HIGH, exploit): submit_sbc dedups owned_card_ids (HashSet) and
bounds the list (MAX_SBC_CARDS=30) before resolution. A repeated id resolved
the same card N times, passed validation, and granted the reward while only
one card was consumed -> any SBC satisfiable with one duplicated card = free
reward. Now rejected with BadRequest. Regression test added.
- Issue 2 (HIGH): TOCTOU economy mutations closed with single-statement
compare-and-swap (no transaction plumbing): club::spend_coins conditional
debit (WHERE coins >= ?) + rows_affected, also rejects negative amounts;
pack::open_pack claims the pack before minting; market::buy_listing claims
the listing before charging and releases on debit failure; market::sell_card
guards the DELETE with owner + rows_affected; checkin::claim uses a
conditional INSERT ... WHERE NOT EXISTS (today) before paying out.
- Issue 4 (LOW): season.rs .expect() on missing rows -> graceful AppError;
checkin index (streak-1) % 7 -> .rem_euclid(7) (guards negative index panic).
- Issue 1 (LOW): migration 0019 adds sbc_submissions.club_id + backfill;
submit_sbc binds it so the MY CLUB milestone query stops silently reading 0.
Core suite 179 green + clippy clean.
Root cause of the fresh-DB multi-connection write failure: economy writes ran in
a DEFERRED transaction (pool.begin() = BEGIN) that read then wrote; SQLite returns
SQLITE_BUSY (code 5, 'database is locked') *immediately* when a deferred tx
upgrades to a write while another holds the write lock, bypassing busy_timeout to
avoid deadlock. Fix: open each write op with BEGIN IMMEDIATE on a dedicated pooled
connection (finish() commits/rolls back), taking the write lock up front so
busy_timeout serializes writers. Reproduction test: 100 fresh DBs x 8 concurrent
grant_reward — was 644/800 failures, now 800/800 succeed with correct final
balance (no lost update). Reads unchanged.
purchase_items: generic atomic debit + mint of several items (fail-closed) for
open-on-buy Store packs (Store BUY returns items immediately) + POST
/economy/purchase-items route. Import: add optional entitlements[] to
ProfileImportRequest, seeding unconsumed packs rows in the same transaction (so a
source's unopened packs become Core entitlements); idempotency via the existing
import fingerprint. Tests: 2 purchase_items unit + endpoint + entitlement-seed
import. Core matrix 45 lib + 116 integration + 9 import green; clippy clean.
Add generic /economy/* routes (balance, entitlements, purchase-entitlement,
redeem-entitlement, sell-item, grant-reward, purchase-item) resolving the club
server-side via the same game-scoped active-profile mechanism as /collection —
callers never supply a club id, so there is no cross-club access. Add
list_unopened_entitlements + Entitlement for the reader side. Game-neutral: no
currency names or wire semantics. 4 endpoint integration tests (balance+reward,
purchase+redeem, purchase-item+sell fail-closed, insufficient-funds fail-closed);
full matrix 43 lib + 115 integration green.
The FIFA17 economy audit proved the transfer market is synthetic-seller:
buy-now mints a new owned item and debits the buyer; no real counterparty,
no sale-credit/expiry/fee. The generic primitive that models this is an
atomic debit + inventory add (purchase_item), NOT a two-party
transfer_item_with_payment (which would be unused). Fail-closed: an
unaffordable purchase debits nothing and mints nothing. 2 unit tests.
Add a game-agnostic economy service that exposes atomic, fail-closed
operations over Core's existing durable tables rather than forking a
parallel persistence stack:
* currency ledger -> clubs.coins
* owned inventory -> owned_cards
* entitlements -> packs (opaque definition_id, consume-once `opened`)
Compound operations run inside a single SQLite transaction, closing the
atomicity gap in the pool-scoped club::{spend,add}_coins helpers whose
read/modify/write spans multiple round-trips. Public ops:
balance, purchase_entitlement (debit+grant), redeem_entitlement
(consume-once + add items, all-or-nothing), sell_item (remove+credit),
grant_reward (credit).
Deliberately game-neutral: currency names, entitlement/pack ids, and
per-save item-id sequences stay in the per-game adapter that drives these
primitives. 9 unit tests cover debit/credit fail-closed rollback,
consume-once, partial-redeem rollback, and non-negative guards.
Core performs a GAME-AGNOSTIC transactional profile import; all FIFA17 semantics
(manifest parse, wire ids, resourceId/nextItemId, squad extension v1,
CardDefinitionId/OwnedItemId choice) stay in openfut-import-fifa17. Core sees
only opaque ids and opaque extension bytes.
services::import::apply_profile_import(pool, card_db, ProfileImportRequest):
- ONE SQLite transaction installs profile + club + all owned cards + canonical
squad + one opaque game extension; commits together or not at all.
- Definition preflight (pre-tx): every owned card_id MUST resolve in loaded
production content, so ownership never points at absent content.
- Squad all-or-nothing (pre-tx): every active-squad OwnedItemId MUST be in the
imported ownership set.
- OwnedItemId uniqueness + generic extension bounds enforced pre-tx.
- Core computes the canonical squad fingerprint itself (never adapter-supplied)
and persists the extension atomically, exactly as the live squad-write path.
Rerun identity via profiles.import_fingerprint (migration 0018, nullable):
- identical source_fingerprint on an already-imported game -> idempotent no-op;
- differing token -> fail (needs explicit update mode);
- pre-existing non-imported profile -> never clobbered.
So a crash after identity-seeding re-runs cleanly with no cleanup/reminting.
CLI: 'openfut-core import <request.json>' loads production content packs, parses
a generic request, applies. squad_fingerprint made pub(crate) for reuse.
8 import-service tests (happy path, idempotent rerun, fingerprint mismatch,
missing-definition no-write, squad-not-owned, dup OwnedItemId, non-imported
clobber guard, empty-owned). clippy -D warnings clean; full suite 159 green.
Production real-profile content is loaded via an explicit path, NOT the dev-only
OPENFUT_DEV_CONTENT_GAMES gate:
- Config.content_packs from env OPENFUT_CONTENT_PACKS (comma-sep file paths).
- CardDb::load_pack(path): merge an explicit CardDefinition[] production pack.
- app::build loads dev packs then production packs.
Preflight (app::build, always on): every owned_cards.card_id MUST resolve to a
loaded CardDefinition. A real profile with owned players but even ONE missing
definition fails LOUDLY instead of silently serving an empty /collection; an
empty owned_cards table (fresh DB / tests) passes.
2 preflight integration tests (missing def fails, loaded def passes). clippy
-D warnings clean; full suite 151 tests green.
Game-independent owned-inventory query (services::inventory::{OwnedItemQuery,
apply_query} + a Quality tier) that filters (AND) -> orders deterministically
(effective_overall desc, owned_card_id asc) -> paginates, wired into
GET /collection. Fixes the FIFA17 My Squad search: the Python oracle applied
only league+team and ignored level/rare/position/nation/start/count (proven by
response sha256 identity across pages -> the request-amplification bug); Core
now applies all proven filters and paginates. rare=SP left UNKNOWN.
Tests: +9 inventory unit, +14 /collection integration (full matrix incl. the
repeated-first-page regression); 9 mutations killed.
Isolated from an unrelated dirty working tree via a clean worktree at eab522a;
touches only the 5 slice files, no unrelated reformatting.
Driven by a retail FIFA 17 capture: the client sends the WHOLE squad on
every save (~2KB, every slot/item/kit number), and a user swapping two
players produced nine changed slots across two saves. Slot deltas
therefore do not describe intent, so the only honest semantic operation
is "this is the squad now".
replace_squad, and why save_squad no longer has its own write path
------------------------------------------------------------------
save_squad UPDATEd the squad, DELETEd every squad_players row, then
INSERTed the new ones one at a time -- all outside a transaction. A
failure part-way through left a squad with some old players deleted and
only some new ones written: a state nobody asked for and no client can
detect. It also never checked that the cards being placed belonged to the
club, and accepted the same card in two slots.
replace_squad validates BEFORE any write (so a rejection leaves the
stored squad untouched) and performs every write in one transaction:
- card must exist AND belong to this club
- a card may occupy at most one slot
- a slot may hold at most one card
- slot indices must not be negative
- the squad being replaced must belong to this club
save_squad is now a thin wrapper over it. That deliberately tightens the
existing Core REST route -- it now validates ownership and rejects
duplicates. Those were bugs, and two write paths with different
guarantees is how the stricter one gets bypassed.
A cross-club card is reported as NotFound, not Forbidden: whether a card
exists in someone else's club is not the caller's business.
Game-rules boundary
-------------------
Core contained calculate_chemistry -- a full FUT-style link-scoring
formula. Chemistry is game-specific and changed between FIFA
generations, so a formula compiled into generic Core quietly makes Core a
FIFA-something server.
It now sits behind SquadRules, with the existing implementation preserved
byte-for-byte in behaviour as DefaultSquadRules ("openfut-default-v2").
Rules take a resolved SquadSnapshot of pure data rather than a pool and a
card database, so they are synchronous, testable without fixtures, and
cannot reach Core's storage. Fifa17SquadRules is deliberately NOT
written: the algorithm is unproven and inventing one is worse than having
none.
Client-reported values
----------------------
FIFA sends its own chemistry/rating/starRating. ClientReportedEvaluation
is a DIFFERENT TYPE from SquadEvaluation, so assigning one where the
other belongs does not compile. Disagreement is reported through
EvaluationComparison and never reconciled in either direction -- the
server's value stands and the mismatch is surfaced for investigation
against the exact squad that produced it.
Evidence
--------
17 unit tests, 113 in the crate, 7/7 mutations killed including
"ownership check removed", "replacement becomes a merge" and
"client-reported chemistry becomes the server value".
Scope note: `cargo fmt` without -p reformatted ~27 unrelated files; those
were reverted so this commit touches only the squad path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Market: record buy/sell history in market_history table; expose via
GET /market/trade-history (last 30 events, newest first)
- Division: GET /division/leaderboard returns 10-club table with 9 seeded
NPC entries + player row, sorted by pts; stable within a season
- rand feature small_rng enabled in Cargo.toml for SmallRng use
- 3 new integration tests (leaderboard count, sort order, empty trade history)
- Core: 96 tests passing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- migrations/0011_season_history.sql: persist one row per completed season
- models/season.rs: SeasonHistoryEntry struct; public SEASON_LENGTH /
PROMOTION_PTS / RELEGATION_PTS consts; pts_above_safe, can_be_relegated,
promotion_achievable helpers
- services/season.rs: write history entry on season rollover; get_history()
returns last 20 seasons newest-first
- routes/division.rs: GET /division now includes promotion_pts, relegation_pts,
season_length, pts_above_safe, promotion_achievable, can_be_relegated;
new GET /division/history endpoint
- 3 new integration tests: history empty, history records after promotion,
division response has zone fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
18 data-driven achievements (achievements.json) across 8 trigger categories:
matches_played, matches_won, goals_scored, packs_opened, sbcs_completed,
cards_owned, level, objectives_completed, drafts_completed. Rarities span
common → epic. Coin rewards range from 500 (first_match) to 6000 (win_50).
check_and_unlock() queries the relevant metric from existing tables, skips
already-earned achievements via INSERT OR IGNORE, grants coin rewards, and
fires a persistent notification per unlock. Trigger values are cached per
call to avoid redundant DB round-trips for same-trigger achievements.
Checks run automatically after every match result (all triggers), every
pack open (packs_opened), and every successful SBC submission (sbcs_completed).
GET /achievements returns all definitions annotated with unlocked/unlocked_at,
plus earned and total counts. POST /matches/result response gains an
achievements_unlocked array (empty when nothing new unlocked).
AppState gains achievement_defs (Arc<Vec<AchievementDefinition>>) loaded
from data/achievements/**/*.json at startup — same pattern as obj_defs.
5 new tests: list endpoint, first_match unlock, first_win unlock, no-dup
guard, coin reward verification. Core now at 82 tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New notifications table (migration 0009) stores event-driven alerts
alongside the existing dynamic state notifications (unclaimed objectives,
expiring loans, season ending soon).
Persistent notifications are created automatically during match
processing: one per level gained, one per expired loan card, one per
objective newly completed, and one when a season ends (with
promotion/relegation result and rewards in the body).
GET /notifications now returns a merged list — persistent entries
(newest-first, limit 50) followed by dynamic entries — plus an
unread_count for the badge. Each item carries type, title, body,
is_read, and (for persistent) id and created_at.
PATCH /notifications/:id/read marks a single persistent notification
read. POST /notifications/read-all marks all persistent ones read.
Four new tests: unread_count field, level-up notification creation,
mark-all-read, single-read PATCH. Core now at 77 tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four new card files (Serie A, Ligue 1, Primeira Liga, Eredivisie) add 45
cards across Italian, French, Portuguese, and Dutch football, bringing the
total card pool from 115 to 160. Each file uses fictional club/player
names and null image_path, keeping the project free of copyrighted assets.
Ten new SBC challenges (league_sbcs.json) targeting specific leagues and
nations increase the total from 7 to 17 challenges. Includes league purity
SBCs (Premier League, Bundesliga, Serie A, Ligue 1, Eredivisie), nation
combo SBCs (Iberian Derby, South American Fire), and utility SBCs (elite
strikers mini-submission, bronze-to-silver recycler, world tour).
NPC market refresh now shuffles the card pool before picking 24 listings
(was: take first 20 unshuffled), and price variance tightened from ±100%
to ±25% of the OVR-tier base price for more realistic NPC competition.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
XP thresholds (500→1200→2000→…→11000→+2500/level) drive automatic level
increases. add_xp_with_levelup() replaces bare add_xp() in match
processing: for each level gained it grants level×500 coins and milestone
packs (bronze@5, silver@10, gold@15, rare_gold@20, gold every 5 after).
GET /profile now returns computed level (recalculated from XP so it
stays consistent), xp_to_next_level, and xp_for_next_level so the
dashboard can render a progress bar without a second call.
POST /matches/result response gains level_ups array (empty when no
level-up occurred) with new_level, coins_granted, pack_granted per event.
Four new tests: profile level fields, level_for_xp boundary checks,
level-up event in match result, milestone pack unit test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
FUT Champions (Weekend League):
- POST /fut-champs/start — open a new 30-match week (one active session at a time)
- GET /fut-champs — current session status with matches_remaining
- POST /fut-champs/:id/result — record a match; auto-completes and computes tier at 30
- POST /fut-champs/:id/claim — claim coins + pack reward (idempotent guard)
- GET /fut-champs/history — past sessions newest-first
- 11 reward tiers: Elite (27+ wins, 50k coins + icon pack) down to Bronze 1 (0 wins, 250 coins)
Division Rivals:
- POST /rivals/claim-weekly — claim weekly reward scaled by current division
- Week counter is monotonic (offline-safe, no real-time calendar dependency)
- Division 1-3 get a bonus pack alongside coins
Migration 0008 adds fut_champs_sessions table and two columns to seasons.
12 new integration tests — all 67 pass, clippy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GET /chemistry-styles — lists 18 FUT-style chemistry styles with stat boost breakdowns
- POST /collection/:id/chemistry-style — apply a style (Shadow, Hunter, Anchor, etc.)
- POST /collection/:id/position — override a player's position for 500 coins
- POST /collection/:id/training — add +1/+2/+3 OVR training bonus (max +3 total)
- GET /collection now includes chemistry_style, position_override, training_bonus,
effective_overall and effective_position fields per owned card
- Migration 0007 adds three columns to owned_cards with safe defaults
- 10 new integration tests — all 55 pass, clippy clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GET /division returns live season stats (points, record, promotion threshold)
- PUT /club allows updating club name and manager_name
- GET /packs/history returns opened packs with full card definitions
- GET /notifications dynamically surfaces completed objectives, expiring loans, season end
- Club model gains manager_name column (migration 0006 already added it)
- Pack model gains opened_cards and opened_at; pack SELECT queries updated
- 9 new integration tests — all 45 pass, clippy clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Chemistry v2: FUT-style link scoring with per-player breakdown
(club +3/link cap 6, league +1/link cap 4, nation +1/link cap 3;
player cap 10, team cap 100); chemistry response now includes
per-player breakdown with club/league/nation link counts and pts
- Card pool: 34 new cards across Premier League, La Liga, Bundesliga
with overlapping clubs/nations for meaningful chemistry testing
- Card search: extended GET /cards with nation, league, club,
min_overall, max_overall, limit query params; results sorted by
overall descending
- Match opponent: added "ultimate" difficulty band (85+ OVR);
random formation selection from 6 tactical formations; falls back
to lower OVR pool when not enough cards at the requested band
- Tests: 9 new integration tests (28 total, all passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a data-driven event system (#47 schema, #48 activation, #49 TOTW):
- EventDefinition JSON schema (event_type, effects, date range, is_manual)
- data/events/totw_week1.json — TOTW event active by date range (2024–2030),
injects 5 TOTW cards into the market as [EVENT] listings
- data/events/seasonal_events.json — Spring Festival and Icon Weekend,
manual-activation-only example events
- migrations/0004_events.sql — events override table (NULL/1/0 per event)
- services/event — load_event_definitions, compute_is_active (date range +
manual override), get_active_events, get_all_with_status, activate/deactivate
- routes/events — GET /events, GET /events/:id, POST .../activate, .../deactivate
- services/market::refresh_npc_listings now accepts event_defs, injects bonus
market cards from active events at 2× premium price
- 5 new integration tests (events list, single, activate/deactivate cycle,
404 on unknown, market injection verification); 19/19 passing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Chemistry & squads:
- Chemistry calculation on GET /squad (club/league/nation links, max 100)
- Formation validation on POST /squad (exactly 11 starters, exactly 1 GK)
Objectives:
- Weekly objectives JSON (4 objectives: warrior, goals, dedicated, SBC)
- Daily objectives auto-reset at midnight UTC (background task)
SBC validation expanded:
- max_overall per-player enforcement
- required_clubs validation
- min_players_from_same_nation validation
- min_players_from_same_club validation
Market:
- GET /market?min_overall=X&position=Y filtering
- Expiry cleanup runs before every NPC refresh
- NPC market auto-refresh every 24h (background task, runs at startup)
Matches:
- GET /matches/opponent?difficulty=beginner|professional|world_class|legendary
generates a random AI opponent squad from the card pool
Statistics:
- win_streak and best_win_streak tracking (migration 0002)
- GET /statistics/history?limit=N — last N matches with summary stats
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>