# 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` extractor: ```rust pub struct AppState { pub pool: Pool, // SQLite connection pool pub card_db: Arc, // in-memory card registry pub pack_defs: Arc>, pub obj_defs: Arc>, pub sbc_defs: Arc>, } ``` 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`.