Files
OpenFUT-Core/docs/architecture.md
T
funman300 bae0a2bdaa
CI / Build, lint & test (push) Successful in 3m15s
fix(matches): close the second, unguarded match-economy authority
`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.
2026-08-21 04:47:57 +00:00

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

  1. POST /packs/open/:pack_idroutes::packs::post_open_pack
  2. Fetch profile + club from DB
  3. Call services::pack::open_pack(pool, card_db, pack_defs, club_id, pack_id)
  4. Validate pack exists + not opened
  5. For each slot in the pack definition, randomly select cards (synchronously — no rng held across await)
  6. Insert owned_cards rows for each card
  7. Mark pack as opened
  8. Increment pack stats + objective progress
  9. Return PackOpenResult { pack_id, cards }

Data Flow: Match Completion

  1. POST /matches/completeroutes::matches::post_match_complete
  2. Fetch profile + club
  3. services::match_service::complete_match(...) — everything below runs in ONE transaction and either commits together or rolls back whole
  4. Insert the match-history row (also takes SQLite's writer lock, serializing overlapping completions)
  5. Insert the match_completions guard 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 with applied = false
  6. Coins, XP + level-ups, W/D/L/DNF statistics, objective metrics, achievements
  7. Opt-in only: expire_loans (loan tick-down/removal) and advance_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
  8. Commit, then the route emits player notifications for what landed (never inside the transaction, and only when applied)
  9. 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:

  1. Create a JSON file in data/cards/
  2. The file must be an array of CardDefinition
  3. Restart the server

The CardDb struct loads all JSON files at startup and holds them in a HashMap<String, CardDefinition>.