`consume_item` was a complete, tested, atomic apply transaction with zero
production callers and no route -- it could not be reached over HTTP because
its effect is an in-process `ItemMutation` trait object and the host is a
separate process on a synchronous JSON boundary.
Closes that gap with a CLOSED, Core-validated effect vocabulary rather than a
pass-through: `InstanceEffect::AddContractMatches { amount, cap,
default_when_unset }`. A generic "apply this field/value" escape hatch would
hand economic authority back to the caller and break the architecture.
The read-modify-write runs INSIDE the caller's transaction
(`min(cap, COALESCE(contract_matches, default) + amount)`) so two concurrent
applies cannot lose an update, and the reported `granted` stays the requested
amount even when the cap clamps the total.
Migration 0028 adds `owned_cards.contract_matches` NULLABLE: NULL means "Core
tracks no contract here", which keeps the pack-fresh default (a FIFA-specific
7) out of Core and leaves every existing row unchanged in meaning. ADD COLUMN,
not a rebuild -- a rebuild would drop 0026's transfer trigger.
Two ordering fixes forced by putting this on the live path:
* consume_item moves from DEFERRED `pool.begin()` to `BEGIN IMMEDIATE`, the
discipline economy.rs documents: three reads precede the first write, which
is exactly the shape that returns SQLITE_BUSY past the busy handler.
* the replay answer now precedes source validation. With DestroyInstance the
first apply deletes the source, so the old order answered a retry with 404
instead of the recorded outcome -- replay semantics were unreachable.
Core could only own players. Everything else a FUT club holds — managers, staff,
consumables, kits, badges, balls, stadiums — had no representation, so the only
way to show one to a client was to synthesise it on read. That is the failure
mode this commit exists to make impossible: read authority, write authority and
persistent ownership authority are now the same rows.
MODEL. There is deliberately NO parallel items table. A manager, a consumable, a
kit and a player are all rows in `owned_cards`, differing only by a new
game-INDEPENDENT `content_kind` (player|manager|staff|consumable|kit|badge|ball|
stadium|misc). A game adapter translates its own taxonomy — FIFA 17's
`cardsubtypeid` and resource ranges — into one of those tokens before ownership
reaches Core; no game's numerics land here. Ownership stays INSTANCE-based:
`card_id` is the definition, `id` is the instance, and two copies of one
definition remain two rows.
`quantity` is a nullable per-instance attribute, not a replacement for the
instance. The real profile settles this: its 17 consumables are instance-based
and only SOME carry a wire `amount` (observed 1,2,4,5,10,15), while two copies of
definition 5003068 exist as two distinct instances. So NULL means "not a stack"
and a positive integer is the stack size; collapsing instances into counts is
forbidden by the model.
ACTIVE DESIGNATIONS. Migration 0024's two-slot kit table becomes
`club_active_items` over the five slots that correspond exactly to the client's
recovered equipped-state vocabulary (activeBadge 100, activeHomeKit 101,
activeAwayKit 102, activeBall 103, activeStadium 104). There is no
activeLeagueLogo or activeMisc token, so those kinds correctly get no slot. The
invariants are schema-enforced rather than conventional: PK(club_id, slot) allows
at most one item per role, `owned_card_id UNIQUE` makes "the same card is both
home and away kit" unstorable, and ON DELETE CASCADE means a quick-sold or
consumed item cannot be projected back as active. 0024's trigger is preserved in
semantics — and dropped EXPLICITLY before its table, because it lives ON
`owned_cards`, so DROP TABLE would have orphaned it and broken every later
ownership transfer. It still exists because the market moves ownership by UPDATE,
which no foreign key can observe.
CONSUMABLE ACTIONS. `services/consume.rs` is one transaction primitive —
validate source ownership and kind, validate target, mutate, consume the source
exactly once, commit — guarded by `UNIQUE(profile_id, action_identity)` in
migration 0027, the same discipline as `match_completions`. It supports both
deleting the row and decrementing a stack, chosen by the caller, inside the one
transaction and the one replay guard. It deliberately contains NO category
formulas: an unreversed effect must not be invented, so callers supply the
mutation and category validation stays explicit.
`/club/kits` is replaced by slot-generic `/club/active-items`. `get_collection`
now carries `content_kind` and `quantity`, accepts a `content_kind` filter, and
— importantly — stops dropping an owned card with a missing definition silently:
the envelope reports `owned_rows`, `unresolved_items` and the offending
definition ids. That silent `filter_map` is the documented cause of a club that
looks empty while the rows are all present.
Verified against a REAL populated club, not a fixture: the production snapshot
(migration 19) is copied to a tempdir, migrated to 0024, given two kit
designations on real owned instances, then migrated to head. 1986 owned rows
survive as content_kind='player', both designations land in `club_active_items`,
no row gains a quantity, and the old table is gone. 258 tests pass, clippy clean.
Kits are ownership-backed club items: the owned instance stays in the
generic owned_cards inventory and only the two active roles get their own
table. This mirrors the squad_managers precedent and keeps every
FIFA-specific resourceId/wire concern in the game adapter.
* migration 0024: club_kit_assignments(club_id, slot, owned_card_id) with a
UNIQUE owned_card_id (one instance cannot hold both roles) and
ON DELETE CASCADE from owned_cards so a quick-sell clears the role.
* a BEFORE UPDATE OF club_id trigger clears the designation on a market
transfer, which moves ownership by UPDATE and so is not covered by the
cascade.
* set_active_club_kits replaces BOTH slots in one transaction, rejects
home == away, and validates each instance against current club ownership,
so a half-applied or dangling designation is not representable.
* get_active_club_kits revalidates ownership on read, so a stale row can
never surface another club's item.
* GET/PUT /club/kits expose the pair.
Tests cover restart persistence, replace/clear without duplicates, atomic
rejection of invalid references, and clearing via delete and transfer.
Add a generic, durable squad->manager assignment (migration 0023
squad_managers) so a manager persists across squad save, reload, and
server restart, backed by authoritative Core owned_cards.
- squad_managers(squad_id PK, owned_card_id, updated_at) with ON DELETE
CASCADE on both FKs: quick-selling the manager auto-clears the
assignment (no resurrection); one manager per squad (no duplicates).
- club::{get,set,clear}_squad_manager validate club ownership of both the
squad and the card, and re-check ownership on read (defends against a
stale row left by a market transfer).
- GET/PUT /club/manager routes expose the assignment; FIFA wire meaning
stays in the adapter.
Tests: persistence across reload+restart (headline), reassignment
replace/no-duplicate, clear/no-resurrection, cascade on quick-sell,
foreign-card rejection.
Add complete_match: one BEGIN/COMMIT that validates identity + result,
enforces a durable (profile_id, match_identity) uniqueness guard
(migration 0022 match_completions), persists match history, and grants
coins + XP/level-ups + W/D/L/DNF statistics + objectives + achievements
exactly once. Any failure rolls the whole match back (no compensating
cleanup). Handles sequential/restart/concurrent replay, conflicting
re-report (first result canonical), DNF (loss economics, own stat
bucket) and no-contest (zero economic effect).
Adds tx-scoped variants: statistics::record_match_tx/
record_position_goals_tx, objective::increment_metric_tx,
achievement::check_and_unlock_tx. New MatchResultKind/CompleteMatchRequest/
MatchCompletionResult models + POST /matches/complete route.
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.
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.
- 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>
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>
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>