Compare commits

..

18 Commits

Author SHA1 Message Date
funman300 8e280de99e feat: add chemistry calculation engine scaffold
CI / Build, lint & test (pull_request) Failing after 1m23s
Adds src/services/chemistry.rs with calculate_chemistry() that scores
club/league/nation links between players (1–3 per player, max 33 total).
Includes passing unit tests for all-same and all-different squads.

Closes #1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:10:40 -07:00
funman300 11a811db6a Fix SQLite create_if_missing so the database is auto-created on first run
CI / Build, lint & test (push) Failing after 1m20s
Previously the binary failed with "unable to open database file" (SQLite
code 14) when no openfut.db existed yet. Using SqliteConnectOptions with
create_if_missing(true) tells SQLite to create the file if absent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:24:02 -07:00
funman300 e438b58d88 Phase 25: division leaderboard, market trade history
CI / Build, lint & test (push) Failing after 2m10s
- Market: record buy/sell history in market_history table; expose via
  GET /market/trade-history (last 30 events, newest first)
- Division: GET /division/leaderboard returns 10-club table with 9 seeded
  NPC entries + player row, sorted by pts; stable within a season
- rand feature small_rng enabled in Cargo.toml for SmallRng use
- 3 new integration tests (leaderboard count, sort order, empty trade history)
- Core: 96 tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:13:10 -07:00
funman300 956bfe7a73 Phase 24: daily check-in system + club milestones endpoint
CI / Build, lint & test (push) Failing after 28s
- migrations/0012_daily_checkin.sql: daily_checkins table tracking streak,
  coins awarded, pack granted, timestamp per profile
- services/checkin.rs: get_status() (available, streak_day, next reward),
  claim() (idempotent same-day guard, streak logic: continue if yesterday
  or today, else reset; 7-day cycle with STREAK_COINS array, day-7 pack)
- routes/club.rs: GET /club/checkin, POST /club/checkin, GET /club/milestones
  (computed from statistics, season_history, owned_cards, sbc_submissions,
  daily_checkins tables; no new DB tables needed)
