Convert to submodule monorepo
Replace inline subdirectories with git submodules pointing to the dedicated OpenFUT-Core and OpenFUT-Bridge repositories. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
[submodule "openfut-core"]
|
||||||
|
path = openfut-core
|
||||||
|
url = https://git.aleshym.co/funman300/OpenFUT-Core.git
|
||||||
|
[submodule "openfut-bridge"]
|
||||||
|
path = openfut-bridge
|
||||||
|
url = https://git.aleshym.co/funman300/OpenFUT-Bridge.git
|
||||||
Submodule
+1
Submodule openfut-bridge added at a826e5f7d3
Generated
-1889
File diff suppressed because it is too large
Load Diff
@@ -1,36 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "openfut-bridge"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
authors = ["OpenFUT Contributors"]
|
|
||||||
description = "FIFA 23 integration layer and reverse-engineering proxy"
|
|
||||||
license = "MIT"
|
|
||||||
repository = "https://github.com/openfut/openfut-bridge"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "openfut_bridge"
|
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "openfut-bridge"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
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"] }
|
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
|
||||||
dotenvy = "0.15"
|
|
||||||
http = "1"
|
|
||||||
bytes = "1"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# OpenFUT Bridge
|
|
||||||
|
|
||||||
**FIFA 23 integration layer and reverse-engineering proxy.**
|
|
||||||
|
|
||||||
OpenFUT Bridge sits between the FIFA 23 client and the internet. It intercepts all FUT API traffic, logs every request, and routes known endpoints to OpenFUT Core. Unknown endpoints receive safe placeholder responses so the client keeps running.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What it does
|
|
||||||
|
|
||||||
- Transparent HTTP proxy — point FIFA 23 here instead of EA's servers
|
|
||||||
- Logs every request (method, path, headers, body, timestamp) to `captures/`
|
|
||||||
- Maps known FUT endpoints to OpenFUT Core API calls
|
|
||||||
- Returns placeholder JSON for unknown endpoints (prevents client crashes)
|
|
||||||
- Admin API for reviewing captures and identifying unmapped routes
|
|
||||||
- Foundation for building the full FIFA 23 integration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
FIFA 23 client
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
OpenFUT Bridge (port 8443)
|
|
||||||
│
|
|
||||||
├── Known route → OpenFUT Core (port 8080)
|
|
||||||
└── Unknown route → Placeholder JSON + saved to captures/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run Core first
|
|
||||||
cd ../openfut-core && cargo run
|
|
||||||
|
|
||||||
# Run Bridge
|
|
||||||
cargo run
|
|
||||||
|
|
||||||
# Or with config
|
|
||||||
CORE_URL=http://127.0.0.1:8080 BRIDGE_LISTEN_ADDR=127.0.0.1:8443 cargo run
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `BRIDGE_LISTEN_ADDR` | `127.0.0.1:8443` | Bridge listen address |
|
|
||||||
| `CORE_URL` | `http://127.0.0.1:8080` | OpenFUT Core URL |
|
|
||||||
| `CAPTURES_DIR` | `captures` | Where to save captured requests |
|
|
||||||
| `PLACEHOLDER_MODE` | `true` | Return 200 for unknown endpoints |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Admin API
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `GET` | `/_bridge/health` | Bridge status |
|
|
||||||
| `GET` | `/_bridge/captures` | All captured requests |
|
|
||||||
| `GET` | `/_bridge/unknown` | Unknown endpoints only |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reverse Engineering Workflow
|
|
||||||
|
|
||||||
1. Point FIFA 23 at the Bridge (see `docs/setup-notes.md` for proxy setup)
|
|
||||||
2. Start a game session
|
|
||||||
3. Check `captures/` or `/_bridge/unknown` for new endpoints
|
|
||||||
4. Document the endpoint in `docs/endpoint-map.md`
|
|
||||||
5. Add a mapping in `src/mapper.rs`
|
|
||||||
6. Implement the Core handler
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proxy Setup (Research Notes)
|
|
||||||
|
|
||||||
See `docs/setup-notes.md` for notes on:
|
|
||||||
- Windows `hosts` file redirection
|
|
||||||
- RPCS3 network config (PS3)
|
|
||||||
- Certificate bypass approaches
|
|
||||||
- mitmproxy integration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo fmt
|
|
||||||
cargo clippy -- -D warnings
|
|
||||||
cargo test
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# OpenFUT Bridge — TODO
|
|
||||||
|
|
||||||
## Reverse Engineering
|
|
||||||
- [ ] #1 Set up mitmproxy and capture a real FIFA 23 FUT session
|
|
||||||
- [ ] #2 Document all observed endpoints in `docs/endpoint-map.md`
|
|
||||||
- [ ] #3 Document FIFA 23 auth flow (token format, headers, session lifecycle)
|
|
||||||
- [ ] #4 Document request body formats for auth, squad, packs
|
|
||||||
- [ ] #5 Identify which endpoints are mandatory vs optional for FUT to load
|
|
||||||
- [ ] #6 Test hosts-file redirect approach on PC
|
|
||||||
- [ ] #7 Research certificate pinning in FIFA 23 PC build
|
|
||||||
- [ ] #8 Test RPCS3 network proxy configuration
|
|
||||||
- [ ] #9 Document response formats EA uses (some differ from request format)
|
|
||||||
- [ ] #10 Identify any binary/protobuf endpoints (most are JSON but verify)
|
|
||||||
|
|
||||||
## Proxy
|
|
||||||
- [ ] #11 Add TLS support (self-signed cert) so FIFA 23 connects via HTTPS
|
|
||||||
- [ ] #12 Add replay CLI: `openfut-bridge replay captures/some_file.json`
|
|
||||||
- [ ] #13 Add `DELETE /_bridge/captures` to wipe capture folder
|
|
||||||
- [ ] #14 Add capture deduplication (same method+path within 1 second)
|
|
||||||
- [ ] #15 Add request diff tool: show what changed between two captures
|
|
||||||
|
|
||||||
## Mapper
|
|
||||||
- [ ] #16 Implement actual auth endpoint mapping (static token response)
|
|
||||||
- [ ] #17 Map squad read endpoint when confirmed
|
|
||||||
- [ ] #18 Map pack details endpoint when confirmed
|
|
||||||
- [ ] #19 Add X-UT-SID session header pass-through to Core
|
|
||||||
- [ ] #20 Add phishing token passthrough
|
|
||||||
|
|
||||||
## Admin UI
|
|
||||||
- [ ] #21 Build a simple web dashboard for viewing captures
|
|
||||||
- [ ] #22 Add endpoint status page (known vs unknown vs confirmed)
|
|
||||||
- [ ] #23 Add live capture stream via SSE
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
- [ ] #24 Add test for placeholder response format
|
|
||||||
- [ ] #25 Add integration test that fires real HTTP at the Bridge
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# FUT → OpenFUT Core Endpoint Map
|
|
||||||
|
|
||||||
This document maps confirmed or suspected FIFA 23 FUT API endpoints to their OpenFUT Core equivalents.
|
|
||||||
|
|
||||||
## Legend
|
|
||||||
|
|
||||||
- ✅ Confirmed + implemented
|
|
||||||
- 🟡 Suspected — implemented with placeholder
|
|
||||||
- ❌ Unknown — not yet mapped
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Auth
|
|
||||||
|
|
||||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `POST /ut/auth` | `POST /auth/local` | 🟡 | EA OAuth flow → local profile creation |
|
|
||||||
|
|
||||||
## Profile / Club
|
|
||||||
|
|
||||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `GET /ut/game/fut/user/settings` | `GET /profile` | 🟡 | |
|
|
||||||
| `GET /ut/game/fut/usermassinfo` | `GET /club` | 🟡 | EA bulk endpoint |
|
|
||||||
|
|
||||||
## Squad
|
|
||||||
|
|
||||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `GET /ut/game/fut/squad/active` | `GET /squad` | 🟡 | |
|
|
||||||
| `PUT /ut/game/fut/squad/active` | `POST /squad` | ❌ | |
|
|
||||||
|
|
||||||
## Packs
|
|
||||||
|
|
||||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `GET /ut/game/fut/store/packdetails` | `GET /packs` | 🟡 | |
|
|
||||||
| `POST /ut/game/fut/pack/buy` | `POST /packs/open/:id` | ❌ | |
|
|
||||||
|
|
||||||
## Transfer Market
|
|
||||||
|
|
||||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `GET /ut/game/fut/transfermarket` | `GET /market` | 🟡 | |
|
|
||||||
| `DELETE /ut/game/fut/trade/:id` | `POST /market/sell` | ❌ | |
|
|
||||||
|
|
||||||
## Matches / Seasons
|
|
||||||
|
|
||||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Unknown | `POST /matches/result` | ❌ | Needs capture |
|
|
||||||
|
|
||||||
## Objectives
|
|
||||||
|
|
||||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Unknown | `GET /objectives` | ❌ | Needs capture |
|
|
||||||
|
|
||||||
## SBCs
|
|
||||||
|
|
||||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Unknown | `GET /sbc` | ❌ | Needs capture |
|
|
||||||
| Unknown | `POST /sbc/submit` | ❌ | Needs capture |
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# Reverse Engineering Notes — FIFA 23 FUT API
|
|
||||||
|
|
||||||
This document tracks what is known and unknown about EA's FUT API as used by FIFA 23.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
🔴 Very early — almost nothing confirmed. All mappings in `src/mapper.rs` are speculative.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known / Suspected Endpoints
|
|
||||||
|
|
||||||
These are guesses based on:
|
|
||||||
- Common FUT API patterns from public research
|
|
||||||
- Observations from older FIFA titles
|
|
||||||
- Community reverse-engineering work
|
|
||||||
|
|
||||||
| Method | Path | Purpose | Status |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `POST` | `/ut/auth` | Authentication / session | Suspected |
|
|
||||||
| `GET` | `/ut/game/fut/user/settings` | User settings | Suspected |
|
|
||||||
| `GET` | `/ut/game/fut/usermassinfo` | Club + profile bulk | Suspected |
|
|
||||||
| `GET` | `/ut/game/fut/squad/active` | Active squad | Suspected |
|
|
||||||
| `GET` | `/ut/game/fut/store/packdetails` | Pack store | Suspected |
|
|
||||||
| `GET` | `/ut/game/fut/transfermarket` | Transfer market | Suspected |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Unknown Endpoints
|
|
||||||
|
|
||||||
Run `GET /_bridge/unknown` after a game session to see what new routes appeared.
|
|
||||||
Each entry represents a real FIFA 23 request that hasn't been mapped yet.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Request Format Notes
|
|
||||||
|
|
||||||
### Auth
|
|
||||||
|
|
||||||
EA FUT auth appears to use a multi-step token flow:
|
|
||||||
1. EA account auth (OAuth2-style)
|
|
||||||
2. FUT-specific auth with a "nucleus ID"
|
|
||||||
3. Session token issued
|
|
||||||
|
|
||||||
For offline purposes, OpenFUT Bridge returns a static token that satisfies the client.
|
|
||||||
|
|
||||||
### Headers
|
|
||||||
|
|
||||||
Common headers seen in FUT traffic:
|
|
||||||
- `X-UT-SID` — session token
|
|
||||||
- `X-UT-PHISHING-TOKEN` — anti-CSRF token
|
|
||||||
- `Content-Type: application/json`
|
|
||||||
- `X-HTTP-Method-Override` — EA sometimes uses POST + this header instead of DELETE/PUT
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tools
|
|
||||||
|
|
||||||
- [mitmproxy](https://mitmproxy.org/) — HTTPS interception
|
|
||||||
- [Fiddler](https://www.telerik.com/fiddler) — Windows-friendly proxy
|
|
||||||
- Wireshark — low-level packet capture
|
|
||||||
- OpenFUT Bridge `captures/` folder — automatic request logging
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- Previous FIFA FUT API research: search GitHub for "fifa-ut-api", "easfc", "futapi"
|
|
||||||
- ea.com documentation: none public
|
|
||||||
- Community wikis: FUT Trading community resources
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TODO
|
|
||||||
|
|
||||||
- [ ] Capture a real FIFA 23 session via mitmproxy
|
|
||||||
- [ ] Document the auth flow completely
|
|
||||||
- [ ] Map the squad endpoints
|
|
||||||
- [ ] Map the pack opening endpoints
|
|
||||||
- [ ] Map the objectives endpoints
|
|
||||||
- [ ] Map the SBC endpoints
|
|
||||||
- [ ] Map the transfer market endpoints
|
|
||||||
- [ ] Identify which endpoints are critical vs optional
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# Proxy Setup Notes
|
|
||||||
|
|
||||||
Research notes for routing FIFA 23 traffic through OpenFUT Bridge.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Intercept all FUT API calls from FIFA 23 so the Bridge can:
|
|
||||||
1. Log every request for reverse engineering
|
|
||||||
2. Route known endpoints to OpenFUT Core
|
|
||||||
3. Return placeholder responses for the rest
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PC (FIFA 23 via EA App / Steam)
|
|
||||||
|
|
||||||
### Option A: Windows hosts file
|
|
||||||
|
|
||||||
Redirect `utas.fut.ea.com` and related domains to localhost.
|
|
||||||
|
|
||||||
```
|
|
||||||
# C:\Windows\System32\drivers\etc\hosts
|
|
||||||
127.0.0.1 utas.fut.ea.com
|
|
||||||
127.0.0.1 utas2.fut.ea.com
|
|
||||||
127.0.0.1 ea.com
|
|
||||||
```
|
|
||||||
|
|
||||||
The Bridge must listen on port 443 (HTTPS) or 80 (HTTP).
|
|
||||||
FIFA 23 expects HTTPS — you'll need a self-signed cert and need to trust it.
|
|
||||||
|
|
||||||
### Option B: mitmproxy
|
|
||||||
|
|
||||||
1. Install mitmproxy
|
|
||||||
2. Set Windows system proxy to 127.0.0.1:8080
|
|
||||||
3. Configure mitmproxy to forward FUT traffic to Bridge
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mitmproxy --mode transparent \
|
|
||||||
--listen-host 127.0.0.1 \
|
|
||||||
--listen-port 8080
|
|
||||||
```
|
|
||||||
|
|
||||||
⚠️ FIFA 23 may use certificate pinning — this is unconfirmed. If pinning is active, the hosts file approach or a traffic-level redirect may be needed instead.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## RPCS3 (PS3 emulator)
|
|
||||||
|
|
||||||
RPCS3 has built-in network settings:
|
|
||||||
|
|
||||||
1. Open RPCS3 → Configuration → Network
|
|
||||||
2. Set DNS to point to your OpenFUT Bridge IP
|
|
||||||
3. Configure `PSN Status` to `RPCN` or custom
|
|
||||||
4. The Bridge intercepts DNS and HTTP/HTTPS traffic
|
|
||||||
|
|
||||||
Research needed: RPCS3 FIFA 23 title ID and specific network behavior.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Certificate Handling
|
|
||||||
|
|
||||||
EA's FUT API uses HTTPS. Options:
|
|
||||||
|
|
||||||
1. **Self-signed cert** — generate with `openssl` and add to system trust store
|
|
||||||
2. **HTTP downgrade** — if the client accepts HTTP (unlikely for production)
|
|
||||||
3. **Traffic-level redirect** — use iptables/nftables to redirect port 443 traffic
|
|
||||||
4. **No cert** — if FIFA 23 PC doesn't verify certs (to be tested)
|
|
||||||
|
|
||||||
### Generating a self-signed cert
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes \
|
|
||||||
-subj "/CN=utas.fut.ea.com"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
🔴 Not tested yet. All of the above is research / theoretical.
|
|
||||||
The first step is to capture real FIFA 23 traffic with mitmproxy and document actual endpoints.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Set up mitmproxy on a FIFA 23 PC
|
|
||||||
2. Start FIFA 23 and navigate to FUT mode
|
|
||||||
3. Export the mitmproxy capture
|
|
||||||
4. Add real endpoints to `docs/endpoint-map.md`
|
|
||||||
5. Add mappings to `src/mapper.rs`
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
use chrono::Utc;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::path::Path;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// A captured HTTP request from the FIFA 23 client.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CapturedRequest {
|
|
||||||
pub id: String,
|
|
||||||
pub timestamp: String,
|
|
||||||
pub method: String,
|
|
||||||
pub path: String,
|
|
||||||
pub query: Option<String>,
|
|
||||||
pub headers: Vec<(String, String)>,
|
|
||||||
pub body: Option<String>,
|
|
||||||
pub response_status: Option<u16>,
|
|
||||||
pub response_body: Option<String>,
|
|
||||||
pub mapped_to_core: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CapturedRequest {
|
|
||||||
pub fn new(
|
|
||||||
method: &str,
|
|
||||||
path: &str,
|
|
||||||
query: Option<&str>,
|
|
||||||
headers: Vec<(String, String)>,
|
|
||||||
body: Option<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
id: Uuid::new_v4().to_string(),
|
|
||||||
timestamp: Utc::now().to_rfc3339(),
|
|
||||||
method: method.to_string(),
|
|
||||||
path: path.to_string(),
|
|
||||||
query: query.map(String::from),
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
response_status: None,
|
|
||||||
response_body: None,
|
|
||||||
mapped_to_core: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_response(mut self, status: u16, body: Option<String>) -> Self {
|
|
||||||
self.response_status = Some(status);
|
|
||||||
self.response_body = body;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist a capture to disk as JSON.
|
|
||||||
pub fn save_capture(captures_dir: &str, capture: &CapturedRequest) -> anyhow::Result<()> {
|
|
||||||
let dir = Path::new(captures_dir);
|
|
||||||
std::fs::create_dir_all(dir)?;
|
|
||||||
|
|
||||||
let filename = format!(
|
|
||||||
"{}_{}_{}.json",
|
|
||||||
capture.timestamp.replace(':', "-"),
|
|
||||||
capture.method,
|
|
||||||
capture.id
|
|
||||||
);
|
|
||||||
let path = dir.join(filename);
|
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(capture)?;
|
|
||||||
std::fs::write(&path, json)?;
|
|
||||||
|
|
||||||
tracing::debug!("Capture saved: {:?}", path);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load all captures from the captures directory.
|
|
||||||
pub fn load_all_captures(captures_dir: &str) -> anyhow::Result<Vec<CapturedRequest>> {
|
|
||||||
let dir = Path::new(captures_dir);
|
|
||||||
if !dir.exists() {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut captures = Vec::new();
|
|
||||||
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)?;
|
|
||||||
if let Ok(capture) = serde_json::from_str::<CapturedRequest>(&content) {
|
|
||||||
captures.push(capture);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
captures.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
|
|
||||||
Ok(captures)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Config {
|
|
||||||
/// Address the Bridge proxy listens on (FIFA 23 should point here)
|
|
||||||
pub listen_addr: String,
|
|
||||||
/// OpenFUT Core base URL
|
|
||||||
pub core_url: String,
|
|
||||||
/// Where to persist captures
|
|
||||||
pub captures_dir: String,
|
|
||||||
/// If true, return placeholder 200 responses for unknown routes
|
|
||||||
pub placeholder_mode: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
pub fn from_env() -> Result<Self> {
|
|
||||||
Ok(Self {
|
|
||||||
listen_addr: std::env::var("BRIDGE_LISTEN_ADDR")
|
|
||||||
.unwrap_or_else(|_| "127.0.0.1:8443".into()),
|
|
||||||
core_url: std::env::var("CORE_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".into()),
|
|
||||||
captures_dir: std::env::var("CAPTURES_DIR").unwrap_or_else(|_| "captures".into()),
|
|
||||||
placeholder_mode: std::env::var("PLACEHOLDER_MODE")
|
|
||||||
.map(|v| v == "true" || v == "1")
|
|
||||||
.unwrap_or(true),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
http::StatusCode,
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use serde_json::json;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum BridgeError {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[error("upstream error: {0}")]
|
|
||||||
Upstream(String),
|
|
||||||
|
|
||||||
#[error("serialization error: {0}")]
|
|
||||||
Serialization(#[from] serde_json::Error),
|
|
||||||
|
|
||||||
#[error("io error: {0}")]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
|
|
||||||
#[error("internal error: {0}")]
|
|
||||||
Internal(#[from] anyhow::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoResponse for BridgeError {
|
|
||||||
fn into_response(self) -> Response {
|
|
||||||
let (status, msg) = match &self {
|
|
||||||
BridgeError::Upstream(e) => (StatusCode::BAD_GATEWAY, e.clone()),
|
|
||||||
BridgeError::Serialization(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
|
||||||
BridgeError::Io(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
|
||||||
BridgeError::Internal(e) => {
|
|
||||||
tracing::error!("Bridge internal error: {e}");
|
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
(status, Json(json!({ "error": msg }))).into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type BridgeResult<T> = Result<T, BridgeError>;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
pub mod capture;
|
|
||||||
pub mod config;
|
|
||||||
pub mod error;
|
|
||||||
pub mod mapper;
|
|
||||||
pub mod proxy;
|
|
||||||
pub mod routes;
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use axum::{
|
|
||||||
routing::{any, get},
|
|
||||||
Router,
|
|
||||||
};
|
|
||||||
use openfut_bridge::{config::Config, proxy::ProxyState, routes};
|
|
||||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
|
||||||
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_bridge=debug,tower_http=info".into()),
|
|
||||||
)
|
|
||||||
.with(tracing_subscriber::fmt::layer())
|
|
||||||
.init();
|
|
||||||
|
|
||||||
let cfg = Config::from_env()?;
|
|
||||||
let listen_addr = cfg.listen_addr.clone();
|
|
||||||
|
|
||||||
info!("OpenFUT Bridge starting on {listen_addr}");
|
|
||||||
info!("Core URL: {}", cfg.core_url);
|
|
||||||
info!("Placeholder mode: {}", cfg.placeholder_mode);
|
|
||||||
info!("Captures dir: {}", cfg.captures_dir);
|
|
||||||
|
|
||||||
std::fs::create_dir_all(&cfg.captures_dir)?;
|
|
||||||
|
|
||||||
let state = ProxyState::new(cfg);
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route("/_bridge/health", get(routes::health::get_health))
|
|
||||||
.route("/_bridge/captures", get(routes::admin::get_captures))
|
|
||||||
.route(
|
|
||||||
"/_bridge/unknown",
|
|
||||||
get(routes::admin::get_unknown_endpoints),
|
|
||||||
)
|
|
||||||
.fallback(any(openfut_bridge::proxy::catch_all_handler))
|
|
||||||
.layer(TraceLayer::new_for_http())
|
|
||||||
.layer(CorsLayer::permissive())
|
|
||||||
.with_state(state);
|
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&listen_addr).await?;
|
|
||||||
info!("Bridge listening on http://{listen_addr}");
|
|
||||||
axum::serve(listener, app).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
/// Mapping layer: translates known FIFA 23 API paths to OpenFUT Core endpoints.
|
|
||||||
/// Unknown paths return None and will be recorded for reverse engineering.
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct CoreMapping {
|
|
||||||
pub method: &'static str,
|
|
||||||
pub core_path: &'static str,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub notes: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempt to map an incoming FIFA 23 request to an OpenFUT Core call.
|
|
||||||
/// Returns Some(mapping) if the route is known, None otherwise.
|
|
||||||
pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
|
|
||||||
let m = method.to_uppercase();
|
|
||||||
let p = path.trim_end_matches('/');
|
|
||||||
|
|
||||||
// These are speculative mappings based on common FUT API patterns.
|
|
||||||
// They will be refined as reverse engineering progresses.
|
|
||||||
let known: &[(&str, &str, CoreMapping)] = &[
|
|
||||||
// Auth
|
|
||||||
(
|
|
||||||
"POST",
|
|
||||||
"/ut/auth",
|
|
||||||
CoreMapping {
|
|
||||||
method: "POST",
|
|
||||||
core_path: "/auth/local",
|
|
||||||
notes: "FUT auth → Core local auth",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"GET",
|
|
||||||
"/ut/game/fut/user/settings",
|
|
||||||
CoreMapping {
|
|
||||||
method: "GET",
|
|
||||||
core_path: "/profile",
|
|
||||||
notes: "FUT settings → Core profile",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// Club
|
|
||||||
(
|
|
||||||
"GET",
|
|
||||||
"/ut/game/fut/usermassinfo",
|
|
||||||
CoreMapping {
|
|
||||||
method: "GET",
|
|
||||||
core_path: "/club",
|
|
||||||
notes: "FUT mass info → Core club",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// Squad
|
|
||||||
(
|
|
||||||
"GET",
|
|
||||||
"/ut/game/fut/squad/active",
|
|
||||||
CoreMapping {
|
|
||||||
method: "GET",
|
|
||||||
core_path: "/squad",
|
|
||||||
notes: "FUT active squad → Core squad",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// Packs
|
|
||||||
(
|
|
||||||
"GET",
|
|
||||||
"/ut/game/fut/store/packdetails",
|
|
||||||
CoreMapping {
|
|
||||||
method: "GET",
|
|
||||||
core_path: "/packs",
|
|
||||||
notes: "FUT pack store → Core pack list",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// Transfer market (guessed)
|
|
||||||
(
|
|
||||||
"GET",
|
|
||||||
"/ut/game/fut/transfermarket",
|
|
||||||
CoreMapping {
|
|
||||||
method: "GET",
|
|
||||||
core_path: "/market",
|
|
||||||
notes: "FUT transfer market → Core NPC market",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (km, kp, mapping) in known {
|
|
||||||
if *km == m && *kp == p {
|
|
||||||
return Some(mapping.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a safe placeholder response for unknown endpoints.
|
|
||||||
/// This prevents the FIFA 23 client from crashing while we log traffic.
|
|
||||||
pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value {
|
|
||||||
tracing::warn!(
|
|
||||||
"UNKNOWN ENDPOINT: {} {} — returning placeholder",
|
|
||||||
method,
|
|
||||||
path
|
|
||||||
);
|
|
||||||
serde_json::json!({
|
|
||||||
"status": "ok",
|
|
||||||
"openfut_note": "This endpoint has not been mapped yet. Check captures/ for details.",
|
|
||||||
"method": method,
|
|
||||||
"path": path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
body::Body,
|
|
||||||
extract::{Request, State},
|
|
||||||
http::StatusCode,
|
|
||||||
response::Response,
|
|
||||||
};
|
|
||||||
use bytes::Bytes;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
capture::{save_capture, CapturedRequest},
|
|
||||||
config::Config,
|
|
||||||
error::BridgeResult,
|
|
||||||
mapper::{map_to_core, placeholder_response},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ProxyState {
|
|
||||||
pub config: Arc<Config>,
|
|
||||||
pub http_client: reqwest::Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProxyState {
|
|
||||||
pub fn new(config: Config) -> Self {
|
|
||||||
Self {
|
|
||||||
config: Arc::new(config),
|
|
||||||
http_client: reqwest::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
|
||||||
.build()
|
|
||||||
.expect("failed to build HTTP client"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn catch_all_handler(
|
|
||||||
State(state): State<ProxyState>,
|
|
||||||
req: Request,
|
|
||||||
) -> BridgeResult<Response<Body>> {
|
|
||||||
let method = req.method().to_string();
|
|
||||||
let uri = req.uri().clone();
|
|
||||||
let path = uri.path().to_string();
|
|
||||||
let query = uri.query().map(String::from);
|
|
||||||
|
|
||||||
let headers: Vec<(String, String)> = req
|
|
||||||
.headers()
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("<binary>").to_string()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Extract body via axum's built-in mechanism
|
|
||||||
let (_parts, body) = req.into_parts();
|
|
||||||
let body_bytes: Bytes = axum::body::to_bytes(body, 1024 * 1024)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let body_str = if body_bytes.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(String::from_utf8_lossy(&body_bytes).to_string())
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
"→ {} {}{}",
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
query
|
|
||||||
.as_deref()
|
|
||||||
.map(|q| format!("?{q}"))
|
|
||||||
.unwrap_or_default()
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut capture =
|
|
||||||
CapturedRequest::new(&method, &path, query.as_deref(), headers, body_str.clone());
|
|
||||||
|
|
||||||
let (response_body, status_code): (Value, u16) =
|
|
||||||
if let Some(mapping) = map_to_core(&method, &path) {
|
|
||||||
tracing::info!(
|
|
||||||
" ↳ Mapped to Core: {} {}",
|
|
||||||
mapping.method,
|
|
||||||
mapping.core_path
|
|
||||||
);
|
|
||||||
capture.mapped_to_core = Some(mapping.core_path.to_string());
|
|
||||||
|
|
||||||
match forward_to_core(
|
|
||||||
&state,
|
|
||||||
mapping.method,
|
|
||||||
mapping.core_path,
|
|
||||||
body_str.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok((body, status)) => (body, status),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Core request failed: {e}");
|
|
||||||
(serde_json::json!({ "error": e.to_string() }), 502)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if state.config.placeholder_mode {
|
|
||||||
(placeholder_response(&method, &path), 200)
|
|
||||||
} else {
|
|
||||||
(serde_json::json!({ "error": "endpoint not mapped" }), 404)
|
|
||||||
};
|
|
||||||
|
|
||||||
capture = capture.with_response(status_code, Some(response_body.to_string()));
|
|
||||||
|
|
||||||
let captures_dir = state.config.captures_dir.clone();
|
|
||||||
let capture_clone = capture.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = save_capture(&captures_dir, &capture_clone) {
|
|
||||||
tracing::warn!("Failed to save capture: {e}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::OK);
|
|
||||||
let json_bytes =
|
|
||||||
serde_json::to_vec(&response_body).map_err(crate::error::BridgeError::Serialization)?;
|
|
||||||
|
|
||||||
let response = Response::builder()
|
|
||||||
.status(status)
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(Body::from(json_bytes))
|
|
||||||
.map_err(|e| anyhow::anyhow!("response build error: {e}"))?;
|
|
||||||
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn forward_to_core(
|
|
||||||
state: &ProxyState,
|
|
||||||
method: &str,
|
|
||||||
path: &str,
|
|
||||||
body: Option<&str>,
|
|
||||||
) -> anyhow::Result<(Value, u16)> {
|
|
||||||
let url = format!("{}{}", state.config.core_url, path);
|
|
||||||
let builder = match method {
|
|
||||||
"POST" => state.http_client.post(&url),
|
|
||||||
"PUT" => state.http_client.put(&url),
|
|
||||||
"DELETE" => state.http_client.delete(&url),
|
|
||||||
_ => state.http_client.get(&url),
|
|
||||||
};
|
|
||||||
|
|
||||||
let builder = if let Some(b) = body {
|
|
||||||
builder
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(b.to_string())
|
|
||||||
} else {
|
|
||||||
builder
|
|
||||||
};
|
|
||||||
|
|
||||||
let resp = builder.send().await?;
|
|
||||||
let status = resp.status().as_u16();
|
|
||||||
let body: Value = resp.json().await.unwrap_or(Value::Null);
|
|
||||||
Ok((body, status))
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
use axum::{extract::State, Json};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use crate::{capture::load_all_captures, error::BridgeResult, proxy::ProxyState};
|
|
||||||
|
|
||||||
/// List all captured requests.
|
|
||||||
pub async fn get_captures(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
|
||||||
let captures = load_all_captures(&state.config.captures_dir)
|
|
||||||
.map_err(crate::error::BridgeError::Internal)?;
|
|
||||||
|
|
||||||
let unknown: Vec<_> = captures
|
|
||||||
.iter()
|
|
||||||
.filter(|c| c.mapped_to_core.is_none())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"total": captures.len(),
|
|
||||||
"unknown_endpoints": unknown.len(),
|
|
||||||
"captures": captures,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List only unknown (unmapped) endpoints.
|
|
||||||
pub async fn get_unknown_endpoints(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
|
||||||
let captures = load_all_captures(&state.config.captures_dir)
|
|
||||||
.map_err(crate::error::BridgeError::Internal)?;
|
|
||||||
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
let unknown: Vec<Value> = captures
|
|
||||||
.iter()
|
|
||||||
.filter(|c| c.mapped_to_core.is_none())
|
|
||||||
.filter_map(|c| {
|
|
||||||
let key = format!("{} {}", c.method, c.path);
|
|
||||||
if seen.insert(key) {
|
|
||||||
Some(json!({
|
|
||||||
"method": c.method,
|
|
||||||
"path": c.path,
|
|
||||||
"first_seen": c.timestamp,
|
|
||||||
"capture_id": c.id,
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"unknown_endpoint_count": unknown.len(),
|
|
||||||
"endpoints": unknown,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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-bridge",
|
|
||||||
"version": env!("CARGO_PKG_VERSION"),
|
|
||||||
"note": "This proxy captures FIFA 23 traffic and routes known endpoints to OpenFUT Core."
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
pub mod admin;
|
|
||||||
pub mod health;
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
use openfut_bridge::{capture::CapturedRequest, mapper::map_to_core};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_known_endpoint_maps_to_core() {
|
|
||||||
let mapping = map_to_core("POST", "/ut/auth");
|
|
||||||
assert!(mapping.is_some());
|
|
||||||
let m = mapping.unwrap();
|
|
||||||
assert_eq!(m.core_path, "/auth/local");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_unknown_endpoint_returns_none() {
|
|
||||||
let mapping = map_to_core("GET", "/ut/game/fut/some/unknown/path");
|
|
||||||
assert!(mapping.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_capture_serializes_cleanly() {
|
|
||||||
let capture = CapturedRequest::new(
|
|
||||||
"GET",
|
|
||||||
"/ut/game/fut/user/settings",
|
|
||||||
None,
|
|
||||||
vec![("user-agent".into(), "FIFA23/1.0".into())],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&capture).expect("serialize");
|
|
||||||
let back: CapturedRequest = serde_json::from_str(&json).expect("deserialize");
|
|
||||||
assert_eq!(back.path, "/ut/game/fut/user/settings");
|
|
||||||
assert_eq!(back.method, "GET");
|
|
||||||
assert!(back.mapped_to_core.is_none());
|
|
||||||
assert!(back.response_status.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_capture_with_response() {
|
|
||||||
let capture = CapturedRequest::new(
|
|
||||||
"POST",
|
|
||||||
"/ut/auth",
|
|
||||||
None,
|
|
||||||
vec![],
|
|
||||||
Some(r#"{"token":"abc"}"#.into()),
|
|
||||||
)
|
|
||||||
.with_response(200, Some(r#"{"status":"ok"}"#.into()));
|
|
||||||
|
|
||||||
assert_eq!(capture.response_status, Some(200));
|
|
||||||
assert!(capture.response_body.is_some());
|
|
||||||
}
|
|
||||||
Submodule
+1
Submodule openfut-core added at 1ffe0ffa9f
Generated
-2753
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
|||||||
[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"] }
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# 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>`.
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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>;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
//! Modding support: load JSON data files from the data/ directory.
|
|
||||||
//! All game content (cards, packs, objectives, SBCs) is data-driven.
|
|
||||||
|
|
||||||
pub mod loader;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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>,
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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>,
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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>,
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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>,
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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."
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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() }),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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")
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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 })))
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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 })))
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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 })))
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
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