`POST /matches/result` granted coins, XP, level-ups, statistics, four objective metrics, loan expiry, season progression and achievements across a dozen SEPARATE writes with no transaction and no idempotency key. Every call re-credited the same match, and any mid-way failure half-applied it. It sat beside `/matches/complete`, so nothing stopped one match being paid twice through two different doors. It cannot be made exactly-once in place: that needs a caller-supplied match identity, and this request shape has none. Deriving one from the body would collapse two legitimate matches with the same scoreline into one — the under-credit trap already documented for the `fp:` fallback. So the route fails closed: it rejects with a message naming `/matches/complete`, rather than 404, so a caller learns why. The behaviour it uniquely drove is kept, not deleted. `process_match` was the ONLY caller of loan expiry and Core's season model, so both move into `complete_match`'s transaction behind opt-in `expire_loans` / `advance_season` flags. Both default OFF, which keeps the FIFA 17 retail path byte-identical: FIFA 17 has its own loan and Seasons models, and Core's season END GRANTS coins and a pack — invisible economy on a path that never asked for it. Their pooled implementations are replaced by `expire_loans_tx` and `season::record_match_tx`, so a loan that expires or a season that ends commits with the match that caused it. Notifications (level-up / objective / loan / season) were pooled side effects of the removed path. They now emit from the route AFTER the commit — never inside the transaction, since a failed notification must not roll back a completed match — and only when `applied`, so a replay no longer re-notifies. The pooled path had no replay concept and notified every time. Also fixes a real bug this surfaced: `/auth/reset` never deleted `match_completions`, which carries un-cascaded foreign keys to BOTH `matches` and `profiles`. Any profile that completed a match through the authoritative route — i.e. every FIFA 17 profile after a retail match — failed to reset with a database error. It is now deleted first, and ordering is documented. Tests: the 20 integration call sites move to the authoritative route through one helper that mints a per-call identity (each call IS a distinct match). New coverage for the closed path: it rejects without moving the balance or writing history; Core progression stays off unless opted into; a replay does not duplicate notifications; and a profile that completed matches can still be reset.
4.8 KiB
OpenFUT Core — Architecture
Overview
HTTP Client (Bridge or direct)
│
▼
Axum Router
│
┌────┴─────┐
│ Routes │ ← thin handlers: extract state, call service, return JSON
└────┬─────┘
│
┌────┴──────┐
│ Services │ ← business logic, DB calls, data loading
└────┬──────┘
│
┌────┴──────┐
│ SQLite │ ← SQLx + migrations
└───────────┘
│
┌────┴──────┐
│ Data/ │ ← JSON files: cards, packs, objectives, SBCs
└───────────┘
Module Map
| Path | Purpose |
|---|---|
src/main.rs |
Entry point: tracing, config, pool, migrations, seed, serve |
src/lib.rs |
Library root: re-exports modules, exposes build_app for tests |
src/app.rs |
Router construction, AppState definition |
src/config.rs |
Config struct, loaded from env vars |
src/db.rs |
Pool initialization and migration runner |
src/error.rs |
AppError enum + IntoResponse impl |
src/models/ |
Pure data types (Serde + SQLx FromRow) |
src/services/ |
Business logic; all DB access lives here |
src/routes/ |
Axum handler functions; one file per domain |
src/seed/ |
First-run starter pack grant |
src/modding/ |
Generic JSON directory loader |
data/ |
Moddable JSON content: cards, packs, objectives, SBCs |
migrations/ |
SQLx SQL migrations |
AppState
AppState is cloned into every request handler via Axum's State<AppState> extractor:
pub struct AppState {
pub pool: Pool, // SQLite connection pool
pub card_db: Arc<CardDb>, // in-memory card registry
pub pack_defs: Arc<Vec<PackDefinition>>,
pub obj_defs: Arc<Vec<ObjectiveDefinition>>,
pub sbc_defs: Arc<Vec<SbcDefinition>>,
}
All game-content data is loaded at startup from data/ into Arc-wrapped collections. This avoids repeated disk I/O per request and keeps the data shared across the multi-threaded Tokio runtime without locking.
Data Flow: Pack Open
POST /packs/open/:pack_id→routes::packs::post_open_pack- Fetch profile + club from DB
- Call
services::pack::open_pack(pool, card_db, pack_defs, club_id, pack_id) - Validate pack exists + not opened
- For each slot in the pack definition, randomly select cards (synchronously — no rng held across await)
- Insert
owned_cardsrows for each card - Mark pack as opened
- Increment pack stats + objective progress
- Return
PackOpenResult { pack_id, cards }
Data Flow: Match Completion
POST /matches/complete→routes::matches::post_match_complete- Fetch profile + club
services::match_service::complete_match(...)— everything below runs in ONE transaction and either commits together or rolls back whole- Insert the match-history row (also takes SQLite's writer lock, serializing overlapping completions)
- Insert the
match_completionsguard row.UNIQUE(profile_id, match_identity)makes the economy exactly-once: a duplicate — sequential, concurrent, after a restart, or a conflicting re-report — collides here and the whole attempt rolls back, then echoes the persisted result withapplied = false - Coins, XP + level-ups, W/D/L/DNF statistics, objective metrics, achievements
- Opt-in only:
expire_loans(loan tick-down/removal) andadvance_season(Core's own division model, which grants coins and a pack at season end). Both default OFF so a game with its own loan/season model — FIFA 17 — is unaffected - Commit, then the route emits player notifications for what landed (never
inside the transaction, and only when
applied) - Return
MatchCompletionResult
POST /matches/result was REMOVED as an economy path. It performed the same
grants across a dozen separate writes with no transaction and no idempotency
key, which made it a second economy authority that re-credited on every call and
could half-apply on any mid-way failure. It now rejects and names
/matches/complete. Exactly-once requires a caller-supplied match identity,
which its request shape did not carry and could not derive.
Single-Profile Design
OpenFUT is single-player. Only one profile is allowed per database. All services fetch "the active profile" by selecting the first row. This is intentional and keeps the system simple.
Modding
All game content is data-driven. To add new cards:
- Create a JSON file in
data/cards/ - The file must be an array of
CardDefinition - Restart the server
The CardDb struct loads all JSON files at startup and holds them in a HashMap<String, CardDefinition>.