Initial commit: OpenFUT Core
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>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
target/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
.env.local
|
||||
Generated
+2753
File diff suppressed because it is too large
Load Diff
+38
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "openfut-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["OpenFUT Contributors"]
|
||||
description = "Offline Ultimate Team backend — game-independent core"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/openfut/openfut-core"
|
||||
|
||||
[lib]
|
||||
name = "openfut_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "openfut-core"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
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"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
rand = "0.8"
|
||||
dotenvy = "0.15"
|
||||
axum-macros = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
axum-test = "14"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
@@ -0,0 +1,135 @@
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
cargo fmt
|
||||
cargo clippy -- -D warnings
|
||||
cargo test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see LICENSE
|
||||
@@ -0,0 +1,82 @@
|
||||
# OpenFUT Core — TODO
|
||||
|
||||
Small, independently completable tasks.
|
||||
|
||||
## Foundation
|
||||
- [ ] #1 Add `.env.example` with all supported env vars documented
|
||||
- [ ] #2 Add `CONTRIBUTING.md` with dev setup instructions
|
||||
- [ ] #3 Write modding guide (`docs/modding.md`) with full JSON schemas
|
||||
- [ ] #4 Add database schema diagram to `docs/`
|
||||
- [ ] #5 Add GitHub Actions CI workflow (fmt + clippy + test)
|
||||
|
||||
## Cards
|
||||
- [ ] #6 Add more bronze card JSON entries (target: 30+ cards)
|
||||
- [ ] #7 Add silver card JSON entries (target: 20+ cards)
|
||||
- [ ] #8 Add rare gold card JSON entries (target: 10+ cards)
|
||||
- [ ] #9 Add TOTW placeholder cards
|
||||
- [ ] #10 Add Hero placeholder cards
|
||||
- [ ] #11 Implement card image_path support (placeholder PNG serving)
|
||||
- [ ] #12 Add `GET /cards/:card_id` endpoint for single card lookup
|
||||
|
||||
## Packs
|
||||
- [ ] #13 Add TOTW pack definition
|
||||
- [ ] #14 Add Icon pack definition
|
||||
- [ ] #15 Implement `POST /packs/buy` (purchase a pack with coins)
|
||||
- [ ] #16 Add pack opening animation hints to the response
|
||||
|
||||
## Squad Builder
|
||||
- [ ] #17 Implement chemistry calculation (same club/league/nation bonuses)
|
||||
- [ ] #18 Add formation validation (11 players, 1 GK, etc.)
|
||||
- [ ] #19 Add `GET /formations` endpoint listing available formations
|
||||
- [ ] #20 Add multiple squad support (save/load named squads)
|
||||
|
||||
## Objectives
|
||||
- [ ] #21 Add weekly objective JSON data
|
||||
- [ ] #22 Add milestone objective JSON data
|
||||
- [ ] #23 Implement `POST /objectives/claim` to claim completed reward
|
||||
- [ ] #24 Add season-level objective tracking
|
||||
- [ ] #25 Reset daily objectives at midnight
|
||||
|
||||
## SBCs
|
||||
- [ ] #26 Add 5 more SBC definitions in JSON
|
||||
- [ ] #27 Add club requirement validation to SBC engine
|
||||
- [ ] #28 Add max_overall requirement validation
|
||||
- [ ] #29 Add chemistry requirement validation
|
||||
- [ ] #30 Add `GET /sbc/:id` endpoint for single SBC
|
||||
|
||||
## Market
|
||||
- [ ] #31 Schedule automatic daily NPC market refresh (via tokio background task)
|
||||
- [ ] #32 Add market listing expiry cleanup job
|
||||
- [ ] #33 Add `GET /market?min_overall=X&position=Y` filtering
|
||||
- [ ] #34 Add sell price floor/ceiling validation
|
||||
- [ ] #35 Add market transaction history endpoint
|
||||
|
||||
## Matches
|
||||
- [ ] #36 Add `GET /matches` history endpoint
|
||||
- [ ] #37 Implement Squad Battles opponent generator (random AI clubs)
|
||||
- [ ] #38 Add Draft mode: generate random squad + play matches
|
||||
- [ ] #39 Add seasonal rank tracking for Squad Battles
|
||||
- [ ] #40 Add `mode` enum: squad_battles, seasons, draft, friendly
|
||||
|
||||
## Statistics
|
||||
- [ ] #41 Add per-position goal stats
|
||||
- [ ] #42 Add win streak tracking
|
||||
- [ ] #43 Add `GET /statistics/history` (last N matches)
|
||||
|
||||
## Settings
|
||||
- [ ] #44 Add `GET /settings` and `PUT /settings` endpoints
|
||||
- [ ] #45 Store difficulty preference in settings
|
||||
- [ ] #46 Store preferred formation in settings
|
||||
|
||||
## Events
|
||||
- [ ] #47 Design JSON schema for limited-time events
|
||||
- [ ] #48 Implement event activation/deactivation
|
||||
- [ ] #49 Add TOTW event that enables special pack
|
||||
|
||||
## Hardening
|
||||
- [ ] #50 Add request body size limits
|
||||
- [ ] #51 Add rate limiting middleware (prevent accidental loops)
|
||||
- [ ] #52 Add proper logging correlation IDs
|
||||
- [ ] #53 Write additional integration tests for SBC engine
|
||||
- [ ] #54 Write integration test for full pack open flow
|
||||
- [ ] #55 Add SQLite WAL mode for better concurrency
|
||||
@@ -0,0 +1,189 @@
|
||||
[
|
||||
{
|
||||
"id": "card_bronze_001",
|
||||
"name": "Lucas Santos",
|
||||
"overall": 62,
|
||||
"position": "ST",
|
||||
"nation": "Brazil",
|
||||
"league": "Serie B",
|
||||
"club": "Santos FC",
|
||||
"pace": 72,
|
||||
"shooting": 61,
|
||||
"passing": 52,
|
||||
"dribbling": 63,
|
||||
"defending": 30,
|
||||
"physical": 64,
|
||||
"rarity": "bronze",
|
||||
"image_path": null
|
||||
},
|
||||
{
|
||||
"id": "card_bronze_002",
|
||||
"name": "Marco Ferri",
|
||||
"overall": 60,
|
||||
"position": "CM",
|
||||
"nation": "Italy",
|
||||
"league": "Serie C",
|
||||
"club": "Modena FC",
|
||||
"pace": 58,
|
||||
"shooting": 52,
|
||||
"passing": 63,
|
||||
"dribbling": 59,
|
||||
"defending": 55,
|
||||
"physical": 60,
|
||||
"rarity": "bronze",
|
||||
"image_path": null
|
||||
},
|
||||
{
|
||||
"id": "card_bronze_003",
|
||||
"name": "Eko Jabari",
|
||||
"overall": 61,
|
||||
"position": "CB",
|
||||
"nation": "Nigeria",
|
||||
"league": "NPFL",
|
||||
"club": "Kano Pillars",
|
||||
"pace": 60,
|
||||
"shooting": 25,
|
||||
"passing": 48,
|
||||
"dribbling": 42,
|
||||
"defending": 64,
|
||||
"physical": 70,
|
||||
"rarity": "bronze",
|
||||
"image_path": null
|
||||
},
|
||||
{
|
||||
"id": "card_bronze_004",
|
||||
"name": "Pieter Van Dijk",
|
||||
"overall": 63,
|
||||
"position": "LB",
|
||||
"nation": "Netherlands",
|
||||
"league": "Eerste Divisie",
|
||||
"club": "FC Volendam",
|
||||
"pace": 65,
|
||||
"shooting": 38,
|
||||
"passing": 60,
|
||||
"dribbling": 61,
|
||||
"defending": 65,
|
||||
"physical": 62,
|
||||
"rarity": "bronze",
|
||||
"image_path": null
|
||||
},
|
||||
{
|
||||
"id": "card_bronze_005",
|
||||
"name": "Tomás Ruiz",
|
||||
"overall": 62,
|
||||
"position": "GK",
|
||||
"nation": "Spain",
|
||||
"league": "Segunda B",
|
||||
"club": "SD Compostela",
|
||||
"pace": 40,
|
||||
"shooting": 10,
|
||||
"passing": 35,
|
||||
"dribbling": 20,
|
||||
"defending": 62,
|
||||
"physical": 58,
|
||||
"rarity": "bronze",
|
||||
"image_path": null
|
||||
},
|
||||
{
|
||||
"id": "card_bronze_006",
|
||||
"name": "James Okafor",
|
||||
"overall": 60,
|
||||
"position": "RM",
|
||||
"nation": "Ghana",
|
||||
"league": "Ghana Premier League",
|
||||
"club": "Asante Kotoko",
|
||||
"pace": 75,
|
||||
"shooting": 55,
|
||||
"passing": 58,
|
||||
"dribbling": 66,
|
||||
"defending": 32,
|
||||
"physical": 55,
|
||||
"rarity": "bronze",
|
||||
"image_path": null
|
||||
},
|
||||
{
|
||||
"id": "card_bronze_007",
|
||||
"name": "Stefan Kovac",
|
||||
"overall": 61,
|
||||
"position": "CDM",
|
||||
"nation": "Serbia",
|
||||
"league": "Super liga Srbije",
|
||||
"club": "FK Partizan",
|
||||
"pace": 55,
|
||||
"shooting": 50,
|
||||
"passing": 60,
|
||||
"dribbling": 56,
|
||||
"defending": 63,
|
||||
"physical": 68,
|
||||
"rarity": "bronze",
|
||||
"image_path": null
|
||||
},
|
||||
{
|
||||
"id": "card_bronze_008",
|
||||
"name": "Hiroshi Tanaka",
|
||||
"overall": 63,
|
||||
"position": "CAM",
|
||||
"nation": "Japan",
|
||||
"league": "J2 League",
|
||||
"club": "Gamba Osaka",
|
||||
"pace": 68,
|
||||
"shooting": 60,
|
||||
"passing": 66,
|
||||
"dribbling": 70,
|
||||
"defending": 38,
|
||||
"physical": 52,
|
||||
"rarity": "bronze",
|
||||
"image_path": null
|
||||
},
|
||||
{
|
||||
"id": "card_bronze_009",
|
||||
"name": "Aleksei Morozov",
|
||||
"overall": 60,
|
||||
"position": "RB",
|
||||
"nation": "Russia",
|
||||
"league": "FNL",
|
||||
"club": "Torpedo Moscow",
|
||||
"pace": 62,
|
||||
"shooting": 40,
|
||||
"passing": 56,
|
||||
"dribbling": 58,
|
||||
"defending": 62,
|
||||
"physical": 60,
|
||||
"rarity": "bronze",
|
||||
"image_path": null
|
||||
},
|
||||
{
|
||||
"id": "card_bronze_010",
|
||||
"name": "Kwame Asante",
|
||||
"overall": 62,
|
||||
"position": "LM",
|
||||
"nation": "Ghana",
|
||||
"league": "Ghana Premier League",
|
||||
"club": "Hearts of Oak",
|
||||
"pace": 73,
|
||||
"shooting": 58,
|
||||
"passing": 60,
|
||||
"dribbling": 65,
|
||||
"defending": 35,
|
||||
"physical": 58,
|
||||
"rarity": "bronze",
|
||||
"image_path": null
|
||||
},
|
||||
{
|
||||
"id": "card_bronze_011",
|
||||
"name": "Ivan Petrov",
|
||||
"overall": 61,
|
||||
"position": "CB",
|
||||
"nation": "Bulgaria",
|
||||
"league": "First League",
|
||||
"club": "CSKA Sofia",
|
||||
"pace": 58,
|
||||
"shooting": 22,
|
||||
"passing": 50,
|
||||
"dribbling": 44,
|
||||
"defending": 63,
|
||||
"physical": 67,
|
||||
"rarity": "bronze",
|
||||
"image_path": null
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,87 @@
|
||||
[
|
||||
{
|
||||
"id": "card_gold_001",
|
||||
"name": "Alejandro Vargas",
|
||||
"overall": 82,
|
||||
"position": "ST",
|
||||
"nation": "Argentina",
|
||||
"league": "Primera Division",
|
||||
"club": "River Plate",
|
||||
"pace": 85,
|
||||
"shooting": 84,
|
||||
"passing": 72,
|
||||
"dribbling": 80,
|
||||
"defending": 40,
|
||||
"physical": 78,
|
||||
"rarity": "gold",
|
||||
"image_path": null
|
||||
},
|
||||
{
|
||||
"id": "card_gold_002",
|
||||
"name": "Thomas Beaumont",
|
||||
"overall": 80,
|
||||
"position": "CM",
|
||||
"nation": "France",
|
||||
"league": "Ligue 2",
|
||||
"club": "FC Toulouse",
|
||||
"pace": 72,
|
||||
"shooting": 74,
|
||||
"passing": 83,
|
||||
"dribbling": 78,
|
||||
"defending": 68,
|
||||
"physical": 74,
|
||||
"rarity": "gold",
|
||||
"image_path": null
|
||||
},
|
||||
{
|
||||
"id": "card_gold_003",
|
||||
"name": "Dimitri Kovalenko",
|
||||
"overall": 81,
|
||||
"position": "CB",
|
||||
"nation": "Ukraine",
|
||||
"league": "Premier League UA",
|
||||
"club": "Shakhtar Donetsk",
|
||||
"pace": 75,
|
||||
"shooting": 38,
|
||||
"passing": 65,
|
||||
"dribbling": 60,
|
||||
"defending": 83,
|
||||
"physical": 82,
|
||||
"rarity": "gold",
|
||||
"image_path": null
|
||||
},
|
||||
{
|
||||
"id": "card_gold_004",
|
||||
"name": "Felipe Moura",
|
||||
"overall": 83,
|
||||
"position": "LW",
|
||||
"nation": "Brazil",
|
||||
"league": "Brasileirao",
|
||||
"club": "Palmeiras",
|
||||
"pace": 88,
|
||||
"shooting": 80,
|
||||
"passing": 76,
|
||||
"dribbling": 86,
|
||||
"defending": 44,
|
||||
"physical": 68,
|
||||
"rarity": "gold",
|
||||
"image_path": null
|
||||
},
|
||||
{
|
||||
"id": "card_gold_005",
|
||||
"name": "Lars Eriksson",
|
||||
"overall": 79,
|
||||
"position": "GK",
|
||||
"nation": "Sweden",
|
||||
"league": "Allsvenskan",
|
||||
"club": "Malmo FF",
|
||||
"pace": 45,
|
||||
"shooting": 12,
|
||||
"passing": 58,
|
||||
"dribbling": 32,
|
||||
"defending": 80,
|
||||
"physical": 76,
|
||||
"rarity": "gold",
|
||||
"image_path": null
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
[
|
||||
{
|
||||
"id": "card_icon_loan_001",
|
||||
"name": "The Maestro (Loan)",
|
||||
"overall": 94,
|
||||
"position": "CAM",
|
||||
"nation": "France",
|
||||
"league": "Icons",
|
||||
"club": "Icons",
|
||||
"pace": 82,
|
||||
"shooting": 88,
|
||||
"passing": 95,
|
||||
"dribbling": 92,
|
||||
"defending": 60,
|
||||
"physical": 72,
|
||||
"rarity": "icon",
|
||||
"image_path": null
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
[
|
||||
{
|
||||
"id": "daily_play_1_match",
|
||||
"title": "Daily Match",
|
||||
"description": "Play 1 match today.",
|
||||
"objective_type": "daily",
|
||||
"metric": "matchesplayed",
|
||||
"target": 1,
|
||||
"reward_coins": 500,
|
||||
"reward_pack_id": null,
|
||||
"reward_xp": 100
|
||||
},
|
||||
{
|
||||
"id": "daily_score_3_goals",
|
||||
"title": "Hat-Trick Hero",
|
||||
"description": "Score 3 goals in matches today.",
|
||||
"objective_type": "daily",
|
||||
"metric": "goalsscored",
|
||||
"target": 3,
|
||||
"reward_coins": 750,
|
||||
"reward_pack_id": null,
|
||||
"reward_xp": 150
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
[
|
||||
{
|
||||
"id": "lifetime_10_wins",
|
||||
"title": "10 Victories",
|
||||
"description": "Win 10 matches total.",
|
||||
"objective_type": "lifetime",
|
||||
"metric": "matcheswon",
|
||||
"target": 10,
|
||||
"reward_coins": 2000,
|
||||
"reward_pack_id": "gold_pack",
|
||||
"reward_xp": 500
|
||||
},
|
||||
{
|
||||
"id": "lifetime_open_5_packs",
|
||||
"title": "Pack Opener",
|
||||
"description": "Open 5 packs.",
|
||||
"objective_type": "lifetime",
|
||||
"metric": "packsopened",
|
||||
"target": 5,
|
||||
"reward_coins": 1000,
|
||||
"reward_pack_id": null,
|
||||
"reward_xp": 250
|
||||
},
|
||||
{
|
||||
"id": "lifetime_complete_3_sbcs",
|
||||
"title": "SBC Enthusiast",
|
||||
"description": "Complete 3 Squad Building Challenges.",
|
||||
"objective_type": "lifetime",
|
||||
"metric": "sbcscompleted",
|
||||
"target": 3,
|
||||
"reward_coins": 5000,
|
||||
"reward_pack_id": "rare_gold_pack",
|
||||
"reward_xp": 750
|
||||
},
|
||||
{
|
||||
"id": "lifetime_earn_50k_coins",
|
||||
"title": "Coin Collector",
|
||||
"description": "Earn 50,000 coins total.",
|
||||
"objective_type": "lifetime",
|
||||
"metric": "coinsearned",
|
||||
"target": 50000,
|
||||
"reward_coins": 10000,
|
||||
"reward_pack_id": null,
|
||||
"reward_xp": 1000
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
[
|
||||
{
|
||||
"id": "bronze_pack",
|
||||
"name": "Bronze Pack",
|
||||
"description": "Contains 12 bronze players.",
|
||||
"cost_coins": 400,
|
||||
"slots": [
|
||||
{
|
||||
"count": 12,
|
||||
"min_overall": null,
|
||||
"rarity_filter": ["bronze"],
|
||||
"guaranteed_rare": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "silver_pack",
|
||||
"name": "Silver Pack",
|
||||
"description": "Contains 12 silver players.",
|
||||
"cost_coins": 2500,
|
||||
"slots": [
|
||||
{
|
||||
"count": 12,
|
||||
"min_overall": 65,
|
||||
"rarity_filter": null,
|
||||
"guaranteed_rare": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gold_pack",
|
||||
"name": "Gold Pack",
|
||||
"description": "Contains 12 gold players, at least one rare.",
|
||||
"cost_coins": 7500,
|
||||
"slots": [
|
||||
{
|
||||
"count": 11,
|
||||
"min_overall": 75,
|
||||
"rarity_filter": ["gold"],
|
||||
"guaranteed_rare": false
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"min_overall": 75,
|
||||
"rarity_filter": ["raregold"],
|
||||
"guaranteed_rare": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rare_gold_pack",
|
||||
"name": "Rare Gold Pack",
|
||||
"description": "Contains 12 rare gold players.",
|
||||
"cost_coins": 15000,
|
||||
"slots": [
|
||||
{
|
||||
"count": 12,
|
||||
"min_overall": 75,
|
||||
"rarity_filter": ["raregold"],
|
||||
"guaranteed_rare": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
[
|
||||
{
|
||||
"id": "sbc_bronze_upgrade",
|
||||
"name": "Bronze Upgrade",
|
||||
"description": "Submit 11 bronze players for a silver pack.",
|
||||
"requirements": {
|
||||
"squad_size": 11,
|
||||
"min_overall": null,
|
||||
"max_overall": 64,
|
||||
"min_chemistry": null,
|
||||
"required_leagues": [],
|
||||
"required_nations": [],
|
||||
"required_clubs": [],
|
||||
"min_players_from_same_league": null,
|
||||
"min_players_from_same_nation": null,
|
||||
"min_players_from_same_club": null
|
||||
},
|
||||
"reward": {
|
||||
"coins": 0,
|
||||
"xp": 200,
|
||||
"pack_id": "silver_pack"
|
||||
},
|
||||
"expires_at": null,
|
||||
"repeatable": true
|
||||
},
|
||||
{
|
||||
"id": "sbc_hybrid_nations",
|
||||
"name": "Hybrid Nations",
|
||||
"description": "Build a squad of 11 players with at least one Brazilian and one Argentinian.",
|
||||
"requirements": {
|
||||
"squad_size": 11,
|
||||
"min_overall": 70,
|
||||
"max_overall": null,
|
||||
"min_chemistry": null,
|
||||
"required_leagues": [],
|
||||
"required_nations": ["Brazil", "Argentina"],
|
||||
"required_clubs": [],
|
||||
"min_players_from_same_league": null,
|
||||
"min_players_from_same_nation": null,
|
||||
"min_players_from_same_club": null
|
||||
},
|
||||
"reward": {
|
||||
"coins": 2000,
|
||||
"xp": 400,
|
||||
"pack_id": "gold_pack"
|
||||
},
|
||||
"expires_at": null,
|
||||
"repeatable": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
# 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:
|
||||
|
||||
```rust
|
||||
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
|
||||
|
||||
1. `POST /packs/open/:pack_id` → `routes::packs::post_open_pack`
|
||||
2. Fetch profile + club from DB
|
||||
3. Call `services::pack::open_pack(pool, card_db, pack_defs, club_id, pack_id)`
|
||||
4. Validate pack exists + not opened
|
||||
5. For each slot in the pack definition, randomly select cards (synchronously — no rng held across await)
|
||||
6. Insert `owned_cards` rows for each card
|
||||
7. Mark pack as opened
|
||||
8. Increment pack stats + objective progress
|
||||
9. Return `PackOpenResult { pack_id, cards }`
|
||||
|
||||
## Data Flow: Match Result
|
||||
|
||||
1. `POST /matches/result` → `routes::matches::post_match_result`
|
||||
2. Fetch profile + club
|
||||
3. `services::match_service::process_match(...)`
|
||||
4. Determine outcome (win/draw/loss), compute coins + XP
|
||||
5. Insert match record
|
||||
6. `club::add_coins`, `profile::add_xp`
|
||||
7. `statistics::record_match`
|
||||
8. `objective::increment_metric` for matches_played, matches_won, goals_scored, coins_earned
|
||||
9. 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:
|
||||
1. Create a JSON file in `data/cards/`
|
||||
2. The file must be an array of `CardDefinition`
|
||||
3. Restart the server
|
||||
|
||||
The `CardDb` struct loads all JSON files at startup and holds them in a `HashMap<String, CardDefinition>`.
|
||||
@@ -0,0 +1,126 @@
|
||||
-- OpenFUT Core initial schema
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
level INTEGER NOT NULL DEFAULT 1,
|
||||
xp INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clubs (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
profile_id TEXT NOT NULL REFERENCES profiles(id),
|
||||
name TEXT NOT NULL,
|
||||
coins INTEGER NOT NULL DEFAULT 0,
|
||||
level INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS owned_cards (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
club_id TEXT NOT NULL REFERENCES clubs(id),
|
||||
card_id TEXT NOT NULL,
|
||||
is_loan INTEGER NOT NULL DEFAULT 0,
|
||||
loan_matches_remaining INTEGER,
|
||||
acquired_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS packs (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
club_id TEXT NOT NULL REFERENCES clubs(id),
|
||||
definition_id TEXT NOT NULL,
|
||||
opened INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS squads (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
club_id TEXT NOT NULL REFERENCES clubs(id),
|
||||
name TEXT NOT NULL DEFAULT 'My Squad',
|
||||
formation TEXT NOT NULL DEFAULT '4-4-2',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS squad_players (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
squad_id TEXT NOT NULL REFERENCES squads(id),
|
||||
owned_card_id TEXT NOT NULL REFERENCES owned_cards(id),
|
||||
position_index INTEGER NOT NULL,
|
||||
is_captain INTEGER NOT NULL DEFAULT 0,
|
||||
is_on_bench INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS objective_progress (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
profile_id TEXT NOT NULL REFERENCES profiles(id),
|
||||
objective_id TEXT NOT NULL,
|
||||
current INTEGER NOT NULL DEFAULT 0,
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
claimed INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(profile_id, objective_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS matches (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
profile_id TEXT NOT NULL REFERENCES profiles(id),
|
||||
squad_id TEXT NOT NULL,
|
||||
opponent_name TEXT NOT NULL,
|
||||
goals_for INTEGER NOT NULL DEFAULT 0,
|
||||
goals_against INTEGER NOT NULL DEFAULT 0,
|
||||
outcome TEXT NOT NULL,
|
||||
coins_awarded INTEGER NOT NULL DEFAULT 0,
|
||||
xp_awarded INTEGER NOT NULL DEFAULT 0,
|
||||
mode TEXT NOT NULL DEFAULT 'squad_battles',
|
||||
played_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS statistics (
|
||||
profile_id TEXT PRIMARY KEY NOT NULL REFERENCES profiles(id),
|
||||
matches_played INTEGER NOT NULL DEFAULT 0,
|
||||
matches_won INTEGER NOT NULL DEFAULT 0,
|
||||
matches_drawn INTEGER NOT NULL DEFAULT 0,
|
||||
matches_lost INTEGER NOT NULL DEFAULT 0,
|
||||
goals_scored INTEGER NOT NULL DEFAULT 0,
|
||||
goals_conceded INTEGER NOT NULL DEFAULT 0,
|
||||
packs_opened INTEGER NOT NULL DEFAULT 0,
|
||||
sbcs_completed INTEGER NOT NULL DEFAULT 0,
|
||||
total_coins_earned INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sbc_submissions (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
profile_id TEXT NOT NULL REFERENCES profiles(id),
|
||||
sbc_id TEXT NOT NULL,
|
||||
submitted_card_ids TEXT NOT NULL,
|
||||
passed INTEGER NOT NULL DEFAULT 0,
|
||||
submitted_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS market_listings (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
card_id TEXT NOT NULL,
|
||||
seller_name TEXT NOT NULL,
|
||||
price INTEGER NOT NULL DEFAULT 0,
|
||||
listed_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
sold INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_owned_cards_club ON owned_cards(club_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_packs_club ON packs(club_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_squad_players_squad ON squad_players(squad_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_obj_progress_profile ON objective_progress(profile_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_profile ON matches(profile_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_market_active ON market_listings(sold, expires_at);
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
use crate::{
|
||||
config::Config,
|
||||
db::Pool,
|
||||
models::{objective::ObjectiveDefinition, pack::PackDefinition, sbc::SbcDefinition},
|
||||
routes,
|
||||
services::{
|
||||
card_db::CardDb, objective::load_objective_definitions, pack::load_pack_definitions,
|
||||
sbc::load_sbc_definitions,
|
||||
},
|
||||
};
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: Pool,
|
||||
pub card_db: Arc<CardDb>,
|
||||
pub pack_defs: Arc<Vec<PackDefinition>>,
|
||||
pub obj_defs: Arc<Vec<ObjectiveDefinition>>,
|
||||
pub sbc_defs: Arc<Vec<SbcDefinition>>,
|
||||
}
|
||||
|
||||
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
let card_db = Arc::new(CardDb::load(&cfg.data_dir)?);
|
||||
let pack_defs = Arc::new(load_pack_definitions(&cfg.data_dir)?);
|
||||
let obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?);
|
||||
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
|
||||
|
||||
tracing::info!(
|
||||
"Loaded {} packs, {} objectives, {} SBCs",
|
||||
pack_defs.len(),
|
||||
obj_defs.len(),
|
||||
sbc_defs.len()
|
||||
);
|
||||
|
||||
let state = AppState {
|
||||
pool,
|
||||
card_db,
|
||||
pack_defs,
|
||||
obj_defs,
|
||||
sbc_defs,
|
||||
};
|
||||
|
||||
let router = Router::new()
|
||||
.route("/health", get(routes::health::get_health))
|
||||
.route("/auth/local", post(routes::auth::post_auth_local))
|
||||
.route("/profile", get(routes::profile::get_profile))
|
||||
.route("/club", get(routes::club::get_club))
|
||||
.route("/cards", get(routes::cards::get_cards))
|
||||
.route("/collection", get(routes::cards::get_collection))
|
||||
.route("/packs", get(routes::packs::get_packs))
|
||||
.route("/packs/open/:pack_id", post(routes::packs::post_open_pack))
|
||||
.route("/squad", get(routes::squad::get_squad))
|
||||
.route("/squad", post(routes::squad::post_squad))
|
||||
.route("/objectives", get(routes::objectives::get_objectives))
|
||||
.route("/matches/result", post(routes::matches::post_match_result))
|
||||
.route("/sbc", get(routes::sbc::get_sbcs))
|
||||
.route("/sbc/submit", post(routes::sbc::post_sbc_submit))
|
||||
.route("/market", get(routes::market::get_market))
|
||||
.route("/market/buy", post(routes::market::post_market_buy))
|
||||
.route("/market/sell", post(routes::market::post_market_sell))
|
||||
.route("/market/refresh", post(routes::market::post_market_refresh))
|
||||
.route("/statistics", get(routes::statistics::get_statistics))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
|
||||
Ok(router)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use anyhow::Result;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub listen_addr: String,
|
||||
pub database_url: String,
|
||||
pub data_dir: String,
|
||||
#[allow(dead_code)]
|
||||
pub max_connections: u32,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
Ok(Self {
|
||||
listen_addr: std::env::var("LISTEN_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".into()),
|
||||
database_url: std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "sqlite://openfut.db".into()),
|
||||
data_dir: std::env::var("DATA_DIR").unwrap_or_else(|_| "data".into()),
|
||||
max_connections: std::env::var("DB_MAX_CONNECTIONS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(5),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
|
||||
use tracing::info;
|
||||
|
||||
pub type Pool = SqlitePool;
|
||||
|
||||
pub async fn init_pool(database_url: &str) -> Result<Pool> {
|
||||
info!("Connecting to database: {}", database_url);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(database_url)
|
||||
.await?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
pub async fn run_migrations(pool: &Pool) -> Result<()> {
|
||||
info!("Running database migrations");
|
||||
sqlx::migrate!("./migrations").run(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AppError {
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
|
||||
#[error("internal error: {0}")]
|
||||
Internal(#[from] anyhow::Error),
|
||||
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||
AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
|
||||
AppError::Database(e) => {
|
||||
tracing::error!("Database error: {e}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "database error".into())
|
||||
}
|
||||
AppError::Internal(e) => {
|
||||
tracing::error!("Internal error: {e}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal server error".into(),
|
||||
)
|
||||
}
|
||||
AppError::Io(e) => {
|
||||
tracing::error!("IO error: {e}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "io error".into())
|
||||
}
|
||||
AppError::Json(e) => (StatusCode::BAD_REQUEST, format!("json parse error: {e}")),
|
||||
};
|
||||
|
||||
let body = Json(json!({ "error": message }));
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
pub mod app;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod modding;
|
||||
pub mod models;
|
||||
pub mod routes;
|
||||
pub mod seed;
|
||||
pub mod services;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::Router;
|
||||
|
||||
/// Build the full Axum application with a provided pool and data directory.
|
||||
/// Used by tests to create in-process app instances.
|
||||
pub async fn build_app(pool: db::Pool, data_dir: &str) -> Result<Router> {
|
||||
let cfg = config::Config {
|
||||
listen_addr: "127.0.0.1:0".into(),
|
||||
database_url: "sqlite::memory:".into(),
|
||||
data_dir: data_dir.to_string(),
|
||||
max_connections: 1,
|
||||
};
|
||||
app::build(pool, cfg).await
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
use anyhow::Result;
|
||||
use openfut_core::{config, db, seed};
|
||||
use tracing::info;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "openfut_core=debug,tower_http=debug".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let cfg = config::Config::from_env()?;
|
||||
info!("OpenFUT Core starting on {}", cfg.listen_addr);
|
||||
|
||||
let pool = db::init_pool(&cfg.database_url).await?;
|
||||
db::run_migrations(&pool).await?;
|
||||
|
||||
seed::maybe_seed(&pool).await?;
|
||||
|
||||
let app = openfut_core::app::build(pool, cfg.clone()).await?;
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&cfg.listen_addr).await?;
|
||||
info!("Listening on http://{}", cfg.listen_addr);
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::path::Path;
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// Generic loader for JSON arrays from a directory.
|
||||
pub fn load_json_dir<T: DeserializeOwned>(dir: &Path) -> Result<Vec<T>> {
|
||||
let mut items = Vec::new();
|
||||
if !dir.exists() {
|
||||
return Ok(items);
|
||||
}
|
||||
for entry in std::fs::read_dir(dir).with_context(|| format!("reading dir {dir:?}"))? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
let content =
|
||||
std::fs::read_to_string(&path).with_context(|| format!("reading {path:?}"))?;
|
||||
let batch: Vec<T> =
|
||||
serde_json::from_str(&content).with_context(|| format!("parsing {path:?}"))?;
|
||||
items.extend(batch);
|
||||
}
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Modding support: load JSON data files from the data/ directory.
|
||||
//! All game content (cards, packs, objectives, SBCs) is data-driven.
|
||||
|
||||
pub mod loader;
|
||||
@@ -0,0 +1,45 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Rarity {
|
||||
#[default]
|
||||
Bronze,
|
||||
Silver,
|
||||
Gold,
|
||||
RareGold,
|
||||
Totw,
|
||||
Hero,
|
||||
Icon,
|
||||
}
|
||||
|
||||
/// A card definition loaded from JSON data files.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CardDefinition {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub overall: u8,
|
||||
pub position: String,
|
||||
pub nation: String,
|
||||
pub league: String,
|
||||
pub club: String,
|
||||
pub pace: u8,
|
||||
pub shooting: u8,
|
||||
pub passing: u8,
|
||||
pub dribbling: u8,
|
||||
pub defending: u8,
|
||||
pub physical: u8,
|
||||
pub rarity: Rarity,
|
||||
pub image_path: Option<String>,
|
||||
}
|
||||
|
||||
/// A card instance owned by a club (stored in DB).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct OwnedCard {
|
||||
pub id: String,
|
||||
pub club_id: String,
|
||||
pub card_id: String,
|
||||
pub is_loan: bool,
|
||||
pub loan_matches_remaining: Option<i64>,
|
||||
pub acquired_at: String,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Club {
|
||||
pub id: String,
|
||||
pub profile_id: String,
|
||||
pub name: String,
|
||||
pub coins: i64,
|
||||
pub level: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Club {
|
||||
pub fn new(profile_id: impl Into<String>, name: impl Into<String>, coins: i64) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
profile_id: profile_id.into(),
|
||||
name: name.into(),
|
||||
coins,
|
||||
level: 1,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// NPC transfer market listing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MarketListing {
|
||||
pub id: String,
|
||||
pub card_id: String,
|
||||
pub seller_name: String,
|
||||
pub price: i64,
|
||||
pub listed_at: String,
|
||||
pub expires_at: String,
|
||||
pub sold: bool,
|
||||
}
|
||||
|
||||
impl MarketListing {
|
||||
pub fn new(card_id: impl Into<String>, seller_name: impl Into<String>, price: i64) -> Self {
|
||||
let now = chrono::Utc::now();
|
||||
let expires = now + chrono::Duration::hours(24);
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
card_id: card_id.into(),
|
||||
seller_name: seller_name.into(),
|
||||
price,
|
||||
listed_at: now.to_rfc3339(),
|
||||
expires_at: expires.to_rfc3339(),
|
||||
sold: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BuyListingRequest {
|
||||
pub listing_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SellCardRequest {
|
||||
pub owned_card_id: String,
|
||||
pub price: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MarketListingWithCard {
|
||||
#[serde(flatten)]
|
||||
pub listing: MarketListing,
|
||||
pub card: crate::models::card::CardDefinition,
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MatchOutcome {
|
||||
Win,
|
||||
Draw,
|
||||
Loss,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SubmitMatchRequest {
|
||||
pub squad_id: String,
|
||||
pub opponent_name: String,
|
||||
pub goals_for: i64,
|
||||
pub goals_against: i64,
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Match {
|
||||
pub id: String,
|
||||
pub profile_id: String,
|
||||
pub squad_id: String,
|
||||
pub opponent_name: String,
|
||||
pub goals_for: i64,
|
||||
pub goals_against: i64,
|
||||
pub outcome: String,
|
||||
pub coins_awarded: i64,
|
||||
pub xp_awarded: i64,
|
||||
pub mode: String,
|
||||
pub played_at: String,
|
||||
}
|
||||
|
||||
impl Match {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
profile_id: &str,
|
||||
squad_id: &str,
|
||||
opponent_name: &str,
|
||||
goals_for: i64,
|
||||
goals_against: i64,
|
||||
mode: &str,
|
||||
coins_awarded: i64,
|
||||
xp_awarded: i64,
|
||||
) -> Self {
|
||||
let outcome = if goals_for > goals_against {
|
||||
"win"
|
||||
} else if goals_for == goals_against {
|
||||
"draw"
|
||||
} else {
|
||||
"loss"
|
||||
};
|
||||
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
profile_id: profile_id.to_string(),
|
||||
squad_id: squad_id.to_string(),
|
||||
opponent_name: opponent_name.to_string(),
|
||||
goals_for,
|
||||
goals_against,
|
||||
outcome: outcome.to_string(),
|
||||
coins_awarded,
|
||||
xp_awarded,
|
||||
mode: mode.to_string(),
|
||||
played_at: chrono::Utc::now().to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MatchRewardResult {
|
||||
pub match_record: Match,
|
||||
pub coins_awarded: i64,
|
||||
pub xp_awarded: i64,
|
||||
pub objectives_updated: Vec<String>,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod card;
|
||||
pub mod club;
|
||||
pub mod market;
|
||||
pub mod match_result;
|
||||
pub mod objective;
|
||||
pub mod pack;
|
||||
pub mod profile;
|
||||
pub mod reward;
|
||||
pub mod sbc;
|
||||
pub mod squad;
|
||||
pub mod statistics;
|
||||
@@ -0,0 +1,57 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ObjectiveType {
|
||||
Daily,
|
||||
Weekly,
|
||||
Lifetime,
|
||||
Milestone,
|
||||
Season,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ObjectiveMetric {
|
||||
MatchesWon,
|
||||
MatchesPlayed,
|
||||
GoalsScored,
|
||||
PacksOpened,
|
||||
SbcsCompleted,
|
||||
CoinsEarned,
|
||||
}
|
||||
|
||||
/// Objective definition from data/objectives/*.json
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObjectiveDefinition {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub objective_type: ObjectiveType,
|
||||
pub metric: ObjectiveMetric,
|
||||
pub target: i64,
|
||||
pub reward_coins: i64,
|
||||
pub reward_pack_id: Option<String>,
|
||||
pub reward_xp: i64,
|
||||
}
|
||||
|
||||
/// Progress row in DB
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct ObjectiveProgress {
|
||||
pub id: String,
|
||||
pub profile_id: String,
|
||||
pub objective_id: String,
|
||||
pub current: i64,
|
||||
pub completed: bool,
|
||||
pub claimed: bool,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ObjectiveWithProgress {
|
||||
#[serde(flatten)]
|
||||
pub definition: ObjectiveDefinition,
|
||||
pub current: i64,
|
||||
pub completed: bool,
|
||||
pub claimed: bool,
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Pack definition loaded from data/packs/*.json
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PackDefinition {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub cost_coins: i64,
|
||||
pub slots: Vec<PackSlot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PackSlot {
|
||||
pub count: u8,
|
||||
/// Minimum overall rating filter
|
||||
pub min_overall: Option<u8>,
|
||||
/// Rarity filter: "bronze", "silver", "gold", "rare_gold", "totw", "hero", "icon"
|
||||
pub rarity_filter: Option<Vec<String>>,
|
||||
/// Whether this slot guarantees rare
|
||||
pub guaranteed_rare: bool,
|
||||
}
|
||||
|
||||
/// A pack instance stored in the DB (assigned but unopened).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Pack {
|
||||
pub id: String,
|
||||
pub club_id: String,
|
||||
pub definition_id: String,
|
||||
pub opened: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// The result of opening a pack.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PackOpenResult {
|
||||
pub pack_id: String,
|
||||
pub cards: Vec<crate::models::card::CardDefinition>,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Profile {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub level: i64,
|
||||
pub xp: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn new(username: impl Into<String>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
username: username.into(),
|
||||
level: 1,
|
||||
xp: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateProfileRequest {
|
||||
pub username: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Reward {
|
||||
pub coins: i64,
|
||||
pub xp: i64,
|
||||
pub pack_id: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Reward {
|
||||
pub fn coins_only(coins: i64) -> Self {
|
||||
Self {
|
||||
coins,
|
||||
xp: 0,
|
||||
pack_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn full(coins: i64, xp: i64, pack_id: Option<String>) -> Self {
|
||||
Self { coins, xp, pack_id }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// SBC definition from data/sbcs/*.json
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SbcDefinition {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub requirements: SbcRequirements,
|
||||
pub reward: SbcReward,
|
||||
pub expires_at: Option<String>,
|
||||
pub repeatable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SbcRequirements {
|
||||
pub squad_size: u8,
|
||||
pub min_overall: Option<u8>,
|
||||
pub max_overall: Option<u8>,
|
||||
pub min_chemistry: Option<u8>,
|
||||
pub required_leagues: Vec<String>,
|
||||
pub required_nations: Vec<String>,
|
||||
pub required_clubs: Vec<String>,
|
||||
pub min_players_from_same_league: Option<u8>,
|
||||
pub min_players_from_same_nation: Option<u8>,
|
||||
pub min_players_from_same_club: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SbcReward {
|
||||
pub coins: i64,
|
||||
pub xp: i64,
|
||||
pub pack_id: Option<String>,
|
||||
}
|
||||
|
||||
/// DB record of a completed submission
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct SbcSubmission {
|
||||
pub id: String,
|
||||
pub profile_id: String,
|
||||
pub sbc_id: String,
|
||||
pub submitted_card_ids: String,
|
||||
pub passed: bool,
|
||||
pub submitted_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SubmitSbcRequest {
|
||||
pub sbc_id: String,
|
||||
pub owned_card_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SbcResult {
|
||||
pub passed: bool,
|
||||
pub failures: Vec<String>,
|
||||
pub reward: Option<SbcReward>,
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Squad {
|
||||
pub id: String,
|
||||
pub club_id: String,
|
||||
pub name: String,
|
||||
pub formation: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl Squad {
|
||||
pub fn new(
|
||||
club_id: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
formation: impl Into<String>,
|
||||
) -> Self {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
club_id: club_id.into(),
|
||||
name: name.into(),
|
||||
formation: formation.into(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct SquadPlayer {
|
||||
pub id: String,
|
||||
pub squad_id: String,
|
||||
pub owned_card_id: String,
|
||||
pub position_index: i64,
|
||||
pub is_captain: bool,
|
||||
pub is_on_bench: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SaveSquadRequest {
|
||||
pub name: Option<String>,
|
||||
pub formation: Option<String>,
|
||||
pub players: Vec<SquadPlayerInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SquadPlayerInput {
|
||||
pub owned_card_id: String,
|
||||
pub position_index: i64,
|
||||
pub is_captain: bool,
|
||||
pub is_on_bench: bool,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Statistics {
|
||||
pub profile_id: String,
|
||||
pub matches_played: i64,
|
||||
pub matches_won: i64,
|
||||
pub matches_drawn: i64,
|
||||
pub matches_lost: i64,
|
||||
pub goals_scored: i64,
|
||||
pub goals_conceded: i64,
|
||||
pub packs_opened: i64,
|
||||
pub sbcs_completed: i64,
|
||||
pub total_coins_earned: i64,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl Statistics {
|
||||
pub fn new(profile_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
profile_id: profile_id.into(),
|
||||
matches_played: 0,
|
||||
matches_won: 0,
|
||||
matches_drawn: 0,
|
||||
matches_lost: 0,
|
||||
goals_scored: 0,
|
||||
goals_conceded: 0,
|
||||
packs_opened: 0,
|
||||
sbcs_completed: 0,
|
||||
total_coins_earned: 0,
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
models::{club::Club, profile::CreateProfileRequest},
|
||||
seed,
|
||||
services::{club as club_svc, profile as profile_svc},
|
||||
};
|
||||
|
||||
pub async fn post_auth_local(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateProfileRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let username = req.username.unwrap_or_else(|| "Player 1".into());
|
||||
|
||||
let profile = profile_svc::create_profile(&state.pool, &username).await?;
|
||||
|
||||
let club = Club::new(&profile.id, "OpenFUT FC", 5000);
|
||||
club_svc::create_club(&state.pool, &club).await?;
|
||||
|
||||
seed::grant_starter_pack(&state.pool, &club.id, &state.pack_defs).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"profile": profile,
|
||||
"club": club,
|
||||
"message": "Welcome to OpenFUT FC! Your club has been created."
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
models::card::OwnedCard,
|
||||
services::{club as club_svc, profile as profile_svc},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CardQuery {
|
||||
pub rarity: Option<String>,
|
||||
pub position: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_cards(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<CardQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let cards = state
|
||||
.card_db
|
||||
.all()
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
query
|
||||
.rarity
|
||||
.as_ref()
|
||||
.map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase())
|
||||
.unwrap_or(true)
|
||||
&& query
|
||||
.position
|
||||
.as_ref()
|
||||
.map(|p| c.position.to_lowercase() == p.to_lowercase())
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Json(json!({ "cards": cards, "total": cards.len() })))
|
||||
}
|
||||
|
||||
pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE club_id = ?"
|
||||
)
|
||||
.bind(&club.id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let with_defs: Vec<Value> = owned
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
state.card_db.get(&o.card_id).map(|def| {
|
||||
json!({
|
||||
"owned_card_id": o.id,
|
||||
"is_loan": o.is_loan,
|
||||
"loan_matches_remaining": o.loan_matches_remaining,
|
||||
"acquired_at": o.acquired_at,
|
||||
"card": def,
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(
|
||||
json!({ "collection": with_defs, "total": with_defs.len() }),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
models::club::Club,
|
||||
services::{club as club_svc, profile as profile_svc},
|
||||
};
|
||||
use axum::{extract::State, Json};
|
||||
|
||||
pub async fn get_club(State(state): State<AppState>) -> AppResult<Json<Club>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
Ok(Json(club))
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use axum::{http::StatusCode, Json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn get_health() -> (StatusCode, Json<Value>) {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"status": "ok",
|
||||
"service": "openfut-core",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
})),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
models::market::{BuyListingRequest, SellCardRequest},
|
||||
services::{club as club_svc, market as market_svc, profile as profile_svc},
|
||||
};
|
||||
|
||||
pub async fn get_market(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let listings = market_svc::get_active_listings(&state.pool, &state.card_db).await?;
|
||||
Ok(Json(
|
||||
json!({ "listings": listings, "total": listings.len() }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn post_market_buy(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<BuyListingRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?;
|
||||
Ok(Json(
|
||||
json!({ "purchased_card": card, "message": "Card purchased successfully" }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn post_market_sell(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SellCardRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let new_balance = market_svc::sell_card(&state.pool, &club.id, &req).await?;
|
||||
Ok(Json(
|
||||
json!({ "new_coin_balance": new_balance, "message": "Card sold to NPC market" }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn post_market_refresh(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let count = market_svc::refresh_npc_listings(&state.pool, &state.card_db).await?;
|
||||
Ok(Json(json!({ "listings_generated": count })))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use axum::{extract::State, Json};
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
models::match_result::{MatchRewardResult, SubmitMatchRequest},
|
||||
services::{club as club_svc, match_service, profile as profile_svc},
|
||||
};
|
||||
|
||||
pub async fn post_match_result(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SubmitMatchRequest>,
|
||||
) -> AppResult<Json<MatchRewardResult>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let result =
|
||||
match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs)
|
||||
.await?;
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
pub mod auth;
|
||||
pub mod cards;
|
||||
pub mod club;
|
||||
pub mod health;
|
||||
pub mod market;
|
||||
pub mod matches;
|
||||
pub mod objectives;
|
||||
pub mod packs;
|
||||
pub mod profile;
|
||||
pub mod sbc;
|
||||
pub mod squad;
|
||||
pub mod statistics;
|
||||
@@ -0,0 +1,15 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
services::{objective as obj_svc, profile as profile_svc},
|
||||
};
|
||||
|
||||
pub async fn get_objectives(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let objectives =
|
||||
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
||||
Ok(Json(json!({ "objectives": objectives })))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
models::pack::PackOpenResult,
|
||||
services::{club as club_svc, objective, pack as pack_svc, profile as profile_svc, statistics},
|
||||
};
|
||||
|
||||
pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?;
|
||||
|
||||
let with_defs: Vec<Value> = packs
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let def = state.pack_defs.iter().find(|d| d.id == p.definition_id);
|
||||
json!({
|
||||
"pack_id": p.id,
|
||||
"definition_id": p.definition_id,
|
||||
"name": def.map(|d| &d.name),
|
||||
"description": def.map(|d| &d.description),
|
||||
"created_at": p.created_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "packs": with_defs })))
|
||||
}
|
||||
|
||||
pub async fn post_open_pack(
|
||||
State(state): State<AppState>,
|
||||
Path(pack_id): Path<String>,
|
||||
) -> AppResult<Json<PackOpenResult>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let result = pack_svc::open_pack(
|
||||
&state.pool,
|
||||
&state.card_db,
|
||||
&state.pack_defs,
|
||||
&club.id,
|
||||
&pack_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
statistics::increment_packs_opened(&state.pool, &profile.id).await?;
|
||||
objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1)
|
||||
.await?;
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use crate::{
|
||||
app::AppState, error::AppResult, models::profile::Profile, services::profile as profile_svc,
|
||||
};
|
||||
use axum::{extract::State, Json};
|
||||
|
||||
pub async fn get_profile(State(state): State<AppState>) -> AppResult<Json<Profile>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
Ok(Json(profile))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
models::sbc::{SbcResult, SubmitSbcRequest},
|
||||
services::{club as club_svc, profile as profile_svc, sbc as sbc_svc},
|
||||
};
|
||||
|
||||
pub async fn get_sbcs(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
Ok(Json(json!({ "sbcs": state.sbc_defs })))
|
||||
}
|
||||
|
||||
pub async fn post_sbc_submit(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SubmitSbcRequest>,
|
||||
) -> AppResult<Json<SbcResult>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let result = sbc_svc::submit_sbc(
|
||||
&state.pool,
|
||||
&state.card_db,
|
||||
&state.sbc_defs,
|
||||
&state.obj_defs,
|
||||
&profile.id,
|
||||
&club.id,
|
||||
&req,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
models::squad::SaveSquadRequest,
|
||||
services::{club as club_svc, profile as profile_svc, squad as squad_svc},
|
||||
};
|
||||
|
||||
pub async fn get_squad(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?;
|
||||
|
||||
let enriched: Vec<Value> = players
|
||||
.iter()
|
||||
.map(|sp| {
|
||||
json!({
|
||||
"squad_player_id": sp.id,
|
||||
"owned_card_id": sp.owned_card_id,
|
||||
"position_index": sp.position_index,
|
||||
"is_captain": sp.is_captain,
|
||||
"is_on_bench": sp.is_on_bench,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"squad": {
|
||||
"id": squad.id,
|
||||
"name": squad.name,
|
||||
"formation": squad.formation,
|
||||
},
|
||||
"players": enriched,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn post_squad(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SaveSquadRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let squad = squad_svc::save_squad(&state.pool, &club.id, &req).await?;
|
||||
Ok(Json(json!({ "squad": squad })))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use axum::{extract::State, Json};
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
models::statistics::Statistics,
|
||||
services::{profile as profile_svc, statistics as stats_svc},
|
||||
};
|
||||
|
||||
pub async fn get_statistics(State(state): State<AppState>) -> AppResult<Json<Statistics>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||
Ok(Json(stats))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use crate::{db::Pool, error::AppResult, models::pack::PackDefinition, services::pack as pack_svc};
|
||||
use tracing::info;
|
||||
|
||||
/// Seeds the market with NPC listings if empty.
|
||||
pub async fn maybe_seed(_pool: &Pool) -> AppResult<()> {
|
||||
// Any one-time startup seeds go here.
|
||||
// Currently we just ensure the market gets listings on first run.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Grants the starter pack to a newly created club.
|
||||
pub async fn grant_starter_pack(
|
||||
pool: &Pool,
|
||||
club_id: &str,
|
||||
pack_defs: &[PackDefinition],
|
||||
) -> AppResult<()> {
|
||||
// Look for the gold starter pack first; fall back to the first available definition.
|
||||
let starter_def = pack_defs
|
||||
.iter()
|
||||
.find(|p| p.id == "gold_pack")
|
||||
.or_else(|| pack_defs.first());
|
||||
|
||||
if let Some(def) = starter_def {
|
||||
info!("Granting starter pack '{}' to club {}", def.id, club_id);
|
||||
pack_svc::grant_pack(pool, club_id, &def.id).await?;
|
||||
} else {
|
||||
tracing::warn!("No pack definitions loaded; skipping starter pack grant");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use crate::models::card::CardDefinition;
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// In-memory card registry loaded from data/cards/*.json
|
||||
pub struct CardDb {
|
||||
pub cards: HashMap<String, CardDefinition>,
|
||||
}
|
||||
|
||||
impl CardDb {
|
||||
pub fn load(data_dir: &str) -> Result<Self> {
|
||||
let cards_dir = Path::new(data_dir).join("cards");
|
||||
let mut cards = HashMap::new();
|
||||
|
||||
if !cards_dir.exists() {
|
||||
tracing::warn!("Card data directory not found: {:?}", cards_dir);
|
||||
return Ok(Self { cards });
|
||||
}
|
||||
|
||||
for entry in std::fs::read_dir(&cards_dir)
|
||||
.with_context(|| format!("reading cards dir {:?}", cards_dir))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("reading {:?}", path))?;
|
||||
let batch: Vec<CardDefinition> = serde_json::from_str(&content)
|
||||
.with_context(|| format!("parsing {:?}", path))?;
|
||||
for card in batch {
|
||||
cards.insert(card.id.clone(), card);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Loaded {} card definitions", cards.len());
|
||||
Ok(Self { cards })
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&CardDefinition> {
|
||||
self.cards.get(id)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn by_rarity(&self, rarity: &str) -> Vec<&CardDefinition> {
|
||||
self.cards
|
||||
.values()
|
||||
.filter(|c| {
|
||||
let r = format!("{:?}", c.rarity).to_lowercase();
|
||||
r == rarity || rarity == "any"
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn all(&self) -> Vec<&CardDefinition> {
|
||||
self.cards.values().collect()
|
||||
}
|
||||
|
||||
pub fn by_min_overall(&self, min: u8) -> Vec<&CardDefinition> {
|
||||
self.cards.values().filter(|c| c.overall >= min).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::club::Club,
|
||||
};
|
||||
use chrono::Utc;
|
||||
|
||||
pub async fn get_club_by_profile(pool: &Pool, profile_id: &str) -> AppResult<Club> {
|
||||
sqlx::query_as::<_, Club>(
|
||||
"SELECT id, profile_id, name, coins, level, created_at, updated_at FROM clubs WHERE profile_id = ? LIMIT 1"
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("club not found".into()))
|
||||
}
|
||||
|
||||
pub async fn create_club(pool: &Pool, club: &Club) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&club.id)
|
||||
.bind(&club.profile_id)
|
||||
.bind(&club.name)
|
||||
.bind(club.coins)
|
||||
.bind(club.level)
|
||||
.bind(club.created_at)
|
||||
.bind(club.updated_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
||||
let now = Utc::now();
|
||||
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
|
||||
.bind(amount)
|
||||
.bind(now)
|
||||
.bind(club_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let new_balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
||||
.bind(club_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(new_balance)
|
||||
}
|
||||
|
||||
pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
||||
let balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
||||
.bind(club_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
if balance < amount {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"insufficient coins: have {balance}, need {amount}"
|
||||
)));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
sqlx::query("UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ?")
|
||||
.bind(amount)
|
||||
.bind(now)
|
||||
.bind(club_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(balance - amount)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
card::CardDefinition,
|
||||
market::{BuyListingRequest, MarketListing, MarketListingWithCard, SellCardRequest},
|
||||
},
|
||||
services::{card_db::CardDb, club},
|
||||
};
|
||||
use rand::Rng;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn get_active_listings(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
) -> AppResult<Vec<MarketListingWithCard>> {
|
||||
let listings = sqlx::query_as::<_, MarketListing>(
|
||||
"SELECT id, card_id, seller_name, price, listed_at, expires_at, sold FROM market_listings WHERE sold = 0 AND expires_at > datetime('now') ORDER BY listed_at DESC LIMIT 50"
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let with_cards: Vec<MarketListingWithCard> = listings
|
||||
.into_iter()
|
||||
.filter_map(|l| {
|
||||
card_db.get(&l.card_id).map(|card| MarketListingWithCard {
|
||||
listing: l,
|
||||
card: card.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(with_cards)
|
||||
}
|
||||
|
||||
/// Refresh NPC market with random listings from the card pool.
|
||||
pub async fn refresh_npc_listings(pool: &Pool, card_db: &CardDb) -> AppResult<usize> {
|
||||
sqlx::query("DELETE FROM market_listings WHERE sold = 0")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Build all listings synchronously before any await — drops &CardDefinition refs before first .await
|
||||
let listings_to_insert: Vec<MarketListing> = {
|
||||
let all_cards: Vec<&CardDefinition> = card_db.all();
|
||||
if all_cards.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let count = 20usize.min(all_cards.len());
|
||||
let npc_names = [
|
||||
"FC Rovers NPC",
|
||||
"Market Bot",
|
||||
"Transfer AI",
|
||||
"Club Atletico Bot",
|
||||
"United NPC",
|
||||
"City AI Club",
|
||||
];
|
||||
let mut rng = rand::thread_rng();
|
||||
all_cards
|
||||
.iter()
|
||||
.take(count)
|
||||
.map(|card| {
|
||||
let base_price = price_for_card(card.overall);
|
||||
let price = rng.gen_range((base_price / 2)..=(base_price * 2)).max(100);
|
||||
let seller = npc_names[rng.gen_range(0..npc_names.len())];
|
||||
MarketListing::new(&card.id, seller, price)
|
||||
})
|
||||
.collect()
|
||||
}; // all_cards refs and ThreadRng both dropped here
|
||||
|
||||
let mut inserted = 0;
|
||||
for listing in &listings_to_insert {
|
||||
sqlx::query(
|
||||
"INSERT INTO market_listings (id, card_id, seller_name, price, listed_at, expires_at, sold) VALUES (?, ?, ?, ?, ?, ?, 0)"
|
||||
)
|
||||
.bind(&listing.id)
|
||||
.bind(&listing.card_id)
|
||||
.bind(&listing.seller_name)
|
||||
.bind(listing.price)
|
||||
.bind(&listing.listed_at)
|
||||
.bind(&listing.expires_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
inserted += 1;
|
||||
}
|
||||
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
pub async fn buy_listing(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
club_id: &str,
|
||||
req: &BuyListingRequest,
|
||||
) -> AppResult<CardDefinition> {
|
||||
let listing = sqlx::query_as::<_, MarketListing>(
|
||||
"SELECT id, card_id, seller_name, price, listed_at, expires_at, sold FROM market_listings WHERE id = ? AND sold = 0"
|
||||
)
|
||||
.bind(&req.listing_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("listing not found or already sold".into()))?;
|
||||
|
||||
club::spend_coins(pool, club_id, listing.price).await?;
|
||||
|
||||
sqlx::query("UPDATE market_listings SET sold = 1 WHERE id = ?")
|
||||
.bind(&listing.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let owned_id = Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) VALUES (?, ?, ?, 0, NULL, ?)"
|
||||
)
|
||||
.bind(&owned_id)
|
||||
.bind(club_id)
|
||||
.bind(&listing.card_id)
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
card_db
|
||||
.get(&listing.card_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| AppError::NotFound("card definition not found".into()))
|
||||
}
|
||||
|
||||
pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> AppResult<i64> {
|
||||
let _owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ? AND club_id = ?"
|
||||
)
|
||||
.bind(&req.owned_card_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("owned card not found".into()))?;
|
||||
|
||||
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
|
||||
.bind(&req.owned_card_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Quick-sell price: 40% of requested price
|
||||
let coins = (req.price as f64 * 0.4) as i64;
|
||||
let new_balance = club::add_coins(pool, club_id, coins).await?;
|
||||
Ok(new_balance)
|
||||
}
|
||||
|
||||
fn price_for_card(overall: u8) -> i64 {
|
||||
match overall {
|
||||
85..=u8::MAX => 50_000,
|
||||
80..=84 => 10_000,
|
||||
75..=79 => 3_000,
|
||||
70..=74 => 1_000,
|
||||
65..=69 => 500,
|
||||
_ => 200,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::AppResult,
|
||||
models::{
|
||||
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
||||
objective::ObjectiveDefinition,
|
||||
},
|
||||
services::{club, objective, profile, statistics},
|
||||
};
|
||||
|
||||
const COINS_WIN: i64 = 400;
|
||||
const COINS_DRAW: i64 = 150;
|
||||
const COINS_LOSS: i64 = 75;
|
||||
const XP_WIN: i64 = 200;
|
||||
const XP_DRAW: i64 = 75;
|
||||
const XP_LOSS: i64 = 30;
|
||||
|
||||
pub async fn process_match(
|
||||
pool: &Pool,
|
||||
profile_id: &str,
|
||||
club_id: &str,
|
||||
req: &SubmitMatchRequest,
|
||||
obj_defs: &[ObjectiveDefinition],
|
||||
) -> AppResult<MatchRewardResult> {
|
||||
let outcome = if req.goals_for > req.goals_against {
|
||||
"win"
|
||||
} else if req.goals_for == req.goals_against {
|
||||
"draw"
|
||||
} else {
|
||||
"loss"
|
||||
};
|
||||
|
||||
let (coins, xp) = match outcome {
|
||||
"win" => (COINS_WIN, XP_WIN),
|
||||
"draw" => (COINS_DRAW, XP_DRAW),
|
||||
_ => (COINS_LOSS, XP_LOSS),
|
||||
};
|
||||
|
||||
let match_record = Match::new(
|
||||
profile_id,
|
||||
&req.squad_id,
|
||||
&req.opponent_name,
|
||||
req.goals_for,
|
||||
req.goals_against,
|
||||
&req.mode,
|
||||
coins,
|
||||
xp,
|
||||
);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO matches (id, profile_id, squad_id, opponent_name, goals_for, goals_against, outcome, coins_awarded, xp_awarded, mode, played_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&match_record.id)
|
||||
.bind(&match_record.profile_id)
|
||||
.bind(&match_record.squad_id)
|
||||
.bind(&match_record.opponent_name)
|
||||
.bind(match_record.goals_for)
|
||||
.bind(match_record.goals_against)
|
||||
.bind(&match_record.outcome)
|
||||
.bind(match_record.coins_awarded)
|
||||
.bind(match_record.xp_awarded)
|
||||
.bind(&match_record.mode)
|
||||
.bind(&match_record.played_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
club::add_coins(pool, club_id, coins).await?;
|
||||
profile::add_xp(pool, profile_id, xp).await?;
|
||||
statistics::record_match(
|
||||
pool,
|
||||
profile_id,
|
||||
outcome,
|
||||
req.goals_for,
|
||||
req.goals_against,
|
||||
coins,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut objectives_updated = Vec::new();
|
||||
|
||||
let mut completed =
|
||||
objective::increment_metric(pool, profile_id, obj_defs, "matchesplayed", 1).await?;
|
||||
objectives_updated.append(&mut completed);
|
||||
|
||||
if outcome == "win" {
|
||||
let mut c =
|
||||
objective::increment_metric(pool, profile_id, obj_defs, "matcheswon", 1).await?;
|
||||
objectives_updated.append(&mut c);
|
||||
}
|
||||
|
||||
let mut c =
|
||||
objective::increment_metric(pool, profile_id, obj_defs, "goalsscored", req.goals_for)
|
||||
.await?;
|
||||
objectives_updated.append(&mut c);
|
||||
|
||||
let mut c =
|
||||
objective::increment_metric(pool, profile_id, obj_defs, "coinsearned", coins).await?;
|
||||
objectives_updated.append(&mut c);
|
||||
|
||||
Ok(MatchRewardResult {
|
||||
match_record,
|
||||
coins_awarded: coins,
|
||||
xp_awarded: xp,
|
||||
objectives_updated,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub mod card_db;
|
||||
pub mod club;
|
||||
pub mod market;
|
||||
pub mod match_service;
|
||||
pub mod objective;
|
||||
pub mod pack;
|
||||
pub mod profile;
|
||||
pub mod sbc;
|
||||
pub mod squad;
|
||||
pub mod statistics;
|
||||
@@ -0,0 +1,122 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::AppResult,
|
||||
models::objective::{ObjectiveDefinition, ObjectiveProgress, ObjectiveWithProgress},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use std::path::Path;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn load_objective_definitions(data_dir: &str) -> anyhow::Result<Vec<ObjectiveDefinition>> {
|
||||
let dir = Path::new(data_dir).join("objectives");
|
||||
let mut defs = Vec::new();
|
||||
if !dir.exists() {
|
||||
return Ok(defs);
|
||||
}
|
||||
for entry in std::fs::read_dir(&dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
let content =
|
||||
std::fs::read_to_string(&path).with_context(|| format!("reading {:?}", path))?;
|
||||
let batch: Vec<ObjectiveDefinition> =
|
||||
serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?;
|
||||
defs.extend(batch);
|
||||
}
|
||||
}
|
||||
Ok(defs)
|
||||
}
|
||||
|
||||
pub async fn get_objectives_with_progress(
|
||||
pool: &Pool,
|
||||
profile_id: &str,
|
||||
defs: &[ObjectiveDefinition],
|
||||
) -> AppResult<Vec<ObjectiveWithProgress>> {
|
||||
let progress_rows = sqlx::query_as::<_, ObjectiveProgress>(
|
||||
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ?"
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let result = defs
|
||||
.iter()
|
||||
.map(|def| {
|
||||
let prog = progress_rows.iter().find(|p| p.objective_id == def.id);
|
||||
ObjectiveWithProgress {
|
||||
definition: def.clone(),
|
||||
current: prog.map(|p| p.current).unwrap_or(0),
|
||||
completed: prog.map(|p| p.completed).unwrap_or(false),
|
||||
claimed: prog.map(|p| p.claimed).unwrap_or(false),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Increment a metric for all objectives that track it.
|
||||
pub async fn increment_metric(
|
||||
pool: &Pool,
|
||||
profile_id: &str,
|
||||
defs: &[ObjectiveDefinition],
|
||||
metric: &str,
|
||||
amount: i64,
|
||||
) -> AppResult<Vec<String>> {
|
||||
let mut completed_ids = Vec::new();
|
||||
|
||||
for def in defs
|
||||
.iter()
|
||||
.filter(|d| format!("{:?}", d.metric).to_lowercase() == metric)
|
||||
{
|
||||
let existing = sqlx::query_as::<_, ObjectiveProgress>(
|
||||
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
|
||||
)
|
||||
.bind(profile_id)
|
||||
.bind(&def.id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
if let Some(prog) = existing {
|
||||
if prog.completed {
|
||||
continue;
|
||||
}
|
||||
let new_val = (prog.current + amount).min(def.target);
|
||||
let now_complete = new_val >= def.target;
|
||||
sqlx::query(
|
||||
"UPDATE objective_progress SET current = ?, completed = ?, updated_at = ? WHERE id = ?"
|
||||
)
|
||||
.bind(new_val)
|
||||
.bind(now_complete)
|
||||
.bind(&now)
|
||||
.bind(&prog.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if now_complete {
|
||||
completed_ids.push(def.id.clone());
|
||||
}
|
||||
} else {
|
||||
let new_val = amount.min(def.target);
|
||||
let now_complete = new_val >= def.target;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO objective_progress (id, profile_id, objective_id, current, completed, claimed, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?)"
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(profile_id)
|
||||
.bind(&def.id)
|
||||
.bind(new_val)
|
||||
.bind(now_complete)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if now_complete {
|
||||
completed_ids.push(def.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(completed_ids)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
card::CardDefinition,
|
||||
pack::{Pack, PackDefinition, PackOpenResult},
|
||||
},
|
||||
services::card_db::CardDb,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use rand::seq::SliceRandom;
|
||||
use std::path::Path;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn load_pack_definitions(data_dir: &str) -> anyhow::Result<Vec<PackDefinition>> {
|
||||
let dir = Path::new(data_dir).join("packs");
|
||||
let mut defs = Vec::new();
|
||||
if !dir.exists() {
|
||||
return Ok(defs);
|
||||
}
|
||||
for entry in std::fs::read_dir(&dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
let content =
|
||||
std::fs::read_to_string(&path).with_context(|| format!("reading {:?}", path))?;
|
||||
let batch: Vec<PackDefinition> =
|
||||
serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?;
|
||||
defs.extend(batch);
|
||||
}
|
||||
}
|
||||
Ok(defs)
|
||||
}
|
||||
|
||||
pub async fn grant_pack(pool: &Pool, club_id: &str, definition_id: &str) -> AppResult<Pack> {
|
||||
let pack = Pack {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
club_id: club_id.to_string(),
|
||||
definition_id: definition_id.to_string(),
|
||||
opened: false,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&pack.id)
|
||||
.bind(&pack.club_id)
|
||||
.bind(&pack.definition_id)
|
||||
.bind(pack.opened)
|
||||
.bind(&pack.created_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(pack)
|
||||
}
|
||||
|
||||
pub async fn open_pack(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
pack_defs: &[PackDefinition],
|
||||
club_id: &str,
|
||||
pack_id: &str,
|
||||
) -> AppResult<PackOpenResult> {
|
||||
let pack = sqlx::query_as::<_, Pack>(
|
||||
"SELECT id, club_id, definition_id, opened, created_at FROM packs WHERE id = ? AND club_id = ?"
|
||||
)
|
||||
.bind(pack_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("pack {pack_id} not found")))?;
|
||||
|
||||
if pack.opened {
|
||||
return Err(AppError::BadRequest("pack already opened".into()));
|
||||
}
|
||||
|
||||
let def = pack_defs
|
||||
.iter()
|
||||
.find(|d| d.id == pack.definition_id)
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!("pack definition {} not found", pack.definition_id))
|
||||
})?;
|
||||
|
||||
let mut cards: Vec<CardDefinition> = Vec::new();
|
||||
|
||||
for slot in &def.slots {
|
||||
let pool_cards: Vec<CardDefinition> = if let Some(rarities) = &slot.rarity_filter {
|
||||
card_db
|
||||
.all()
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
let r = format!("{:?}", c.rarity).to_lowercase();
|
||||
rarities.contains(&r)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
} else if let Some(min) = slot.min_overall {
|
||||
card_db.by_min_overall(min).into_iter().cloned().collect()
|
||||
} else {
|
||||
card_db.all().into_iter().cloned().collect()
|
||||
};
|
||||
|
||||
// Choose cards synchronously before any awaits so ThreadRng is not held across .await
|
||||
let chosen: Vec<CardDefinition> = {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..slot.count)
|
||||
.filter_map(|_| pool_cards.choose(&mut rng).cloned())
|
||||
.collect()
|
||||
};
|
||||
|
||||
for card in chosen {
|
||||
let owned_id = Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) VALUES (?, ?, ?, 0, NULL, ?)"
|
||||
)
|
||||
.bind(&owned_id)
|
||||
.bind(club_id)
|
||||
.bind(&card.id)
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
cards.push(card);
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE packs SET opened = 1 WHERE id = ?")
|
||||
.bind(pack_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(PackOpenResult {
|
||||
pack_id: pack_id.to_string(),
|
||||
cards,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_unopened_packs(pool: &Pool, club_id: &str) -> AppResult<Vec<Pack>> {
|
||||
let packs = sqlx::query_as::<_, Pack>(
|
||||
"SELECT id, club_id, definition_id, opened, created_at FROM packs WHERE club_id = ? AND opened = 0"
|
||||
)
|
||||
.bind(club_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(packs)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::profile::Profile,
|
||||
};
|
||||
use chrono::Utc;
|
||||
|
||||
pub async fn get_active_profile(pool: &Pool) -> AppResult<Profile> {
|
||||
sqlx::query_as::<_, Profile>(
|
||||
"SELECT id, username, level, xp, created_at, updated_at FROM profiles ORDER BY created_at ASC LIMIT 1"
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into()))
|
||||
}
|
||||
|
||||
pub async fn create_profile(pool: &Pool, username: &str) -> AppResult<Profile> {
|
||||
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if existing > 0 {
|
||||
return Err(AppError::Conflict(
|
||||
"a profile already exists; OpenFUT is single-player only".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let profile = Profile::new(username);
|
||||
sqlx::query(
|
||||
"INSERT INTO profiles (id, username, level, xp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&profile.id)
|
||||
.bind(&profile.username)
|
||||
.bind(profile.level)
|
||||
.bind(profile.xp)
|
||||
.bind(profile.created_at)
|
||||
.bind(profile.updated_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub async fn add_xp(pool: &Pool, profile_id: &str, xp: i64) -> AppResult<()> {
|
||||
let now = Utc::now();
|
||||
sqlx::query("UPDATE profiles SET xp = xp + ?, updated_at = ? WHERE id = ?")
|
||||
.bind(xp)
|
||||
.bind(now)
|
||||
.bind(profile_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
card::CardDefinition,
|
||||
objective::ObjectiveDefinition,
|
||||
sbc::{SbcDefinition, SbcResult, SubmitSbcRequest},
|
||||
},
|
||||
services::{card_db::CardDb, club, objective, statistics},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use std::path::Path;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn load_sbc_definitions(data_dir: &str) -> anyhow::Result<Vec<SbcDefinition>> {
|
||||
let dir = Path::new(data_dir).join("sbcs");
|
||||
let mut defs = Vec::new();
|
||||
if !dir.exists() {
|
||||
return Ok(defs);
|
||||
}
|
||||
for entry in std::fs::read_dir(&dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
let content =
|
||||
std::fs::read_to_string(&path).with_context(|| format!("reading {:?}", path))?;
|
||||
let batch: Vec<SbcDefinition> =
|
||||
serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?;
|
||||
defs.extend(batch);
|
||||
}
|
||||
}
|
||||
Ok(defs)
|
||||
}
|
||||
|
||||
pub async fn submit_sbc(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
sbc_defs: &[SbcDefinition],
|
||||
obj_defs: &[ObjectiveDefinition],
|
||||
profile_id: &str,
|
||||
club_id: &str,
|
||||
req: &SubmitSbcRequest,
|
||||
) -> AppResult<SbcResult> {
|
||||
let def = sbc_defs
|
||||
.iter()
|
||||
.find(|d| d.id == req.sbc_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("SBC {} not found", req.sbc_id)))?;
|
||||
|
||||
// Resolve cards from DB
|
||||
let mut cards: Vec<CardDefinition> = Vec::new();
|
||||
for owned_id in &req.owned_card_ids {
|
||||
let row = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ? AND club_id = ?"
|
||||
)
|
||||
.bind(owned_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("owned card {owned_id} not found")))?;
|
||||
|
||||
let card = card_db.get(&row.card_id).ok_or_else(|| {
|
||||
AppError::NotFound(format!("card definition {} not found", row.card_id))
|
||||
})?;
|
||||
cards.push(card.clone());
|
||||
}
|
||||
|
||||
let (passed, failures) = validate_sbc(def, &cards);
|
||||
|
||||
if passed {
|
||||
// Consume cards
|
||||
for owned_id in &req.owned_card_ids {
|
||||
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
|
||||
.bind(owned_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Record submission
|
||||
let sub_id = Uuid::new_v4().to_string();
|
||||
let card_ids_json = serde_json::to_string(&req.owned_card_ids)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO sbc_submissions (id, profile_id, sbc_id, submitted_card_ids, passed, submitted_at) VALUES (?, ?, ?, ?, 1, ?)"
|
||||
)
|
||||
.bind(&sub_id)
|
||||
.bind(profile_id)
|
||||
.bind(&req.sbc_id)
|
||||
.bind(&card_ids_json)
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Grant reward
|
||||
if def.reward.coins > 0 {
|
||||
club::add_coins(pool, club_id, def.reward.coins).await?;
|
||||
}
|
||||
if let Some(pack_id) = &def.reward.pack_id {
|
||||
crate::services::pack::grant_pack(pool, club_id, pack_id).await?;
|
||||
}
|
||||
|
||||
statistics::increment_sbcs_completed(pool, profile_id).await?;
|
||||
objective::increment_metric(pool, profile_id, obj_defs, "sbcscompleted", 1).await?;
|
||||
|
||||
Ok(SbcResult {
|
||||
passed: true,
|
||||
failures: vec![],
|
||||
reward: Some(def.reward.clone()),
|
||||
})
|
||||
} else {
|
||||
Ok(SbcResult {
|
||||
passed: false,
|
||||
failures,
|
||||
reward: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_sbc(def: &SbcDefinition, cards: &[CardDefinition]) -> (bool, Vec<String>) {
|
||||
let mut failures = Vec::new();
|
||||
let req = &def.requirements;
|
||||
|
||||
if cards.len() != req.squad_size as usize {
|
||||
failures.push(format!(
|
||||
"need exactly {} cards, got {}",
|
||||
req.squad_size,
|
||||
cards.len()
|
||||
));
|
||||
return (false, failures);
|
||||
}
|
||||
|
||||
if let Some(min) = req.min_overall {
|
||||
let avg = cards.iter().map(|c| c.overall as i64).sum::<i64>() / cards.len() as i64;
|
||||
if avg < min as i64 {
|
||||
failures.push(format!("average overall {avg} < required {min}"));
|
||||
}
|
||||
}
|
||||
|
||||
if !req.required_leagues.is_empty() {
|
||||
for league in &req.required_leagues {
|
||||
if !cards.iter().any(|c| &c.league == league) {
|
||||
failures.push(format!("need at least one player from league {league}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !req.required_nations.is_empty() {
|
||||
for nation in &req.required_nations {
|
||||
if !cards.iter().any(|c| &c.nation == nation) {
|
||||
failures.push(format!("need at least one player from nation {nation}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(min_same_league) = req.min_players_from_same_league {
|
||||
let league_counts: std::collections::HashMap<&str, usize> =
|
||||
cards.iter().fold(Default::default(), |mut m, c| {
|
||||
*m.entry(c.league.as_str()).or_default() += 1;
|
||||
m
|
||||
});
|
||||
let max = league_counts.values().copied().max().unwrap_or(0);
|
||||
if max < min_same_league as usize {
|
||||
failures.push(format!("need {min_same_league} players from same league"));
|
||||
}
|
||||
}
|
||||
|
||||
(failures.is_empty(), failures)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::squad::{SaveSquadRequest, Squad, SquadPlayer},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn get_squad(pool: &Pool, club_id: &str) -> AppResult<(Squad, Vec<SquadPlayer>)> {
|
||||
let squad = sqlx::query_as::<_, Squad>(
|
||||
"SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE club_id = ? ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("no squad found for this club".into()))?;
|
||||
|
||||
let players = sqlx::query_as::<_, SquadPlayer>(
|
||||
"SELECT id, squad_id, owned_card_id, position_index, is_captain, is_on_bench FROM squad_players WHERE squad_id = ?"
|
||||
)
|
||||
.bind(&squad.id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok((squad, players))
|
||||
}
|
||||
|
||||
pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> AppResult<Squad> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let existing = sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT id FROM squads WHERE club_id = ? ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(club_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let squad_id = if let Some(id) = existing {
|
||||
sqlx::query("UPDATE squads SET name = COALESCE(?, name), formation = COALESCE(?, formation), updated_at = ? WHERE id = ?")
|
||||
.bind(req.name.as_deref())
|
||||
.bind(req.formation.as_deref())
|
||||
.bind(&now)
|
||||
.bind(&id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM squad_players WHERE squad_id = ?")
|
||||
.bind(&id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
id
|
||||
} else {
|
||||
let squad = Squad::new(
|
||||
club_id,
|
||||
req.name.as_deref().unwrap_or("My Squad"),
|
||||
req.formation.as_deref().unwrap_or("4-4-2"),
|
||||
);
|
||||
sqlx::query(
|
||||
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&squad.id)
|
||||
.bind(&squad.club_id)
|
||||
.bind(&squad.name)
|
||||
.bind(&squad.formation)
|
||||
.bind(&squad.created_at)
|
||||
.bind(&squad.updated_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
squad.id
|
||||
};
|
||||
|
||||
for player in &req.players {
|
||||
let sp_id = Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&sp_id)
|
||||
.bind(&squad_id)
|
||||
.bind(&player.owned_card_id)
|
||||
.bind(player.position_index)
|
||||
.bind(player.is_captain)
|
||||
.bind(player.is_on_bench)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let squad = sqlx::query_as::<_, Squad>(
|
||||
"SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE id = ?",
|
||||
)
|
||||
.bind(&squad_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(squad)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use crate::{db::Pool, error::AppResult, models::statistics::Statistics};
|
||||
|
||||
pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Statistics> {
|
||||
let existing = sqlx::query_as::<_, Statistics>(
|
||||
"SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, updated_at FROM statistics WHERE profile_id = ?"
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let Some(s) = existing {
|
||||
return Ok(s);
|
||||
}
|
||||
|
||||
let stats = Statistics::new(profile_id);
|
||||
sqlx::query(
|
||||
"INSERT INTO statistics (profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, updated_at) VALUES (?, 0, 0, 0, 0, 0, 0, 0, 0, 0, ?)"
|
||||
)
|
||||
.bind(profile_id)
|
||||
.bind(&stats.updated_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
pub async fn record_match(
|
||||
pool: &Pool,
|
||||
profile_id: &str,
|
||||
outcome: &str,
|
||||
goals_for: i64,
|
||||
goals_against: i64,
|
||||
coins: i64,
|
||||
) -> AppResult<()> {
|
||||
get_or_create(pool, profile_id).await?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let (w, d, l) = match outcome {
|
||||
"win" => (1i64, 0i64, 0i64),
|
||||
"draw" => (0, 1, 0),
|
||||
_ => (0, 0, 1),
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE statistics SET
|
||||
matches_played = matches_played + 1,
|
||||
matches_won = matches_won + ?,
|
||||
matches_drawn = matches_drawn + ?,
|
||||
matches_lost = matches_lost + ?,
|
||||
goals_scored = goals_scored + ?,
|
||||
goals_conceded = goals_conceded + ?,
|
||||
total_coins_earned = total_coins_earned + ?,
|
||||
updated_at = ?
|
||||
WHERE profile_id = ?",
|
||||
)
|
||||
.bind(w)
|
||||
.bind(d)
|
||||
.bind(l)
|
||||
.bind(goals_for)
|
||||
.bind(goals_against)
|
||||
.bind(coins)
|
||||
.bind(&now)
|
||||
.bind(profile_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn increment_packs_opened(pool: &Pool, profile_id: &str) -> AppResult<()> {
|
||||
get_or_create(pool, profile_id).await?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE statistics SET packs_opened = packs_opened + 1, updated_at = ? WHERE profile_id = ?")
|
||||
.bind(&now)
|
||||
.bind(profile_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn increment_sbcs_completed(pool: &Pool, profile_id: &str) -> AppResult<()> {
|
||||
get_or_create(pool, profile_id).await?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE statistics SET sbcs_completed = sbcs_completed + 1, updated_at = ? WHERE profile_id = ?")
|
||||
.bind(&now)
|
||||
.bind(profile_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tower::ServiceExt;
|
||||
|
||||
// Bring in the crate modules via the library path
|
||||
// We test by spinning up the full app against an in-memory SQLite database.
|
||||
|
||||
async fn build_test_app() -> axum::Router {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("in-memory sqlite");
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrations");
|
||||
|
||||
// Seed with test card + pack data
|
||||
let data_dir = "data";
|
||||
openfut_core::build_app(pool, data_dir)
|
||||
.await
|
||||
.expect("app build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_endpoint() {
|
||||
let app = build_test_app().await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["status"], "ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_local_creates_profile_and_club() {
|
||||
let app = build_test_app().await;
|
||||
|
||||
let payload = serde_json::json!({ "username": "TestPlayer" });
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/auth/local")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(payload.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["profile"]["username"], "TestPlayer");
|
||||
assert_eq!(json["club"]["name"], "OpenFUT FC");
|
||||
assert_eq!(json["club"]["coins"], 5000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_not_found_before_auth() {
|
||||
let app = build_test_app().await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/profile")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_match_result_awards_coins() {
|
||||
let app = build_test_app().await;
|
||||
|
||||
// First create a profile
|
||||
let auth_resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/auth/local")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"username":"MatchPlayer"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(auth_resp.status(), StatusCode::OK);
|
||||
|
||||
// Submit a match win
|
||||
let payload = serde_json::json!({
|
||||
"squad_id": "dummy-squad",
|
||||
"opponent_name": "AI Club",
|
||||
"goals_for": 3,
|
||||
"goals_against": 1,
|
||||
"mode": "squad_battles"
|
||||
});
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/matches/result")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(payload.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["match_record"]["outcome"], "win");
|
||||
assert!(json["coins_awarded"].as_i64().unwrap() > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sbc_list_returns_definitions() {
|
||||
let app = build_test_app().await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(Request::builder().uri("/sbc").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(json["sbcs"].is_array());
|
||||
}
|
||||
Reference in New Issue
Block a user