31ab4a683e23758c5035a4cd80701be4183fd351
Core had no way to settle a real sale. Every "buy" MINTED a new owned_cards row (services/economy.rs::purchase_item), so a sold card existed twice; the seller was never credited; no fee arithmetic existed anywhere in the project; and no /economy/* route could even name a counterparty, since all eight resolve one club from the X-OpenFUT-Game active profile. Adds settle_sale() beside the existing tx-scoped primitives, so it inherits the module's proven atomicity (pool.acquire + BEGIN IMMEDIATE + finish) rather than re-deriving it: debit buyer gross -> evict from squads -> transfer the EXISTING row -> credit seller gross-fee. The fee is simply never credited anywhere, which is what destroys it. Exposed as POST /economy/settle-sale, the one economy route that names clubs explicitly because a sale has two sides; an omitted buyer means a counterparty OUTSIDE the modelled economy, never a silent fallback to the active club (which would settle a club against itself). Ownership moves by UPDATE ... WHERE id = ? AND club_id = ?, an ownership CAS. No INSERT and no DELETE on the two-party path, so duplication is ruled out structurally, not by an assertion. Two bugs found by writing the tests rather than by reading the code: 1. Deriving the seller from CURRENT ownership let two racing buyers BOTH succeed — after the first sale the item belonged to B, so the second call read B as the seller and chain-sold it B -> C. Core has no listing concept and could not notice the replay. The seller is now the caller's EXPECTED owner and every ownership statement is predicated on it, which makes the CAS authoritative about "already sold" independently of caller-side listing state. 2. Debiting before transferring made a replay fail as "insufficient balance" (the buyer had spent the coins on the sale that succeeded), so the ownership guard was shadowed and settling_the_same_sale_twice_pays_once passed WITHOUT exercising the guard it named. Ownership is now judged first; the test asserts NotFound specifically and adds a cheap affordable replay that only ownership can refuse. Squad eviction is mandatory, not cosmetic: squad_players.owned_card_id is a FK and the pool enables foreign_keys, so an Outside sale of a squadded card would fail outright, and a transfer preserves the row id so a stale lineup row would leave the PREVIOUS owner fielding a card they no longer own. Tests: 21 service (canonical 15,000/750/14,250 fixture, conservation, squad eviction, Outside retirement, 8 invalid paths, zero-price, replay, two-buyer race) + 6 route-level over HTTP with two parties. Core 194 pass. Deliberately NOT done: no wire/route output change, no deployment, and nothing yet decides that a player's listing has sold — the seller-facing sold wire state needs live client evidence and must not be guessed.
OpenFUT Core
Offline Ultimate Team backend — game-independent.
OpenFUT Core is the heart of the OpenFUT project: a fully offline, single-player FUT-style backend written in Rust. It is deliberately decoupled from any specific game, though it is designed to power a FIFA 23 offline experience.
What it does
- Creates and manages local player profiles and clubs
- Manages coins, XP, and progression
- Generates packs from weighted JSON definitions
- Tracks your full card collection
- Squad builder with formations and chemistry (chemistry calculations: WIP)
- Objectives engine (daily, weekly, lifetime, milestone)
- SBC (Squad Building Challenge) engine with JSON-defined challenges
- Match result processing with coin and XP rewards
- NPC transfer market with daily refreshes
- Statistics tracking
- Fully moddable via JSON data files
Tech Stack
- Rust + Axum (HTTP framework)
- Tokio (async runtime)
- SQLite + SQLx (database + migrations)
- Serde (JSON data layer)
- tower-http (middleware: CORS, tracing)
Quick Start
# Build
cargo build --release
# Run (creates openfut.db in current directory)
./target/release/openfut-core
# Or with custom config
DATABASE_URL=sqlite://./myclub.db LISTEN_ADDR=127.0.0.1:8080 ./target/release/openfut-core
Environment Variables
| Variable | Default | Description |
|---|---|---|
LISTEN_ADDR |
127.0.0.1:8080 |
Address to listen on |
DATABASE_URL |
sqlite://openfut.db |
SQLite database path |
DATA_DIR |
data |
Path to JSON data files |
API Routes
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check |
POST |
/auth/local |
Create first-run profile + club |
GET |
/profile |
Get active profile |
GET |
/club |
Get active club with coins |
GET |
/cards |
Browse all card definitions |
GET |
/collection |
Get owned cards |
GET |
/packs |
List unopened packs |
POST |
/packs/open/:pack_id |
Open a pack |
GET |
/squad |
Get active squad |
POST |
/squad |
Save squad |
GET |
/objectives |
List objectives with progress |
POST |
/matches/result |
Submit match result + receive rewards |
GET |
/sbc |
List SBC definitions |
POST |
/sbc/submit |
Submit SBC solution |
GET |
/market |
Browse NPC transfer market |
POST |
/market/buy |
Buy listing |
POST |
/market/sell |
Quick-sell card |
POST |
/market/refresh |
Refresh NPC listings |
GET |
/statistics |
Get match/pack/SBC stats |
First Run
# Create your profile
curl -X POST http://localhost:8080/auth/local \
-H 'Content-Type: application/json' \
-d '{"username": "Player 1"}'
# Check your club (5000 coins + a gold pack waiting)
curl http://localhost:8080/club
# Open your starter pack
curl -X POST http://localhost:8080/packs/open/<pack_id>
# Submit a match win
curl -X POST http://localhost:8080/matches/result \
-H 'Content-Type: application/json' \
-d '{"squad_id":"any","opponent_name":"Beginner AI","goals_for":3,"goals_against":0,"mode":"squad_battles"}'
Modding
All game content lives in data/. Drop JSON files into the appropriate folder and restart.
data/
cards/ ← CardDefinition[]
packs/ ← PackDefinition[]
objectives/ ← ObjectiveDefinition[]
sbcs/ ← SbcDefinition[]
events/ ← (future)
See docs/modding.md for schema reference.
Development
cargo fmt
cargo clippy -- -D warnings
cargo test
License
MIT — see LICENSE
Description
Languages
Rust
100%