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

114 lines
4.8 KiB
Markdown

# 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:
```rust
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_id``routes::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/complete``routes::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>`.