1ffe0ffa9f
Offline Ultimate Team backend — game-independent REST API. - 19 API endpoints: auth, profiles, clubs, cards, packs, squads, objectives, SBCs, match rewards, NPC market, statistics - Axum + SQLite + SQLx with full migrations - Weighted pack generator, SBC validation engine - JSON-driven mod data (cards, packs, objectives, SBCs) - 5 integration tests passing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3.6 KiB
3.6 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 Result
POST /matches/result→routes::matches::post_match_result- Fetch profile + club
services::match_service::process_match(...)- Determine outcome (win/draw/loss), compute coins + XP
- Insert match record
club::add_coins,profile::add_xpstatistics::record_matchobjective::increment_metricfor matches_played, matches_won, goals_scored, coins_earned- Return
MatchRewardResult
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>.