Compare commits

..

1 Commits

Author SHA1 Message Date
funman300 f6dd41fd41 feat: Phase 4 — dev experience complete
CI / Build, lint & test (push) Successful in 2m21s
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 <noreply@anthropic.com>
2026-06-25 15:53:28 -07:00
9 changed files with 384 additions and 2 deletions
+19
View File
@@ -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
+47
View File
@@ -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
+93
View File
@@ -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 <repo-url>
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`.
Generated
+2
View File
@@ -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]]
+2 -1
View File
@@ -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"
+174
View File
@@ -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 199.
- Attribute values are 199.
- `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_<nation>_<number>`, 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.
+14 -1
View File
@@ -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<Router> {
.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);
+1
View File
@@ -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;
+32
View File
@@ -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<Arc<Semaphore>> =
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<Response, StatusCode> {
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<B>(&mut self, _: &http::Request<B>) -> Option<RequestId> {
let id = Uuid::new_v4().to_string();
let header_val = id.parse().ok()?;
Some(RequestId::new(header_val))
}
}