- 4 new integration tests: checkin available initially, claim awards coins,
  idempotent same-day, milestones endpoint structure (93 → 93+4=97 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:05:52 -07:00
funman300 28d7490555 Phase 23: season history + division zone data + new route
CI / Build, lint & test (push) Failing after 1m19s
- migrations/0011_season_history.sql: persist one row per completed season
- models/season.rs: SeasonHistoryEntry struct; public SEASON_LENGTH /
  PROMOTION_PTS / RELEGATION_PTS consts; pts_above_safe, can_be_relegated,
  promotion_achievable helpers
- services/season.rs: write history entry on season rollover; get_history()
  returns last 20 seasons newest-first
- routes/division.rs: GET /division now includes promotion_pts, relegation_pts,
  season_length, pts_above_safe, promotion_achievable, can_be_relegated;
  new GET /division/history endpoint
- 3 new integration tests: history empty, history records after promotion,
  division response has zone fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 18:38:02 -07:00
funman300 c458b8cdbd Phase 21: auth status + full game reset
CI / Build, lint & test (push) Failing after 53s
GET /auth/status returns { has_profile: true/false } without erroring,
so the dashboard can check on load whether an onboarding flow is needed.

POST /auth/reset wipes every user-data table (profiles, clubs, owned_cards,
packs, squads, matches, statistics, achievements, notifications, seasons,
market_listings, sbc_submissions, draft_sessions, fut_champs_sessions,
objective_progress, position_goals, events, settings) in reverse-dependency
order, leaving the schema intact. A fresh POST /auth/local creates a new
club on the clean slate.

4 new tests: status before/after profile creation, reset clears profile
and allows a new one. Core now at 86 tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 18:07:36 -07:00
funman300 679f147c6a Phase 20: achievement system
CI / Build, lint & test (push) Failing after 1m19s
18 data-driven achievements (achievements.json) across 8 trigger categories:
matches_played, matches_won, goals_scored, packs_opened, sbcs_completed,
cards_owned, level, objectives_completed, drafts_completed. Rarities span
common → epic. Coin rewards range from 500 (first_match) to 6000 (win_50).

check_and_unlock() queries the relevant metric from existing tables, skips
already-earned achievements via INSERT OR IGNORE, grants coin rewards, and
fires a persistent notification per unlock. Trigger values are cached per
call to avoid redundant DB round-trips for same-trigger achievements.

Checks run automatically after every match result (all triggers), every
pack open (packs_opened), and every successful SBC submission (sbcs_completed).

GET /achievements returns all definitions annotated with unlocked/unlocked_at,
plus earned and total counts. POST /matches/result response gains an
achievements_unlocked array (empty when nothing new unlocked).

AppState gains achievement_defs (Arc<Vec<AchievementDefinition>>) loaded
from data/achievements/**/*.json at startup — same pattern as obj_defs.

5 new tests: list endpoint, first_match unlock, first_win unlock, no-dup
guard, coin reward verification. Core now at 82 tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 18:03:54 -07:00
funman300 f0dbabc409 Phase 19: persistent notifications system
CI / Build, lint & test (push) Failing after 58s
New notifications table (migration 0009) stores event-driven alerts
alongside the existing dynamic state notifications (unclaimed objectives,
expiring loans, season ending soon).

Persistent notifications are created automatically during match
processing: one per level gained, one per expired loan card, one per
objective newly completed, and one when a season ends (with
promotion/relegation result and rewards in the body).

GET /notifications now returns a merged list — persistent entries
(newest-first, limit 50) followed by dynamic entries — plus an
unread_count for the badge. Each item carries type, title, body,
is_read, and (for persistent) id and created_at.

PATCH /notifications/:id/read marks a single persistent notification
read. POST /notifications/read-all marks all persistent ones read.

Four new tests: unread_count field, level-up notification creation,
mark-all-read, single-read PATCH. Core now at 77 tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:57:24 -07:00
funman300 9e481ec072 Phase 18: data expansion and market pricing fix
CI / Build, lint & test (push) Failing after 1m15s
Four new card files (Serie A, Ligue 1, Primeira Liga, Eredivisie) add 45
cards across Italian, French, Portuguese, and Dutch football, bringing the
total card pool from 115 to 160. Each file uses fictional club/player
names and null image_path, keeping the project free of copyrighted assets.

Ten new SBC challenges (league_sbcs.json) targeting specific leagues and
nations increase the total from 7 to 17 challenges. Includes league purity
SBCs (Premier League, Bundesliga, Serie A, Ligue 1, Eredivisie), nation
combo SBCs (Iberian Derby, South American Fire), and utility SBCs (elite
strikers mini-submission, bronze-to-silver recycler, world tour).

NPC market refresh now shuffles the card pool before picking 24 listings
(was: take first 20 unshuffled), and price variance tightened from ±100%
to ±25% of the OVR-tier base price for more realistic NPC competition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:49:38 -07:00
funman300 d97695d414 Phase 17: profile level-up system
CI / Build, lint & test (push) Failing after 57s
XP thresholds (500→1200→2000→…→11000→+2500/level) drive automatic level
increases. add_xp_with_levelup() replaces bare add_xp() in match
processing: for each level gained it grants level×500 coins and milestone
packs (bronze@5, silver@10, gold@15, rare_gold@20, gold every 5 after).

GET /profile now returns computed level (recalculated from XP so it
stays consistent), xp_to_next_level, and xp_for_next_level so the
dashboard can render a progress bar without a second call.

POST /matches/result response gains level_ups array (empty when no
level-up occurred) with new_level, coins_granted, pack_granted per event.

Four new tests: profile level fields, level_for_xp boundary checks,
level-up event in match result, milestone pack unit test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:41:13 -07:00
funman300 3d50da3589 Phase 15: add GET /packs/store endpoint
CI / Build, lint & test (push) Failing after 52s
Returns all pack definitions with id, name, description, cost_coins, and
total_cards so the dashboard can render a purchasable pack store without
needing to know the definitions at compile time.

Two new tests: store listing shape and buy-then-open round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:25:37 -07:00
funman300 26b8e9efef Phase 11: FUT Champions mode and Division Rivals weekly rewards
CI / Build, lint & test (push) Failing after 1m33s
FUT Champions (Weekend League):
- POST /fut-champs/start — open a new 30-match week (one active session at a time)
- GET /fut-champs — current session status with matches_remaining
- POST /fut-champs/:id/result — record a match; auto-completes and computes tier at 30
- POST /fut-champs/:id/claim — claim coins + pack reward (idempotent guard)
- GET /fut-champs/history — past sessions newest-first
- 11 reward tiers: Elite (27+ wins, 50k coins + icon pack) down to Bronze 1 (0 wins, 250 coins)

Division Rivals:
- POST /rivals/claim-weekly — claim weekly reward scaled by current division
- Week counter is monotonic (offline-safe, no real-time calendar dependency)
- Division 1-3 get a bonus pack alongside coins

Migration 0008 adds fut_champs_sessions table and two columns to seasons.
12 new integration tests — all 67 pass, clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:06:18 -07:00
funman300 19c7b9989d Phase 10: card upgrade system (chemistry styles, position change, training)
CI / Build, lint & test (push) Failing after 1m37s
- GET /chemistry-styles — lists 18 FUT-style chemistry styles with stat boost breakdowns
- POST /collection/:id/chemistry-style — apply a style (Shadow, Hunter, Anchor, etc.)
- POST /collection/:id/position — override a player's position for 500 coins
- POST /collection/:id/training — add +1/+2/+3 OVR training bonus (max +3 total)
- GET /collection now includes chemistry_style, position_override, training_bonus,
  effective_overall and effective_position fields per owned card
- Migration 0007 adds three columns to owned_cards with safe defaults
- 10 new integration tests — all 55 pass, clippy clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:02:44 -07:00
funman300 a749fba93c Phase 9: division/season tracking, club customization, pack history, notifications
CI / Build, lint & test (push) Failing after 1m42s
- GET /division returns live season stats (points, record, promotion threshold)
- PUT /club allows updating club name and manager_name
- GET /packs/history returns opened packs with full card definitions
- GET /notifications dynamically surfaces completed objectives, expiring loans, season end
- Club model gains manager_name column (migration 0006 already added it)
- Pack model gains opened_cards and opened_at; pack SELECT queries updated
- 9 new integration tests — all 45 pass, clippy clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:50:35 -07:00
funman300 bfd6de6896 Phase 8 (Core): stateful draft, quick-sell, objectives by ID, market listings
CI / Build, lint & test (push) Failing after 1m21s
Draft v2 — stateful FUT-style pick sessions:
  - POST /draft/start?difficulty=<> — creates session, returns 5
    candidates for GK slot (position order: GK RB CB CB LB CDM CM CAM RW ST LW)
  - POST /draft/sessions/:id/pick { card_id } — validates candidate,
    advances to next position; on last pick grants coins+pack reward
    (avg OVR ≥84 → gold pack + 2000 coins, ≥78 → silver + 1000, else 400)
  - GET /draft/sessions/:id — session state with per-pick cards
  - POST /draft/sessions/:id/abandon — cancel without reward
  - Migration 0005_draft_sessions.sql

Quick-sell:
  - DELETE /collection/:owned_card_id — removes card, credits coins based
    on overall (85+ → 1500, 80-84 → 900, 75-79 → 600, 65-74 → 300, <65 → 150)

Objectives:
  - GET /objectives/:id — single objective with progress
  - POST /objectives/:id/claim — claim reward by URL param (complement to
    existing POST /objectives/claim body-param endpoint)

Market:
  - GET /market/my-listings — active listings posted by current club
  - DELETE /market/listings/:id — cancel a listing, returns card to collection

Tests: 9 new integration tests (37 total, all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:41:55 -07:00
funman300 8fa125cfd6 Phase 7 (Core): chemistry v2, expanded card pool & search filters
CI / Build, lint & test (push) Failing after 1m46s
- Chemistry v2: FUT-style link scoring with per-player breakdown
  (club +3/link cap 6, league +1/link cap 4, nation +1/link cap 3;
   player cap 10, team cap 100); chemistry response now includes
   per-player breakdown with club/league/nation link counts and pts
- Card pool: 34 new cards across Premier League, La Liga, Bundesliga
  with overlapping clubs/nations for meaningful chemistry testing
- Card search: extended GET /cards with nation, league, club,
  min_overall, max_overall, limit query params; results sorted by
  overall descending
- Match opponent: added "ultimate" difficulty band (85+ OVR);
  random formation selection from 6 tactical formations; falls back
  to lower OVR pool when not enough cards at the requested band
- Tests: 9 new integration tests (28 total, all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:31:56 -07:00
funman300 1ef2c436ab feat: Phase 5 — events system complete
CI / Build, lint & test (push) Successful in 2m5s
Adds a data-driven event system (#47 schema, #48 activation, #49 TOTW):

- EventDefinition JSON schema (event_type, effects, date range, is_manual)
- data/events/totw_week1.json — TOTW event active by date range (2024–2030),
  injects 5 TOTW cards into the market as [EVENT] listings
- data/events/seasonal_events.json — Spring Festival and Icon Weekend,
  manual-activation-only example events
- migrations/0004_events.sql — events override table (NULL/1/0 per event)
- services/event — load_event_definitions, compute_is_active (date range +
  manual override), get_active_events, get_all_with_status, activate/deactivate
- routes/events — GET /events, GET /events/:id, POST .../activate, .../deactivate
- services/market::refresh_npc_listings now accepts event_defs, injects bonus
  market cards from active events at 2× premium price
- 5 new integration tests (events list, single, activate/deactivate cycle,
  404 on unknown, market injection verification); 19/19 passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:02:45 -07:00
funman300 f6dd41fd41 feat: Phase 4 — dev experience complete
CI / Build, lint & test (push) Successful in 2m21s
Docs:
- .env.example: documents all environment variables with defaults
- CONTRIBUTING.md: dev setup, running tests, code style, design constraints
- MODDING.md: full schema reference for cards, packs, objectives, and SBCs
- .gitea/workflows/ci.yml: Gitea Actions CI (fmt check, clippy, build, test)

Middleware (#50-52):
- Body limit (256 KB) via DefaultBodyLimit on all routes (#50)
- Concurrency limit (256 concurrent requests) via semaphore middleware;
  returns 429 Too Many Requests when at capacity (#51)
- Correlation IDs via tower_http request_id layers; sets x-request-id
  UUID on every request and propagates it to the response headers (#52)

Layer order (outermost → innermost):
  CorsLayer → DefaultBodyLimit → concurrency limit → SetRequestId
  → TraceLayer → PropagateRequestId → routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 15:53:28 -07:00
80 changed files with 6969 additions and 144 deletions
+19
View File
@@ -0,0 +1,19 @@
# OpenFUT Core — environment configuration
# Copy this file to .env and adjust values for your setup.
# Server listen address (host:port)
LISTEN_ADDR=127.0.0.1:8080
# SQLite database path (relative to the working directory)
DATABASE_URL=sqlite://openfut.db
# Directory containing card, pack, objective, and SBC data files
DATA_DIR=data
# Max SQLite connection pool size
DB_MAX_CONNECTIONS=5
# Log level for the application
# Options: error, warn, info, debug, trace
# You can also use module-level filters: openfut_core=debug,tower_http=info
RUST_LOG=openfut_core=info,tower_http=debug
+47
View File
@@ -0,0 +1,47 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Build, lint & test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust stable
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
components: clippy, rustfmt
- name: Cache Cargo registry & build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Check formatting
run: cargo fmt --check
- name: Clippy (deny warnings)
run: cargo clippy -- -D warnings
- name: Build
run: cargo build --locked
- name: Test
run: cargo test --locked
env:
RUST_BACKTRACE: 1
+93
View File
@@ -0,0 +1,93 @@
# Contributing to OpenFUT Core
## Prerequisites
- **Rust** (stable, 1.80+) — install via [rustup](https://rustup.rs)
- **SQLite 3** — usually pre-installed on Linux/macOS
Recommended tools:
```bash
rustup component add clippy rustfmt
```
## Setup
```bash
git clone <repo-url>
cd openfut-core
cp .env.example .env
cargo build
```
## Running locally
```bash
cargo run
```
The server starts at `http://127.0.0.1:8080` by default. Logs appear in the
terminal. On first run, the SQLite database is created and all migrations are
applied automatically.
## Running tests
```bash
cargo test
```
Tests spin up a full in-process server against an in-memory SQLite database —
no setup required and nothing is written to disk.
## Code style
All PRs must pass before merge:
```bash
cargo fmt --check # formatting
cargo clippy -- -D warnings # lints
cargo test # full test suite
```
Auto-fix formatting with `cargo fmt`.
## Adding card / pack / objective data
See [MODDING.md](MODDING.md) for the data file schemas. No Rust code changes
are needed to add new cards, packs, objectives, or SBC definitions.
## Project layout
```
src/
app.rs router + middleware setup
config.rs Config struct (reads from env)
db.rs SQLite pool init + migrations
error.rs AppError enum
middleware.rs concurrency limit, request ID
models/ Serde structs for DB rows and API payloads
routes/ Axum handler functions (one file per domain)
services/ Business logic (no HTTP types here)
seed/ One-time startup seeds
modding/ JSON data loader utilities
data/
cards/ CardDefinition JSON arrays
packs/ PackDefinition JSON arrays
objectives/ ObjectiveDefinition JSON arrays
sbcs/ SbcDefinition JSON arrays
migrations/ SQLite migration SQL files
```
## Branching & pull requests
- Create a branch from `main`: `git checkout -b feat/my-feature`
- Keep PRs focused — one feature or fix per PR
- PR description should explain *why*, not just *what*
## Design constraints
- **No online multiplayer.** This is an offline local backend only.
- **No copyrighted assets.** All card data in `data/` must be original.
- **No real EA services.** Do not hardcode or reverse-engineer EA endpoints.
- **Game-independent core.** `openfut-core` must stay game-agnostic;
FIFA-specific logic belongs in `openfut-bridge`.
Generated
+2
View File
@@ -1115,6 +1115,7 @@ dependencies = [
"axum-test", "axum-test",
"chrono", "chrono",
"dotenvy", "dotenvy",
"http 1.4.2",
"rand", "rand",
"serde", "serde",
"serde_json", "serde_json",
@@ -2134,6 +2135,7 @@ dependencies = [
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
"uuid",
] ]
[[package]] [[package]]
+3 -2
View File
@@ -17,18 +17,19 @@ path = "src/main.rs"
[dependencies] [dependencies]
axum = { version = "0.7", features = ["macros"] } axum = { version = "0.7", features = ["macros"] }
http = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls", "migrate", "chrono", "uuid"] } sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls", "migrate", "chrono", "uuid"] }
tower-http = { version = "0.5", features = ["cors", "trace"] } tower-http = { version = "0.5", features = ["cors", "trace", "request-id"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
thiserror = "1" thiserror = "1"
anyhow = "1" anyhow = "1"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
rand = "0.8" rand = { version = "0.8", features = ["small_rng"] }
dotenvy = "0.15" dotenvy = "0.15"
axum-macros = "0.4" axum-macros = "0.4"
+174
View File
@@ -0,0 +1,174 @@
# OpenFUT Modding Guide
All game content is driven by JSON files in the `data/` directory. You can add
new cards, packs, objectives, and SBCs without touching any Rust code.
The server reloads data files on startup. After changing a file, restart
`openfut-core` to pick up the changes.
---
## Cards — `data/cards/*.json`
Each file contains a JSON array of `CardDefinition` objects. All files in the
directory are loaded at startup.
```json
[
{
"id": "card_unique_id",
"name": "Player Name",
"overall": 82,
"position": "ST",
"nation": "Brazil",
"league": "Brasileirao",
"club": "Flamengo",
"pace": 88,
"shooting": 85,
"passing": 74,
"dribbling": 82,
"defending": 38,
"physical": 76,
"rarity": "gold",
"image_path": null
}
]
```
**Valid rarity values:** `bronze`, `silver`, `gold`, `raregold`, `totw`, `hero`, `icon`
**Valid positions:** `GK`, `CB`, `LB`, `RB`, `CDM`, `CM`, `CAM`, `LM`, `RM`,
`LW`, `RW`, `ST`, `CF`
- `id` must be unique across all card files.
- `overall` is 199.
- Attribute values are 199.
- `image_path` can be a relative path to an image or `null`.
---
## Packs — `data/packs/*.json`
Each file contains a JSON array of `PackDefinition` objects.
```json
[
{
"id": "my_custom_pack",
"name": "Custom Pack",
"description": "12 random gold players.",
"cost_coins": 7500,
"slots": [
{
"count": 12,
"min_overall": 75,
"rarity_filter": ["gold", "raregold"],
"guaranteed_rare": false
}
]
}
]
```
Each **slot** defines one group of cards to draw:
- `count` — how many cards to draw for this slot.
- `min_overall` — minimum overall rating (inclusive), or `null` for no filter.
- `rarity_filter` — array of rarity strings to draw from, or `null` for any rarity.
- `guaranteed_rare` — if `true`, this slot's cards are marked as rare.
A pack can have multiple slots (e.g., one slot of 11 gold cards + one guaranteed
rare slot of 1 card).
---
## Objectives — `data/objectives/*.json`
Each file contains a JSON array of `ObjectiveDefinition` objects.
```json
[
{
"id": "daily_score_3",
"title": "Hat-Trick Hero",
"description": "Score 3 goals in one session.",
"objective_type": "daily",
"metric": "goalsscored",
"target": 3,
"reward_coins": 500,
"reward_xp": 200,
"reward_pack_id": null
}
]
```
**Valid `objective_type` values:** `daily`, `weekly`, `milestone`, `lifetime`, `season`
**Valid `metric` values:**
| Metric | Incremented by |
|------------------|--------------------------------------|
| `matcheswon` | Winning a match |
| `matchesplayed` | Playing any match |
| `goalsscored` | Goals scored in a match |
| `packsopened` | Opening a pack |
| `sbcscompleted` | Completing an SBC |
| `coinsearned` | Coins awarded after a match |
- `reward_pack_id` must match a pack `id` from `data/packs/` or be `null`.
- Daily objectives are auto-reset to 0 at midnight UTC.
---
## SBCs — `data/sbcs/*.json`
Each file contains a JSON array of `SbcDefinition` objects.
```json
[
{
"id": "my_sbc",
"name": "Custom SBC",
"description": "Submit 5 gold players for a reward.",
"expires_at": null,
"requirements": {
"squad_size": 5,
"min_overall": 75,
"max_overall": null,
"required_leagues": ["Premier League"],
"required_nations": [],
"required_clubs": [],
"min_players_from_same_league": null,
"min_players_from_same_nation": null,
"min_players_from_same_club": null
},
"reward": {
"coins": 1000,
"xp": 300,
"pack_id": null
}
}
]
```
**Requirements:**
- `squad_size` — exact number of cards to submit (required).
- `min_overall` — average overall must meet or exceed this (optional).
- `max_overall` — no individual card may exceed this (optional).
- `required_leagues` — at least one card from each listed league.
- `required_nations` — at least one card from each listed nation.
- `required_clubs` — at least one card from each listed club.
- `min_players_from_same_league` — max count of any single league ≥ this.
- `min_players_from_same_nation` — max count of any single nation ≥ this.
- `min_players_from_same_club` — max count of any single club ≥ this.
Cards submitted to an SBC are permanently consumed from the club's collection.
---
## Tips
- Use unique `id` values that won't collide with future official cards.
A good convention is `card_<nation>_<number>`, e.g. `card_br_042`.
- You can have multiple JSON files per directory — they are all merged at load time.
- Invalid JSON will prevent the server from starting; check the log output.
- Cards referenced by packs must exist in `data/cards/` or they will not be drawn.
+182
View File
@@ -0,0 +1,182 @@
[
{
"id": "first_match",
"title": "Debut Match",
"description": "Play your very first match.",
"icon": "⚽",
"trigger": "matches_played",
"threshold": 1,
"reward_coins": 500,
"rarity": "common"
},
{
"id": "match_veteran",
"title": "Match Veteran",
"description": "Play 10 matches.",
"icon": "🏟️",
"trigger": "matches_played",
"threshold": 10,
"reward_coins": 1500,
"rarity": "common"
},
{
"id": "match_pro",
"title": "Pro Player",
"description": "Play 50 matches.",
"icon": "🎖️",
"trigger": "matches_played",
"threshold": 50,
"reward_coins": 4000,
"rarity": "rare"
},
{
"id": "first_win",
"title": "First Blood",
"description": "Win your first match.",
"icon": "🏆",
"trigger": "matches_won",
"threshold": 1,
"reward_coins": 500,
"rarity": "common"
},
{
"id": "win_10",
"title": "On Fire",
"description": "Win 10 matches.",
"icon": "🔥",
"trigger": "matches_won",
"threshold": 10,
"reward_coins": 2000,
"rarity": "rare"
},
{
"id": "win_50",
"title": "Unstoppable",
"description": "Win 50 matches.",
"icon": "👑",
"trigger": "matches_won",
"threshold": 50,
"reward_coins": 6000,
"rarity": "epic"
},
{
"id": "goalscorer_10",
"title": "Goalscorer",
"description": "Score 10 goals across all matches.",
"icon": "⚡",
"trigger": "goals_scored",
"threshold": 10,
"reward_coins": 750,
"rarity": "common"
},
{
"id": "top_scorer_50",
"title": "Top Scorer",
"description": "Score 50 goals across all matches.",
"icon": "🥅",
"trigger": "goals_scored",
"threshold": 50,
"reward_coins": 3000,
"rarity": "rare"
},
{
"id": "first_pack",
"title": "Pack Opener",
"description": "Open your first pack.",
"icon": "📦",
"trigger": "packs_opened",
"threshold": 1,
"reward_coins": 500,
"rarity": "common"
},
{
"id": "pack_10",
"title": "Pack Addict",
"description": "Open 10 packs.",
"icon": "🎁",
"trigger": "packs_opened",
"threshold": 10,
"reward_coins": 2500,
"rarity": "rare"
},
{
"id": "collector_20",
"title": "Collector",
"description": "Own 20 cards in your collection.",
"icon": "🗂️",
"trigger": "cards_owned",
"threshold": 20,
"reward_coins": 1000,
"rarity": "common"
},
{
"id": "hoarder_50",
"title": "Hoarder",
"description": "Own 50 cards in your collection.",
"icon": "💎",
"trigger": "cards_owned",
"threshold": 50,
"reward_coins": 3500,
"rarity": "rare"
},
{
"id": "first_sbc",
"title": "Squad Builder",
"description": "Complete your first SBC challenge.",
"icon": "🧩",
"trigger": "sbcs_completed",
"threshold": 1,
"reward_coins": 750,
"rarity": "common"
},
{
"id": "sbc_master",
"title": "SBC Master",
"description": "Complete 5 SBC challenges.",
"icon": "🔧",
"trigger": "sbcs_completed",
"threshold": 5,
"reward_coins": 4000,
"rarity": "epic"
},
{
"id": "level_5",
"title": "Rising Star",
"description": "Reach Level 5.",
"icon": "⬆",
"trigger": "level",
"threshold": 5,
"reward_coins": 2000,
"rarity": "rare"
},
{
"id": "level_10",
"title": "Elite Player",
"description": "Reach Level 10.",
"icon": "⭐",
"trigger": "level",
"threshold": 10,
"reward_coins": 5000,
"rarity": "epic"
},
{
"id": "first_objective",
"title": "On a Mission",
"description": "Complete your first objective.",
"icon": "✅",
"trigger": "objectives_completed",
"threshold": 1,
"reward_coins": 500,
"rarity": "common"
},
{
"id": "draft_debut",
"title": "Draft Veteran",
"description": "Complete a draft session.",
"icon": "🃏",
"trigger": "drafts_completed",
"threshold": 1,
"reward_coins": 1500,
"rarity": "rare"
}
]
+172
View File
@@ -0,0 +1,172 @@
[
{
"id": "card_bl_001",
"name": "Klaus Berger",
"overall": 84,
"position": "ST",
"nation": "Germany",
"league": "Bundesliga",
"club": "Röder FC",
"pace": 82,
"shooting": 87,
"passing": 72,
"dribbling": 80,
"defending": 36,
"physical": 84,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_002",
"name": "Felix Hartmann",
"overall": 83,
"position": "CM",
"nation": "Germany",
"league": "Bundesliga",
"club": "Röder FC",
"pace": 74,
"shooting": 74,
"passing": 86,
"dribbling": 80,
"defending": 72,
"physical": 76,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_003",
"name": "Matteo Ricci",
"overall": 82,
"position": "CAM",
"nation": "Italy",
"league": "Bundesliga",
"club": "Röder FC",
"pace": 76,
"shooting": 80,
"passing": 85,
"dribbling": 84,
"defending": 42,
"physical": 66,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_004",
"name": "Jan Schreiber",
"overall": 81,
"position": "CB",
"nation": "Germany",
"league": "Bundesliga",
"club": "Röder FC",
"pace": 74,
"shooting": 40,
"passing": 66,
"dribbling": 56,
"defending": 85,
"physical": 87,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_005",
"name": "Lucas Fonseca",
"overall": 83,
"position": "LW",
"nation": "Brazil",
"league": "Bundesliga",
"club": "Blau Stern München",
"pace": 90,
"shooting": 78,
"passing": 77,
"dribbling": 88,
"defending": 32,
"physical": 66,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_006",
"name": "Stefan Wolf",
"overall": 82,
"position": "CDM",
"nation": "Germany",
"league": "Bundesliga",
"club": "Blau Stern München",
"pace": 72,
"shooting": 64,
"passing": 78,
"dribbling": 74,
"defending": 85,
"physical": 82,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_007",
"name": "Marco Bauer",
"overall": 80,
"position": "RB",
"nation": "Germany",
"league": "Bundesliga",
"club": "Blau Stern München",
"pace": 82,
"shooting": 54,
"passing": 74,
"dribbling": 74,
"defending": 80,
"physical": 76,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_008",
"name": "Henri Delacroix",
"overall": 84,
"position": "ST",
"nation": "France",
"league": "Bundesliga",
"club": "Rhein United",
"pace": 86,
"shooting": 87,
"passing": 73,
"dribbling": 82,
"defending": 38,
"physical": 80,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_009",
"name": "Tobias Kohl",
"overall": 80,
"position": "GK",
"nation": "Germany",
"league": "Bundesliga",
"club": "Rhein United",
"pace": 56,
"shooting": 16,
"passing": 60,
"dribbling": 30,
"defending": 82,
"physical": 82,
"rarity": "gold",
"image_path": null
},
{
"id": "card_bl_010",
"name": "Nicolas Braun",
"overall": 81,
"position": "LB",
"nation": "Germany",
"league": "Bundesliga",
"club": "Rhein United",
"pace": 78,
"shooting": 52,
"passing": 74,
"dribbling": 72,
"defending": 81,
"physical": 78,
"rarity": "gold",
"image_path": null
}
]
+10
View File
@@ -0,0 +1,10 @@
[
{"id":"card_er_001","name":"Daan van der Berg","overall":84,"position":"CAM","nation":"Netherlands","league":"Eredivisie","club":"Ajax Amsterdam","pace":80,"shooting":78,"passing":88,"dribbling":86,"defending":45,"physical":70,"rarity":"gold","image_path":null},
{"id":"card_er_002","name":"Luca van Dijk","overall":83,"position":"CB","nation":"Netherlands","league":"Eredivisie","club":"PSV Eindhoven","pace":76,"shooting":44,"passing":70,"dribbling":66,"defending":87,"physical":84,"rarity":"gold","image_path":null},
{"id":"card_er_003","name":"Joost Bakker","overall":82,"position":"ST","nation":"Netherlands","league":"Eredivisie","club":"Feyenoord Rotterdam","pace":84,"shooting":86,"passing":68,"dribbling":80,"defending":28,"physical":80,"rarity":"gold","image_path":null},
{"id":"card_er_004","name":"Sven Bosman","overall":81,"position":"CM","nation":"Netherlands","league":"Eredivisie","club":"Ajax Amsterdam","pace":76,"shooting":68,"passing":84,"dribbling":78,"defending":66,"physical":74,"rarity":"gold","image_path":null},
{"id":"card_er_005","name":"Tom Hendriks","overall":80,"position":"GK","nation":"Netherlands","league":"Eredivisie","club":"PSV Eindhoven","pace":55,"shooting":18,"passing":60,"dribbling":28,"defending":81,"physical":80,"rarity":"gold","image_path":null},
{"id":"card_er_006","name":"Lars Kuipers","overall":79,"position":"RB","nation":"Netherlands","league":"Eredivisie","club":"Feyenoord Rotterdam","pace":84,"shooting":54,"passing":70,"dribbling":72,"defending":78,"physical":72,"rarity":"gold","image_path":null},
{"id":"card_er_007","name":"Arjan Visser","overall":78,"position":"LW","nation":"Netherlands","league":"Eredivisie","club":"Ajax Amsterdam","pace":86,"shooting":72,"passing":70,"dribbling":82,"defending":32,"physical":62,"rarity":"gold","image_path":null},
{"id":"card_er_008","name":"Pieter Smits","overall":77,"position":"CDM","nation":"Netherlands","league":"Eredivisie","club":"PSV Eindhoven","pace":72,"shooting":56,"passing":72,"dribbling":68,"defending":82,"physical":78,"rarity":"gold","image_path":null}
]
+206
View File
@@ -0,0 +1,206 @@
[
{
"id": "card_ll_001",
"name": "Carlos Mendez",
"overall": 85,
"position": "ST",
"nation": "Spain",
"league": "La Liga",
"club": "Atletico Fuego",
"pace": 86,
"shooting": 89,
"passing": 72,
"dribbling": 84,
"defending": 36,
"physical": 82,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_002",
"name": "Pablo Serrano",
"overall": 83,
"position": "CAM",
"nation": "Spain",
"league": "La Liga",
"club": "Atletico Fuego",
"pace": 78,
"shooting": 80,
"passing": 86,
"dribbling": 86,
"defending": 46,
"physical": 70,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_003",
"name": "Rodrigo Vidal",
"overall": 82,
"position": "CM",
"nation": "Spain",
"league": "La Liga",
"club": "Atletico Fuego",
"pace": 74,
"shooting": 70,
"passing": 84,
"dribbling": 80,
"defending": 72,
"physical": 74,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_004",
"name": "Andrés Torres",
"overall": 81,
"position": "CB",
"nation": "Spain",
"league": "La Liga",
"club": "Atletico Fuego",
"pace": 73,
"shooting": 38,
"passing": 68,
"dribbling": 58,
"defending": 84,
"physical": 86,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_005",
"name": "Gabriel Costa",
"overall": 84,
"position": "LW",
"nation": "Brazil",
"league": "La Liga",
"club": "Real Viento",
"pace": 92,
"shooting": 80,
"passing": 78,
"dribbling": 90,
"defending": 32,
"physical": 64,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_006",
"name": "Javier Ruiz",
"overall": 83,
"position": "CDM",
"nation": "Spain",
"league": "La Liga",
"club": "Real Viento",
"pace": 72,
"shooting": 62,
"passing": 80,
"dribbling": 76,
"defending": 86,
"physical": 82,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_007",
"name": "Miguel Ángel Prieto",
"overall": 80,
"position": "RB",
"nation": "Spain",
"league": "La Liga",
"club": "Real Viento",
"pace": 80,
"shooting": 52,
"passing": 76,
"dribbling": 74,
"defending": 80,
"physical": 74,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_008",
"name": "Diego Reyes",
"overall": 82,
"position": "ST",
"nation": "Argentina",
"league": "La Liga",
"club": "Valencia Azul",
"pace": 85,
"shooting": 84,
"passing": 70,
"dribbling": 82,
"defending": 36,
"physical": 78,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_009",
"name": "Marc Gutiérrez",
"overall": 80,
"position": "GK",
"nation": "Spain",
"league": "La Liga",
"club": "Valencia Azul",
"pace": 54,
"shooting": 14,
"passing": 62,
"dribbling": 28,
"defending": 81,
"physical": 80,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_010",
"name": "Emilio Navarro",
"overall": 81,
"position": "LB",
"nation": "Spain",
"league": "La Liga",
"club": "Valencia Azul",
"pace": 79,
"shooting": 52,
"passing": 74,
"dribbling": 72,
"defending": 82,
"physical": 76,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_011",
"name": "Thiago Drummond",
"overall": 83,
"position": "CB",
"nation": "Brazil",
"league": "La Liga",
"club": "Atletico Fuego",
"pace": 76,
"shooting": 38,
"passing": 68,
"dribbling": 62,
"defending": 86,
"physical": 88,
"rarity": "gold",
"image_path": null
},
{
"id": "card_ll_012",
"name": "Fran Molina",
"overall": 82,
"position": "RW",
"nation": "Spain",
"league": "La Liga",
"club": "Real Viento",
"pace": 87,
"shooting": 76,
"passing": 76,
"dribbling": 85,
"defending": 34,
"physical": 66,
"rarity": "gold",
"image_path": null
}
]
+12
View File
@@ -0,0 +1,12 @@
[
{"id":"card_ln_001","name":"Diogo Ferreira","overall":85,"position":"ST","nation":"Portugal","league":"Primeira Liga","club":"Lisboa United","pace":82,"shooting":88,"passing":74,"dribbling":84,"defending":30,"physical":82,"rarity":"raregold","image_path":null},
{"id":"card_ln_002","name":"Rúben Costa","overall":83,"position":"CAM","nation":"Portugal","league":"Primeira Liga","club":"Porto Sul","pace":78,"shooting":78,"passing":88,"dribbling":84,"defending":46,"physical":70,"rarity":"gold","image_path":null},
{"id":"card_ln_003","name":"André Sousa","overall":82,"position":"CB","nation":"Portugal","league":"Primeira Liga","club":"Sporting Algarve","pace":72,"shooting":44,"passing":68,"dribbling":62,"defending":86,"physical":82,"rarity":"gold","image_path":null},
{"id":"card_ln_004","name":"Tiago Fonseca","overall":81,"position":"CDM","nation":"Portugal","league":"Primeira Liga","club":"Braga Norte","pace":70,"shooting":56,"passing":74,"dribbling":70,"defending":84,"physical":80,"rarity":"gold","image_path":null},
{"id":"card_ln_005","name":"João Mendes","overall":80,"position":"LB","nation":"Portugal","league":"Primeira Liga","club":"Lisboa United","pace":82,"shooting":54,"passing":72,"dribbling":74,"defending":80,"physical":72,"rarity":"gold","image_path":null},
{"id":"card_ln_006","name":"Nuno Pinto","overall":79,"position":"GK","nation":"Portugal","league":"Primeira Liga","club":"Porto Sul","pace":52,"shooting":16,"passing":58,"dribbling":26,"defending":80,"physical":80,"rarity":"gold","image_path":null},
{"id":"card_ln_007","name":"Gonçalo Lopes","overall":78,"position":"RW","nation":"Portugal","league":"Primeira Liga","club":"Sporting Algarve","pace":84,"shooting":72,"passing":68,"dribbling":80,"defending":32,"physical":64,"rarity":"gold","image_path":null},
{"id":"card_ln_008","name":"Carlos Rodrigues","overall":77,"position":"CM","nation":"Portugal","league":"Primeira Liga","club":"Braga Norte","pace":74,"shooting":66,"passing":78,"dribbling":74,"defending":68,"physical":72,"rarity":"gold","image_path":null},
{"id":"card_ln_009","name":"Mamadou Diallo","overall":80,"position":"ST","nation":"Senegal","league":"Primeira Liga","club":"Lisboa United","pace":90,"shooting":80,"passing":62,"dribbling":76,"defending":26,"physical":84,"rarity":"gold","image_path":null},
{"id":"card_ln_010","name":"Bruno Fernandes Júnior","overall":76,"position":"LW","nation":"Portugal","league":"Primeira Liga","club":"Porto Sul","pace":82,"shooting":70,"passing":70,"dribbling":78,"defending":30,"physical":62,"rarity":"gold","image_path":null}
]
+14
View File
@@ -0,0 +1,14 @@
[
{"id":"card_l1_001","name":"Antoine Mercier","overall":88,"position":"ST","nation":"France","league":"Ligue 1","club":"Paris Olympique","pace":85,"shooting":91,"passing":75,"dribbling":86,"defending":30,"physical":83,"rarity":"raregold","image_path":null},
{"id":"card_l1_002","name":"Kylian Dupont","overall":86,"position":"CAM","nation":"France","league":"Ligue 1","club":"Marseille Olympia","pace":92,"shooting":80,"passing":84,"dribbling":90,"defending":38,"physical":70,"rarity":"raregold","image_path":null},
{"id":"card_l1_003","name":"Théo Girard","overall":84,"position":"CM","nation":"France","league":"Ligue 1","club":"Lyon Premier","pace":76,"shooting":72,"passing":86,"dribbling":82,"defending":65,"physical":74,"rarity":"gold","image_path":null},
{"id":"card_l1_004","name":"Hugo Blanc","overall":83,"position":"GK","nation":"France","league":"Ligue 1","club":"Paris Olympique","pace":54,"shooting":18,"passing":60,"dribbling":28,"defending":84,"physical":82,"rarity":"gold","image_path":null},
{"id":"card_l1_005","name":"Alexis Bernard","overall":82,"position":"CB","nation":"France","league":"Ligue 1","club":"Marseille Olympia","pace":74,"shooting":42,"passing":66,"dribbling":62,"defending":86,"physical":84,"rarity":"gold","image_path":null},
{"id":"card_l1_006","name":"Lucas Petit","overall":81,"position":"LB","nation":"France","league":"Ligue 1","club":"Lyon Premier","pace":84,"shooting":56,"passing":76,"dribbling":74,"defending":80,"physical":72,"rarity":"gold","image_path":null},
{"id":"card_l1_007","name":"Mathieu Renard","overall":80,"position":"CDM","nation":"France","league":"Ligue 1","club":"Bordeaux Atlantique","pace":72,"shooting":58,"passing":74,"dribbling":70,"defending":83,"physical":80,"rarity":"gold","image_path":null},
{"id":"card_l1_008","name":"Romain Favre","overall":79,"position":"RW","nation":"France","league":"Ligue 1","club":"Paris Olympique","pace":86,"shooting":74,"passing":70,"dribbling":84,"defending":36,"physical":64,"rarity":"gold","image_path":null},
{"id":"card_l1_009","name":"Nicolas Aubert","overall":78,"position":"RB","nation":"France","league":"Ligue 1","club":"Marseille Olympia","pace":82,"shooting":52,"passing":70,"dribbling":72,"defending":80,"physical":72,"rarity":"gold","image_path":null},
{"id":"card_l1_010","name":"Samuel Perrin","overall":77,"position":"LW","nation":"France","league":"Ligue 1","club":"Lyon Premier","pace":84,"shooting":70,"passing":68,"dribbling":80,"defending":34,"physical":62,"rarity":"gold","image_path":null},
{"id":"card_l1_011","name":"Ibrahim Coulibaly","overall":81,"position":"ST","nation":"Ivory Coast","league":"Ligue 1","club":"Bordeaux Atlantique","pace":88,"shooting":82,"passing":65,"dribbling":80,"defending":28,"physical":82,"rarity":"gold","image_path":null},
{"id":"card_l1_012","name":"Pedro Alves","overall":80,"position":"CAM","nation":"Portugal","league":"Ligue 1","club":"Paris Olympique","pace":80,"shooting":76,"passing":82,"dribbling":84,"defending":44,"physical":68,"rarity":"gold","image_path":null}
]
+206
View File
@@ -0,0 +1,206 @@
[
{
"id": "card_pl_001",
"name": "James Caldwell",
"overall": 84,
"position": "ST",
"nation": "England",
"league": "Premier League",
"club": "Northgate United",
"pace": 84,
"shooting": 87,
"passing": 70,
"dribbling": 80,
"defending": 36,
"physical": 82,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_002",
"name": "Danny Walsh",
"overall": 82,
"position": "CM",
"nation": "England",
"league": "Premier League",
"club": "Northgate United",
"pace": 74,
"shooting": 72,
"passing": 84,
"dribbling": 79,
"defending": 70,
"physical": 74,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_003",
"name": "Reece Okafor",
"overall": 83,
"position": "LW",
"nation": "England",
"league": "Premier League",
"club": "Northgate United",
"pace": 91,
"shooting": 78,
"passing": 75,
"dribbling": 88,
"defending": 34,
"physical": 68,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_004",
"name": "Tom Briggs",
"overall": 80,
"position": "CB",
"nation": "England",
"league": "Premier League",
"club": "Northgate United",
"pace": 72,
"shooting": 36,
"passing": 62,
"dribbling": 52,
"defending": 83,
"physical": 86,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_005",
"name": "Pierre-Marc Dubois",
"overall": 84,
"position": "CAM",
"nation": "France",
"league": "Premier League",
"club": "Southfield City",
"pace": 79,
"shooting": 80,
"passing": 87,
"dribbling": 85,
"defending": 44,
"physical": 70,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_006",
"name": "Kwame Asante",
"overall": 85,
"position": "ST",
"nation": "Ghana",
"league": "Premier League",
"club": "Southfield City",
"pace": 88,
"shooting": 88,
"passing": 70,
"dribbling": 84,
"defending": 38,
"physical": 86,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_007",
"name": "Liam Foster",
"overall": 81,
"position": "CDM",
"nation": "England",
"league": "Premier League",
"club": "Southfield City",
"pace": 74,
"shooting": 62,
"passing": 78,
"dribbling": 74,
"defending": 84,
"physical": 80,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_008",
"name": "Sergio Rojas",
"overall": 83,
"position": "RW",
"nation": "Argentina",
"league": "Premier League",
"club": "Westbrook FC",
"pace": 89,
"shooting": 80,
"passing": 78,
"dribbling": 90,
"defending": 30,
"physical": 62,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_009",
"name": "Ben Archer",
"overall": 79,
"position": "GK",
"nation": "England",
"league": "Premier League",
"club": "Westbrook FC",
"pace": 52,
"shooting": 14,
"passing": 62,
"dribbling": 28,
"defending": 80,
"physical": 80,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_010",
"name": "Nathan Cross",
"overall": 80,
"position": "RB",
"nation": "England",
"league": "Premier League",
"club": "Westbrook FC",
"pace": 81,
"shooting": 50,
"passing": 74,
"dribbling": 72,
"defending": 80,
"physical": 76,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_011",
"name": "Marco Silva Jr.",
"overall": 83,
"position": "CB",
"nation": "Brazil",
"league": "Premier League",
"club": "Westbrook FC",
"pace": 77,
"shooting": 40,
"passing": 66,
"dribbling": 60,
"defending": 86,
"physical": 88,
"rarity": "gold",
"image_path": null
},
{
"id": "card_pl_012",
"name": "Erik Strand",
"overall": 82,
"position": "LB",
"nation": "Norway",
"league": "Premier League",
"club": "Northgate United",
"pace": 80,
"shooting": 54,
"passing": 76,
"dribbling": 74,
"defending": 82,
"physical": 78,
"rarity": "gold",
"image_path": null
}
]
+17
View File
@@ -0,0 +1,17 @@
[
{"id":"card_sa_001","name":"Lorenzo Ricci","overall":87,"position":"ST","nation":"Italy","league":"Serie A","club":"Roma Capitale FC","pace":83,"shooting":90,"passing":72,"dribbling":84,"defending":32,"physical":85,"rarity":"raregold","image_path":null},
{"id":"card_sa_002","name":"Marco Ferretti","overall":85,"position":"CAM","nation":"Italy","league":"Serie A","club":"Milano Azzurri","pace":80,"shooting":82,"passing":88,"dribbling":87,"defending":40,"physical":72,"rarity":"raregold","image_path":null},
{"id":"card_sa_003","name":"Davide Conti","overall":83,"position":"CB","nation":"Italy","league":"Serie A","club":"Napoli Sud FC","pace":72,"shooting":45,"passing":68,"dribbling":65,"defending":87,"physical":84,"rarity":"gold","image_path":null},
{"id":"card_sa_004","name":"Emanuele Vitali","overall":82,"position":"CM","nation":"Italy","league":"Serie A","club":"Roma Capitale FC","pace":74,"shooting":70,"passing":84,"dribbling":80,"defending":68,"physical":76,"rarity":"gold","image_path":null},
{"id":"card_sa_005","name":"Filippo Marchetti","overall":84,"position":"GK","nation":"Italy","league":"Serie A","club":"Milano Azzurri","pace":55,"shooting":20,"passing":58,"dribbling":30,"defending":85,"physical":82,"rarity":"gold","image_path":null},
{"id":"card_sa_006","name":"Gianluca Russo","overall":81,"position":"LB","nation":"Italy","league":"Serie A","club":"Torino Granata","pace":82,"shooting":52,"passing":74,"dribbling":76,"defending":80,"physical":74,"rarity":"gold","image_path":null},
{"id":"card_sa_007","name":"Alessandro Greco","overall":80,"position":"RB","nation":"Italy","league":"Serie A","club":"Napoli Sud FC","pace":84,"shooting":55,"passing":72,"dribbling":74,"defending":78,"physical":72,"rarity":"gold","image_path":null},
{"id":"card_sa_008","name":"Federico Palermo","overall":82,"position":"CDM","nation":"Italy","league":"Serie A","club":"Torino Granata","pace":70,"shooting":58,"passing":76,"dribbling":72,"defending":84,"physical":82,"rarity":"gold","image_path":null},
{"id":"card_sa_009","name":"Simone Bianchi","overall":79,"position":"RW","nation":"Italy","league":"Serie A","club":"Roma Capitale FC","pace":88,"shooting":76,"passing":72,"dribbling":82,"defending":38,"physical":65,"rarity":"gold","image_path":null},
{"id":"card_sa_010","name":"Roberto Neri","overall":78,"position":"LW","nation":"Italy","league":"Serie A","club":"Milano Azzurri","pace":86,"shooting":74,"passing":70,"dribbling":80,"defending":35,"physical":62,"rarity":"gold","image_path":null},
{"id":"card_sa_011","name":"Matteo De Luca","overall":77,"position":"CB","nation":"Italy","league":"Serie A","club":"Napoli Sud FC","pace":68,"shooting":40,"passing":62,"dribbling":60,"defending":80,"physical":80,"rarity":"gold","image_path":null},
{"id":"card_sa_012","name":"Claudio Esposito","overall":76,"position":"ST","nation":"Italy","league":"Serie A","club":"Torino Granata","pace":80,"shooting":78,"passing":62,"dribbling":74,"defending":28,"physical":76,"rarity":"gold","image_path":null},
{"id":"card_sa_013","name":"Nicolás Peralta","overall":83,"position":"ST","nation":"Argentina","league":"Serie A","club":"Roma Capitale FC","pace":82,"shooting":86,"passing":70,"dribbling":82,"defending":30,"physical":80,"rarity":"gold","image_path":null},
{"id":"card_sa_014","name":"Henrique Sousa","overall":80,"position":"CAM","nation":"Brazil","league":"Serie A","club":"Milano Azzurri","pace":82,"shooting":76,"passing":82,"dribbling":86,"defending":42,"physical":68,"rarity":"gold","image_path":null},
{"id":"card_sa_015","name":"Sven Holm","overall":75,"position":"CM","nation":"Sweden","league":"Serie A","club":"Napoli Sud FC","pace":72,"shooting":65,"passing":78,"dribbling":74,"defending":70,"physical":72,"rarity":"gold","image_path":null}
]
+110
View File
@@ -0,0 +1,110 @@
[
{
"id": "basic",
"name": "Basic",
"description": "Small boosts across all attributes",
"boosts": { "pace": 3, "shooting": 3, "passing": 3, "dribbling": 3, "defending": 3, "physical": 3 }
},
{
"id": "shadow",
"name": "Shadow",
"description": "Maximises pace and defending — ideal for full-backs",
"boosts": { "pace": 10, "shooting": 0, "passing": 0, "dribbling": 0, "defending": 10, "physical": 0 }
},
{
"id": "anchor",
"name": "Anchor",
"description": "Boosts defending and physicality — built for centre-backs",
"boosts": { "pace": 0, "shooting": 0, "passing": 0, "dribbling": 0, "defending": 10, "physical": 10 }
},
{
"id": "hunter",
"name": "Hunter",
"description": "Pace and shooting — the striker's best friend",
"boosts": { "pace": 10, "shooting": 10, "passing": 0, "dribbling": 0, "defending": 0, "physical": 0 }
},
{
"id": "catalyst",
"name": "Catalyst",
"description": "Pace and passing for box-to-box and wide midfielders",
"boosts": { "pace": 10, "shooting": 0, "passing": 10, "dribbling": 0, "defending": 0, "physical": 0 }
},
{
"id": "engine",
"name": "Engine",
"description": "Pace, passing and dribbling for creative midfielders",
"boosts": { "pace": 10, "shooting": 0, "passing": 5, "dribbling": 5, "defending": 0, "physical": 0 }
},
{
"id": "hawk",
"name": "Hawk",
"description": "Balanced pace, shooting and physicality",
"boosts": { "pace": 5, "shooting": 5, "passing": 0, "dribbling": 0, "defending": 0, "physical": 5 }
},
{
"id": "marksman",
"name": "Marksman",
"description": "Shooting, dribbling and physicality for physical strikers",
"boosts": { "pace": 0, "shooting": 10, "passing": 0, "dribbling": 5, "defending": 0, "physical": 5 }
},
{
"id": "deadeye",
"name": "Deadeye",
"description": "Shooting and passing — classic number 10 style",
"boosts": { "pace": 0, "shooting": 10, "passing": 10, "dribbling": 0, "defending": 0, "physical": 0 }
},
{
"id": "artist",
"name": "Artist",
"description": "Passing and dribbling for technical playmakers",
"boosts": { "pace": 0, "shooting": 0, "passing": 5, "dribbling": 10, "defending": 0, "physical": 0 }
},
{
"id": "sniper",
"name": "Sniper",
"description": "Shooting and dribbling for agile forwards",
"boosts": { "pace": 0, "shooting": 5, "passing": 0, "dribbling": 5, "defending": 0, "physical": 0 }
},
{
"id": "finisher",
"name": "Finisher",
"description": "Shooting and dribbling — precision over power",
"boosts": { "pace": 0, "shooting": 8, "passing": 0, "dribbling": 7, "defending": 0, "physical": 0 }
},
{
"id": "guardian",
"name": "Guardian",
"description": "Dribbling and defending for defensive midfielders",
"boosts": { "pace": 0, "shooting": 0, "passing": 0, "dribbling": 5, "defending": 10, "physical": 0 }
},
{
"id": "backbone",
"name": "Backbone",
"description": "Passing, defending and physicality for the defensive shield",
"boosts": { "pace": 0, "shooting": 0, "passing": 5, "dribbling": 0, "defending": 5, "physical": 5 }
},
{
"id": "sentinel",
"name": "Sentinel",
"description": "Defending and physicality for no-nonsense defenders",
"boosts": { "pace": 0, "shooting": 0, "passing": 0, "dribbling": 0, "defending": 5, "physical": 5 }
},
{
"id": "powerhouse",
"name": "Powerhouse",
"description": "Passing and defending for dominant central midfielders",
"boosts": { "pace": 0, "shooting": 0, "passing": 10, "dribbling": 0, "defending": 10, "physical": 0 }
},
{
"id": "maestro",
"name": "Maestro",
"description": "Shooting, passing and dribbling — the complete midfielder",
"boosts": { "pace": 0, "shooting": 5, "passing": 5, "dribbling": 5, "defending": 0, "physical": 0 }
},
{
"id": "gladiator",
"name": "Gladiator",
"description": "Shooting and defending for all-action midfielders",
"boosts": { "pace": 0, "shooting": 5, "passing": 0, "dribbling": 0, "defending": 5, "physical": 0 }
}
]
+44
View File
@@ -0,0 +1,44 @@
[
{
"id": "spring_festival",
"name": "Spring Festival",
"description": "Celebrate the season! Special Hero players appear in the market. Activate manually to begin.",
"event_type": "seasonal",
"starts_at": null,
"expires_at": null,
"is_manual": true,
"effects": {
"pack_weight_boosts": [],
"bonus_market_cards": [
"card_hero_001",
"card_hero_002",
"card_hero_003"
],
"unlocks_sbc_ids": [],
"unlocks_objective_ids": []
}
},
{
"id": "icon_weekend",
"name": "Icon Weekend",
"description": "Legendary Icons are available on the market. Activate manually to begin.",
"event_type": "special",
"starts_at": null,
"expires_at": null,
"is_manual": true,
"effects": {
"pack_weight_boosts": [
{ "rarity": "icon", "multiplier": 5.0 }
],
"bonus_market_cards": [
"card_icon_001",
"card_icon_002",
"card_icon_003",
"card_icon_004",
"card_icon_005"
],
"unlocks_sbc_ids": [],
"unlocks_objective_ids": []
}
}
]
+25
View File
@@ -0,0 +1,25 @@
[
{
"id": "totw_week1",
"name": "Team of the Week 1",
"description": "This week's TOTW is live! TOTW players appear in the transfer market at a premium and pack weights are boosted.",
"event_type": "totw",
"starts_at": "2024-01-01T00:00:00Z",
"expires_at": "2030-12-31T23:59:59Z",
"is_manual": false,
"effects": {
"pack_weight_boosts": [
{ "rarity": "totw", "multiplier": 3.0 }
],
"bonus_market_cards": [
"card_totw_001",
"card_totw_002",
"card_totw_003",
"card_totw_004",
"card_totw_005"
],
"unlocks_sbc_ids": [],
"unlocks_objective_ids": []
}
}
]
+202
View File
@@ -0,0 +1,202 @@
[
{
"id": "sbc_serie_a_rising",
"name": "Serie A Rising Stars",
"description": "Build a squad using 7 or more Serie A players (average overall 74+).",
"requirements": {
"squad_size": 11,
"min_overall": 74,
"max_overall": null,
"min_chemistry": null,
"required_leagues": ["Serie A"],
"required_nations": [],
"required_clubs": [],
"min_players_from_same_league": 7,
"min_players_from_same_nation": null,
"min_players_from_same_club": null
},
"reward": { "coins": 2000, "xp": 250, "pack_id": "gold_pack" },
"expires_at": null,
"repeatable": false
},
{
"id": "sbc_ligue1_galacticos",
"name": "Ligue 1 Galacticos",
"description": "Field a Ligue 1 XI with at least one player rated 84 or higher.",
"requirements": {
"squad_size": 11,
"min_overall": 72,
"max_overall": null,
"min_chemistry": null,
"required_leagues": ["Ligue 1"],
"required_nations": [],
"required_clubs": [],
"min_players_from_same_league": 11,
"min_players_from_same_nation": null,
"min_players_from_same_club": null
},
"reward": { "coins": 3500, "xp": 400, "pack_id": "rare_gold_pack" },
"expires_at": null,
"repeatable": false
},
{
"id": "sbc_iberian_derby",
"name": "Iberian Derby",
"description": "An all-Iberian battle — include at least 3 Spanish and 3 Portuguese players.",
"requirements": {
"squad_size": 11,
"min_overall": 70,
"max_overall": null,
"min_chemistry": null,
"required_leagues": [],
"required_nations": ["Spain", "Portugal"],
"required_clubs": [],
"min_players_from_same_league": null,
"min_players_from_same_nation": 3,
"min_players_from_same_club": null
},
"reward": { "coins": 2500, "xp": 300, "pack_id": "silver_pack" },
"expires_at": null,
"repeatable": false
},
{
"id": "sbc_dutch_masters",
"name": "Dutch Masters",
"description": "8 or more Eredivisie players in one squad (average overall 70+).",
"requirements": {
"squad_size": 11,
"min_overall": 70,
"max_overall": null,
"min_chemistry": null,
"required_leagues": ["Eredivisie"],
"required_nations": [],
"required_clubs": [],
"min_players_from_same_league": 8,
"min_players_from_same_nation": null,
"min_players_from_same_club": null
},
"reward": { "coins": 2000, "xp": 250, "pack_id": "gold_pack" },
"expires_at": null,
"repeatable": false
},
{
"id": "sbc_elite_strikers",
"name": "Elite Strikers",
"description": "Submit any 3 players rated 82+ — the club receives a bonus pack.",
"requirements": {
"squad_size": 3,
"min_overall": 82,
"max_overall": null,
"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": 1000, "xp": 200, "pack_id": "rare_gold_pack" },
"expires_at": null,
"repeatable": true
},
{
"id": "sbc_world_tour",
"name": "World Tour",
"description": "11 players from at least 8 different nations — celebrate global football.",
"requirements": {
"squad_size": 11,
"min_overall": 65,
"max_overall": null,
"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": 1500, "xp": 200, "pack_id": "silver_pack" },
"expires_at": null,
"repeatable": true
},
{
"id": "sbc_bundesliga_clash",
"name": "Bundesliga Clash",
"description": "7+ Bundesliga players (average overall 72+) for a gold pack reward.",
"requirements": {
"squad_size": 11,
"min_overall": 72,
"max_overall": null,
"min_chemistry": null,
"required_leagues": ["Bundesliga"],
"required_nations": [],
"required_clubs": [],
"min_players_from_same_league": 7,
"min_players_from_same_nation": null,
"min_players_from_same_club": null
},
"reward": { "coins": 2000, "xp": 250, "pack_id": "gold_pack" },
"expires_at": null,
"repeatable": false
},
{
"id": "sbc_premier_power",
"name": "Premier Power",
"description": "7+ Premier League players (average overall 75+) for a rare gold pack.",
"requirements": {
"squad_size": 11,
"min_overall": 75,
"max_overall": null,
"min_chemistry": null,
"required_leagues": ["Premier League"],
"required_nations": [],
"required_clubs": [],
"min_players_from_same_league": 7,
"min_players_from_same_nation": null,
"min_players_from_same_club": null
},
"reward": { "coins": 3000, "xp": 350, "pack_id": "rare_gold_pack" },
"expires_at": null,
"repeatable": false
},
{
"id": "sbc_south_american_fire",
"name": "South American Fire",
"description": "At least 4 Brazilian and 4 Argentinian players in a squad.",
"requirements": {
"squad_size": 11,
"min_overall": 65,
"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": 4,
"min_players_from_same_club": null
},
"reward": { "coins": 3000, "xp": 400, "pack_id": "silver_pack" },
"expires_at": null,
"repeatable": false
},
{
"id": "sbc_bronze_to_silver",
"name": "Bronze to Silver",
"description": "Submit 7 bronze players (overall 5564) to get a silver pack.",
"requirements": {
"squad_size": 7,
"min_overall": 55,
"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": 150, "pack_id": "silver_pack" },
"expires_at": null,
"repeatable": true
}
]
+8
View File
@@ -0,0 +1,8 @@
-- Manual override table for event activation/deactivation.
-- is_active_override: NULL = use date range, 1 = force active, 0 = force inactive
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
is_active_override INTEGER,
activated_at TEXT,
deactivated_at TEXT
);
+16
View File
@@ -0,0 +1,16 @@
-- Stateful FUT-style draft sessions.
-- Each session tracks position order, per-position candidates, and picks made.
CREATE TABLE IF NOT EXISTS draft_sessions (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL,
difficulty TEXT NOT NULL DEFAULT 'professional',
pick_order TEXT NOT NULL, -- JSON array of position strings, e.g. ["GK","RB",...]
picks TEXT NOT NULL DEFAULT '[]', -- JSON array of picked card_ids (len tracks progress)
current_candidates TEXT, -- JSON array of card_ids offered for the current pick
status TEXT NOT NULL DEFAULT 'active', -- active | completed | abandoned
reward_coins INTEGER DEFAULT 0,
reward_pack_id TEXT,
squad_rating INTEGER DEFAULT 0,
started_at TEXT NOT NULL,
completed_at TEXT
);
+19
View File
@@ -0,0 +1,19 @@
-- Division / season tracking (one row per profile, reset each season)
CREATE TABLE IF NOT EXISTS seasons (
profile_id TEXT PRIMARY KEY,
division INTEGER NOT NULL DEFAULT 5,
season_number INTEGER NOT NULL DEFAULT 1,
season_points INTEGER NOT NULL DEFAULT 0,
matches_played INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
draws INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
started_at TEXT NOT NULL
);
-- Pack open history: store which card_ids came out of each open
ALTER TABLE packs ADD COLUMN opened_cards TEXT;
ALTER TABLE packs ADD COLUMN opened_at TEXT;
-- Club cosmetic customization
ALTER TABLE clubs ADD COLUMN manager_name TEXT DEFAULT 'Player Manager';
+7
View File
@@ -0,0 +1,7 @@
-- Phase 10: card upgrade system
-- chemistry_style: applied cosmetic/stat modifier (default "basic")
-- position_override: player moved to a different position from their card default
-- training_bonus: extra OVR points from training cards (capped at +3)
ALTER TABLE owned_cards ADD COLUMN chemistry_style TEXT NOT NULL DEFAULT 'basic';
ALTER TABLE owned_cards ADD COLUMN position_override TEXT;
ALTER TABLE owned_cards ADD COLUMN training_bonus INTEGER NOT NULL DEFAULT 0;
+21
View File
@@ -0,0 +1,21 @@
-- Phase 11: FUT Champions / Weekend League sessions
CREATE TABLE IF NOT EXISTS fut_champs_sessions (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL,
week_number INTEGER NOT NULL DEFAULT 1,
matches_played INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
draws INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
reward_tier TEXT,
reward_coins INTEGER,
reward_pack_id TEXT,
reward_claimed INTEGER NOT NULL DEFAULT 0,
started_at TEXT NOT NULL,
completed_at TEXT
);
-- Division Rivals: track when each profile last claimed their weekly reward
ALTER TABLE seasons ADD COLUMN rivals_week_claimed INTEGER NOT NULL DEFAULT 0;
ALTER TABLE seasons ADD COLUMN rivals_total_points INTEGER NOT NULL DEFAULT 0;
+13
View File
@@ -0,0 +1,13 @@
-- Persistent event-driven notifications (level-ups, loan expiry, season end, etc.)
-- Dynamic state notifications (expiring loans, unclaimed objectives) remain in code.
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
is_read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications (is_read);
CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications (created_at DESC);
+7
View File
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS player_achievements (
id TEXT PRIMARY KEY,
achievement_id TEXT NOT NULL UNIQUE,
unlocked_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_player_achievements_id ON player_achievements (achievement_id);
+18
View File
@@ -0,0 +1,18 @@
-- Season history: one row per completed season
CREATE TABLE IF NOT EXISTS season_history (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL,
season_number INTEGER NOT NULL,
division INTEGER NOT NULL,
season_points INTEGER NOT NULL,
wins INTEGER NOT NULL,
draws INTEGER NOT NULL,
losses INTEGER NOT NULL,
result TEXT NOT NULL, -- 'promoted' | 'maintained' | 'relegated'
new_division INTEGER NOT NULL,
coins_awarded INTEGER NOT NULL,
pack_awarded TEXT, -- nullable
ended_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_season_history_profile ON season_history (profile_id, season_number DESC);
+11
View File
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS daily_checkins (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL,
club_id TEXT NOT NULL,
streak_day INTEGER NOT NULL DEFAULT 1,
coins_awarded INTEGER NOT NULL DEFAULT 0,
pack_granted TEXT, -- nullable pack definition id
checked_in_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_daily_checkins_profile ON daily_checkins (profile_id, checked_in_at DESC);
+13
View File
@@ -0,0 +1,13 @@
-- One row per buy or sell event, for trade history display
CREATE TABLE IF NOT EXISTS market_history (
id TEXT PRIMARY KEY,
club_id TEXT NOT NULL,
card_id TEXT NOT NULL, -- definition card id
card_name TEXT NOT NULL,
card_overall INTEGER NOT NULL,
trade_type TEXT NOT NULL, -- 'buy' | 'sell'
price INTEGER NOT NULL,
traded_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_market_history_club ON market_history (club_id, traded_at DESC);
+119 -7
View File
@@ -1,22 +1,33 @@
use crate::middleware::{limit_concurrency, MakeRequestUuid};
use crate::{ use crate::{
config::Config, config::Config,
db::Pool, db::Pool,
models::achievement::AchievementDefinition,
models::chemistry_style::{load_chemistry_styles, ChemistryStyle},
models::event::EventDefinition,
models::objective::{ObjectiveDefinition, ObjectiveType}, models::objective::{ObjectiveDefinition, ObjectiveType},
models::pack::PackDefinition, models::pack::PackDefinition,
models::sbc::SbcDefinition, models::sbc::SbcDefinition,
routes, routes,
services::{ services::{
card_db::CardDb, market, objective::load_objective_definitions, achievement::load_achievement_definitions, card_db::CardDb, event::load_event_definitions,
pack::load_pack_definitions, sbc::load_sbc_definitions, market, objective::load_objective_definitions, pack::load_pack_definitions,
sbc::load_sbc_definitions,
}, },
}; };
use anyhow::Result; use anyhow::Result;
use axum::{ use axum::{
routing::{delete, get, post, put}, extract::DefaultBodyLimit,
middleware,
routing::{delete, get, patch, post, put},
Router, Router,
}; };
use std::sync::Arc; use std::sync::Arc;
use tower_http::{cors::CorsLayer, trace::TraceLayer}; use tower_http::{
cors::CorsLayer,
request_id::{PropagateRequestIdLayer, SetRequestIdLayer},
trace::TraceLayer,
};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@@ -25,6 +36,9 @@ pub struct AppState {
pub pack_defs: Arc<Vec<PackDefinition>>, pub pack_defs: Arc<Vec<PackDefinition>>,
pub obj_defs: Arc<Vec<ObjectiveDefinition>>, pub obj_defs: Arc<Vec<ObjectiveDefinition>>,
pub sbc_defs: Arc<Vec<SbcDefinition>>, pub sbc_defs: Arc<Vec<SbcDefinition>>,
pub event_defs: Arc<Vec<EventDefinition>>,
pub chem_styles: Arc<Vec<ChemistryStyle>>,
pub achievement_defs: Arc<Vec<AchievementDefinition>>,
} }
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> { pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
@@ -32,13 +46,19 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
let pack_defs = Arc::new(load_pack_definitions(&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 obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?);
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?); let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
let event_defs = Arc::new(load_event_definitions(&cfg.data_dir)?);
let chem_styles = Arc::new(load_chemistry_styles(&cfg.data_dir)?);
let achievement_defs = Arc::new(load_achievement_definitions(&cfg.data_dir)?);
tracing::info!( tracing::info!(
"Loaded {} cards, {} packs, {} objectives, {} SBCs", "Loaded {} cards, {} packs, {} objectives, {} SBCs, {} events, {} chemistry styles, {} achievements",
card_db.cards.len(), card_db.cards.len(),
pack_defs.len(), pack_defs.len(),
obj_defs.len(), obj_defs.len(),
sbc_defs.len(), sbc_defs.len(),
event_defs.len(),
chem_styles.len(),
achievement_defs.len(),
); );
let state = AppState { let state = AppState {
@@ -47,19 +67,24 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
pack_defs, pack_defs,
obj_defs: obj_defs.clone(), obj_defs: obj_defs.clone(),
sbc_defs, sbc_defs,
event_defs: event_defs.clone(),
chem_styles,
achievement_defs,
}; };
// Background task: refresh NPC market at startup and every 24 hours // Background task: refresh NPC market at startup and every 24 hours
{ {
let pool = pool.clone(); let pool = pool.clone();
let card_db = card_db.clone(); let card_db = card_db.clone();
let event_defs_bg = event_defs.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = market::refresh_npc_listings(&pool, &card_db).await { if let Err(e) = market::refresh_npc_listings(&pool, &card_db, &event_defs_bg).await {
tracing::warn!("initial market refresh failed: {e}"); tracing::warn!("initial market refresh failed: {e}");
} }
loop { loop {
tokio::time::sleep(tokio::time::Duration::from_secs(86_400)).await; tokio::time::sleep(tokio::time::Duration::from_secs(86_400)).await;
if let Err(e) = market::refresh_npc_listings(&pool, &card_db).await { if let Err(e) = market::refresh_npc_listings(&pool, &card_db, &event_defs_bg).await
{
tracing::warn!("market refresh failed: {e}"); tracing::warn!("market refresh failed: {e}");
} }
} }
@@ -105,12 +130,40 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/health", get(routes::health::get_health)) .route("/health", get(routes::health::get_health))
.route("/formations", get(routes::health::get_formations)) .route("/formations", get(routes::health::get_formations))
.route("/auth/local", post(routes::auth::post_auth_local)) .route("/auth/local", post(routes::auth::post_auth_local))
.route("/auth/status", get(routes::auth::get_auth_status))
.route("/auth/reset", post(routes::auth::post_auth_reset))
.route("/profile", get(routes::profile::get_profile)) .route("/profile", get(routes::profile::get_profile))
.route("/club", get(routes::club::get_club)) .route("/club", get(routes::club::get_club))
.route("/club", put(routes::club::put_club))
.route("/club/checkin", get(routes::club::get_checkin_status))
.route("/club/checkin", post(routes::club::post_checkin))
.route("/club/milestones", get(routes::club::get_milestones))
.route("/cards", get(routes::cards::get_cards)) .route("/cards", get(routes::cards::get_cards))
.route("/cards/:card_id", get(routes::cards::get_card)) .route("/cards/:card_id", get(routes::cards::get_card))
.route("/collection", get(routes::cards::get_collection)) .route("/collection", get(routes::cards::get_collection))
.route(
"/collection/:owned_card_id",
delete(routes::cards::delete_owned_card),
)
.route(
"/collection/:owned_card_id/chemistry-style",
post(routes::upgrades::post_apply_chemistry_style),
)
.route(
"/collection/:owned_card_id/position",
post(routes::upgrades::post_change_position),
)
.route(
"/collection/:owned_card_id/training",
post(routes::upgrades::post_apply_training),
)
.route(
"/chemistry-styles",
get(routes::upgrades::get_chemistry_styles),
)
.route("/packs", get(routes::packs::get_packs)) .route("/packs", get(routes::packs::get_packs))
.route("/packs/store", get(routes::packs::get_pack_store))
.route("/packs/history", get(routes::packs::get_pack_history))
.route("/packs/buy", post(routes::packs::post_buy_pack)) .route("/packs/buy", post(routes::packs::post_buy_pack))
.route("/packs/open/:pack_id", post(routes::packs::post_open_pack)) .route("/packs/open/:pack_id", post(routes::packs::post_open_pack))
.route("/squad", get(routes::squad::get_squad)) .route("/squad", get(routes::squad::get_squad))
@@ -119,10 +172,15 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id)) .route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
.route("/squads/:squad_id", delete(routes::squad::delete_squad)) .route("/squads/:squad_id", delete(routes::squad::delete_squad))
.route("/objectives", get(routes::objectives::get_objectives)) .route("/objectives", get(routes::objectives::get_objectives))
.route("/objectives/:objective_id", get(routes::objectives::get_objective))
.route( .route(
"/objectives/claim", "/objectives/claim",
post(routes::objectives::post_claim_objective), post(routes::objectives::post_claim_objective),
) )
.route(
"/objectives/:objective_id/claim",
post(routes::objectives::post_claim_objective_by_id),
)
.route("/matches", get(routes::matches::get_matches)) .route("/matches", get(routes::matches::get_matches))
.route("/matches/opponent", get(routes::matches::get_opponent)) .route("/matches/opponent", get(routes::matches::get_opponent))
.route("/matches/result", post(routes::matches::post_match_result)) .route("/matches/result", post(routes::matches::post_match_result))
@@ -132,7 +190,13 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/market", get(routes::market::get_market)) .route("/market", get(routes::market::get_market))
.route("/market/buy", post(routes::market::post_market_buy)) .route("/market/buy", post(routes::market::post_market_buy))
.route("/market/sell", post(routes::market::post_market_sell)) .route("/market/sell", post(routes::market::post_market_sell))
.route("/market/trade-history", get(routes::market::get_trade_history))
.route("/market/refresh", post(routes::market::post_market_refresh)) .route("/market/refresh", post(routes::market::post_market_refresh))
.route("/market/my-listings", get(routes::market::get_my_listings))
.route(
"/market/listings/:listing_id",
delete(routes::market::delete_market_listing),
)
.route("/statistics", get(routes::statistics::get_statistics)) .route("/statistics", get(routes::statistics::get_statistics))
.route( .route(
"/statistics/history", "/statistics/history",
@@ -140,8 +204,56 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
) )
.route("/settings", get(routes::settings::get_settings)) .route("/settings", get(routes::settings::get_settings))
.route("/settings", put(routes::settings::put_settings)) .route("/settings", put(routes::settings::put_settings))
.route("/division", get(routes::division::get_division))
.route("/division/history", get(routes::division::get_division_history))
.route("/division/leaderboard", get(routes::division::get_division_leaderboard))
.route("/achievements", get(routes::achievements::get_achievements))
.route("/notifications", get(routes::notifications::get_notifications))
.route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read))
.route("/notifications/:id/read", patch(routes::notifications::mark_notification_read))
.route("/fut-champs", get(routes::fut_champs::get_fut_champs))
.route("/fut-champs/start", post(routes::fut_champs::post_start_fut_champs))
.route("/fut-champs/history", get(routes::fut_champs::get_champs_history))
.route(
"/fut-champs/:session_id/result",
post(routes::fut_champs::post_champs_result),
)
.route(
"/fut-champs/:session_id/claim",
post(routes::fut_champs::post_claim_champs_rewards),
)
.route(
"/rivals/claim-weekly",
post(routes::fut_champs::post_claim_rivals_reward),
)
.route("/draft/squad", get(routes::draft::get_draft_squad)) .route("/draft/squad", get(routes::draft::get_draft_squad))
.route("/draft/start", post(routes::draft::post_draft_start))
.route("/draft/sessions/:id", get(routes::draft::get_draft_session))
.route(
"/draft/sessions/:id/pick",
post(routes::draft::post_draft_pick),
)
.route(
"/draft/sessions/:id/abandon",
post(routes::draft::post_draft_abandon),
)
.route("/events", get(routes::events::get_events))
.route("/events/:event_id", get(routes::events::get_event))
.route(
"/events/:event_id/activate",
post(routes::events::post_activate_event),
)
.route(
"/events/:event_id/deactivate",
post(routes::events::post_deactivate_event),
)
// Layers run outermost-first (last added = outermost).
// CORS → body limit → concurrency limit → request ID → trace
.layer(PropagateRequestIdLayer::x_request_id())
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
.layer(middleware::from_fn(limit_concurrency))
.layer(DefaultBodyLimit::max(256 * 1024))
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive())
.with_state(state); .with_state(state);
+7 -2
View File
@@ -1,14 +1,19 @@
use anyhow::Result; use anyhow::Result;
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool}; use sqlx::{
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
SqlitePool,
};
use std::str::FromStr;
use tracing::info; use tracing::info;
pub type Pool = SqlitePool; pub type Pool = SqlitePool;
pub async fn init_pool(database_url: &str) -> Result<Pool> { pub async fn init_pool(database_url: &str) -> Result<Pool> {
info!("Connecting to database: {}", database_url); info!("Connecting to database: {}", database_url);
let opts = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(5) .max_connections(5)
.connect(database_url) .connect_with(opts)
.await?; .await?;
sqlx::query("PRAGMA journal_mode=WAL") sqlx::query("PRAGMA journal_mode=WAL")
.execute(&pool) .execute(&pool)
+1
View File
@@ -2,6 +2,7 @@ pub mod app;
pub mod config; pub mod config;
pub mod db; pub mod db;
pub mod error; pub mod error;
pub mod middleware;
pub mod modding; pub mod modding;
pub mod models; pub mod models;
pub mod routes; pub mod routes;
+32
View File
@@ -0,0 +1,32 @@
use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response};
use std::sync::{Arc, LazyLock};
use tokio::sync::Semaphore;
use tower_http::request_id::{MakeRequestId, RequestId};
use uuid::Uuid;
/// Maximum concurrent in-flight requests. Responds 429 when at capacity.
const MAX_CONCURRENT: usize = 256;
static SEMAPHORE: LazyLock<Arc<Semaphore>> =
LazyLock::new(|| Arc::new(Semaphore::new(MAX_CONCURRENT)));
/// Axum middleware that limits concurrent in-flight requests.
/// Returns 429 Too Many Requests when the limit is reached.
pub async fn limit_concurrency(req: Request, next: Next) -> Result<Response, StatusCode> {
let Ok(_permit) = SEMAPHORE.try_acquire() else {
return Err(StatusCode::TOO_MANY_REQUESTS);
};
Ok(next.run(req).await)
}
/// Generates a UUID v4 for each request and attaches it as `x-request-id`.
#[derive(Clone, Copy)]
pub struct MakeRequestUuid;
impl MakeRequestId for MakeRequestUuid {
fn make_request_id<B>(&mut self, _: &http::Request<B>) -> Option<RequestId> {
let id = Uuid::new_v4().to_string();
let header_val = id.parse().ok()?;
Some(RequestId::new(header_val))
}
}
+20
View File
@@ -0,0 +1,20 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AchievementDefinition {
pub id: String,
pub title: String,
pub description: String,
pub icon: String,
pub trigger: String,
pub threshold: i64,
pub reward_coins: i64,
pub rarity: String,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct PlayerAchievement {
pub id: String,
pub achievement_id: String,
pub unlocked_at: String,
}
+3
View File
@@ -42,4 +42,7 @@ pub struct OwnedCard {
pub is_loan: bool, pub is_loan: bool,
pub loan_matches_remaining: Option<i64>, pub loan_matches_remaining: Option<i64>,
pub acquired_at: String, pub acquired_at: String,
pub chemistry_style: String,
pub position_override: Option<String>,
pub training_bonus: i64,
} }
+26
View File
@@ -0,0 +1,26 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatBoosts {
pub pace: i32,
pub shooting: i32,
pub passing: i32,
pub dribbling: i32,
pub defending: i32,
pub physical: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChemistryStyle {
pub id: String,
pub name: String,
pub description: String,
pub boosts: StatBoosts,
}
pub fn load_chemistry_styles(data_dir: &str) -> anyhow::Result<Vec<ChemistryStyle>> {
let path = format!("{data_dir}/chemistry_styles.json");
let raw = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("failed to read {path}: {e}"))?;
Ok(serde_json::from_str(&raw)?)
}
+2
View File
@@ -11,6 +11,7 @@ pub struct Club {
pub level: i64, pub level: i64,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
pub manager_name: Option<String>,
} }
impl Club { impl Club {
@@ -24,6 +25,7 @@ impl Club {
level: 1, level: 1,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
manager_name: Some("Player Manager".to_string()),
} }
} }
} }
+26
View File
@@ -0,0 +1,26 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct DraftSession {
pub id: String,
pub profile_id: String,
pub difficulty: String,
/// JSON-encoded Vec<String> — position labels in pick order
pub pick_order: String,
/// JSON-encoded Vec<String> — card_ids picked so far
pub picks: String,
/// JSON-encoded Vec<String> — card_ids offered for the current slot (None if complete/abandoned)
pub current_candidates: Option<String>,
pub status: String,
pub reward_coins: i64,
pub reward_pack_id: Option<String>,
pub squad_rating: i64,
pub started_at: String,
pub completed_at: Option<String>,
}
/// Default 4-3-3 position pick order for a draft.
pub const DRAFT_PICK_ORDER: &[&str] = &[
"GK", "RB", "CB", "CB", "LB", "CDM", "CM", "CAM", "RW", "ST", "LW",
];
+45
View File
@@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventType {
Totw,
Seasonal,
Special,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackWeightBoost {
pub rarity: String,
pub multiplier: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventEffects {
pub pack_weight_boosts: Vec<PackWeightBoost>,
pub bonus_market_cards: Vec<String>,
pub unlocks_sbc_ids: Vec<String>,
pub unlocks_objective_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventDefinition {
pub id: String,
pub name: String,
pub description: String,
pub event_type: EventType,
pub starts_at: Option<String>,
pub expires_at: Option<String>,
pub is_manual: bool,
pub effects: EventEffects,
}
/// Event definition bundled with its current runtime active status.
#[derive(Debug, Clone, Serialize)]
pub struct EventWithStatus {
#[serde(flatten)]
pub definition: EventDefinition,
pub is_active: bool,
pub activated_at: Option<String>,
pub deactivated_at: Option<String>,
}
+108
View File
@@ -0,0 +1,108 @@
use serde::{Deserialize, Serialize};
pub const MAX_MATCHES: i64 = 30;
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct FutChampsSession {
pub id: String,
pub profile_id: String,
pub week_number: i64,
pub matches_played: i64,
pub wins: i64,
pub draws: i64,
pub losses: i64,
pub status: String,
pub reward_tier: Option<String>,
pub reward_coins: Option<i64>,
pub reward_pack_id: Option<String>,
pub reward_claimed: bool,
pub started_at: String,
pub completed_at: Option<String>,
}
impl FutChampsSession {
pub fn matches_remaining(&self) -> i64 {
(MAX_MATCHES - self.matches_played).max(0)
}
pub fn is_complete(&self) -> bool {
self.status == "completed"
}
}
/// Reward tier based on wins out of 30 matches — mirrors FUT 23 structure.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ChampsTier {
Elite,
WorldClass,
Gold3,
Gold2,
Gold1,
Silver3,
Silver2,
Silver1,
Bronze3,
Bronze2,
Bronze1,
}
impl ChampsTier {
pub fn from_wins(wins: i64) -> Self {
match wins {
27.. => Self::Elite,
23..=26 => Self::WorldClass,
20..=22 => Self::Gold3,
17..=19 => Self::Gold2,
14..=16 => Self::Gold1,
11..=13 => Self::Silver3,
8..=10 => Self::Silver2,
5..=7 => Self::Silver1,
2..=4 => Self::Bronze3,
1 => Self::Bronze2,
_ => Self::Bronze1,
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Elite => "Elite",
Self::WorldClass => "World Class",
Self::Gold3 => "Gold 3",
Self::Gold2 => "Gold 2",
Self::Gold1 => "Gold 1",
Self::Silver3 => "Silver 3",
Self::Silver2 => "Silver 2",
Self::Silver1 => "Silver 1",
Self::Bronze3 => "Bronze 3",
Self::Bronze2 => "Bronze 2",
Self::Bronze1 => "Bronze 1",
}
}
pub fn reward_coins(&self) -> i64 {
match self {
Self::Elite => 50_000,
Self::WorldClass => 30_000,
Self::Gold3 => 15_000,
Self::Gold2 => 10_000,
Self::Gold1 => 7_500,
Self::Silver3 => 5_000,
Self::Silver2 => 3_500,
Self::Silver1 => 2_000,
Self::Bronze3 => 1_000,
Self::Bronze2 => 500,
Self::Bronze1 => 250,
}
}
pub fn reward_pack(&self) -> Option<&'static str> {
match self {
Self::Elite => Some("icon_pack"),
Self::WorldClass => Some("rare_gold_pack"),
Self::Gold3 | Self::Gold2 | Self::Gold1 => Some("gold_pack"),
Self::Silver3 | Self::Silver2 | Self::Silver1 => Some("silver_pack"),
Self::Bronze3 => Some("bronze_pack"),
Self::Bronze2 | Self::Bronze1 => None,
}
}
}
+9
View File
@@ -1,3 +1,4 @@
use crate::models::{achievement::AchievementDefinition, profile::LevelUpEvent};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@@ -77,4 +78,12 @@ pub struct MatchRewardResult {
pub coins_awarded: i64, pub coins_awarded: i64,
pub xp_awarded: i64, pub xp_awarded: i64,
pub objectives_updated: Vec<String>, pub objectives_updated: Vec<String>,
/// Owned card IDs removed because the loan expired this match.
pub expired_loans: Vec<String>,
/// Present when this match completed the current season.
pub season_end: Option<crate::models::season::SeasonEndSummary>,
/// Non-empty when the player levelled up one or more times from this match's XP.
pub level_ups: Vec<LevelUpEvent>,
/// Achievements unlocked as a result of this match.
pub achievements_unlocked: Vec<AchievementDefinition>,
} }
+7
View File
@@ -1,5 +1,12 @@
pub mod achievement;
pub mod card; pub mod card;
pub mod chemistry_style;
pub mod notification;
pub mod club; pub mod club;
pub mod draft;
pub mod fut_champs;
pub mod event;
pub mod season;
pub mod market; pub mod market;
pub mod match_result; pub mod match_result;
pub mod objective; pub mod objective;
+25
View File
@@ -0,0 +1,25 @@
use serde::Serialize;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Notification {
pub id: String,
pub kind: String,
pub title: String,
pub body: String,
pub is_read: bool,
pub created_at: String,
}
impl Notification {
pub fn new(kind: &str, title: &str, body: &str) -> Self {
Self {
id: Uuid::new_v4().to_string(),
kind: kind.to_string(),
title: title.to_string(),
body: body.to_string(),
is_read: false,
created_at: chrono::Utc::now().to_rfc3339(),
}
}
}
+2
View File
@@ -29,6 +29,8 @@ pub struct Pack {
pub definition_id: String, pub definition_id: String,
pub opened: bool, pub opened: bool,
pub created_at: String, pub created_at: String,
pub opened_cards: Option<String>,
pub opened_at: Option<String>,
} }
/// The result of opening a pack. /// The result of opening a pack.
+56
View File
@@ -30,3 +30,59 @@ impl Profile {
pub struct CreateProfileRequest { pub struct CreateProfileRequest {
pub username: Option<String>, pub username: Option<String>,
} }
/// XP required to reach each level (cumulative total from level 1).
/// Level 1 starts at 0 XP. Level 2 needs 500 total XP, etc.
pub const XP_THRESHOLDS: &[i64] = &[
0, // level 1
500, // level 2
1200, // level 3
2000, // level 4
3000, // level 5
4200, // level 6
5600, // level 7
7200, // level 8
9000, // level 9
11000, // level 10
];
/// Compute the level for a given cumulative XP total.
pub fn level_for_xp(total_xp: i64) -> i64 {
let base = XP_THRESHOLDS
.iter()
.rposition(|&t| total_xp >= t)
.map(|i| i as i64 + 1)
.unwrap_or(1);
// Beyond the table: every 2500 XP is another level.
let overflow_xp = total_xp - XP_THRESHOLDS.last().copied().unwrap_or(0);
if overflow_xp > 0 {
let table_max = XP_THRESHOLDS.len() as i64;
table_max + (overflow_xp / 2500)
} else {
base
}
}
/// Coins awarded per new level gained.
pub fn coins_for_level(new_level: i64) -> i64 {
new_level * 500
}
/// Pack granted at milestone levels (5, 10, 15, 20, …).
pub fn pack_for_level(new_level: i64) -> Option<&'static str> {
match new_level {
5 => Some("bronze_pack"),
10 => Some("silver_pack"),
15 => Some("gold_pack"),
20 => Some("rare_gold_pack"),
l if l > 20 && l % 5 == 0 => Some("gold_pack"),
_ => None,
}
}
#[derive(Debug, Clone, Serialize)]
pub struct LevelUpEvent {
pub new_level: i64,
pub coins_granted: i64,
pub pack_granted: Option<String>,
}
+127
View File
@@ -0,0 +1,127 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Season {
pub profile_id: String,
pub division: i64,
pub season_number: i64,
pub season_points: i64,
pub matches_played: i64,
pub wins: i64,
pub draws: i64,
pub losses: i64,
pub started_at: String,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct SeasonHistoryEntry {
pub id: String,
pub profile_id: String,
pub season_number: i64,
pub division: i64,
pub season_points: i64,
pub wins: i64,
pub draws: i64,
pub losses: i64,
pub result: String,
pub new_division: i64,
pub coins_awarded: i64,
pub pack_awarded: Option<String>,
pub ended_at: String,
}
pub const SEASON_LENGTH: i64 = 10;
pub const PROMOTION_PTS: i64 = 21;
pub const RELEGATION_PTS: i64 = 3;
const MIN_DIVISION: i64 = 1;
const MAX_DIVISION: i64 = 10;
impl Season {
/// Whether this season's match quota is complete.
pub fn is_complete(&self) -> bool {
self.matches_played >= SEASON_LENGTH
}
/// Season end result: promote / maintain / relegate.
pub fn end_result(&self) -> SeasonResult {
if self.season_points >= PROMOTION_PTS && self.division > MIN_DIVISION {
SeasonResult::Promoted
} else if self.season_points <= RELEGATION_PTS && self.division < MAX_DIVISION {
SeasonResult::Relegated
} else {
SeasonResult::Maintained
}
}
/// Coin reward for ending the season in this division.
pub fn season_reward_coins(&self) -> i64 {
match self.division {
1 => 5000,
2 => 4000,
3 => 3000,
4 => 2000,
5 => 1500,
6 | 7 => 1000,
_ => 500,
}
}
/// Pack reward (if any) for ending the season in this division.
pub fn season_reward_pack(&self) -> Option<&'static str> {
match self.division {
1 | 2 => Some("gold_pack"),
3 | 4 => Some("silver_pack"),
_ => None,
}
}
/// Matches left in the current season.
pub fn matches_remaining(&self) -> i64 {
(SEASON_LENGTH - self.matches_played).max(0)
}
/// Points needed for promotion (None if already at div 1).
pub fn pts_for_promotion(&self) -> Option<i64> {
if self.division <= MIN_DIVISION {
None
} else {
Some((PROMOTION_PTS - self.season_points).max(0))
}
}
/// Points above the relegation threshold (always ≥ 0 means safe).
pub fn pts_above_safe(&self) -> i64 {
self.season_points - RELEGATION_PTS
}
/// Whether relegation is still possible (false if at max division already).
pub fn can_be_relegated(&self) -> bool {
self.division < MAX_DIVISION
}
/// Whether promotion is still achievable given matches remaining.
pub fn promotion_achievable(&self) -> bool {
self.division > MIN_DIVISION
&& self.season_points + self.matches_remaining() * 3 >= PROMOTION_PTS
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum SeasonResult {
Promoted,
Maintained,
Relegated,
}
/// Returned in match result when a season ends.
#[derive(Debug, Clone, Serialize)]
pub struct SeasonEndSummary {
pub result: SeasonResult,
pub old_division: i64,
pub new_division: i64,
pub new_season_number: i64,
pub coins_awarded: i64,
pub pack_awarded: Option<String>,
}
+21
View File
@@ -0,0 +1,21 @@
use axum::{extract::State, Json};
use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
services::{achievement as ach_svc, club as club_svc, profile as profile_svc},
};
pub async fn get_achievements(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 _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id).await;
let achievements = ach_svc::list_with_status(&state.pool, &state.achievement_defs).await?;
let earned = achievements.iter().filter(|a| a["unlocked"].as_bool().unwrap_or(false)).count();
Ok(Json(json!({
"achievements": achievements,
"earned": earned,
"total": achievements.len(),
})))
}
+51
View File
@@ -28,3 +28,54 @@ pub async fn post_auth_local(
"message": "Welcome to OpenFUT FC! Your club has been created." "message": "Welcome to OpenFUT FC! Your club has been created."
}))) })))
} }
/// GET /auth/status — lightweight check: does a profile exist?
/// Returns 200 `{ "has_profile": true/false }` without erroring.
pub async fn get_auth_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles")
.fetch_one(&state.pool)
.await?;
Ok(Json(json!({ "has_profile": count > 0 })))
}
/// POST /auth/reset — wipe all game data and start fresh.
/// Deletes every user-data table in dependency order. The schema tables
/// (migrations) are left intact; calling POST /auth/local afterwards
/// creates a new profile.
pub async fn post_auth_reset(State(state): State<AppState>) -> AppResult<Json<Value>> {
// Delete in reverse-dependency order to satisfy FK constraints
// (SQLite FK enforcement is opt-in, but we follow the order anyway)
let tables = [
"player_achievements",
"notifications",
"fut_champs_sessions",
"sbc_submissions",
"objective_progress",
"position_goals",
"statistics",
"seasons",
"matches",
"market_listings",
"squad_players",
"squads",
"owned_cards",
"packs",
"events",
"draft_sessions",
"settings",
"clubs",
"profiles",
];
for table in &tables {
sqlx::query(&format!("DELETE FROM {table}"))
.execute(&state.pool)
.await?;
}
tracing::info!("Full game reset performed");
Ok(Json(json!({
"reset": true,
"message": "All progress has been wiped. Call POST /auth/local to start a new club."
})))
}
+98 -11
View File
@@ -12,10 +12,25 @@ use crate::{
services::{club as club_svc, profile as profile_svc}, services::{club as club_svc, profile as profile_svc},
}; };
/// Quick-sell value for a card based on overall rating.
fn quick_sell_coins(overall: u8) -> i64 {
if overall >= 85 { 1500 }
else if overall >= 80 { 900 }
else if overall >= 75 { 600 }
else if overall >= 65 { 300 }
else { 150 }
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CardQuery { pub struct CardQuery {
pub rarity: Option<String>, pub rarity: Option<String>,
pub position: Option<String>, pub position: Option<String>,
pub nation: Option<String>,
pub league: Option<String>,
pub club: Option<String>,
pub min_overall: Option<u8>,
pub max_overall: Option<u8>,
pub limit: Option<usize>,
} }
pub async fn get_card( pub async fn get_card(
@@ -33,25 +48,49 @@ pub async fn get_cards(
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<CardQuery>, Query(query): Query<CardQuery>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let cards = state let mut cards: Vec<_> = state
.card_db .card_db
.all() .all()
.into_iter() .into_iter()
.filter(|c| { .filter(|c| {
query let rarity_ok = query
.rarity .rarity
.as_ref() .as_ref()
.map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase()) .map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase())
.unwrap_or(true) .unwrap_or(true);
&& query let pos_ok = query
.position .position
.as_ref() .as_ref()
.map(|p| c.position.to_lowercase() == p.to_lowercase()) .map(|p| c.position.eq_ignore_ascii_case(p))
.unwrap_or(true) .unwrap_or(true);
let nation_ok = query
.nation
.as_ref()
.map(|n| c.nation.eq_ignore_ascii_case(n))
.unwrap_or(true);
let league_ok = query
.league
.as_ref()
.map(|l| c.league.eq_ignore_ascii_case(l))
.unwrap_or(true);
let club_ok = query
.club
.as_ref()
.map(|cl| c.club.eq_ignore_ascii_case(cl))
.unwrap_or(true);
let min_ok = query.min_overall.map(|m| c.overall >= m).unwrap_or(true);
let max_ok = query.max_overall.map(|m| c.overall <= m).unwrap_or(true);
rarity_ok && pos_ok && nation_ok && league_ok && club_ok && min_ok && max_ok
}) })
.collect::<Vec<_>>(); .collect();
Ok(Json(json!({ "cards": cards, "total": cards.len() }))) cards.sort_by_key(|c| std::cmp::Reverse(c.overall));
let total = cards.len();
if let Some(limit) = query.limit {
cards.truncate(limit);
}
Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() })))
} }
pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Value>> {
@@ -59,7 +98,7 @@ pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Val
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let owned = sqlx::query_as::<_, OwnedCard>( 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 = ?" "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE club_id = ?"
) )
.bind(&club.id) .bind(&club.id)
.fetch_all(&state.pool) .fetch_all(&state.pool)
@@ -69,11 +108,19 @@ pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Val
.iter() .iter()
.filter_map(|o| { .filter_map(|o| {
state.card_db.get(&o.card_id).map(|def| { state.card_db.get(&o.card_id).map(|def| {
let effective_overall = def.overall as i64 + o.training_bonus;
let effective_position =
o.position_override.as_deref().unwrap_or(&def.position);
json!({ json!({
"owned_card_id": o.id, "owned_card_id": o.id,
"is_loan": o.is_loan, "is_loan": o.is_loan,
"loan_matches_remaining": o.loan_matches_remaining, "loan_matches_remaining": o.loan_matches_remaining,
"acquired_at": o.acquired_at, "acquired_at": o.acquired_at,
"chemistry_style": o.chemistry_style,
"position_override": o.position_override,
"training_bonus": o.training_bonus,
"effective_overall": effective_overall,
"effective_position": effective_position,
"card": def, "card": def,
}) })
}) })
@@ -84,3 +131,43 @@ pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Val
json!({ "collection": with_defs, "total": with_defs.len() }), json!({ "collection": with_defs, "total": with_defs.len() }),
)) ))
} }
/// Quick-sell an owned card for instant coins. The card is removed from the collection.
pub async fn delete_owned_card(
State(state): State<AppState>,
Path(owned_card_id): Path<String>,
) -> 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, \
chemistry_style, position_override, training_bonus \
FROM owned_cards WHERE id = ? AND club_id = ?",
)
.bind(&owned_card_id)
.bind(&club.id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("owned card '{owned_card_id}' not found")))?;
let card = state
.card_db
.get(&owned.card_id)
.ok_or_else(|| AppError::NotFound(format!("card definition '{}' missing", owned.card_id)))?;
let coins = quick_sell_coins(card.overall);
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
.bind(&owned_card_id)
.execute(&state.pool)
.await?;
club_svc::add_coins(&state.pool, &club.id, coins).await?;
Ok(Json(json!({
"quick_sold": owned_card_id,
"card_id": owned.card_id,
"coins_received": coins,
})))
}
+112 -1
View File
@@ -2,12 +2,123 @@ use crate::{
app::AppState, app::AppState,
error::AppResult, error::AppResult,
models::club::Club, models::club::Club,
services::{club as club_svc, profile as profile_svc}, services::{checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc},
}; };
use axum::{extract::State, Json}; use axum::{extract::State, Json};
use serde::Deserialize;
use serde_json::{json, Value};
pub async fn get_club(State(state): State<AppState>) -> AppResult<Json<Club>> { pub async fn get_club(State(state): State<AppState>) -> AppResult<Json<Club>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
Ok(Json(club)) Ok(Json(club))
} }
#[derive(Deserialize)]
pub struct UpdateClubRequest {
pub name: Option<String>,
pub manager_name: Option<String>,
}
pub async fn put_club(
State(state): State<AppState>,
Json(req): Json<UpdateClubRequest>,
) -> 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 updated = club_svc::update_club(
&state.pool,
&club.id,
req.name.as_deref(),
req.manager_name.as_deref(),
)
.await?;
Ok(Json(json!({ "club": updated })))
}
pub async fn get_checkin_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
Ok(Json(json!({
"available": status.available,
"streak_day": status.streak_day,
"next_reward_coins": status.next_reward_coins,
"next_reward_pack": status.next_reward_pack,
"last_checked_in": status.last_checked_in,
})))
}
pub async fn post_checkin(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 r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?;
Ok(Json(json!({
"coins_awarded": r.coins_awarded,
"pack_awarded": r.pack_awarded,
"new_streak": r.new_streak,
"already_claimed": r.already_claimed,
})))
}
pub async fn get_milestones(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 stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
let seasons_completed: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM season_history WHERE profile_id = ?",
)
.bind(&profile.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let highest_division: i64 = sqlx::query_scalar(
"SELECT COALESCE(MIN(new_division), 10) FROM season_history WHERE profile_id = ?",
)
.bind(&profile.id)
.fetch_one(&state.pool)
.await
.unwrap_or(10);
let cards_owned: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
)
.bind(&club.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let sbcs_completed: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1",
)
.bind(&club.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let total_checkins: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?",
)
.bind(&profile.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
Ok(Json(json!({
"total_wins": stats.matches_won,
"total_draws": stats.matches_drawn,
"total_losses": stats.matches_lost,
"total_matches": stats.matches_played,
"total_goals_scored": stats.goals_scored,
"total_goals_conceded": stats.goals_conceded,
"best_win_streak": stats.best_win_streak,
"total_packs_opened": stats.packs_opened,
"seasons_completed": seasons_completed,
"highest_division_reached": highest_division,
"cards_owned": cards_owned,
"sbcs_completed": sbcs_completed,
"total_checkins": total_checkins,
"club_level": club.level,
})))
}
+133
View File
@@ -0,0 +1,133 @@
use axum::{extract::State, Json};
use rand::{Rng, SeedableRng};
use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
models::season::{PROMOTION_PTS, RELEGATION_PTS, SEASON_LENGTH},
services::{club as club_svc, profile as profile_svc, season as season_svc},
};
pub async fn get_division(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 season = season_svc::get_or_create(&state.pool, &profile.id).await?;
Ok(Json(json!({
"division": season.division,
"season_number": season.season_number,
"season_points": season.season_points,
"matches_played": season.matches_played,
"matches_remaining": season.matches_remaining(),
"season_length": SEASON_LENGTH,
"promotion_pts": PROMOTION_PTS,
"relegation_pts": RELEGATION_PTS,
"pts_above_safe": season.pts_above_safe(),
"promotion_achievable": season.promotion_achievable(),
"can_be_relegated": season.can_be_relegated(),
"record": {
"wins": season.wins,
"draws": season.draws,
"losses": season.losses,
},
"pts_for_promotion": season.pts_for_promotion(),
"started_at": season.started_at,
})))
}
pub async fn get_division_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let history = season_svc::get_history(&state.pool, &profile.id).await?;
Ok(Json(json!({ "history": history, "total": history.len() })))
}
pub async fn get_division_leaderboard(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 season = season_svc::get_or_create(&state.pool, &profile.id).await?;
// Seed from division + season_number so the NPC table is stable within a season
let seed = (season.division as u64) * 1000 + season.season_number as u64;
let mut rng = rand::rngs::SmallRng::seed_from_u64(seed);
const NPC_NAMES: &[&str] = &[
"Riverside FC", "City Athletic", "County United", "Valley Rangers",
"Harbor Town FC", "Mountside City", "Lakewood Athletic", "Eastbrook United",
"Westfield Rovers", "Northgate FC", "Southport Athletic", "Ironbridge City",
"Milldale United", "Hillcrest Rangers", "Bayside FC", "Thornfield Athletic",
"Greenhill United", "Coldwater City", "Redbury Rangers", "Ashdown FC",
];
// Pick 9 NPC names without repetition using the seeded RNG
let mut name_indices: Vec<usize> = (0..NPC_NAMES.len()).collect();
name_indices.sort_by_key(|&_i| rng.gen::<u64>());
let npc_names: Vec<&str> = name_indices[..9].iter().map(|&i| NPC_NAMES[i]).collect();
// Generate NPC records: clubs in top of table have more wins, bottom have more losses
let matches_played = season.matches_played;
let npc_matches = matches_played.max(1); // NPC clubs play same number of matches as player
let mut table: Vec<serde_json::Value> = npc_names
.iter()
.enumerate()
.map(|(idx, name)| {
// Quality bias: index 0-2 = stronger, 6-8 = weaker
let quality: f64 = 1.0 - (idx as f64 / 8.0); // 1.0 → 0.0
let expected_win_rate = 0.2 + quality * 0.6; // 0.20.8
let wins = (npc_matches as f64 * expected_win_rate * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
let losses = (npc_matches as f64 * (1.0 - expected_win_rate) * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
let draws = (npc_matches - wins - losses).max(0);
let pts = wins * 3 + draws;
json!({
"club_name": name,
"wins": wins,
"draws": draws,
"losses": losses,
"pts": pts,
"matches_played": npc_matches,
"is_player": false,
})
})
.collect();
// Add player club row
let player_pts = season.wins * 3 + season.draws;
table.push(json!({
"club_name": club.name,
"wins": season.wins,
"draws": season.draws,
"losses": season.losses,
"pts": player_pts,
"matches_played": season.matches_played,
"is_player": true,
}));
// Sort by pts desc, then wins desc
table.sort_by(|a, b| {
let pts_a = a["pts"].as_i64().unwrap_or(0);
let pts_b = b["pts"].as_i64().unwrap_or(0);
pts_b.cmp(&pts_a).then_with(|| {
let w_b = b["wins"].as_i64().unwrap_or(0);
let w_a = a["wins"].as_i64().unwrap_or(0);
w_b.cmp(&w_a)
})
});
// Add position numbers
let table_with_pos: Vec<serde_json::Value> = table
.into_iter()
.enumerate()
.map(|(i, mut entry)| {
entry["position"] = json!(i as i64 + 1);
entry
})
.collect();
Ok(Json(json!({
"leaderboard": table_with_pos,
"division": season.division,
"season_number": season.season_number,
"promotion_threshold": 3, // top 3 promote
"relegation_threshold": 8, // bottom 2 relegate
})))
}
+71 -6
View File
@@ -1,27 +1,28 @@
use axum::{ use axum::{
extract::{Query, State}, extract::{Path, Query, State},
Json, Json,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::{app::AppState, error::AppResult, services::match_service}; use crate::{
app::AppState,
error::AppResult,
services::{club as club_svc, draft as draft_svc, match_service, profile as profile_svc},
};
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct DraftQuery { pub struct DraftQuery {
pub difficulty: Option<String>, pub difficulty: Option<String>,
} }
/// Returns a randomly generated draft squad from the card pool. /// Legacy endpoint: returns a pre-built random draft squad (no session state).
/// Difficulty controls minimum card overall. The front-end is responsible
/// for presenting per-position pick choices; this endpoint provides the pool.
pub async fn get_draft_squad( pub async fn get_draft_squad(
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<DraftQuery>, Query(query): Query<DraftQuery>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let difficulty = query.difficulty.as_deref().unwrap_or("beginner"); let difficulty = query.difficulty.as_deref().unwrap_or("beginner");
let generated = match_service::generate_opponent(&state.card_db, difficulty); let generated = match_service::generate_opponent(&state.card_db, difficulty);
Ok(Json(json!({ Ok(Json(json!({
"mode": "draft", "mode": "draft",
"difficulty": difficulty, "difficulty": difficulty,
@@ -30,3 +31,67 @@ pub async fn get_draft_squad(
"cards": generated["cards"], "cards": generated["cards"],
}))) })))
} }
/// Start a new stateful FUT-style draft session.
///
/// Returns a session ID and the 5 candidates for the first position (GK).
/// The caller picks one candidate, then calls `POST /draft/sessions/:id/pick`
/// until all 11 slots are filled.
pub async fn post_draft_start(
State(state): State<AppState>,
Query(query): Query<DraftQuery>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let difficulty = query.difficulty.as_deref().unwrap_or("professional");
let session = draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
Ok(Json(session))
}
/// Get the current state of a draft session.
pub async fn get_draft_session(
State(state): State<AppState>,
Path(session_id): Path<String>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let session = draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?;
Ok(Json(session))
}
#[derive(Deserialize)]
pub struct PickRequest {
pub card_id: String,
}
/// Pick one card from the current candidates list.
///
/// Advances the session to the next position.
/// When the last pick is made the session status changes to "completed"
/// and rewards (coins + optional pack) are granted automatically.
pub async fn post_draft_pick(
State(state): State<AppState>,
Path(session_id): Path<String>,
Json(req): Json<PickRequest>,
) -> 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 session = draft_svc::pick_card(
&state.pool,
&state.card_db,
&club.id,
&profile.id,
&session_id,
&req.card_id,
)
.await?;
Ok(Json(session))
}
/// Abandon an active draft session. No rewards are granted.
pub async fn post_draft_abandon(
State(state): State<AppState>,
Path(session_id): Path<String>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let result = draft_svc::abandon_draft(&state.pool, &profile.id, &session_id).await?;
Ok(Json(result))
}
+54
View File
@@ -0,0 +1,54 @@
use axum::{
extract::{Path, State},
Json,
};
use serde_json::{json, Value};
use crate::{
app::AppState,
error::{AppError, AppResult},
services::event as event_svc,
};
pub async fn get_events(State(state): State<AppState>) -> AppResult<Json<Value>> {
let events = event_svc::get_all_with_status(&state.pool, &state.event_defs).await?;
let active_count = events.iter().filter(|e| e.is_active).count();
Ok(Json(json!({
"events": events,
"total": events.len(),
"active_count": active_count,
})))
}
pub async fn get_event(
State(state): State<AppState>,
Path(event_id): Path<String>,
) -> AppResult<Json<Value>> {
let def = state
.event_defs
.iter()
.find(|d| d.id == event_id)
.ok_or_else(|| AppError::NotFound(format!("event '{event_id}' not found")))?;
let is_active = event_svc::is_event_active(&state.pool, def).await?;
Ok(Json(json!({
"event": def,
"is_active": is_active,
})))
}
pub async fn post_activate_event(
State(state): State<AppState>,
Path(event_id): Path<String>,
) -> AppResult<Json<Value>> {
event_svc::activate_event(&state.pool, &event_id, &state.event_defs).await?;
Ok(Json(json!({ "activated": event_id, "is_active": true })))
}
pub async fn post_deactivate_event(
State(state): State<AppState>,
Path(event_id): Path<String>,
) -> AppResult<Json<Value>> {
event_svc::deactivate_event(&state.pool, &event_id, &state.event_defs).await?;
Ok(Json(json!({ "deactivated": event_id, "is_active": false })))
}
+123
View File
@@ -0,0 +1,123 @@
use axum::{
extract::{Path, State},
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
services::{club as club_svc, fut_champs as champs_svc, profile as profile_svc, season as season_svc},
};
/// GET /fut-champs — current active session, or null if none.
pub async fn get_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let session = champs_svc::get_active_session(&state.pool, &profile.id).await?;
Ok(Json(json!({
"session": session,
"max_matches": crate::models::fut_champs::MAX_MATCHES,
})))
}
/// POST /fut-champs/start — open a new FUT Champions week.
pub async fn post_start_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let session = champs_svc::start_session(&state.pool, &profile.id).await?;
Ok(Json(json!({
"session": session,
"message": "FUT Champions week started — play up to 30 matches",
})))
}
#[derive(Deserialize)]
pub struct ChampsMatchRequest {
pub goals_for: i64,
pub goals_against: i64,
}
/// POST /fut-champs/:session_id/result — record a match in this session.
pub async fn post_champs_result(
State(state): State<AppState>,
Path(session_id): Path<String>,
Json(req): Json<ChampsMatchRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let session = champs_svc::record_match(
&state.pool,
&profile.id,
&session_id,
req.goals_for,
req.goals_against,
)
.await?;
let outcome = if req.goals_for > req.goals_against {
"win"
} else if req.goals_for == req.goals_against {
"draw"
} else {
"loss"
};
Ok(Json(json!({
"session": session,
"outcome": outcome,
"matches_remaining": session.matches_remaining(),
"auto_completed": session.is_complete(),
})))
}
/// POST /fut-champs/:session_id/claim — claim end-of-week rewards.
pub async fn post_claim_champs_rewards(
State(state): State<AppState>,
Path(session_id): Path<String>,
) -> 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 result = champs_svc::claim_rewards(
&state.pool,
&profile.id,
&session_id,
&club.id,
&state.pack_defs,
)
.await?;
Ok(Json(result))
}
/// GET /fut-champs/history — past sessions, newest first.
pub async fn get_champs_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let history = champs_svc::get_history(&state.pool, &profile.id).await?;
Ok(Json(json!({
"history": history,
"total": history.len(),
})))
}
/// POST /rivals/claim-weekly — claim weekly Division Rivals reward.
pub async fn post_claim_rivals_reward(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?;
// Ensure a season row exists
season_svc::get_or_create(&state.pool, &profile.id).await?;
let result = champs_svc::claim_rivals_reward(
&state.pool,
&profile.id,
&club.id,
&state.pack_defs,
)
.await?;
Ok(Json(result))
}
+31 -3
View File
@@ -1,5 +1,5 @@
use axum::{ use axum::{
extract::{Query, State}, extract::{Path, Query, State},
Json, Json,
}; };
use serde::Deserialize; use serde::Deserialize;
@@ -12,6 +12,14 @@ use crate::{
services::{club as club_svc, market as market_svc, profile as profile_svc}, services::{club as club_svc, market as market_svc, profile as profile_svc},
}; };
pub async fn get_trade_history(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 trades = market_svc::get_trade_history(&state.pool, &club.id).await?;
Ok(Json(json!({ "trades": trades, "total": trades.len() })))
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct MarketQuery { pub struct MarketQuery {
pub min_overall: Option<u8>, pub min_overall: Option<u8>,
@@ -62,13 +70,33 @@ pub async fn post_market_sell(
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).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?; let new_balance = market_svc::sell_card(&state.pool, &state.card_db, &club.id, &req).await?;
Ok(Json( Ok(Json(
json!({ "new_coin_balance": new_balance, "message": "Card sold to NPC market" }), 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>> { 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?; let count =
market_svc::refresh_npc_listings(&state.pool, &state.card_db, &state.event_defs).await?;
Ok(Json(json!({ "listings_generated": count }))) Ok(Json(json!({ "listings_generated": count })))
} }
/// Return all active market listings posted by the current player's club.
pub async fn get_my_listings(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 listings = market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
Ok(Json(json!({ "listings": listings, "total": listings.len() })))
}
/// Cancel a player-posted listing and return the card to the collection.
pub async fn delete_market_listing(
State(state): State<AppState>,
Path(listing_id): Path<String>,
) -> 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?;
market_svc::cancel_listing(&state.pool, &club.id, &listing_id).await?;
Ok(Json(json!({ "cancelled": listing_id })))
}
+1 -1
View File
@@ -69,7 +69,7 @@ pub async fn post_match_result(
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = let result =
match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs) match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs, &state.achievement_defs)
.await?; .await?;
Ok(Json(result)) Ok(Json(result))
+6
View File
@@ -1,10 +1,15 @@
pub mod achievements;
pub mod auth; pub mod auth;
pub mod cards; pub mod cards;
pub mod club; pub mod club;
pub mod division;
pub mod draft; pub mod draft;
pub mod fut_champs;
pub mod events;
pub mod health; pub mod health;
pub mod market; pub mod market;
pub mod matches; pub mod matches;
pub mod notifications;
pub mod objectives; pub mod objectives;
pub mod packs; pub mod packs;
pub mod profile; pub mod profile;
@@ -12,3 +17,4 @@ pub mod sbc;
pub mod settings; pub mod settings;
pub mod squad; pub mod squad;
pub mod statistics; pub mod statistics;
pub mod upgrades;
+132
View File
@@ -0,0 +1,132 @@
use axum::{
extract::{Path, State},
Json,
};
use serde_json::{json, Value};
use crate::{
app::AppState,
error::{AppError, AppResult},
services::{club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc},
};
/// GET /notifications
///
/// Returns persistent (event-driven) notifications merged with dynamic state
/// notifications (unclaimed objectives, expiring loans, season ending soon).
/// Persistent notifications carry an `id` and `is_read` flag; dynamic ones
/// have `id: null` and are always considered unread.
pub async fn get_notifications(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?;
// ── Persistent notifications ─────────────────────────────────────────────
let persistent = notif_svc::list(&state.pool).await?;
let persistent_unread = persistent.iter().filter(|n| !n.is_read).count() as i64;
// ── Dynamic: unclaimed objective rewards ─────────────────────────────────
let objectives =
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
let mut dynamic: Vec<Value> = objectives
.iter()
.filter(|o| o.completed && !o.claimed)
.map(|o| json!({
"id": null,
"type": "objective_complete",
"title": format!("Objective complete: {}", o.definition.title),
"body": format!("Claim your reward for \"{}\" (+{} coins)", o.definition.title, o.definition.reward_coins),
"is_read": false,
"created_at": null,
}))
.collect();
// ── Dynamic: loan cards expiring within 2 matches ────────────────────────
let expiring: Vec<(String, String, Option<i64>)> = sqlx::query_as(
"SELECT oc.id, oc.card_id, oc.loan_matches_remaining \
FROM owned_cards oc WHERE oc.club_id = ? AND oc.is_loan = 1 \
AND oc.loan_matches_remaining IS NOT NULL AND oc.loan_matches_remaining <= 2",
)
.bind(&club.id)
.fetch_all(&state.pool)
.await?;
for (owned_id, card_id, remaining) in expiring {
let card_name = state
.card_db
.get(&card_id)
.map(|c| c.name.clone())
.unwrap_or_else(|| card_id.clone());
dynamic.push(json!({
"id": null,
"type": "loan_expiring",
"title": format!("Loan expiring: {card_name}"),
"body": format!("{card_name} has {} match(es) remaining on loan.", remaining.unwrap_or(0)),
"is_read": false,
"created_at": null,
"owned_card_id": owned_id,
}));
}
// ── Dynamic: season ending soon ──────────────────────────────────────────
let season_row: Option<(i64, i64)> =
sqlx::query_as("SELECT matches_played, division FROM seasons WHERE profile_id = ?")
.bind(&profile.id)
.fetch_optional(&state.pool)
.await?;
if let Some((played, division)) = season_row {
let remaining = (10 - played).max(0);
if remaining <= 2 && remaining > 0 {
dynamic.push(json!({
"id": null,
"type": "season_ending",
"title": format!("Season ending — Division {division}"),
"body": format!("{remaining} match(es) left in the current season."),
"is_read": false,
"created_at": null,
}));
}
}
// ── Merge: persistent first (newest first), then dynamic ─────────────────
// Use "type" key for compatibility with dashboard and existing tests.
let all: Vec<Value> = persistent
.iter()
.map(|n| json!({
"id": n.id,
"type": n.kind,
"title": n.title,
"body": n.body,
"is_read": n.is_read,
"created_at": n.created_at,
}))
.chain(dynamic.iter().cloned())
.collect();
let total_unread = persistent_unread + dynamic.len() as i64;
Ok(Json(json!({
"notifications": all,
"unread_count": total_unread,
})))
}
/// PATCH /notifications/:id/read
pub async fn mark_notification_read(
State(state): State<AppState>,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let found = notif_svc::mark_read(&state.pool, &id).await?;
if !found {
return Err(AppError::NotFound(format!("notification '{id}' not found")));
}
Ok(Json(json!({ "marked_read": id })))
}
/// POST /notifications/read-all
pub async fn mark_all_notifications_read(
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
let count = notif_svc::mark_all_read(&state.pool).await?;
Ok(Json(json!({ "marked_read": count })))
}
+36 -2
View File
@@ -1,10 +1,13 @@
use axum::{extract::State, Json}; use axum::{
extract::{Path, State},
Json,
};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::{ use crate::{
app::AppState, app::AppState,
error::AppResult, error::{AppError, AppResult},
services::{club as club_svc, objective as obj_svc, profile as profile_svc}, services::{club as club_svc, objective as obj_svc, profile as profile_svc},
}; };
@@ -15,6 +18,37 @@ pub async fn get_objectives(State(state): State<AppState>) -> AppResult<Json<Val
Ok(Json(json!({ "objectives": objectives }))) Ok(Json(json!({ "objectives": objectives })))
} }
pub async fn get_objective(
State(state): State<AppState>,
Path(objective_id): Path<String>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let all =
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
let obj = all
.into_iter()
.find(|o| o.definition.id == objective_id)
.ok_or_else(|| AppError::NotFound(format!("objective '{objective_id}' not found")))?;
Ok(Json(json!({ "objective": obj })))
}
pub async fn post_claim_objective_by_id(
State(state): State<AppState>,
Path(objective_id): Path<String>,
) -> 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 reward = obj_svc::claim_objective(
&state.pool,
&profile.id,
&club.id,
&state.obj_defs,
&objective_id,
)
.await?;
Ok(Json(json!({ "claimed": true, "reward": reward })))
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ClaimRequest { pub struct ClaimRequest {
pub objective_id: String, pub objective_id: String,
+66
View File
@@ -35,6 +35,25 @@ pub async fn post_buy_pack(
)) ))
} }
/// List all purchasable pack definitions with their coin costs.
pub async fn get_pack_store(State(state): State<AppState>) -> AppResult<Json<Value>> {
let store: Vec<Value> = state
.pack_defs
.iter()
.map(|d| {
let total_cards: u8 = d.slots.iter().map(|s| s.count).sum();
json!({
"id": d.id,
"name": d.name,
"description": d.description,
"cost_coins": d.cost_coins,
"total_cards": total_cards,
})
})
.collect();
Ok(Json(json!({ "packs": store })))
}
pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
@@ -57,6 +76,50 @@ pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>>
Ok(Json(json!({ "packs": with_defs }))) Ok(Json(json!({ "packs": with_defs })))
} }
/// Return recently opened packs with the card IDs they contained.
pub async fn get_pack_history(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 opened = sqlx::query_as::<_, crate::models::pack::Pack>(
"SELECT id, club_id, definition_id, opened, created_at, opened_cards, opened_at \
FROM packs WHERE club_id = ? AND opened = 1 ORDER BY opened_at DESC LIMIT 50",
)
.bind(&club.id)
.fetch_all(&state.pool)
.await?;
let history: Vec<Value> = opened
.iter()
.map(|p| {
let def = state.pack_defs.iter().find(|d| d.id == p.definition_id);
// Expand card_ids into full card definitions
let cards: Vec<Value> = p
.opened_cards
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.unwrap_or_default()
.iter()
.map(|id| {
json!({
"card_id": id,
"card": state.card_db.get(id),
})
})
.collect();
json!({
"pack_id": p.id,
"definition_id": p.definition_id,
"name": def.map(|d| &d.name),
"opened_at": p.opened_at,
"cards": cards,
})
})
.collect();
Ok(Json(json!({ "history": history, "total": history.len() })))
}
pub async fn post_open_pack( pub async fn post_open_pack(
State(state): State<AppState>, State(state): State<AppState>,
Path(pack_id): Path<String>, Path(pack_id): Path<String>,
@@ -76,6 +139,9 @@ pub async fn post_open_pack(
statistics::increment_packs_opened(&state.pool, &profile.id).await?; statistics::increment_packs_opened(&state.pool, &profile.id).await?;
objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1) objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1)
.await?; .await?;
let _ = crate::services::achievement::check_and_unlock(
&state.pool, &state.achievement_defs, &profile.id, &club.id,
).await;
Ok(Json(result)) Ok(Json(result))
} }
+28 -3
View File
@@ -1,9 +1,34 @@
use crate::{ use crate::{
app::AppState, error::AppResult, models::profile::Profile, services::profile as profile_svc, app::AppState,
error::AppResult,
models::profile::{level_for_xp, XP_THRESHOLDS},
services::profile as profile_svc,
}; };
use axum::{extract::State, Json}; use axum::{extract::State, Json};
use serde_json::{json, Value};
pub async fn get_profile(State(state): State<AppState>) -> AppResult<Json<Profile>> { pub async fn get_profile(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool).await?;
Ok(Json(profile)) let computed_level = level_for_xp(profile.xp);
let next_level = computed_level + 1;
let xp_for_next = if next_level as usize <= XP_THRESHOLDS.len() {
XP_THRESHOLDS[next_level as usize - 1]
} else {
let overflow_base = XP_THRESHOLDS.last().copied().unwrap_or(0);
let levels_past = next_level - XP_THRESHOLDS.len() as i64;
overflow_base + levels_past * 2500
};
let xp_to_next = (xp_for_next - profile.xp).max(0);
Ok(Json(json!({
"id": profile.id,
"username": profile.username,
"level": computed_level,
"xp": profile.xp,
"xp_to_next_level": xp_to_next,
"xp_for_next_level": xp_for_next,
"created_at": profile.created_at,
"updated_at": profile.updated_at,
})))
} }
+6
View File
@@ -45,5 +45,11 @@ pub async fn post_sbc_submit(
) )
.await?; .await?;
if result.passed {
let _ = crate::services::achievement::check_and_unlock(
&state.pool, &state.achievement_defs, &profile.id, &club.id,
).await;
}
Ok(Json(result)) Ok(Json(result))
} }
+120
View File
@@ -0,0 +1,120 @@
use axum::{
extract::{Path, State},
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
services::{club as club_svc, profile as profile_svc, upgrades as upgrade_svc},
};
/// GET /chemistry-styles — list all available styles with their stat boosts.
pub async fn get_chemistry_styles(State(state): State<AppState>) -> AppResult<Json<Value>> {
Ok(Json(json!({
"chemistry_styles": *state.chem_styles,
"total": state.chem_styles.len(),
})))
}
#[derive(Deserialize)]
pub struct ApplyChemStyleRequest {
pub style_id: String,
}
/// POST /collection/:owned_card_id/chemistry-style
pub async fn post_apply_chemistry_style(
State(state): State<AppState>,
Path(owned_card_id): Path<String>,
Json(req): Json<ApplyChemStyleRequest>,
) -> 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 updated = upgrade_svc::apply_chemistry_style(
&state.pool,
&club.id,
&owned_card_id,
&req.style_id,
&state.chem_styles,
)
.await?;
let style = state
.chem_styles
.iter()
.find(|s| s.id == updated.chemistry_style);
Ok(Json(json!({
"owned_card_id": updated.id,
"card_id": updated.card_id,
"chemistry_style": updated.chemistry_style,
"style_details": style,
})))
}
#[derive(Deserialize)]
pub struct ChangePositionRequest {
pub position: String,
}
/// POST /collection/:owned_card_id/position — costs 500 coins.
pub async fn post_change_position(
State(state): State<AppState>,
Path(owned_card_id): Path<String>,
Json(req): Json<ChangePositionRequest>,
) -> 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 updated = upgrade_svc::change_position(
&state.pool,
&club.id,
&owned_card_id,
&req.position,
)
.await?;
let card_def = state.card_db.get(&updated.card_id);
Ok(Json(json!({
"owned_card_id": updated.id,
"card_id": updated.card_id,
"original_position": card_def.map(|c| &c.position),
"position_override": updated.position_override,
"cost_coins": upgrade_svc::POSITION_CHANGE_COST,
})))
}
#[derive(Deserialize)]
pub struct ApplyTrainingRequest {
/// OVR points to add. Must be 13. Total capped at +3.
pub boost: i64,
}
/// POST /collection/:owned_card_id/training — applies a training boost (up to +3 OVR total).
pub async fn post_apply_training(
State(state): State<AppState>,
Path(owned_card_id): Path<String>,
Json(req): Json<ApplyTrainingRequest>,
) -> 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 updated =
upgrade_svc::apply_training(&state.pool, &club.id, &owned_card_id, req.boost).await?;
let card_def = state.card_db.get(&updated.card_id);
let base_overall = card_def.map(|c| c.overall as i64).unwrap_or(0);
Ok(Json(json!({
"owned_card_id": updated.id,
"card_id": updated.card_id,
"base_overall": base_overall,
"training_bonus": updated.training_bonus,
"effective_overall": base_overall + updated.training_bonus,
"max_bonus": upgrade_svc::MAX_TRAINING_BONUS,
})))
}
+215
View File
@@ -0,0 +1,215 @@
use crate::{
db::Pool,
error::AppResult,
models::achievement::{AchievementDefinition, PlayerAchievement},
services::{club as club_svc, notification},
};
use anyhow::Context;
use std::path::Path;
use uuid::Uuid;
pub fn load_achievement_definitions(data_dir: &str) -> anyhow::Result<Vec<AchievementDefinition>> {
let dir = Path::new(data_dir).join("achievements");
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<AchievementDefinition> =
serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?;
defs.extend(batch);
}
}
Ok(defs)
}
/// Query the current value for the given trigger metric.
async fn metric_value(pool: &Pool, profile_id: &str, club_id: &str, trigger: &str) -> AppResult<i64> {
let v: i64 = match trigger {
"matches_played" => sqlx::query_scalar(
"SELECT matches_played FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"matches_won" => sqlx::query_scalar(
"SELECT matches_won FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"goals_scored" => sqlx::query_scalar(
"SELECT goals_scored FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"packs_opened" => sqlx::query_scalar(
"SELECT packs_opened FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"sbcs_completed" => sqlx::query_scalar(
"SELECT sbcs_completed FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"cards_owned" => sqlx::query_scalar(
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
)
.bind(club_id)
.fetch_one(pool)
.await?,
"level" => sqlx::query_scalar(
"SELECT level FROM profiles WHERE id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(1),
"objectives_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
)
.bind(profile_id)
.fetch_one(pool)
.await?,
"drafts_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
)
.bind(profile_id)
.fetch_one(pool)
.await?,
_ => 0,
};
Ok(v)
}
/// Check all achievement definitions and unlock any not yet earned.
/// Returns definitions of newly unlocked achievements.
pub async fn check_and_unlock(
pool: &Pool,
defs: &[AchievementDefinition],
profile_id: &str,
club_id: &str,
) -> AppResult<Vec<AchievementDefinition>> {
if defs.is_empty() {
return Ok(vec![]);
}
// Load already-unlocked IDs in one query
let unlocked_ids: Vec<String> =
sqlx::query_scalar("SELECT achievement_id FROM player_achievements")
.fetch_all(pool)
.await?;
let unlocked_set: std::collections::HashSet<&str> =
unlocked_ids.iter().map(|s| s.as_str()).collect();
let candidates: Vec<&AchievementDefinition> = defs
.iter()
.filter(|d| !unlocked_set.contains(d.id.as_str()))
.collect();
if candidates.is_empty() {
return Ok(vec![]);
}
// Group candidates by trigger to minimise DB round-trips
let mut trigger_cache: std::collections::HashMap<String, i64> = Default::default();
let mut newly_unlocked: Vec<AchievementDefinition> = Vec::new();
let now = chrono::Utc::now().to_rfc3339();
for def in candidates {
let value = match trigger_cache.get(&def.trigger) {
Some(&v) => v,
None => {
let v = metric_value(pool, profile_id, club_id, &def.trigger).await?;
trigger_cache.insert(def.trigger.clone(), v);
v
}
};
if value >= def.threshold {
let pa_id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT OR IGNORE INTO player_achievements (id, achievement_id, unlocked_at) VALUES (?, ?, ?)",
)
.bind(&pa_id)
.bind(&def.id)
.bind(&now)
.execute(pool)
.await?;
if def.reward_coins > 0 {
club_svc::add_coins(pool, club_id, def.reward_coins).await?;
}
let body = format!(
"{} Reward: {} coins.",
def.description, def.reward_coins
);
let _ = notification::create(pool, "achievement", &format!("Achievement: {}", def.title), &body).await;
newly_unlocked.push(def.clone());
}
}
Ok(newly_unlocked)
}
/// Return all achievement definitions annotated with unlock status.
pub async fn list_with_status(
pool: &Pool,
defs: &[AchievementDefinition],
) -> AppResult<Vec<serde_json::Value>> {
let earned: Vec<PlayerAchievement> = sqlx::query_as::<_, PlayerAchievement>(
"SELECT id, achievement_id, unlocked_at FROM player_achievements",
)
.fetch_all(pool)
.await?;
let earned_map: std::collections::HashMap<&str, &str> = earned
.iter()
.map(|pa| (pa.achievement_id.as_str(), pa.unlocked_at.as_str()))
.collect();
Ok(defs
.iter()
.map(|d| {
let unlocked_at = earned_map.get(d.id.as_str()).copied();
serde_json::json!({
"id": d.id,
"title": d.title,
"description": d.description,
"icon": d.icon,
"trigger": d.trigger,
"threshold": d.threshold,
"reward_coins": d.reward_coins,
"rarity": d.rarity,
"unlocked": unlocked_at.is_some(),
"unlocked_at": unlocked_at,
})
})
.collect())
}
+133
View File
@@ -0,0 +1,133 @@
use crate::{db::Pool, error::AppResult, services::{club, pack}};
use uuid::Uuid;
const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000];
// Day 7 (index 6) also grants a pack:
const STREAK_7_PACK: &str = "silver_pack";
#[derive(Debug, serde::Serialize)]
pub struct CheckinStatus {
pub available: bool,
pub streak_day: i64, // current streak (17 cycle, 0 if never checked in)
pub next_reward_coins: i64,
pub next_reward_pack: Option<&'static str>,
pub last_checked_in: Option<String>,
}
#[derive(Debug, serde::Serialize)]
pub struct CheckinResult {
pub coins_awarded: i64,
pub pack_awarded: Option<String>,
pub new_streak: i64,
pub already_claimed: bool,
}
pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatus> {
let row: Option<(i64, String)> = sqlx::query_as(
"SELECT streak_day, checked_in_at FROM daily_checkins \
WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1",
)
.bind(profile_id)
.fetch_optional(pool)
.await?;
let today = today_date();
match row {
None => Ok(CheckinStatus {
available: true,
streak_day: 0,
next_reward_coins: STREAK_COINS[0],
next_reward_pack: None,
last_checked_in: None,
}),
Some((last_streak, last_at)) => {
let last_day = &last_at[..10]; // YYYY-MM-DD
let available = last_day != today.as_str();
let next_streak = compute_next_streak(last_streak, &last_at);
let idx = ((next_streak - 1) % 7) as usize;
Ok(CheckinStatus {
available,
streak_day: if available { next_streak } else { last_streak },
next_reward_coins: STREAK_COINS[idx],
next_reward_pack: if idx == 6 { Some(STREAK_7_PACK) } else { None },
last_checked_in: Some(last_at),
})
}
}
}
pub async fn claim(
pool: &Pool,
profile_id: &str,
club_id: &str,
) -> AppResult<CheckinResult> {
let row: Option<(i64, String)> = sqlx::query_as(
"SELECT streak_day, checked_in_at FROM daily_checkins \
WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1",
)
.bind(profile_id)
.fetch_optional(pool)
.await?;
let today = today_date();
if let Some((_, ref last_at)) = row {
if &last_at[..10] == today.as_str() {
let last_streak = row.as_ref().map(|(s, _)| *s).unwrap_or(1);
return Ok(CheckinResult {
coins_awarded: 0,
pack_awarded: None,
new_streak: last_streak,
already_claimed: true,
});
}
}
let last_streak = row.as_ref().map(|(s, last_at)| compute_next_streak(*s, last_at)).unwrap_or(1);
let idx = ((last_streak - 1) % 7) as usize;
let coins = STREAK_COINS[idx];
let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None };
club::add_coins(pool, club_id, coins).await?;
if let Some(def) = pack_def {
let _ = pack::grant_pack(pool, club_id, def).await;
}
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO daily_checkins (id, profile_id, club_id, streak_day, coins_awarded, pack_granted, checked_in_at) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(Uuid::new_v4().to_string())
.bind(profile_id)
.bind(club_id)
.bind(last_streak)
.bind(coins)
.bind(pack_def)
.bind(&now)
.execute(pool)
.await?;
Ok(CheckinResult {
coins_awarded: coins,
pack_awarded: pack_def.map(String::from),
new_streak: last_streak,
already_claimed: false,
})
}
fn today_date() -> String {
chrono::Utc::now().format("%Y-%m-%d").to_string()
}
/// If last check-in was yesterday or today → continue streak; otherwise reset to 1.
fn compute_next_streak(last_streak: i64, last_at: &str) -> i64 {
let last_day = &last_at[..10];
let today = chrono::Utc::now().date_naive();
let yesterday = (today - chrono::Days::new(1)).to_string();
let today_str = today.to_string();
if last_day == yesterday || last_day == today_str {
(last_streak % 7) + 1
} else {
1
}
}
+115
View File
@@ -0,0 +1,115 @@
use crate::models::card::CardDefinition;
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChemistryResult {
/// Sum of all player chemistry values, max 33 (11 × 3).
pub total: u8,
/// Per-player chemistry in the same order as the input slice (13 each).
pub per_player: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq)]
enum LinkStrength {
/// Same club AND same nationality.
Full,
/// Same league OR same nationality (but not Full).
Half,
None,
}
fn link_strength(a: &CardDefinition, b: &CardDefinition) -> LinkStrength {
if a.club == b.club && a.nation == b.nation {
return LinkStrength::Full;
}
if a.league == b.league || a.nation == b.nation {
return LinkStrength::Half;
}
LinkStrength::None
}
/// Compute team chemistry for a squad given the resolved `CardDefinition`s.
///
/// Each player starts at 1 chemistry. Full links add 2 to both players;
/// half links add 1. Each player is clamped to [1, 3].
/// Squad chemistry is the sum of all player chemistry values (max 33).
pub fn calculate_chemistry(cards: &[CardDefinition]) -> ChemistryResult {
let n = cards.len();
let mut per_player: Vec<i16> = vec![1; n];
for i in 0..n {
for j in (i + 1)..n {
let bonus = match link_strength(&cards[i], &cards[j]) {
LinkStrength::Full => 2,
LinkStrength::Half => 1,
LinkStrength::None => 0,
};
if bonus > 0 {
per_player[i] += bonus;
per_player[j] += bonus;
}
}
}
let per_player: Vec<u8> = per_player
.into_iter()
.map(|c| c.clamp(1, 3) as u8)
.collect();
let total = per_player.iter().map(|&c| c as u16).sum::<u16>() as u8;
ChemistryResult { total, per_player }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::card::Rarity;
fn card(club: &str, league: &str, nation: &str) -> CardDefinition {
CardDefinition {
id: "x".into(),
name: "Player".into(),
overall: 75,
position: "CM".into(),
nation: nation.into(),
league: league.into(),
club: club.into(),
pace: 75,
shooting: 75,
passing: 75,
dribbling: 75,
defending: 75,
physical: 75,
rarity: Rarity::Gold,
image_path: None,
}
}
#[test]
fn all_same_club_and_nationality_max_chem() {
let cards: Vec<_> = (0..11).map(|_| card("FC Test", "LaLiga", "ESP")).collect();
let result = calculate_chemistry(&cards);
assert_eq!(result.total, 33);
assert!(result.per_player.iter().all(|&c| c == 3));
}
#[test]
fn all_different_attributes_min_chem() {
let cards: Vec<_> = (0..11)
.map(|i| card(&format!("Club{i}"), &format!("League{i}"), &format!("N{i}")))
.collect();
let result = calculate_chemistry(&cards);
assert_eq!(result.total, 11);
assert!(result.per_player.iter().all(|&c| c == 1));
}
#[test]
fn same_league_gives_half_links() {
// All same league but different club and nation → half links only.
let cards: Vec<_> = (0..11)
.map(|i| card(&format!("Club{i}"), "PremierLeague", &format!("N{i}")))
.collect();
let result = calculate_chemistry(&cards);
// Each player has half links to all 10 others → +10, clamped to 3.
assert!(result.per_player.iter().all(|&c| c == 3));
}
}
+46 -8
View File
@@ -5,19 +5,30 @@ use crate::{
}; };
use chrono::Utc; use chrono::Utc;
const CLUB_SELECT: &str =
"SELECT id, profile_id, name, coins, level, created_at, updated_at, manager_name \
FROM clubs";
pub async fn get_club_by_profile(pool: &Pool, profile_id: &str) -> AppResult<Club> { pub async fn get_club_by_profile(pool: &Pool, profile_id: &str) -> AppResult<Club> {
sqlx::query_as::<_, Club>( sqlx::query_as::<_, Club>(&format!("{CLUB_SELECT} WHERE profile_id = ? LIMIT 1"))
"SELECT id, profile_id, name, coins, level, created_at, updated_at FROM clubs WHERE profile_id = ? LIMIT 1" .bind(profile_id)
) .fetch_optional(pool)
.bind(profile_id) .await?
.fetch_optional(pool) .ok_or_else(|| AppError::NotFound("club not found".into()))
.await? }
.ok_or_else(|| AppError::NotFound("club not found".into()))
pub async fn get_club_by_id(pool: &Pool, club_id: &str) -> AppResult<Club> {
sqlx::query_as::<_, Club>(&format!("{CLUB_SELECT} WHERE id = ? LIMIT 1"))
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound("club not found".into()))
} }
pub async fn create_club(pool: &Pool, club: &Club) -> AppResult<()> { pub async fn create_club(pool: &Pool, club: &Club) -> AppResult<()> {
sqlx::query( sqlx::query(
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)" "INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at, manager_name) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
) )
.bind(&club.id) .bind(&club.id)
.bind(&club.profile_id) .bind(&club.profile_id)
@@ -26,11 +37,38 @@ pub async fn create_club(pool: &Pool, club: &Club) -> AppResult<()> {
.bind(club.level) .bind(club.level)
.bind(club.created_at) .bind(club.created_at)
.bind(club.updated_at) .bind(club.updated_at)
.bind(&club.manager_name)
.execute(pool) .execute(pool)
.await?; .await?;
Ok(()) Ok(())
} }
pub async fn update_club(
pool: &Pool,
club_id: &str,
name: Option<&str>,
manager_name: Option<&str>,
) -> AppResult<Club> {
let now = Utc::now();
if let Some(n) = name {
sqlx::query("UPDATE clubs SET name = ?, updated_at = ? WHERE id = ?")
.bind(n)
.bind(now)
.bind(club_id)
.execute(pool)
.await?;
}
if let Some(m) = manager_name {
sqlx::query("UPDATE clubs SET manager_name = ?, updated_at = ? WHERE id = ?")
.bind(m)
.bind(now)
.bind(club_id)
.execute(pool)
.await?;
}
get_club_by_id(pool, club_id).await
}
pub async fn add_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> { pub async fn add_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
let now = Utc::now(); let now = Utc::now();
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?") sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
+327
View File
@@ -0,0 +1,327 @@
use rand::seq::SliceRandom;
use serde_json::json;
use uuid::Uuid;
use crate::{
db::Pool,
error::{AppError, AppResult},
models::draft::{DraftSession, DRAFT_PICK_ORDER},
services::{card_db::CardDb, pack},
};
const CANDIDATES_PER_SLOT: usize = 5;
fn difficulty_min_overall(difficulty: &str) -> u8 {
match difficulty {
"world_class" => 78,
"legendary" => 83,
"ultimate" => 88,
"professional" => 70,
_ => 55,
}
}
/// Pick up to N candidates for a given position slot.
/// Tries exact position match first, fills remainder from the general pool.
fn pick_candidates(card_db: &CardDb, position: &str, min_overall: u8, n: usize) -> Vec<String> {
let mut rng = rand::thread_rng();
let all = card_db.by_min_overall(min_overall);
// CDM / CAM → also accept CM as fallback
let pos_match = |p: &str| -> bool {
p.eq_ignore_ascii_case(position)
|| matches!(
(position.to_uppercase().as_str(), p.to_uppercase().as_str()),
("CDM", "CM") | ("CAM", "CM") | ("CM", "CDM") | ("CM", "CAM")
)
};
let mut exact: Vec<_> = all.iter().filter(|c| pos_match(&c.position)).collect();
exact.shuffle(&mut rng);
let mut pool: Vec<_> = all.iter().filter(|c| !pos_match(&c.position)).collect();
pool.shuffle(&mut rng);
exact
.into_iter()
.chain(pool)
.take(n)
.map(|c| c.id.clone())
.collect()
}
/// Compute reward tier based on average overall of picked cards.
fn compute_reward(card_db: &CardDb, picks: &[String]) -> (i64, Option<String>, i64) {
if picks.is_empty() {
return (0, None, 0);
}
let overalls: Vec<i64> = picks
.iter()
.filter_map(|id| card_db.get(id).map(|c| c.overall as i64))
.collect();
let avg = if overalls.is_empty() {
0
} else {
overalls.iter().sum::<i64>() / overalls.len() as i64
};
let (coins, pack_id) = if avg >= 84 {
(2000, Some("gold_pack".to_string()))
} else if avg >= 78 {
(1000, Some("silver_pack".to_string()))
} else {
(400, None)
};
(coins, pack_id, avg)
}
pub async fn start_draft(
pool: &Pool,
card_db: &CardDb,
profile_id: &str,
difficulty: &str,
) -> AppResult<serde_json::Value> {
let min_overall = difficulty_min_overall(difficulty);
let pick_order: Vec<String> = DRAFT_PICK_ORDER.iter().map(|s| s.to_string()).collect();
let first_position = &pick_order[0];
let candidates = pick_candidates(card_db, first_position, min_overall, CANDIDATES_PER_SLOT);
let session = DraftSession {
id: Uuid::new_v4().to_string(),
profile_id: profile_id.to_string(),
difficulty: difficulty.to_string(),
pick_order: serde_json::to_string(&pick_order).unwrap(),
picks: "[]".to_string(),
current_candidates: Some(serde_json::to_string(&candidates).unwrap()),
status: "active".to_string(),
reward_coins: 0,
reward_pack_id: None,
squad_rating: 0,
started_at: chrono::Utc::now().to_rfc3339(),
completed_at: None,
};
sqlx::query(
"INSERT INTO draft_sessions (id, profile_id, difficulty, pick_order, picks, \
current_candidates, status, reward_coins, reward_pack_id, squad_rating, \
started_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&session.id)
.bind(&session.profile_id)
.bind(&session.difficulty)
.bind(&session.pick_order)
.bind(&session.picks)
.bind(&session.current_candidates)
.bind(&session.status)
.bind(session.reward_coins)
.bind(&session.reward_pack_id)
.bind(session.squad_rating)
.bind(&session.started_at)
.bind(&session.completed_at)
.execute(pool)
.await?;
Ok(render_session(&session, card_db))
}
pub async fn get_draft(
pool: &Pool,
card_db: &CardDb,
profile_id: &str,
session_id: &str,
) -> AppResult<serde_json::Value> {
let session = fetch_session(pool, profile_id, session_id).await?;
Ok(render_session(&session, card_db))
}
pub async fn pick_card(
pool: &Pool,
card_db: &CardDb,
club_id: &str,
profile_id: &str,
session_id: &str,
card_id: &str,
) -> AppResult<serde_json::Value> {
let mut session = fetch_session(pool, profile_id, session_id).await?;
if session.status != "active" {
return Err(AppError::BadRequest(format!(
"draft session is '{}'",
session.status
)));
}
// Validate the picked card is in the current candidates list
let candidates: Vec<String> = session
.current_candidates
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
if !candidates.contains(&card_id.to_string()) {
return Err(AppError::BadRequest(
"card_id is not among the current candidates".into(),
));
}
let pick_order: Vec<String> = serde_json::from_str(&session.pick_order)
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad pick_order json: {e}")))?;
let mut picks: Vec<String> = serde_json::from_str(&session.picks)
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad picks json: {e}")))?;
picks.push(card_id.to_string());
let next_index = picks.len();
let all_filled = next_index >= pick_order.len();
let (new_candidates, new_status, reward_coins, reward_pack_id, squad_rating, completed_at) =
if all_filled {
let (coins, pack, avg) = compute_reward(card_db, &picks);
(None, "completed".to_string(), coins, pack, avg, Some(chrono::Utc::now().to_rfc3339()))
} else {
let min_overall = difficulty_min_overall(&session.difficulty);
let next_pos = &pick_order[next_index];
let next_candidates =
pick_candidates(card_db, next_pos, min_overall, CANDIDATES_PER_SLOT);
(
Some(serde_json::to_string(&next_candidates).unwrap()),
"active".to_string(),
0,
None,
0,
None,
)
};
let picks_json = serde_json::to_string(&picks).unwrap();
sqlx::query(
"UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \
reward_coins = ?, reward_pack_id = ?, squad_rating = ?, completed_at = ? WHERE id = ?",
)
.bind(&picks_json)
.bind(&new_candidates)
.bind(&new_status)
.bind(reward_coins)
.bind(&reward_pack_id)
.bind(squad_rating)
.bind(&completed_at)
.bind(session_id)
.execute(pool)
.await?;
session.picks = picks_json;
session.current_candidates = new_candidates;
session.status = new_status.clone();
session.reward_coins = reward_coins;
session.reward_pack_id = reward_pack_id.clone();
session.squad_rating = squad_rating;
session.completed_at = completed_at;
// Grant reward when draft is completed
if new_status == "completed" {
if reward_coins > 0 {
crate::services::club::add_coins(pool, club_id, reward_coins).await?;
}
if let Some(ref pack_def_id) = reward_pack_id {
pack::grant_pack(pool, club_id, pack_def_id).await?;
}
tracing::info!(
session_id,
profile_id,
reward_coins,
squad_rating,
"draft completed"
);
}
let mut rendered = render_session(&session, card_db);
rendered["just_completed"] = json!(new_status == "completed");
if new_status == "completed" {
rendered["reward"] = json!({
"coins": reward_coins,
"pack_id": reward_pack_id,
"squad_rating": squad_rating,
});
}
Ok(rendered)
}
pub async fn abandon_draft(
pool: &Pool,
profile_id: &str,
session_id: &str,
) -> AppResult<serde_json::Value> {
let session = fetch_session(pool, profile_id, session_id).await?;
if session.status == "completed" {
return Err(AppError::BadRequest(
"cannot abandon a completed draft".into(),
));
}
sqlx::query("UPDATE draft_sessions SET status = 'abandoned' WHERE id = ?")
.bind(session_id)
.execute(pool)
.await?;
Ok(json!({ "abandoned": session_id }))
}
async fn fetch_session(pool: &Pool, profile_id: &str, session_id: &str) -> AppResult<DraftSession> {
sqlx::query_as::<_, DraftSession>(
"SELECT id, profile_id, difficulty, pick_order, picks, current_candidates, \
status, reward_coins, reward_pack_id, squad_rating, started_at, completed_at \
FROM draft_sessions WHERE id = ? AND profile_id = ?",
)
.bind(session_id)
.bind(profile_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("draft session '{session_id}' not found")))
}
fn render_session(session: &DraftSession, card_db: &CardDb) -> serde_json::Value {
let pick_order: Vec<String> =
serde_json::from_str(&session.pick_order).unwrap_or_default();
let picks: Vec<String> = serde_json::from_str(&session.picks).unwrap_or_default();
let candidates: Vec<String> = session
.current_candidates
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
let picks_with_defs: Vec<serde_json::Value> = picks
.iter()
.enumerate()
.map(|(i, card_id)| {
json!({
"slot": i,
"position": pick_order.get(i).cloned().unwrap_or_default(),
"card_id": card_id,
"card": card_db.get(card_id),
})
})
.collect();
let current_position = pick_order.get(picks.len()).cloned();
let candidates_with_defs: Vec<serde_json::Value> = candidates
.iter()
.map(|id| json!({ "card_id": id, "card": card_db.get(id) }))
.collect();
json!({
"session_id": session.id,
"difficulty": session.difficulty,
"status": session.status,
"progress": { "filled": picks.len(), "total": pick_order.len() },
"current_position": current_position,
"candidates": candidates_with_defs,
"picks": picks_with_defs,
"squad_rating": session.squad_rating,
"started_at": session.started_at,
"completed_at": session.completed_at,
})
}
+172
View File
@@ -0,0 +1,172 @@
use crate::{
db::Pool,
error::{AppError, AppResult},
models::event::{EventDefinition, EventWithStatus},
};
use anyhow::Context;
use std::{collections::HashMap, path::Path};
pub fn load_event_definitions(data_dir: &str) -> anyhow::Result<Vec<EventDefinition>> {
let dir = Path::new(data_dir).join("events");
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<EventDefinition> =
serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?;
defs.extend(batch);
}
}
Ok(defs)
}
fn compute_is_active(def: &EventDefinition, override_val: Option<i64>) -> bool {
match override_val {
Some(1) => return true,
Some(0) => return false,
_ => {}
}
if def.is_manual {
return false;
}
let now = chrono::Utc::now();
let after_start = def
.starts_at
.as_ref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.is_none_or(|dt| now >= dt);
let before_end = def
.expires_at
.as_ref()
.and_then(|e| chrono::DateTime::parse_from_rfc3339(e).ok())
.is_none_or(|dt| now <= dt);
after_start && before_end
}
async fn fetch_overrides(pool: &Pool) -> AppResult<HashMap<String, Option<i64>>> {
let rows: Vec<(String, Option<i64>)> =
sqlx::query_as("SELECT id, is_active_override FROM events")
.fetch_all(pool)
.await?;
Ok(rows.into_iter().collect())
}
pub async fn is_event_active(pool: &Pool, def: &EventDefinition) -> AppResult<bool> {
let row: Option<(Option<i64>,)> =
sqlx::query_as("SELECT is_active_override FROM events WHERE id = ?")
.bind(&def.id)
.fetch_optional(pool)
.await?;
let override_val = row.and_then(|(v,)| v);
Ok(compute_is_active(def, override_val))
}
pub async fn get_active_events(
pool: &Pool,
defs: &[EventDefinition],
) -> AppResult<Vec<EventDefinition>> {
let overrides = fetch_overrides(pool).await?;
Ok(defs
.iter()
.filter(|def| {
let ov = overrides.get(&def.id).copied().flatten();
compute_is_active(def, ov)
})
.cloned()
.collect())
}
type EventRow = (String, Option<i64>, Option<String>, Option<String>);
type EventOverrideMap = HashMap<String, (Option<i64>, Option<String>, Option<String>)>;
pub async fn get_all_with_status(
pool: &Pool,
defs: &[EventDefinition],
) -> AppResult<Vec<EventWithStatus>> {
let rows: Vec<EventRow> =
sqlx::query_as("SELECT id, is_active_override, activated_at, deactivated_at FROM events")
.fetch_all(pool)
.await?;
let db_map: EventOverrideMap = rows
.into_iter()
.map(|(id, ov, at, dt)| (id, (ov, at, dt)))
.collect();
let result = defs
.iter()
.map(|def| {
let (override_val, activated_at, deactivated_at) = db_map
.get(&def.id)
.map(|(ov, at, dt)| (*ov, at.clone(), dt.clone()))
.unwrap_or((None, None, None));
EventWithStatus {
is_active: compute_is_active(def, override_val),
definition: def.clone(),
activated_at,
deactivated_at,
}
})
.collect();
Ok(result)
}
pub async fn activate_event(
pool: &Pool,
event_id: &str,
defs: &[EventDefinition],
) -> AppResult<()> {
defs.iter()
.find(|d| d.id == event_id)
.ok_or_else(|| AppError::NotFound(format!("event '{event_id}' not found")))?;
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO events (id, is_active_override, activated_at, deactivated_at)
VALUES (?, 1, ?, NULL)
ON CONFLICT(id) DO UPDATE SET
is_active_override = 1,
activated_at = excluded.activated_at,
deactivated_at = NULL",
)
.bind(event_id)
.bind(&now)
.execute(pool)
.await?;
Ok(())
}
pub async fn deactivate_event(
pool: &Pool,
event_id: &str,
defs: &[EventDefinition],
) -> AppResult<()> {
defs.iter()
.find(|d| d.id == event_id)
.ok_or_else(|| AppError::NotFound(format!("event '{event_id}' not found")))?;
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO events (id, is_active_override, activated_at, deactivated_at)
VALUES (?, 0, NULL, ?)
ON CONFLICT(id) DO UPDATE SET
is_active_override = 0,
deactivated_at = excluded.deactivated_at,
activated_at = NULL",
)
.bind(event_id)
.bind(&now)
.execute(pool)
.await?;
Ok(())
}
+291
View File
@@ -0,0 +1,291 @@
use uuid::Uuid;
use crate::{
db::Pool,
error::{AppError, AppResult},
models::fut_champs::{ChampsTier, FutChampsSession, MAX_MATCHES},
models::pack::PackDefinition,
services::{club as club_svc, pack as pack_svc},
};
const SESSION_SELECT: &str =
"SELECT id, profile_id, week_number, matches_played, wins, draws, losses, \
status, reward_tier, reward_coins, reward_pack_id, reward_claimed, \
started_at, completed_at FROM fut_champs_sessions";
pub async fn get_active_session(
pool: &Pool,
profile_id: &str,
) -> AppResult<Option<FutChampsSession>> {
sqlx::query_as::<_, FutChampsSession>(&format!(
"{SESSION_SELECT} WHERE profile_id = ? AND status = 'active' ORDER BY started_at DESC LIMIT 1"
))
.bind(profile_id)
.fetch_optional(pool)
.await
.map_err(Into::into)
}
pub async fn get_session(pool: &Pool, session_id: &str, profile_id: &str) -> AppResult<FutChampsSession> {
sqlx::query_as::<_, FutChampsSession>(&format!(
"{SESSION_SELECT} WHERE id = ? AND profile_id = ?"
))
.bind(session_id)
.bind(profile_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound("FUT Champions session not found".into()))
}
pub async fn get_history(pool: &Pool, profile_id: &str) -> AppResult<Vec<FutChampsSession>> {
sqlx::query_as::<_, FutChampsSession>(&format!(
"{SESSION_SELECT} WHERE profile_id = ? ORDER BY started_at DESC LIMIT 20"
))
.bind(profile_id)
.fetch_all(pool)
.await
.map_err(Into::into)
}
pub async fn start_session(pool: &Pool, profile_id: &str) -> AppResult<FutChampsSession> {
// Only one active session at a time
if let Some(existing) = get_active_session(pool, profile_id).await? {
return Err(AppError::Conflict(format!(
"active FUT Champions session already exists: {}",
existing.id
)));
}
// Determine next week number from history
let week_number: i64 = sqlx::query_scalar(
"SELECT COALESCE(MAX(week_number), 0) + 1 FROM fut_champs_sessions WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_one(pool)
.await?;
let id = Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO fut_champs_sessions \
(id, profile_id, week_number, matches_played, wins, draws, losses, \
status, reward_claimed, started_at) \
VALUES (?, ?, ?, 0, 0, 0, 0, 'active', 0, ?)",
)
.bind(&id)
.bind(profile_id)
.bind(week_number)
.bind(&now)
.execute(pool)
.await?;
get_session(pool, &id, profile_id).await
}
/// Record a FUT Champions match result. Auto-completes the session at 30 matches.
pub async fn record_match(
pool: &Pool,
profile_id: &str,
session_id: &str,
goals_for: i64,
goals_against: i64,
) -> AppResult<FutChampsSession> {
let session = get_session(pool, session_id, profile_id).await?;
if session.is_complete() {
return Err(AppError::BadRequest(
"this FUT Champions session is already complete".into(),
));
}
let (win_delta, draw_delta, loss_delta) = if goals_for > goals_against {
(1i64, 0i64, 0i64)
} else if goals_for == goals_against {
(0, 1, 0)
} else {
(0, 0, 1)
};
sqlx::query(
"UPDATE fut_champs_sessions \
SET matches_played = matches_played + 1, \
wins = wins + ?, \
draws = draws + ?, \
losses = losses + ? \
WHERE id = ?",
)
.bind(win_delta)
.bind(draw_delta)
.bind(loss_delta)
.bind(session_id)
.execute(pool)
.await?;
let updated = get_session(pool, session_id, profile_id).await?;
if updated.matches_played >= MAX_MATCHES {
complete_session(pool, &updated).await?;
get_session(pool, session_id, profile_id).await
} else {
Ok(updated)
}
}
/// Compute tier and write reward fields; mark session completed.
async fn complete_session(pool: &Pool, session: &FutChampsSession) -> AppResult<()> {
let tier = ChampsTier::from_wins(session.wins);
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"UPDATE fut_champs_sessions \
SET status = 'completed', completed_at = ?, \
reward_tier = ?, reward_coins = ?, reward_pack_id = ? \
WHERE id = ?",
)
.bind(&now)
.bind(tier.label())
.bind(tier.reward_coins())
.bind(tier.reward_pack())
.bind(&session.id)
.execute(pool)
.await?;
Ok(())
}
/// Claim the rewards for a completed session. Idempotent — double-claim returns an error.
pub async fn claim_rewards(
pool: &Pool,
profile_id: &str,
session_id: &str,
club_id: &str,
pack_defs: &[PackDefinition],
) -> AppResult<serde_json::Value> {
let session = get_session(pool, session_id, profile_id).await?;
if !session.is_complete() {
return Err(AppError::BadRequest(
"session is still active — play all 30 matches first".into(),
));
}
if session.reward_claimed {
return Err(AppError::Conflict("rewards already claimed".into()));
}
let coins = session.reward_coins.unwrap_or(0);
let new_balance = club_svc::add_coins(pool, club_id, coins).await?;
let pack = if let Some(ref pack_id) = session.reward_pack_id {
// Only grant if the pack definition exists; ignore unknown packs gracefully
if pack_defs.iter().any(|d| &d.id == pack_id) {
let granted = pack_svc::grant_pack(pool, club_id, pack_id).await?;
Some(granted.id)
} else {
None
}
} else {
None
};
sqlx::query("UPDATE fut_champs_sessions SET reward_claimed = 1 WHERE id = ?")
.bind(session_id)
.execute(pool)
.await?;
Ok(serde_json::json!({
"session_id": session_id,
"tier": session.reward_tier,
"coins_awarded": coins,
"new_balance": new_balance,
"pack_granted": pack,
}))
}
// ── Division Rivals weekly reward ─────────────────────────────────────────────
/// Rivals points awarded per Division Rivals match outcome.
pub fn rivals_points(goals_for: i64, goals_against: i64) -> i64 {
if goals_for > goals_against {
25
} else if goals_for == goals_against {
10
} else {
5
}
}
/// Weekly coin reward for Division Rivals, scaling by division.
pub fn rivals_weekly_coins(division: i64) -> i64 {
match division {
1 => 10_000,
2 => 7_500,
3 => 5_000,
4 => 3_500,
5 => 2_500,
6 | 7 => 1_500,
_ => 1_000,
}
}
/// Claim the weekly Division Rivals reward.
///
/// "Week" is tracked by a monotonic counter — since this is offline,
/// the player claims whenever they feel like ending their rivals week.
pub async fn claim_rivals_reward(
pool: &Pool,
profile_id: &str,
club_id: &str,
pack_defs: &[PackDefinition],
) -> AppResult<serde_json::Value> {
// Fetch current season row (must exist)
let row: Option<(i64, i64, i64)> = sqlx::query_as(
"SELECT division, rivals_week_claimed, rivals_total_points FROM seasons WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?;
let (division, week_claimed, total_pts) =
row.ok_or_else(|| AppError::NotFound("no season found — play a match first".into()))?;
let next_week = week_claimed + 1;
let coins = rivals_weekly_coins(division);
let new_balance = club_svc::add_coins(pool, club_id, coins).await?;
// Division 13 get a silver pack; Division 45 get a bronze pack
let pack_id = match division {
1..=3 => Some("silver_pack"),
4..=5 => Some("bronze_pack"),
_ => None,
};
let pack = if let Some(pid) = pack_id {
if pack_defs.iter().any(|d| d.id == pid) {
let granted = pack_svc::grant_pack(pool, club_id, pid).await?;
Some(granted.id)
} else {
None
}
} else {
None
};
sqlx::query(
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100 \
WHERE profile_id = ?",
)
.bind(next_week)
.bind(profile_id)
.execute(pool)
.await?;
Ok(serde_json::json!({
"week_number": next_week,
"division": division,
"rivals_total_points": total_pts + 100,
"coins_awarded": coins,
"new_balance": new_balance,
"pack_granted": pack,
}))
}
+189 -23
View File
@@ -3,11 +3,12 @@ use crate::{
error::{AppError, AppResult}, error::{AppError, AppResult},
models::{ models::{
card::CardDefinition, card::CardDefinition,
event::EventDefinition,
market::{BuyListingRequest, MarketListing, MarketListingWithCard, SellCardRequest}, market::{BuyListingRequest, MarketListing, MarketListingWithCard, SellCardRequest},
}, },
services::{card_db::CardDb, club}, services::{card_db::CardDb, club, event as event_svc},
}; };
use rand::Rng; use rand::{seq::SliceRandom, Rng};
use uuid::Uuid; use uuid::Uuid;
pub async fn get_active_listings( pub async fn get_active_listings(
@@ -15,7 +16,9 @@ pub async fn get_active_listings(
card_db: &CardDb, card_db: &CardDb,
) -> AppResult<Vec<MarketListingWithCard>> { ) -> AppResult<Vec<MarketListingWithCard>> {
let listings = sqlx::query_as::<_, MarketListing>( 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" "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) .fetch_all(pool)
.await?; .await?;
@@ -34,8 +37,13 @@ pub async fn get_active_listings(
} }
/// Refresh NPC market with random listings from the card pool. /// Refresh NPC market with random listings from the card pool.
pub async fn refresh_npc_listings(pool: &Pool, card_db: &CardDb) -> AppResult<usize> { /// Also injects bonus cards from currently active events.
// Clean up expired listings first pub async fn refresh_npc_listings(
pool: &Pool,
card_db: &CardDb,
event_defs: &[EventDefinition],
) -> AppResult<usize> {
// Clean up expired and unsold listings
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')") sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
.execute(pool) .execute(pool)
.await?; .await?;
@@ -43,13 +51,17 @@ pub async fn refresh_npc_listings(pool: &Pool, card_db: &CardDb) -> AppResult<us
.execute(pool) .execute(pool)
.await?; .await?;
// Build all listings synchronously before any await — drops &CardDefinition refs before first .await // Resolve active events before entering the sync block (no await inside)
let active_events = event_svc::get_active_events(pool, event_defs).await?;
// Build all listings synchronously — ThreadRng and &CardDefinition refs
// are both dropped before the first .await below.
let listings_to_insert: Vec<MarketListing> = { let listings_to_insert: Vec<MarketListing> = {
let all_cards: Vec<&CardDefinition> = card_db.all(); let all_cards: Vec<&CardDefinition> = card_db.all();
if all_cards.is_empty() { if all_cards.is_empty() {
return Ok(0); return Ok(0);
} }
let count = 20usize.min(all_cards.len()); let count = 24usize.min(all_cards.len());
let npc_names = [ let npc_names = [
"FC Rovers NPC", "FC Rovers NPC",
"Market Bot", "Market Bot",
@@ -59,22 +71,44 @@ pub async fn refresh_npc_listings(pool: &Pool, card_db: &CardDb) -> AppResult<us
"City AI Club", "City AI Club",
]; ];
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
all_cards
.iter() let mut indices: Vec<usize> = (0..all_cards.len()).collect();
indices.shuffle(&mut rng);
let mut listings: Vec<MarketListing> = indices
.into_iter()
.take(count) .take(count)
.map(|card| { .map(|i| {
let base_price = price_for_card(card.overall); let card = all_cards[i];
let price = rng.gen_range((base_price / 2)..=(base_price * 2)).max(100); let base = price_for_card(card.overall);
// ±25% variance for realistic NPC pricing
let pct = rng.gen_range(75i64..=125i64);
let price = (base * pct / 100).max(100);
let seller = npc_names[rng.gen_range(0..npc_names.len())]; let seller = npc_names[rng.gen_range(0..npc_names.len())];
MarketListing::new(&card.id, seller, price) MarketListing::new(&card.id, seller, price)
}) })
.collect() .collect();
}; // all_cards refs and ThreadRng both dropped here
// Inject event bonus cards as special NPC listings
for event in &active_events {
for card_id in &event.effects.bonus_market_cards {
if let Some(card) = card_db.get(card_id) {
let price = price_for_card(card.overall) * 2; // premium pricing
let label = format!("[EVENT] {}", event.name);
listings.push(MarketListing::new(card_id.as_str(), label.as_str(), price));
}
}
}
listings
}; // rng + card refs + active_events all dropped here
let mut inserted = 0; let mut inserted = 0;
for listing in &listings_to_insert { for listing in &listings_to_insert {
sqlx::query( sqlx::query(
"INSERT INTO market_listings (id, card_id, seller_name, price, listed_at, expires_at, sold) VALUES (?, ?, ?, ?, ?, ?, 0)" "INSERT INTO market_listings \
(id, card_id, seller_name, price, listed_at, expires_at, sold) \
VALUES (?, ?, ?, ?, ?, ?, 0)",
) )
.bind(&listing.id) .bind(&listing.id)
.bind(&listing.card_id) .bind(&listing.card_id)
@@ -98,7 +132,8 @@ pub async fn buy_listing(
req: &BuyListingRequest, req: &BuyListingRequest,
) -> AppResult<CardDefinition> { ) -> AppResult<CardDefinition> {
let listing = sqlx::query_as::<_, MarketListing>( 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" "SELECT id, card_id, seller_name, price, listed_at, expires_at, sold \
FROM market_listings WHERE id = ? AND sold = 0",
) )
.bind(&req.listing_id) .bind(&req.listing_id)
.fetch_optional(pool) .fetch_optional(pool)
@@ -114,7 +149,9 @@ pub async fn buy_listing(
let owned_id = Uuid::new_v4().to_string(); let owned_id = Uuid::new_v4().to_string();
sqlx::query( sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) VALUES (?, ?, ?, 0, NULL, ?)" "INSERT INTO owned_cards \
(id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
VALUES (?, ?, ?, 0, NULL, ?)",
) )
.bind(&owned_id) .bind(&owned_id)
.bind(club_id) .bind(club_id)
@@ -123,15 +160,40 @@ pub async fn buy_listing(
.execute(pool) .execute(pool)
.await?; .await?;
card_db let card = card_db
.get(&listing.card_id) .get(&listing.card_id)
.cloned() .cloned()
.ok_or_else(|| AppError::NotFound("card definition not found".into())) .ok_or_else(|| AppError::NotFound("card definition not found".into()))?;
// Record trade history (best-effort — never abort the buy on failure)
let _ = sqlx::query(
"INSERT INTO market_history (id, club_id, card_id, card_name, card_overall, \
trade_type, price, traded_at) VALUES (?,?,?,?,?,?,?,?)",
)
.bind(Uuid::new_v4().to_string())
.bind(club_id)
.bind(&card.id)
.bind(&card.name)
.bind(card.overall as i64)
.bind("buy")
.bind(listing.price)
.bind(chrono::Utc::now().to_rfc3339())
.execute(pool)
.await;
Ok(card)
} }
pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> AppResult<i64> { pub async fn sell_card(
let _owned = sqlx::query_as::<_, crate::models::card::OwnedCard>( pool: &Pool,
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ? AND club_id = ?" card_db: &CardDb,
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, \
chemistry_style, position_override, training_bonus \
FROM owned_cards WHERE id = ? AND club_id = ?",
) )
.bind(&req.owned_card_id) .bind(&req.owned_card_id)
.bind(club_id) .bind(club_id)
@@ -144,12 +206,56 @@ pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> App
.execute(pool) .execute(pool)
.await?; .await?;
// Quick-sell price: 40% of requested price
let coins = (req.price as f64 * 0.4) as i64; let coins = (req.price as f64 * 0.4) as i64;
let new_balance = club::add_coins(pool, club_id, coins).await?; let new_balance = club::add_coins(pool, club_id, coins).await?;
// Record trade history
if let Some(card) = card_db.get(&owned.card_id) {
let _ = sqlx::query(
"INSERT INTO market_history (id, club_id, card_id, card_name, card_overall, \
trade_type, price, traded_at) VALUES (?,?,?,?,?,?,?,?)",
)
.bind(Uuid::new_v4().to_string())
.bind(club_id)
.bind(&card.id)
.bind(&card.name)
.bind(card.overall as i64)
.bind("sell")
.bind(coins)
.bind(chrono::Utc::now().to_rfc3339())
.execute(pool)
.await;
}
Ok(new_balance) Ok(new_balance)
} }
/// Return the last 30 buy/sell events for this club, newest first.
pub async fn get_trade_history(pool: &Pool, club_id: &str) -> AppResult<Vec<serde_json::Value>> {
let rows = sqlx::query(
"SELECT card_name, card_overall, trade_type, price, traded_at \
FROM market_history WHERE club_id = ? ORDER BY traded_at DESC LIMIT 30",
)
.bind(club_id)
.fetch_all(pool)
.await?;
use sqlx::Row;
let trades = rows
.iter()
.map(|r| {
serde_json::json!({
"card_name": r.get::<String, _>("card_name"),
"card_overall": r.get::<i64, _>("card_overall"),
"trade_type": r.get::<String, _>("trade_type"),
"price": r.get::<i64, _>("price"),
"traded_at": r.get::<String, _>("traded_at"),
})
})
.collect();
Ok(trades)
}
fn price_for_card(overall: u8) -> i64 { fn price_for_card(overall: u8) -> i64 {
match overall { match overall {
85..=u8::MAX => 50_000, 85..=u8::MAX => 50_000,
@@ -160,3 +266,63 @@ fn price_for_card(overall: u8) -> i64 {
_ => 200, _ => 200,
} }
} }
/// Return all active listings posted by the given club (player listings only).
pub async fn get_listings_by_seller(
pool: &Pool,
card_db: &CardDb,
club_id: &str,
) -> 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 seller_name = ? \
AND expires_at > datetime('now') ORDER BY listed_at DESC",
)
.bind(club_id)
.fetch_all(pool)
.await?;
let with_cards = listings
.into_iter()
.filter_map(|l| {
card_db
.get(&l.card_id)
.map(|card| MarketListingWithCard { listing: l, card: card.clone() })
})
.collect();
Ok(with_cards)
}
/// Cancel a player-posted listing and return the card to the owner's collection.
pub async fn cancel_listing(pool: &Pool, club_id: &str, listing_id: &str) -> AppResult<()> {
let listing = sqlx::query_as::<_, MarketListing>(
"SELECT id, card_id, seller_name, price, listed_at, expires_at, sold \
FROM market_listings WHERE id = ? AND seller_name = ? AND sold = 0",
)
.bind(listing_id)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("listing '{listing_id}' not found or already sold")))?;
sqlx::query("DELETE FROM market_listings WHERE id = ?")
.bind(listing_id)
.execute(pool)
.await?;
// Return the card to the collection
let owned_id = Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
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(&now)
.execute(pool)
.await?;
Ok(())
}
+133 -38
View File
@@ -2,80 +2,79 @@ use crate::{
db::Pool, db::Pool,
error::AppResult, error::AppResult,
models::{ models::{
achievement::AchievementDefinition,
card::OwnedCard,
match_result::{Match, MatchRewardResult, SubmitMatchRequest}, match_result::{Match, MatchRewardResult, SubmitMatchRequest},
objective::ObjectiveDefinition, objective::ObjectiveDefinition,
}, },
services::{card_db::CardDb, club, objective, profile, statistics}, services::{achievement, card_db::CardDb, club, notification, objective, profile, season as season_svc, statistics},
}; };
use rand::{seq::SliceRandom, Rng}; use rand::{seq::SliceRandom, Rng};
const FORMATIONS: &[&str] = &[
"4-3-3", "4-4-2", "4-2-3-1", "4-1-2-1-2", "3-5-2", "5-3-2",
];
/// Generate a random AI opponent squad for Squad Battles. /// Generate a random AI opponent squad for Squad Battles.
///
/// Difficulty bands:
/// beginner — overall 55+ (same as "any")
/// professional — overall 70+
/// world_class — overall 78+
/// legendary — overall 85+
/// ultimate — overall 90+
pub fn generate_opponent(card_db: &CardDb, difficulty: &str) -> serde_json::Value { pub fn generate_opponent(card_db: &CardDb, difficulty: &str) -> serde_json::Value {
let (min_overall, names): (u8, &[&str]) = match difficulty { let (min_overall, names): (u8, &[&str]) = match difficulty {
"professional" => ( "professional" => (
70, 70,
&[ &["Athletic CF", "City Wanderers", "The Rovers", "United Select", "Blue Stars FC"],
"Athletic CF",
"City Wanderers",
"The Rovers",
"United Select",
"Blue Stars FC",
],
), ),
"world_class" => ( "world_class" => (
78, 78,
&[ &["Elite Stars FC", "Champions Select", "Premier XI", "Galaxy United", "Titan FC"],
"Elite Stars FC",
"Champions Select",
"Premier XI",
"Galaxy United",
"Titan FC",
],
), ),
"legendary" => ( "legendary" => (
85, 85,
&[ &["Legends United", "Ultimate XI", "Gold Standard FC", "The Icons", "Heritage FC"],
"Legends United", ),
"Ultimate XI", "ultimate" => (
"Gold Standard FC", 90,
"The Icons", &["Apex XI", "Pantheon FC", "Gods of FUT", "Invincibles Select", "Eternal XI"],
"Heritage FC",
],
), ),
_ => ( _ => (
58, 55,
&[ &["Amateur Town FC", "Sunday League XI", "Park FC", "Village Stars", "Reserve XI"],
"Amateur Town FC",
"Sunday League XI",
"Park FC",
"Village Stars",
"Reserve XI",
],
), ),
}; };
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let all = card_db.by_min_overall(min_overall); let all = card_db.by_min_overall(min_overall);
let mut indices: Vec<usize> = (0..all.len()).collect();
// Fall back to lower overall band if not enough cards at this difficulty
let pool: Vec<_> = if all.len() >= 11 {
all
} else {
card_db.by_min_overall(55)
};
let mut indices: Vec<usize> = (0..pool.len()).collect();
indices.shuffle(&mut rng); indices.shuffle(&mut rng);
let cards: Vec<_> = indices let cards: Vec<_> = indices.into_iter().take(11).map(|i| pool[i].clone()).collect();
.into_iter()
.take(11)
.map(|i| all[i].clone())
.collect();
let squad_rating = if cards.is_empty() { let squad_rating = if cards.is_empty() {
0 0
} else { } else {
cards.iter().map(|c| c.overall as i64).sum::<i64>() / cards.len() as i64 cards.iter().map(|c| c.overall as i64).sum::<i64>() / cards.len() as i64
}; };
let name = names[rng.gen_range(0..names.len())]; let name = names[rng.gen_range(0..names.len())];
let formation = FORMATIONS[rng.gen_range(0..FORMATIONS.len())];
serde_json::json!({ serde_json::json!({
"opponent_name": name, "opponent_name": name,
"difficulty": difficulty, "difficulty": difficulty,
"squad_rating": squad_rating, "squad_rating": squad_rating,
"formation": "4-4-2", "formation": formation,
"cards": cards, "cards": cards,
}) })
} }
@@ -93,6 +92,7 @@ pub async fn process_match(
club_id: &str, club_id: &str,
req: &SubmitMatchRequest, req: &SubmitMatchRequest,
obj_defs: &[ObjectiveDefinition], obj_defs: &[ObjectiveDefinition],
ach_defs: &[AchievementDefinition],
) -> AppResult<MatchRewardResult> { ) -> AppResult<MatchRewardResult> {
let outcome = if req.goals_for > req.goals_against { let outcome = if req.goals_for > req.goals_against {
"win" "win"
@@ -137,7 +137,17 @@ pub async fn process_match(
.await?; .await?;
club::add_coins(pool, club_id, coins).await?; club::add_coins(pool, club_id, coins).await?;
profile::add_xp(pool, profile_id, xp).await?; let level_ups = profile::add_xp_with_levelup(pool, profile_id, club_id, xp).await?;
for ev in &level_ups {
let body = if let Some(ref pack) = ev.pack_granted {
format!("You reached level {}! Reward: {} coins + {pack}.", ev.new_level, ev.coins_granted)
} else {
format!("You reached level {}! Reward: {} coins.", ev.new_level, ev.coins_granted)
};
let _ = notification::create(pool, "level_up", &format!("Level {}!", ev.new_level), &body).await;
}
statistics::record_match( statistics::record_match(
pool, pool,
profile_id, profile_id,
@@ -173,10 +183,95 @@ pub async fn process_match(
objective::increment_metric(pool, profile_id, obj_defs, "coinsearned", coins).await?; objective::increment_metric(pool, profile_id, obj_defs, "coinsearned", coins).await?;
objectives_updated.append(&mut c); objectives_updated.append(&mut c);
for obj_id in &objectives_updated {
let title = "Objective complete!";
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", obj_id);
let _ = notification::create(pool, "objective_complete", title, &body).await;
}
// Decrement loan matches remaining for squad starters; collect expired loan IDs.
let expired_loans = process_loan_expiry(pool, club_id, &req.squad_id).await?;
for owned_id in &expired_loans {
let body = format!("Loan card (id: {owned_id}) has expired and been removed from your club.");
let _ = notification::create(pool, "loan_expired", "Loan card expired", &body).await;
}
// Update season progress (creates the season row if it doesn't exist yet).
season_svc::get_or_create(pool, profile_id).await?;
let (_, season_end) = season_svc::record_match(pool, club_id, profile_id, outcome).await?;
if let Some(ref se) = season_end {
use crate::models::season::SeasonResult;
let direction = match se.result {
SeasonResult::Promoted => "Promoted",
SeasonResult::Relegated => "Relegated",
SeasonResult::Maintained => "Maintained",
};
let body = format!("{direction} — now in Division {}. Rewards: {} coins{}.", se.new_division, se.coins_awarded, se.pack_awarded.as_deref().map(|p| format!(" + {p}")).unwrap_or_default());
let _ = notification::create(pool, "season_end", "Season complete!", &body).await;
}
let achievements_unlocked =
achievement::check_and_unlock(pool, ach_defs, profile_id, club_id)
.await
.unwrap_or_default();
Ok(MatchRewardResult { Ok(MatchRewardResult {
match_record, match_record,
coins_awarded: coins, coins_awarded: coins,
xp_awarded: xp, xp_awarded: xp,
objectives_updated, objectives_updated,
expired_loans,
season_end,
level_ups,
achievements_unlocked,
}) })
} }
/// Decrement `loan_matches_remaining` for each loan card in the squad's starting XI.
/// Removes cards whose remaining count hits 0 and returns their owned_card_ids.
async fn process_loan_expiry(pool: &Pool, club_id: &str, squad_id: &str) -> AppResult<Vec<String>> {
// Get starters (is_on_bench = 0) for this squad
let starters: Vec<(String, String)> = sqlx::query_as(
"SELECT sp.id, sp.owned_card_id FROM squad_players sp \
JOIN squads s ON s.id = sp.squad_id \
WHERE sp.squad_id = ? AND sp.is_on_bench = 0 AND s.club_id = ?",
)
.bind(squad_id)
.bind(club_id)
.fetch_all(pool)
.await?;
let mut expired = Vec::new();
for (_sp_id, owned_id) in starters {
let card = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \
FROM owned_cards WHERE id = ? AND is_loan = 1",
)
.bind(&owned_id)
.fetch_optional(pool)
.await?;
if let Some(c) = card {
let remaining = c.loan_matches_remaining.unwrap_or(0);
if remaining <= 1 {
// Loan expired — remove from collection
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
.bind(&owned_id)
.execute(pool)
.await?;
expired.push(owned_id);
} else {
sqlx::query("UPDATE owned_cards SET loan_matches_remaining = ? WHERE id = ?")
.bind(remaining - 1)
.bind(&owned_id)
.execute(pool)
.await?;
}
}
}
Ok(expired)
}
+8
View File
@@ -1,5 +1,12 @@
pub mod achievement;
pub mod card_db; pub mod card_db;
pub mod checkin;
pub mod club; pub mod club;
pub mod notification;
pub mod draft;
pub mod event;
pub mod fut_champs;
pub mod season;
pub mod market; pub mod market;
pub mod match_service; pub mod match_service;
pub mod objective; pub mod objective;
@@ -9,3 +16,4 @@ pub mod sbc;
pub mod settings; pub mod settings;
pub mod squad; pub mod squad;
pub mod statistics; pub mod statistics;
pub mod upgrades;
+49
View File
@@ -0,0 +1,49 @@
use crate::{db::Pool, error::AppResult, models::notification::Notification};
pub async fn create(pool: &Pool, kind: &str, title: &str, body: &str) -> AppResult<()> {
let n = Notification::new(kind, title, body);
sqlx::query(
"INSERT INTO notifications (id, kind, title, body, is_read, created_at) \
VALUES (?, ?, ?, ?, 0, ?)",
)
.bind(&n.id)
.bind(&n.kind)
.bind(&n.title)
.bind(&n.body)
.bind(&n.created_at)
.execute(pool)
.await?;
Ok(())
}
pub async fn list(pool: &Pool) -> AppResult<Vec<Notification>> {
sqlx::query_as::<_, Notification>(
"SELECT id, kind, title, body, is_read, created_at \
FROM notifications ORDER BY created_at DESC LIMIT 50",
)
.fetch_all(pool)
.await
.map_err(Into::into)
}
pub async fn mark_read(pool: &Pool, id: &str) -> AppResult<bool> {
let r = sqlx::query("UPDATE notifications SET is_read = 1 WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
Ok(r.rows_affected() > 0)
}
pub async fn mark_all_read(pool: &Pool) -> AppResult<i64> {
let r = sqlx::query("UPDATE notifications SET is_read = 1 WHERE is_read = 0")
.execute(pool)
.await?;
Ok(r.rows_affected() as i64)
}
pub async fn unread_count(pool: &Pool) -> AppResult<i64> {
sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE is_read = 0")
.fetch_one(pool)
.await
.map_err(Into::into)
}
+11 -3
View File
@@ -39,6 +39,8 @@ pub async fn grant_pack(pool: &Pool, club_id: &str, definition_id: &str) -> AppR
definition_id: definition_id.to_string(), definition_id: definition_id.to_string(),
opened: false, opened: false,
created_at: chrono::Utc::now().to_rfc3339(), created_at: chrono::Utc::now().to_rfc3339(),
opened_cards: None,
opened_at: None,
}; };
sqlx::query( sqlx::query(
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, ?, ?)", "INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, ?, ?)",
@@ -61,7 +63,7 @@ pub async fn open_pack(
pack_id: &str, pack_id: &str,
) -> AppResult<PackOpenResult> { ) -> AppResult<PackOpenResult> {
let pack = sqlx::query_as::<_, Pack>( let pack = sqlx::query_as::<_, Pack>(
"SELECT id, club_id, definition_id, opened, created_at FROM packs WHERE id = ? AND club_id = ?" "SELECT id, club_id, definition_id, opened, created_at, opened_cards, opened_at FROM packs WHERE id = ? AND club_id = ?"
) )
.bind(pack_id) .bind(pack_id)
.bind(club_id) .bind(club_id)
@@ -122,7 +124,13 @@ pub async fn open_pack(
} }
} }
sqlx::query("UPDATE packs SET opened = 1 WHERE id = ?") let card_ids_json = serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>())
.unwrap_or_default();
let now = chrono::Utc::now().to_rfc3339();
sqlx::query("UPDATE packs SET opened = 1, opened_cards = ?, opened_at = ? WHERE id = ?")
.bind(&card_ids_json)
.bind(&now)
.bind(pack_id) .bind(pack_id)
.execute(pool) .execute(pool)
.await?; .await?;
@@ -152,7 +160,7 @@ pub async fn buy_pack(
pub async fn get_unopened_packs(pool: &Pool, club_id: &str) -> AppResult<Vec<Pack>> { pub async fn get_unopened_packs(pool: &Pool, club_id: &str) -> AppResult<Vec<Pack>> {
let packs = sqlx::query_as::<_, Pack>( let packs = sqlx::query_as::<_, Pack>(
"SELECT id, club_id, definition_id, opened, created_at FROM packs WHERE club_id = ? AND opened = 0" "SELECT id, club_id, definition_id, opened, created_at, opened_cards, opened_at FROM packs WHERE club_id = ? AND opened = 0"
) )
.bind(club_id) .bind(club_id)
.fetch_all(pool) .fetch_all(pool)
+42 -1
View File
@@ -1,7 +1,7 @@
use crate::{ use crate::{
db::Pool, db::Pool,
error::{AppError, AppResult}, error::{AppError, AppResult},
models::profile::Profile, models::profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent, Profile},
}; };
use chrono::Utc; use chrono::Utc;
@@ -50,3 +50,44 @@ pub async fn add_xp(pool: &Pool, profile_id: &str, xp: i64) -> AppResult<()> {
.await?; .await?;
Ok(()) Ok(())
} }
/// Add XP, check for level-ups, grant rewards, and return every level gained.
/// Callers should use this instead of `add_xp` when level-up feedback matters.
pub async fn add_xp_with_levelup(
pool: &Pool,
profile_id: &str,
club_id: &str,
xp_to_add: i64,
) -> AppResult<Vec<LevelUpEvent>> {
let profile = get_active_profile(pool).await?;
let old_level = level_for_xp(profile.xp);
let new_total_xp = profile.xp + xp_to_add;
let new_level = level_for_xp(new_total_xp);
let now = Utc::now();
sqlx::query("UPDATE profiles SET xp = ?, level = ?, updated_at = ? WHERE id = ?")
.bind(new_total_xp)
.bind(new_level)
.bind(now)
.bind(profile_id)
.execute(pool)
.await?;
let mut events = Vec::new();
for lvl in (old_level + 1)..=new_level {
let coins = coins_for_level(lvl);
let pack = pack_for_level(lvl).map(String::from);
if coins > 0 {
crate::services::club::add_coins(pool, club_id, coins).await?;
}
if let Some(ref pack_id) = pack {
crate::services::pack::grant_pack(pool, club_id, pack_id).await?;
}
tracing::info!(profile_id, new_level = lvl, coins, "level up");
events.push(LevelUpEvent { new_level: lvl, coins_granted: coins, pack_granted: pack });
}
Ok(events)
}
+1 -1
View File
@@ -50,7 +50,7 @@ pub async fn submit_sbc(
let mut cards: Vec<CardDefinition> = Vec::new(); let mut cards: Vec<CardDefinition> = Vec::new();
for owned_id in &req.owned_card_ids { for owned_id in &req.owned_card_ids {
let row = sqlx::query_as::<_, crate::models::card::OwnedCard>( 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 = ?" "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ? AND club_id = ?"
) )
.bind(owned_id) .bind(owned_id)
.bind(club_id) .bind(club_id)
+162
View File
@@ -0,0 +1,162 @@
use crate::{
db::Pool,
error::AppResult,
models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult},
services::{club, pack},
};
use uuid::Uuid;
/// Get the current season for a profile, creating it if it doesn't exist.
pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Season> {
if let Some(s) = fetch(pool, profile_id).await? {
return Ok(s);
}
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO seasons (profile_id, division, season_number, season_points, \
matches_played, wins, draws, losses, started_at) VALUES (?, 5, 1, 0, 0, 0, 0, 0, ?)",
)
.bind(profile_id)
.bind(&now)
.execute(pool)
.await?;
Ok(fetch(pool, profile_id).await?.expect("just inserted"))
}
async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> {
let s = sqlx::query_as::<_, Season>(
"SELECT profile_id, division, season_number, season_points, matches_played, \
wins, draws, losses, started_at FROM seasons WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?;
Ok(s)
}
/// Record a match result in the season; end the season if the quota is met.
///
/// Returns the updated season and an optional end-of-season summary.
pub async fn record_match(
pool: &Pool,
club_id: &str,
profile_id: &str,
outcome: &str,
) -> AppResult<(Season, Option<SeasonEndSummary>)> {
let points = match outcome {
"win" => 3,
"draw" => 1,
_ => 0,
};
sqlx::query(
"UPDATE seasons SET \
season_points = season_points + ?, \
matches_played = matches_played + 1, \
wins = wins + CASE WHEN ? = 'win' THEN 1 ELSE 0 END, \
draws = draws + CASE WHEN ? = 'draw' THEN 1 ELSE 0 END, \
losses = losses + CASE WHEN ? = 'loss' THEN 1 ELSE 0 END \
WHERE profile_id = ?",
)
.bind(points)
.bind(outcome)
.bind(outcome)
.bind(outcome)
.bind(profile_id)
.execute(pool)
.await?;
let season = fetch(pool, profile_id).await?.expect("season must exist");
if !season.is_complete() {
return Ok((season, None));
}
// Season complete — calculate result and start next
let result = season.end_result();
let old_div = season.division;
let coins = season.season_reward_coins();
let pack_id = season.season_reward_pack();
let new_div = match result {
SeasonResult::Promoted => (old_div - 1).max(1),
SeasonResult::Relegated => (old_div + 1).min(10),
SeasonResult::Maintained => old_div,
};
let new_season = season.season_number + 1;
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"UPDATE seasons SET division = ?, season_number = ?, season_points = 0, \
matches_played = 0, wins = 0, draws = 0, losses = 0, started_at = ? \
WHERE profile_id = ?",
)
.bind(new_div)
.bind(new_season)
.bind(&now)
.bind(profile_id)
.execute(pool)
.await?;
// Grant rewards
club::add_coins(pool, club_id, coins).await?;
if let Some(pack_def) = pack_id {
let dummy_pack_id = Uuid::new_v4().to_string();
// Grant via pack system so it shows in inventory
let _ = dummy_pack_id; // will use grant_pack instead
pack::grant_pack(pool, club_id, pack_def).await?;
}
// Persist history entry before rolling over
let result_str = match result {
SeasonResult::Promoted => "promoted",
SeasonResult::Maintained => "maintained",
SeasonResult::Relegated => "relegated",
};
let history_id = Uuid::new_v4().to_string();
let _ = sqlx::query(
"INSERT INTO season_history (id, profile_id, season_number, division, season_points, \
wins, draws, losses, result, new_division, coins_awarded, pack_awarded, ended_at) \
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
)
.bind(&history_id)
.bind(profile_id)
.bind(season.season_number)
.bind(old_div)
.bind(season.season_points)
.bind(season.wins)
.bind(season.draws)
.bind(season.losses)
.bind(result_str)
.bind(new_div)
.bind(coins)
.bind(pack_id)
.bind(&now)
.execute(pool)
.await;
let summary = SeasonEndSummary {
result,
old_division: old_div,
new_division: new_div,
new_season_number: new_season,
coins_awarded: coins,
pack_awarded: pack_id.map(String::from),
};
let updated = fetch(pool, profile_id).await?.expect("season must exist");
Ok((updated, Some(summary)))
}
/// Return past seasons for a profile, newest first (max 20).
pub async fn get_history(pool: &Pool, profile_id: &str) -> AppResult<Vec<SeasonHistoryEntry>> {
let rows = sqlx::query_as::<_, SeasonHistoryEntry>(
"SELECT id, profile_id, season_number, division, season_points, wins, draws, losses, \
result, new_division, coins_awarded, pack_awarded, ended_at \
FROM season_history WHERE profile_id = ? ORDER BY season_number DESC LIMIT 20",
)
.bind(profile_id)
.fetch_all(pool)
.await?;
Ok(rows)
}
+60 -32
View File
@@ -77,7 +77,7 @@ pub async fn validate_formation(
let mut gk_count = 0usize; let mut gk_count = 0usize;
for sp in &starters { for sp in &starters {
let owned = sqlx::query_as::<_, OwnedCard>( let owned = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ?", "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ?",
) )
.bind(&sp.owned_card_id) .bind(&sp.owned_card_id)
.fetch_optional(pool) .fetch_optional(pool)
@@ -105,6 +105,15 @@ pub async fn validate_formation(
Ok(()) Ok(())
} }
/// Chemistry v2: FUT-style link scoring.
///
/// Each player earns up to 10 chemistry from three link types:
/// - Club links: strongest signal — +3 per shared club teammate (max 6 pts)
/// - League links: medium signal — +1 per shared league mate (max 4 pts)
/// - Nation links: weakest signal — +1 per shared nation mate (max 3 pts)
///
/// Individual player chemistry is capped at 10.
/// Team chemistry = sum of all player chemistries, capped at 100.
pub async fn calculate_chemistry( pub async fn calculate_chemistry(
pool: &Pool, pool: &Pool,
card_db: &CardDb, card_db: &CardDb,
@@ -112,10 +121,12 @@ pub async fn calculate_chemistry(
) -> AppResult<serde_json::Value> { ) -> AppResult<serde_json::Value> {
let starters: Vec<&SquadPlayer> = players.iter().filter(|p| !p.is_on_bench).collect(); let starters: Vec<&SquadPlayer> = players.iter().filter(|p| !p.is_on_bench).collect();
let mut cards: Vec<CardDefinition> = Vec::new(); // Load all starter card definitions (N separate queries, fine for 11 players)
let mut player_cards: Vec<(String, CardDefinition)> = Vec::new();
for sp in &starters { for sp in &starters {
let owned = sqlx::query_as::<_, OwnedCard>( let owned = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ?", "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \
FROM owned_cards WHERE id = ?",
) )
.bind(&sp.owned_card_id) .bind(&sp.owned_card_id)
.fetch_optional(pool) .fetch_optional(pool)
@@ -123,43 +134,60 @@ pub async fn calculate_chemistry(
if let Some(o) = owned { if let Some(o) = owned {
if let Some(card) = card_db.get(&o.card_id) { if let Some(card) = card_db.get(&o.card_id) {
cards.push(card.clone()); player_cards.push((sp.owned_card_id.clone(), card.clone()));
} }
} }
} }
let player_chems: Vec<i64> = cards let mut player_chemistries = Vec::with_capacity(player_cards.len());
.iter() let mut total_chem: i64 = 0;
.enumerate()
.map(|(i, c)| {
let same_club = cards
.iter()
.enumerate()
.filter(|(j, o)| *j != i && o.club == c.club)
.count()
.min(4) as i64;
let same_league = cards
.iter()
.enumerate()
.filter(|(j, o)| *j != i && o.league == c.league)
.count()
.min(3) as i64;
let same_nation = cards
.iter()
.enumerate()
.filter(|(j, o)| *j != i && o.nation == c.nation)
.count()
.min(3) as i64;
(same_club + same_league + same_nation).min(10)
})
.collect();
let total: i64 = player_chems.iter().sum::<i64>().min(100); for (i, (owned_id, card)) in player_cards.iter().enumerate() {
let club_links = player_cards
.iter()
.enumerate()
.filter(|(j, (_, c))| *j != i && c.club == card.club)
.count() as i64;
let league_links = player_cards
.iter()
.enumerate()
.filter(|(j, (_, c))| *j != i && c.league == card.league)
.count() as i64;
let nation_links = player_cards
.iter()
.enumerate()
.filter(|(j, (_, c))| *j != i && c.nation == card.nation)
.count() as i64;
let club_pts = (club_links * 3).min(6);
let league_pts = league_links.min(4);
let nation_pts = nation_links.min(3);
let player_chem = (club_pts + league_pts + nation_pts).min(10);
total_chem += player_chem;
player_chemistries.push(serde_json::json!({
"owned_card_id": owned_id,
"card_id": &card.id,
"name": &card.name,
"chemistry": player_chem,
"breakdown": {
"club_links": club_links,
"league_links": league_links,
"nation_links": nation_links,
"club_pts": club_pts,
"league_pts": league_pts,
"nation_pts": nation_pts,
}
}));
}
let team_chemistry = total_chem.min(100);
Ok(serde_json::json!({ Ok(serde_json::json!({
"total": total, "total": team_chemistry,
"max": 100, "max": 100,
"player_chemistries": player_chems, "player_count": player_chemistries.len(),
"player_chemistries": player_chemistries,
})) }))
} }
+125
View File
@@ -0,0 +1,125 @@
use crate::{
db::Pool,
error::{AppError, AppResult},
models::card::OwnedCard,
models::chemistry_style::ChemistryStyle,
};
const OWNED_CARD_SELECT: &str =
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
chemistry_style, position_override, training_bonus \
FROM owned_cards";
pub const MAX_TRAINING_BONUS: i64 = 3;
/// Cost in coins to change a player's position.
pub const POSITION_CHANGE_COST: i64 = 500;
async fn fetch_owned(pool: &Pool, owned_card_id: &str, club_id: &str) -> AppResult<OwnedCard> {
sqlx::query_as::<_, OwnedCard>(&format!(
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
))
.bind(owned_card_id)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound("owned card not found".into()))
}
/// Apply a chemistry style to an owned card.
///
/// The style is validated against the loaded definitions. The card is not
/// mutated in memory — callers should re-fetch if they need the updated state.
pub async fn apply_chemistry_style(
pool: &Pool,
club_id: &str,
owned_card_id: &str,
style_id: &str,
styles: &[ChemistryStyle],
) -> AppResult<OwnedCard> {
// Validate style exists
if !styles.iter().any(|s| s.id == style_id) {
return Err(AppError::NotFound(format!(
"chemistry style '{style_id}' not found"
)));
}
// Ownership check
fetch_owned(pool, owned_card_id, club_id).await?;
sqlx::query("UPDATE owned_cards SET chemistry_style = ? WHERE id = ?")
.bind(style_id)
.bind(owned_card_id)
.execute(pool)
.await?;
fetch_owned(pool, owned_card_id, club_id).await
}
/// Override a player's position. Costs POSITION_CHANGE_COST coins.
pub async fn change_position(
pool: &Pool,
club_id: &str,
owned_card_id: &str,
new_position: &str,
) -> AppResult<OwnedCard> {
let valid_positions = [
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW",
"CF", "ST",
];
if !valid_positions.contains(&new_position) {
return Err(AppError::BadRequest(format!(
"unknown position '{new_position}'"
)));
}
// Ownership check
fetch_owned(pool, owned_card_id, club_id).await?;
// Deduct coins
crate::services::club::spend_coins(pool, club_id, POSITION_CHANGE_COST).await?;
sqlx::query("UPDATE owned_cards SET position_override = ? WHERE id = ?")
.bind(new_position)
.bind(owned_card_id)
.execute(pool)
.await?;
fetch_owned(pool, owned_card_id, club_id).await
}
/// Apply a training boost to an owned card.
///
/// `boost` is the number of OVR points to add (13).
/// The total training_bonus is capped at MAX_TRAINING_BONUS.
/// Training is free — the "cost" is consuming a training card item, which is
/// handled at the route layer (future: deduct a training_card from inventory).
pub async fn apply_training(
pool: &Pool,
club_id: &str,
owned_card_id: &str,
boost: i64,
) -> AppResult<OwnedCard> {
if !(1..=3).contains(&boost) {
return Err(AppError::BadRequest(
"training boost must be between 1 and 3".into(),
));
}
let card = fetch_owned(pool, owned_card_id, club_id).await?;
let new_bonus = (card.training_bonus + boost).min(MAX_TRAINING_BONUS);
if new_bonus == card.training_bonus {
return Err(AppError::BadRequest(format!(
"card has already reached the maximum training bonus of +{MAX_TRAINING_BONUS}"
)));
}
sqlx::query("UPDATE owned_cards SET training_bonus = ? WHERE id = ?")
.bind(new_bonus)
.bind(owned_card_id)
.execute(pool)
.await?;
fetch_owned(pool, owned_card_id, club_id).await
}
File diff suppressed because it is too large Load Diff