From f6dd41fd415429cf162ac397529c61b8a77b6bd3 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 25 Jun 2026 15:53:28 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204=20=E2=80=94=20dev=20experienc?= =?UTF-8?q?e=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docs: - .env.example: documents all environment variables with defaults - CONTRIBUTING.md: dev setup, running tests, code style, design constraints - MODDING.md: full schema reference for cards, packs, objectives, and SBCs - .gitea/workflows/ci.yml: Gitea Actions CI (fmt check, clippy, build, test) Middleware (#50-52): - Body limit (256 KB) via DefaultBodyLimit on all routes (#50) - Concurrency limit (256 concurrent requests) via semaphore middleware; returns 429 Too Many Requests when at capacity (#51) - Correlation IDs via tower_http request_id layers; sets x-request-id UUID on every request and propagates it to the response headers (#52) Layer order (outermost → innermost): CorsLayer → DefaultBodyLimit → concurrency limit → SetRequestId → TraceLayer → PropagateRequestId → routes Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 19 +++++ .gitea/workflows/ci.yml | 47 +++++++++++ CONTRIBUTING.md | 93 +++++++++++++++++++++ Cargo.lock | 2 + Cargo.toml | 3 +- MODDING.md | 174 ++++++++++++++++++++++++++++++++++++++++ src/app.rs | 15 +++- src/lib.rs | 1 + src/middleware.rs | 32 ++++++++ 9 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 .gitea/workflows/ci.yml create mode 100644 CONTRIBUTING.md create mode 100644 MODDING.md create mode 100644 src/middleware.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7cfd3cd --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# OpenFUT Core — environment configuration +# Copy this file to .env and adjust values for your setup. + +# Server listen address (host:port) +LISTEN_ADDR=127.0.0.1:8080 + +# SQLite database path (relative to the working directory) +DATABASE_URL=sqlite://openfut.db + +# Directory containing card, pack, objective, and SBC data files +DATA_DIR=data + +# Max SQLite connection pool size +DB_MAX_CONNECTIONS=5 + +# Log level for the application +# Options: error, warn, info, debug, trace +# You can also use module-level filters: openfut_core=debug,tower_http=info +RUST_LOG=openfut_core=info,tower_http=debug diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..f35a127 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Build, lint & test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust stable + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + components: clippy, rustfmt + + - name: Cache Cargo registry & build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Check formatting + run: cargo fmt --check + + - name: Clippy (deny warnings) + run: cargo clippy -- -D warnings + + - name: Build + run: cargo build --locked + + - name: Test + run: cargo test --locked + env: + RUST_BACKTRACE: 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2672cb1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,93 @@ +# Contributing to OpenFUT Core + +## Prerequisites + +- **Rust** (stable, 1.80+) — install via [rustup](https://rustup.rs) +- **SQLite 3** — usually pre-installed on Linux/macOS + +Recommended tools: + +```bash +rustup component add clippy rustfmt +``` + +## Setup + +```bash +git clone +cd openfut-core +cp .env.example .env +cargo build +``` + +## Running locally + +```bash +cargo run +``` + +The server starts at `http://127.0.0.1:8080` by default. Logs appear in the +terminal. On first run, the SQLite database is created and all migrations are +applied automatically. + +## Running tests + +```bash +cargo test +``` + +Tests spin up a full in-process server against an in-memory SQLite database — +no setup required and nothing is written to disk. + +## Code style + +All PRs must pass before merge: + +```bash +cargo fmt --check # formatting +cargo clippy -- -D warnings # lints +cargo test # full test suite +``` + +Auto-fix formatting with `cargo fmt`. + +## Adding card / pack / objective data + +See [MODDING.md](MODDING.md) for the data file schemas. No Rust code changes +are needed to add new cards, packs, objectives, or SBC definitions. + +## Project layout + +``` +src/ + app.rs router + middleware setup + config.rs Config struct (reads from env) + db.rs SQLite pool init + migrations + error.rs AppError enum + middleware.rs concurrency limit, request ID + models/ Serde structs for DB rows and API payloads + routes/ Axum handler functions (one file per domain) + services/ Business logic (no HTTP types here) + seed/ One-time startup seeds + modding/ JSON data loader utilities +data/ + cards/ CardDefinition JSON arrays + packs/ PackDefinition JSON arrays + objectives/ ObjectiveDefinition JSON arrays + sbcs/ SbcDefinition JSON arrays +migrations/ SQLite migration SQL files +``` + +## Branching & pull requests + +- Create a branch from `main`: `git checkout -b feat/my-feature` +- Keep PRs focused — one feature or fix per PR +- PR description should explain *why*, not just *what* + +## Design constraints + +- **No online multiplayer.** This is an offline local backend only. +- **No copyrighted assets.** All card data in `data/` must be original. +- **No real EA services.** Do not hardcode or reverse-engineer EA endpoints. +- **Game-independent core.** `openfut-core` must stay game-agnostic; + FIFA-specific logic belongs in `openfut-bridge`. diff --git a/Cargo.lock b/Cargo.lock index 861a96c..c2b44d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1115,6 +1115,7 @@ dependencies = [ "axum-test", "chrono", "dotenvy", + "http 1.4.2", "rand", "serde", "serde_json", @@ -2134,6 +2135,7 @@ dependencies = [ "tower-layer", "tower-service", "tracing", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cfb589f..3548c6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,11 +17,12 @@ path = "src/main.rs" [dependencies] axum = { version = "0.7", features = ["macros"] } +http = "1" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls", "migrate", "chrono", "uuid"] } -tower-http = { version = "0.5", features = ["cors", "trace"] } +tower-http = { version = "0.5", features = ["cors", "trace", "request-id"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } thiserror = "1" diff --git a/MODDING.md b/MODDING.md new file mode 100644 index 0000000..61bd927 --- /dev/null +++ b/MODDING.md @@ -0,0 +1,174 @@ +# OpenFUT Modding Guide + +All game content is driven by JSON files in the `data/` directory. You can add +new cards, packs, objectives, and SBCs without touching any Rust code. + +The server reloads data files on startup. After changing a file, restart +`openfut-core` to pick up the changes. + +--- + +## Cards — `data/cards/*.json` + +Each file contains a JSON array of `CardDefinition` objects. All files in the +directory are loaded at startup. + +```json +[ + { + "id": "card_unique_id", + "name": "Player Name", + "overall": 82, + "position": "ST", + "nation": "Brazil", + "league": "Brasileirao", + "club": "Flamengo", + "pace": 88, + "shooting": 85, + "passing": 74, + "dribbling": 82, + "defending": 38, + "physical": 76, + "rarity": "gold", + "image_path": null + } +] +``` + +**Valid rarity values:** `bronze`, `silver`, `gold`, `raregold`, `totw`, `hero`, `icon` + +**Valid positions:** `GK`, `CB`, `LB`, `RB`, `CDM`, `CM`, `CAM`, `LM`, `RM`, +`LW`, `RW`, `ST`, `CF` + +- `id` must be unique across all card files. +- `overall` is 1–99. +- Attribute values are 1–99. +- `image_path` can be a relative path to an image or `null`. + +--- + +## Packs — `data/packs/*.json` + +Each file contains a JSON array of `PackDefinition` objects. + +```json +[ + { + "id": "my_custom_pack", + "name": "Custom Pack", + "description": "12 random gold players.", + "cost_coins": 7500, + "slots": [ + { + "count": 12, + "min_overall": 75, + "rarity_filter": ["gold", "raregold"], + "guaranteed_rare": false + } + ] + } +] +``` + +Each **slot** defines one group of cards to draw: +- `count` — how many cards to draw for this slot. +- `min_overall` — minimum overall rating (inclusive), or `null` for no filter. +- `rarity_filter` — array of rarity strings to draw from, or `null` for any rarity. +- `guaranteed_rare` — if `true`, this slot's cards are marked as rare. + +A pack can have multiple slots (e.g., one slot of 11 gold cards + one guaranteed +rare slot of 1 card). + +--- + +## Objectives — `data/objectives/*.json` + +Each file contains a JSON array of `ObjectiveDefinition` objects. + +```json +[ + { + "id": "daily_score_3", + "title": "Hat-Trick Hero", + "description": "Score 3 goals in one session.", + "objective_type": "daily", + "metric": "goalsscored", + "target": 3, + "reward_coins": 500, + "reward_xp": 200, + "reward_pack_id": null + } +] +``` + +**Valid `objective_type` values:** `daily`, `weekly`, `milestone`, `lifetime`, `season` + +**Valid `metric` values:** + +| Metric | Incremented by | +|------------------|--------------------------------------| +| `matcheswon` | Winning a match | +| `matchesplayed` | Playing any match | +| `goalsscored` | Goals scored in a match | +| `packsopened` | Opening a pack | +| `sbcscompleted` | Completing an SBC | +| `coinsearned` | Coins awarded after a match | + +- `reward_pack_id` must match a pack `id` from `data/packs/` or be `null`. +- Daily objectives are auto-reset to 0 at midnight UTC. + +--- + +## SBCs — `data/sbcs/*.json` + +Each file contains a JSON array of `SbcDefinition` objects. + +```json +[ + { + "id": "my_sbc", + "name": "Custom SBC", + "description": "Submit 5 gold players for a reward.", + "expires_at": null, + "requirements": { + "squad_size": 5, + "min_overall": 75, + "max_overall": null, + "required_leagues": ["Premier League"], + "required_nations": [], + "required_clubs": [], + "min_players_from_same_league": null, + "min_players_from_same_nation": null, + "min_players_from_same_club": null + }, + "reward": { + "coins": 1000, + "xp": 300, + "pack_id": null + } + } +] +``` + +**Requirements:** +- `squad_size` — exact number of cards to submit (required). +- `min_overall` — average overall must meet or exceed this (optional). +- `max_overall` — no individual card may exceed this (optional). +- `required_leagues` — at least one card from each listed league. +- `required_nations` — at least one card from each listed nation. +- `required_clubs` — at least one card from each listed club. +- `min_players_from_same_league` — max count of any single league ≥ this. +- `min_players_from_same_nation` — max count of any single nation ≥ this. +- `min_players_from_same_club` — max count of any single club ≥ this. + +Cards submitted to an SBC are permanently consumed from the club's collection. + +--- + +## Tips + +- Use unique `id` values that won't collide with future official cards. + A good convention is `card__`, e.g. `card_br_042`. +- You can have multiple JSON files per directory — they are all merged at load time. +- Invalid JSON will prevent the server from starting; check the log output. +- Cards referenced by packs must exist in `data/cards/` or they will not be drawn. diff --git a/src/app.rs b/src/app.rs index a74d914..58ca30d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,4 @@ +use crate::middleware::{limit_concurrency, MakeRequestUuid}; use crate::{ config::Config, db::Pool, @@ -12,11 +13,17 @@ use crate::{ }; use anyhow::Result; use axum::{ + extract::DefaultBodyLimit, + middleware, routing::{delete, get, post, put}, Router, }; use std::sync::Arc; -use tower_http::{cors::CorsLayer, trace::TraceLayer}; +use tower_http::{ + cors::CorsLayer, + request_id::{PropagateRequestIdLayer, SetRequestIdLayer}, + trace::TraceLayer, +}; #[derive(Clone)] pub struct AppState { @@ -141,7 +148,13 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/settings", get(routes::settings::get_settings)) .route("/settings", put(routes::settings::put_settings)) .route("/draft/squad", get(routes::draft::get_draft_squad)) + // Layers run outermost-first (last added = outermost). + // CORS → body limit → concurrency limit → request ID → trace + .layer(PropagateRequestIdLayer::x_request_id()) .layer(TraceLayer::new_for_http()) + .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid)) + .layer(middleware::from_fn(limit_concurrency)) + .layer(DefaultBodyLimit::max(256 * 1024)) .layer(CorsLayer::permissive()) .with_state(state); diff --git a/src/lib.rs b/src/lib.rs index 971df47..68dddea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod app; pub mod config; pub mod db; pub mod error; +pub mod middleware; pub mod modding; pub mod models; pub mod routes; diff --git a/src/middleware.rs b/src/middleware.rs new file mode 100644 index 0000000..153066a --- /dev/null +++ b/src/middleware.rs @@ -0,0 +1,32 @@ +use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response}; +use std::sync::{Arc, LazyLock}; +use tokio::sync::Semaphore; +use tower_http::request_id::{MakeRequestId, RequestId}; +use uuid::Uuid; + +/// Maximum concurrent in-flight requests. Responds 429 when at capacity. +const MAX_CONCURRENT: usize = 256; + +static SEMAPHORE: LazyLock> = + LazyLock::new(|| Arc::new(Semaphore::new(MAX_CONCURRENT))); + +/// Axum middleware that limits concurrent in-flight requests. +/// Returns 429 Too Many Requests when the limit is reached. +pub async fn limit_concurrency(req: Request, next: Next) -> Result { + let Ok(_permit) = SEMAPHORE.try_acquire() else { + return Err(StatusCode::TOO_MANY_REQUESTS); + }; + Ok(next.run(req).await) +} + +/// Generates a UUID v4 for each request and attaches it as `x-request-id`. +#[derive(Clone, Copy)] +pub struct MakeRequestUuid; + +impl MakeRequestId for MakeRequestUuid { + fn make_request_id(&mut self, _: &http::Request) -> Option { + let id = Uuid::new_v4().to_string(); + let header_val = id.parse().ok()?; + Some(RequestId::new(header_val)) + } +}