Compare commits
1 Commits
4ae1081aa9
...
f6dd41fd41
| Author | SHA1 | Date | |
|---|---|---|---|
| f6dd41fd41 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -1115,6 +1115,7 @@ dependencies = [
|
|||||||
"axum-test",
|
"axum-test",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"http 1.4.2",
|
||||||
"rand",
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2134,6 +2135,7 @@ dependencies = [
|
|||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+2
-1
@@ -17,11 +17,12 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
|
http = "1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls", "migrate", "chrono", "uuid"] }
|
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 = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
|
|||||||
+174
@@ -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_<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
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::middleware::{limit_concurrency, MakeRequestUuid};
|
||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
db::Pool,
|
db::Pool,
|
||||||
@@ -12,11 +13,17 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
|
middleware,
|
||||||
routing::{delete, get, post, put},
|
routing::{delete, get, post, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
use tower_http::{
|
||||||
|
cors::CorsLayer,
|
||||||
|
request_id::{PropagateRequestIdLayer, SetRequestIdLayer},
|
||||||
|
trace::TraceLayer,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
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", get(routes::settings::get_settings))
|
||||||
.route("/settings", put(routes::settings::put_settings))
|
.route("/settings", put(routes::settings::put_settings))
|
||||||
.route("/draft/squad", get(routes::draft::get_draft_squad))
|
.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(TraceLayer::new_for_http())
|
||||||
|
.layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
|
||||||
|
.layer(middleware::from_fn(limit_concurrency))
|
||||||
|
.layer(DefaultBodyLimit::max(256 * 1024))
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod app;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod middleware;
|
||||||
pub mod modding;
|
pub mod modding;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user