1 Commits

Author SHA1 Message Date
funman300 8c8a4116bf wip: checkpoint multi-game core work 2026-08-07 12:03:21 -07:00
56 changed files with 873 additions and 2739 deletions
-12
View File
@@ -1,12 +0,0 @@
target/
**/target/
*.db
*.db-shm
*.db-wal
.env
.env.local
Dockerfile
.dockerignore
.git
.gitignore
openfut.db
+1 -1
View File
@@ -90,4 +90,4 @@ migrations/ SQLite migration SQL files
- **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 the emulation layer (`fifa17-recon/`).
FIFA-specific logic belongs in `openfut-bridge`.
+1 -1
View File
@@ -5,7 +5,7 @@ edition = "2021"
authors = ["OpenFUT Contributors"]
description = "Offline Ultimate Team backend — game-independent core"
license = "MIT"
repository = "https://git.aleshym.co/funman300/OpenFUT-Core.git"
repository = "https://github.com/openfut/openfut-core"
[lib]
name = "openfut_core"
-56
View File
@@ -1,56 +0,0 @@
# syntax=docker/dockerfile:1
# ---- OpenFUT Core: offline FUT backend (Axum + bundled SQLite) ----
# Multi-stage: build with the Rust toolchain, ship a slim Debian runtime.
# rustls + bundled SQLite mean no OpenSSL/system-sqlite at runtime.
FROM rust:1-bookworm AS builder
WORKDIR /build
# Cache dependency compilation: copy manifests first, build a stub, then the
# real sources. sqlx migrations are compiled in via sqlx::migrate!, so the
# migrations/ dir must be present at build time.
COPY Cargo.toml Cargo.lock* ./
RUN mkdir -p src \
&& echo 'fn main() {}' > src/main.rs \
&& echo '' > src/lib.rs \
&& cargo build --release --bin openfut-core 2>/dev/null || true
RUN rm -rf src
COPY src ./src
COPY migrations ./migrations
# Touch so cargo rebuilds against the real sources rather than the stub.
RUN touch src/main.rs src/lib.rs \
&& cargo build --release --bin openfut-core
# ---- Runtime ----
FROM debian:bookworm-slim AS runtime
# curl is used by the compose/Docker healthcheck to hit /health.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
# Run unprivileged.
RUN useradd --system --uid 10001 --create-home --home-dir /app openfut
WORKDIR /app
COPY --from=builder /build/target/release/openfut-core /usr/local/bin/openfut-core
# data/ is read at runtime from DATA_DIR (moddable JSON content) — bundle it.
COPY --chown=openfut:openfut data ./data
# Persist the SQLite database on a named volume.
RUN mkdir -p /app/db && chown openfut:openfut /app/db
USER openfut
ENV LISTEN_ADDR=0.0.0.0:8080 \
DATABASE_URL=sqlite:///app/db/openfut.db \
DATA_DIR=/app/data \
RUST_LOG=openfut_core=info,tower_http=info
EXPOSE 8080
VOLUME ["/app/db"]
HEALTHCHECK --interval=15s --timeout=4s --start-period=10s --retries=5 \
CMD curl -fsS http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["openfut-core"]
+1 -5
View File
@@ -2,11 +2,7 @@
**Offline Ultimate Team backend — game-independent.**
OpenFUT Core is the heart of the OpenFUT project: a fully offline, single-player FUT-style backend
written in Rust. It is deliberately decoupled from any specific game; it is designed to power the
offline FUT economy behind the project's FIFA 17 emulation layer (and, eventually, a FIFA 23 port).
The emulation layer and Core are **not yet wired together** — see the OpenFUT Vault
(`../OpenFUT-Vault/`) for canonical project state.
OpenFUT Core is the heart of the OpenFUT project: a fully offline, single-player FUT-style backend written in Rust. It is deliberately decoupled from any specific game, though it is designed to power a FIFA 23 offline experience.
---
+5
View File
@@ -0,0 +1,5 @@
-- Distinguish NPC-generated listings from player-posted ones so that the
-- periodic NPC refresh does not accidentally wipe player listings.
ALTER TABLE market_listings ADD COLUMN is_npc INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_market_npc ON market_listings(is_npc, sold);
+3
View File
@@ -0,0 +1,3 @@
-- Track when the player last claimed their rivals weekly reward to enforce a
-- 24-hour cooldown between claims.
ALTER TABLE seasons ADD COLUMN rivals_last_claimed_at TEXT;
+14
View File
@@ -0,0 +1,14 @@
-- Multi-game support. Every profile (and therefore all of its downstream state,
-- which hangs off profiles(id) via profile_id / club_id foreign keys) is scoped to
-- a game. Bridges identify their game with the X-OpenFUT-Game request header.
--
-- Default 'fifa23' preserves the existing single-game behaviour: the current bridge
-- and the integration tests send no game header, so they keep operating on the same
-- (now fifa23-tagged) profile with zero behaviour change. The FIFA 17 bridge sends
-- X-OpenFUT-Game: fifa17 and therefore gets its own isolated profile/club/state.
--
-- Only profiles needs the column: get_active_profile becomes game-scoped, and because
-- all other tables reference a profile (directly via profile_id or via
-- club_id -> clubs.profile_id), scoping the active profile isolates the whole tree.
ALTER TABLE profiles ADD COLUMN game_id TEXT NOT NULL DEFAULT 'fifa23';
CREATE INDEX IF NOT EXISTS idx_profiles_game ON profiles(game_id);
+9 -36
View File
@@ -172,10 +172,7 @@ 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", delete(routes::squad::delete_squad))
.route("/objectives", get(routes::objectives::get_objectives))
.route(
"/objectives/:objective_id",
get(routes::objectives::get_objective),
)
.route("/objectives/:objective_id", get(routes::objectives::get_objective))
.route(
"/objectives/claim",
post(routes::objectives::post_claim_objective),
@@ -193,10 +190,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/market", get(routes::market::get_market))
.route("/market/buy", post(routes::market::post_market_buy))
.route("/market/sell", post(routes::market::post_market_sell))
.route(
"/market/trade-history",
get(routes::market::get_trade_history),
)
.route("/market/trade-history", get(routes::market::get_trade_history))
.route("/market/refresh", post(routes::market::post_market_refresh))
.route("/market/my-listings", get(routes::market::get_my_listings))
.route(
@@ -211,36 +205,15 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/settings", get(routes::settings::get_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("/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("/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/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),
-1
View File
@@ -5,7 +5,6 @@ pub struct Config {
pub listen_addr: String,
pub database_url: String,
pub data_dir: String,
#[allow(dead_code)]
pub max_connections: u32,
}
+2 -2
View File
@@ -8,11 +8,11 @@ use tracing::info;
pub type Pool = SqlitePool;
pub async fn init_pool(database_url: &str) -> Result<Pool> {
pub async fn init_pool(database_url: &str, max_connections: u32) -> Result<Pool> {
info!("Connecting to database: {}", database_url);
let opts = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.max_connections(max_connections)
.connect_with(opts)
.await?;
sqlx::query("PRAGMA journal_mode=WAL")
+51
View File
@@ -0,0 +1,51 @@
//! Request extractors shared across routes.
use axum::{
async_trait,
extract::FromRequestParts,
http::{request::Parts, HeaderName},
};
use std::convert::Infallible;
/// The game a request belongs to, read from the `X-OpenFUT-Game` header.
///
/// Multi-game support: each game bridge tags its requests with its own id
/// (e.g. `fifa17`, `fifa23`) so core can scope the active profile - and thus all
/// downstream club/card/squad/market state - to that game. Defaults to `fifa23`
/// when the header is absent, so the existing FIFA 23 bridge and the integration
/// tests (which send no header) keep operating on their game unchanged.
///
/// Extraction never fails: a missing or malformed header falls back to the default.
#[derive(Debug, Clone)]
pub struct GameId(pub String);
/// The game assumed when no `X-OpenFUT-Game` header is present.
pub const DEFAULT_GAME: &str = "fifa23";
static HEADER: HeaderName = HeaderName::from_static("x-openfut-game");
impl GameId {
pub fn as_str(&self) -> &str {
&self.0
}
}
#[async_trait]
impl<S> FromRequestParts<S> for GameId
where
S: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let game = parts
.headers
.get(&HEADER)
.and_then(|v| v.to_str().ok())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.unwrap_or(DEFAULT_GAME)
.to_string();
Ok(GameId(game))
}
}
+1
View File
@@ -2,6 +2,7 @@ pub mod app;
pub mod config;
pub mod db;
pub mod error;
pub mod extractors;
pub mod middleware;
pub mod modding;
pub mod models;
+1 -1
View File
@@ -18,7 +18,7 @@ async fn main() -> Result<()> {
let cfg = config::Config::from_env()?;
info!("OpenFUT Core starting on {}", cfg.listen_addr);
let pool = db::init_pool(&cfg.database_url).await?;
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
db::run_migrations(&pool).await?;
seed::maybe_seed(&pool).await?;
+1 -24
View File
@@ -1,24 +1 @@
use anyhow::{Context, Result};
use serde::de::DeserializeOwned;
use std::path::Path;
#[allow(dead_code)]
/// Generic loader for JSON arrays from a directory.
pub fn load_json_dir<T: DeserializeOwned>(dir: &Path) -> Result<Vec<T>> {
let mut items = Vec::new();
if !dir.exists() {
return Ok(items);
}
for entry in std::fs::read_dir(dir).with_context(|| format!("reading dir {dir:?}"))? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "json").unwrap_or(false) {
let content =
std::fs::read_to_string(&path).with_context(|| format!("reading {path:?}"))?;
let batch: Vec<T> =
serde_json::from_str(&content).with_context(|| format!("parsing {path:?}"))?;
items.extend(batch);
}
}
Ok(items)
}
// Reserved for future modding loader utilities.
-2
View File
@@ -1,4 +1,2 @@
//! Modding support: load JSON data files from the data/ directory.
//! All game content (cards, packs, objectives, SBCs) is data-driven.
pub mod loader;
+10 -23
View File
@@ -13,29 +13,16 @@ pub enum Rarity {
Icon,
}
/// Visual card quality tier (gold/silver/bronze).
///
/// Game-independent semantic dimension, kept distinct from `Rarity` (which also
/// carries special-card programs like TOTW/Hero/Icon). Derived from a card's base
/// overall using FIFA 17's proven tier convention: gold >= 75, silver >= 65,
/// otherwise bronze (evidence: `fifa17-recon/tools/fut_cards.py` `tier()`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Quality {
Bronze,
Silver,
Gold,
}
impl Quality {
/// Classify a base overall rating into its quality tier.
pub fn from_overall(overall: u8) -> Self {
if overall >= 75 {
Quality::Gold
} else if overall >= 65 {
Quality::Silver
} else {
Quality::Bronze
impl Rarity {
pub fn as_str(&self) -> &'static str {
match self {
Rarity::Bronze => "bronze",
Rarity::Silver => "silver",
Rarity::Gold => "gold",
Rarity::RareGold => "raregold",
Rarity::Totw => "totw",
Rarity::Hero => "hero",
Rarity::Icon => "icon",
}
}
}
+3 -3
View File
@@ -1,18 +1,18 @@
pub mod achievement;
pub mod card;
pub mod chemistry_style;
pub mod notification;
pub mod club;
pub mod draft;
pub mod event;
pub mod fut_champs;
pub mod event;
pub mod season;
pub mod market;
pub mod match_result;
pub mod notification;
pub mod objective;
pub mod pack;
pub mod profile;
pub mod reward;
pub mod sbc;
pub mod season;
pub mod squad;
pub mod statistics;
+13
View File
@@ -21,6 +21,19 @@ pub enum ObjectiveMetric {
CoinsEarned,
}
impl ObjectiveMetric {
pub fn as_str(&self) -> &'static str {
match self {
ObjectiveMetric::MatchesWon => "matcheswon",
ObjectiveMetric::MatchesPlayed => "matchesplayed",
ObjectiveMetric::GoalsScored => "goalsscored",
ObjectiveMetric::PacksOpened => "packsopened",
ObjectiveMetric::SbcsCompleted => "sbcscompleted",
ObjectiveMetric::CoinsEarned => "coinsearned",
}
}
}
/// Objective definition from data/objectives/*.json
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectiveDefinition {
+16 -12
View File
@@ -8,18 +8,22 @@ pub struct Profile {
pub username: String,
pub level: i64,
pub xp: i64,
/// The game this profile belongs to (e.g. "fifa17", "fifa23"). Scopes all of
/// this profile's downstream state so multiple games share one core + DB.
pub game_id: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl Profile {
pub fn new(username: impl Into<String>) -> Self {
pub fn new(username: impl Into<String>, game_id: impl Into<String>) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
username: username.into(),
level: 1,
xp: 0,
game_id: game_id.into(),
created_at: now,
updated_at: now,
}
@@ -34,16 +38,16 @@ pub struct CreateProfileRequest {
/// 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
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.
@@ -71,7 +75,7 @@ pub fn coins_for_level(new_level: i64) -> i64 {
/// 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"),
5 => Some("bronze_pack"),
10 => Some("silver_pack"),
15 => Some("gold_pack"),
20 => Some("rare_gold_pack"),
-43
View File
@@ -54,46 +54,3 @@ pub struct SquadPlayerInput {
pub is_captain: bool,
pub is_on_bench: bool,
}
/// A complete squad, as a game client sends it.
///
/// # Why replacement rather than edits
///
/// Retail FIFA 17 sends the WHOLE squad on every save — roughly 2 KB carrying
/// every slot, item id and kit number — and a user swapping two players
/// produced nine changed slots across two saves. Slot deltas therefore do not
/// describe what the user did, and any attempt to derive `swap_players` or
/// `move_player` from them would be inventing intent the wire never carried.
///
/// So the only honest semantic operation is: *this is the squad now*.
///
/// Empty slots are simply absent from `slots`; a client that models an empty
/// slot as a zero item id must drop it at the adapter boundary rather than
/// sending a player Core would have to special-case.
#[derive(Debug, Clone, Default)]
pub struct SquadReplacement {
pub name: Option<String>,
pub formation: Option<String>,
pub slots: Vec<SlotAssignment>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SlotAssignment {
pub owned_card_id: String,
/// Core's slot numbering. The adapter maps the game's numbering onto it.
pub slot: i64,
pub is_captain: bool,
pub is_on_bench: bool,
}
/// Outcome of a replacement.
///
/// Carries the server's own evaluation and, separately, any disagreement with
/// what the client claimed — never a merged value.
#[derive(Debug, Clone)]
pub struct SquadReplaced {
pub squad: Squad,
pub slots_written: usize,
pub evaluation: crate::services::squad_rules::SquadEvaluation,
pub client_disagreements: Vec<crate::services::squad_rules::EvaluationComparison>,
}
+5 -8
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{extract::State, Json};
use serde_json::{json, Value};
@@ -7,16 +8,12 @@ use crate::{
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?;
pub async fn get_achievements(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).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 _ = 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();
let earned = achievements.iter().filter(|a| a["unlocked"].as_bool().unwrap_or(false)).count();
Ok(Json(json!({
"achievements": achievements,
"earned": earned,
+85 -28
View File
@@ -1,9 +1,11 @@
use axum::{extract::State, Json};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
error::{AppError, AppResult},
extractors::GameId,
models::{club::Club, profile::CreateProfileRequest},
seed,
services::{club as club_svc, profile as profile_svc},
@@ -11,11 +13,12 @@ use crate::{
pub async fn post_auth_local(
State(state): State<AppState>,
game: GameId,
Json(req): Json<CreateProfileRequest>,
) -> AppResult<Json<Value>> {
let username = req.username.unwrap_or_else(|| "Player 1".into());
let profile = profile_svc::create_profile(&state.pool, &username).await?;
let profile = profile_svc::create_profile(&state.pool, &username, game.as_str()).await?;
let club = Club::new(&profile.id, "OpenFUT FC", 5000);
club_svc::create_club(&state.pool, &club).await?;
@@ -31,51 +34,105 @@ pub async fn post_auth_local(
/// 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")
pub async fn get_auth_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
.bind(game.as_str())
.fetch_one(&state.pool)
.await?;
Ok(Json(json!({ "has_profile": count > 0 })))
}
#[derive(Deserialize)]
pub struct ResetRequest {
pub confirm: Option<String>,
}
/// 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",
/// Requires `{"confirm":"reset"}` in the request body as a safeguard against
/// accidental or unauthenticated calls. Deletes every user-data table in
/// dependency order; schema (migrations) is preserved.
pub async fn post_auth_reset(
State(state): State<AppState>,
game: GameId,
Json(req): Json<ResetRequest>,
) -> AppResult<Json<Value>> {
if req.confirm.as_deref() != Some("reset") {
return Err(AppError::BadRequest(
r#"include {"confirm":"reset"} in the request body to confirm data wipe"#.into(),
));
}
// Multi-game: reset ONLY this game's profile subtree so a FIFA 17 reset never
// wipes FIFA 23 (and vice versa). Resolve the game's profile + its clubs, then
// delete their dependent rows in reverse-dependency order.
let profile_id: Option<String> = sqlx::query_scalar(
"SELECT id FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
)
.bind(game.as_str())
.fetch_optional(&state.pool)
.await?;
let Some(profile_id) = profile_id else {
return Ok(Json(json!({
"reset": true,
"message": "nothing to reset for this game"
})));
};
let club_ids: Vec<String> = sqlx::query_scalar("SELECT id FROM clubs WHERE profile_id = ?")
.bind(&profile_id)
.fetch_all(&state.pool)
.await?;
for club_id in &club_ids {
sqlx::query(
"DELETE FROM squad_players WHERE squad_id IN (SELECT id FROM squads WHERE club_id = ?)",
)
.bind(club_id)
.execute(&state.pool)
.await?;
for table in ["squads", "owned_cards", "packs", "market_history"] {
sqlx::query(&format!("DELETE FROM {table} WHERE club_id = ?"))
.bind(club_id)
.execute(&state.pool)
.await?;
}
}
for table in [
"fut_champs_sessions",
"sbc_submissions",
"objective_progress",
"position_goals",
"statistics",
"seasons",
"season_history",
"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}"))
"daily_checkins",
] {
sqlx::query(&format!("DELETE FROM {table} WHERE profile_id = ?"))
.bind(&profile_id)
.execute(&state.pool)
.await?;
}
tracing::info!("Full game reset performed");
sqlx::query("DELETE FROM clubs WHERE profile_id = ?")
.bind(&profile_id)
.execute(&state.pool)
.await?;
sqlx::query("DELETE FROM profiles WHERE id = ?")
.bind(&profile_id)
.execute(&state.pool)
.await?;
// NOTE (follow-up): settings (global key/value), events (shared content),
// market_listings (keyed by seller_name, includes the shared NPC market),
// notifications and player_achievements are not yet game-scoped and are left
// intact. They need a game/profile key before a per-game reset can cover them.
tracing::info!(game = %game.as_str(), "Per-game reset performed");
Ok(Json(json!({
"reset": true,
"message": "All progress has been wiped. Call POST /auth/local to start a new club."
"message": "All progress for this game has been wiped. Call POST /auth/local to start a new club."
})))
}
+25 -52
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, Query, State},
Json,
@@ -9,26 +10,16 @@ use crate::{
app::AppState,
error::{AppError, AppResult},
models::card::OwnedCard,
services::{
club as club_svc,
inventory::{self, OwnedItemQuery, OwnedItemView},
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
}
if overall >= 85 { 1500 }
else if overall >= 80 { 900 }
else if overall >= 75 { 600 }
else if overall >= 65 { 300 }
else { 150 }
}
#[derive(Debug, Deserialize)]
@@ -66,7 +57,7 @@ pub async fn get_cards(
let rarity_ok = query
.rarity
.as_ref()
.map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase())
.map(|r| c.rarity.as_str().eq_ignore_ascii_case(r))
.unwrap_or(true);
let pos_ok = query
.position
@@ -100,16 +91,11 @@ pub async fn get_cards(
cards.truncate(limit);
}
Ok(Json(
json!({ "cards": cards, "total": total, "returned": cards.len() }),
))
Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() })))
}
pub async fn get_collection(
State(state): State<AppState>,
Query(query): Query<OwnedItemQuery>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
pub async fn get_collection(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let owned = sqlx::query_as::<_, OwnedCard>(
@@ -119,13 +105,14 @@ pub async fn get_collection(
.fetch_all(&state.pool)
.await?;
let views: Vec<OwnedItemView> = owned
let with_defs: Vec<Value> = owned
.iter()
.filter_map(|o| {
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);
let body = json!({
let effective_position =
o.position_override.as_deref().unwrap_or(&def.position);
json!({
"owned_card_id": o.id,
"is_loan": o.is_loan,
"loan_matches_remaining": o.loan_matches_remaining,
@@ -136,38 +123,23 @@ pub async fn get_collection(
"effective_overall": effective_overall,
"effective_position": effective_position,
"card": def,
});
OwnedItemView {
owned_card_id: o.id.clone(),
base_overall: def.overall,
effective_overall,
position: effective_position.to_string(),
nation: def.nation.clone(),
league: def.league.clone(),
club: def.club.clone(),
body,
}
})
})
})
.collect();
let page = inventory::apply_query(views, &query);
let returned = page.items.len();
Ok(Json(json!({
"collection": page.items,
"total": page.total,
"returned": returned,
"offset": page.offset,
"limit": page.limit,
})))
Ok(Json(
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>,
game: GameId,
Path(owned_card_id): Path<String>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let owned = sqlx::query_as::<_, OwnedCard>(
@@ -181,9 +153,10 @@ pub async fn delete_owned_card(
.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 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);
+40 -35
View File
@@ -1,17 +1,16 @@
use crate::extractors::GameId;
use crate::{
app::AppState,
error::AppResult,
models::club::Club,
services::{
checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc,
},
services::{checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc},
};
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>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
pub async fn get_club(State(state): State<AppState>, game: GameId) -> AppResult<Json<Club>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
Ok(Json(club))
}
@@ -24,9 +23,10 @@ pub struct UpdateClubRequest {
pub async fn put_club(
State(state): State<AppState>,
game: GameId,
Json(req): Json<UpdateClubRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated = club_svc::update_club(
&state.pool,
@@ -38,8 +38,8 @@ pub async fn put_club(
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?;
pub async fn get_checkin_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
Ok(Json(json!({
"available": status.available,
@@ -50,8 +50,8 @@ pub async fn get_checkin_status(State(state): State<AppState>) -> AppResult<Json
})))
}
pub async fn post_checkin(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
pub async fn post_checkin(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).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!({
@@ -62,17 +62,18 @@ pub async fn post_checkin(State(state): State<AppState>) -> AppResult<Json<Value
})))
}
pub async fn get_milestones(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).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 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 = ?",
@@ -82,25 +83,29 @@ pub async fn get_milestones(State(state): State<AppState>) -> AppResult<Json<Val
.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 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 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);
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,
+16 -33
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{extract::State, Json};
use rand::{Rng, SeedableRng};
use serde_json::{json, Value};
@@ -9,8 +10,8 @@ use crate::{
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?;
pub async fn get_division(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).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?;
@@ -36,14 +37,14 @@ pub async fn get_division(State(state): State<AppState>) -> AppResult<Json<Value
})))
}
pub async fn get_division_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
pub async fn get_division_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).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?;
pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).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?;
@@ -52,26 +53,11 @@ pub async fn get_division_leaderboard(State(state): State<AppState>) -> AppResul
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",
"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
@@ -89,13 +75,10 @@ pub async fn get_division_leaderboard(State(state): State<AppState>) -> AppResul
// 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;
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,
+11 -8
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, Query, State},
Json,
@@ -39,23 +40,23 @@ pub async fn get_draft_squad(
/// until all 11 slots are filled.
pub async fn post_draft_start(
State(state): State<AppState>,
game: GameId,
Query(query): Query<DraftQuery>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).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?;
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>,
game: GameId,
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?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let session = draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?;
Ok(Json(session))
}
@@ -71,10 +72,11 @@ pub struct PickRequest {
/// and rewards (coins + optional pack) are granted automatically.
pub async fn post_draft_pick(
State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>,
Json(req): Json<PickRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let session = draft_svc::pick_card(
&state.pool,
@@ -91,9 +93,10 @@ pub async fn post_draft_pick(
/// Abandon an active draft session. No rewards are granted.
pub async fn post_draft_abandon(
State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let result = draft_svc::abandon_draft(&state.pool, &profile.id, &session_id).await?;
Ok(Json(result))
}
+21 -16
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -8,14 +9,12 @@ 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,
},
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?;
pub async fn get_fut_champs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let session = champs_svc::get_active_session(&state.pool, &profile.id).await?;
Ok(Json(json!({
@@ -25,8 +24,8 @@ pub async fn get_fut_champs(State(state): State<AppState>) -> AppResult<Json<Val
}
/// 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?;
pub async fn post_start_fut_champs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let session = champs_svc::start_session(&state.pool, &profile.id).await?;
Ok(Json(json!({
@@ -44,10 +43,11 @@ pub struct ChampsMatchRequest {
/// POST /fut-champs/:session_id/result — record a match in this session.
pub async fn post_champs_result(
State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>,
Json(req): Json<ChampsMatchRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let session = champs_svc::record_match(
&state.pool,
@@ -77,9 +77,10 @@ pub async fn post_champs_result(
/// POST /fut-champs/:session_id/claim — claim end-of-week rewards.
pub async fn post_claim_champs_rewards(
State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = champs_svc::claim_rewards(
@@ -95,8 +96,8 @@ pub async fn post_claim_champs_rewards(
}
/// 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?;
pub async fn get_champs_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let history = champs_svc::get_history(&state.pool, &profile.id).await?;
Ok(Json(json!({
@@ -106,16 +107,20 @@ pub async fn get_champs_history(State(state): State<AppState>) -> AppResult<Json
}
/// 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?;
pub async fn post_claim_rivals_reward(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).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?;
let result = champs_svc::claim_rivals_reward(
&state.pool,
&profile.id,
&club.id,
&state.pack_defs,
)
.await?;
Ok(Json(result))
}
+14 -12
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, Query, State},
Json,
@@ -12,13 +13,14 @@ use crate::{
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?;
pub async fn get_trade_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).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)]
pub struct MarketQuery {
pub min_overall: Option<u8>,
@@ -51,9 +53,10 @@ pub async fn get_market(
pub async fn post_market_buy(
State(state): State<AppState>,
game: GameId,
Json(req): Json<BuyListingRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?;
@@ -64,9 +67,10 @@ pub async fn post_market_buy(
pub async fn post_market_sell(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SellCardRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let new_balance = market_svc::sell_card(&state.pool, &state.card_db, &club.id, &req).await?;
@@ -82,22 +86,20 @@ pub async fn post_market_refresh(State(state): State<AppState>) -> AppResult<Jso
}
/// 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?;
pub async fn get_my_listings(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).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() }),
))
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>,
game: GameId,
Path(listing_id): Path<String>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).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 })))
+8 -11
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{
extract::{Query, State},
Json,
@@ -20,9 +21,10 @@ pub struct MatchHistoryQuery {
pub async fn get_matches(
State(state): State<AppState>,
game: GameId,
Query(query): Query<MatchHistoryQuery>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let limit = query.limit.unwrap_or(20).clamp(1, 100);
let matches = if let Some(mode) = &query.mode {
@@ -63,20 +65,15 @@ pub async fn get_opponent(
pub async fn post_match_result(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SubmitMatchRequest>,
) -> AppResult<Json<MatchRewardResult>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = match_service::process_match(
&state.pool,
&profile.id,
&club.id,
&req,
&state.obj_defs,
&state.achievement_defs,
)
.await?;
let result =
match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs, &state.achievement_defs)
.await?;
Ok(Json(result))
}
+1 -1
View File
@@ -4,8 +4,8 @@ pub mod cards;
pub mod club;
pub mod division;
pub mod draft;
pub mod events;
pub mod fut_champs;
pub mod events;
pub mod health;
pub mod market;
pub mod matches;
+15 -16
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -7,9 +8,7 @@ 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,
},
services::{club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc},
};
/// GET /notifications
@@ -18,8 +17,8 @@ use crate::{
/// 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?;
pub async fn get_notifications(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
// ── Persistent notifications ─────────────────────────────────────────────
@@ -94,16 +93,14 @@ pub async fn get_notifications(State(state): State<AppState>) -> AppResult<Json<
// 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,
})
})
.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();
@@ -128,7 +125,9 @@ pub async fn mark_notification_read(
}
/// POST /notifications/read-all
pub async fn mark_all_notifications_read(State(state): State<AppState>) -> AppResult<Json<Value>> {
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 })))
}
+9 -5
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -11,8 +12,8 @@ use crate::{
services::{club as club_svc, objective as obj_svc, profile as profile_svc},
};
pub async fn get_objectives(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
pub async fn get_objectives(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let objectives =
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
Ok(Json(json!({ "objectives": objectives })))
@@ -20,9 +21,10 @@ pub async fn get_objectives(State(state): State<AppState>) -> AppResult<Json<Val
pub async fn get_objective(
State(state): State<AppState>,
game: GameId,
Path(objective_id): Path<String>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let all =
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
let obj = all
@@ -34,9 +36,10 @@ pub async fn get_objective(
pub async fn post_claim_objective_by_id(
State(state): State<AppState>,
game: GameId,
Path(objective_id): Path<String>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let reward = obj_svc::claim_objective(
&state.pool,
@@ -56,9 +59,10 @@ pub struct ClaimRequest {
pub async fn post_claim_objective(
State(state): State<AppState>,
game: GameId,
Json(req): Json<ClaimRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let reward = obj_svc::claim_objective(
&state.pool,
+11 -12
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -19,9 +20,10 @@ pub struct BuyPackRequest {
pub async fn post_buy_pack(
State(state): State<AppState>,
game: GameId,
Json(req): Json<BuyPackRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let pack = pack_svc::buy_pack(
&state.pool,
@@ -54,8 +56,8 @@ pub async fn get_pack_store(State(state): State<AppState>) -> AppResult<Json<Val
Ok(Json(json!({ "packs": store })))
}
pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
pub async fn get_packs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?;
@@ -77,8 +79,8 @@ pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>>
}
/// 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?;
pub async fn get_pack_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let opened = sqlx::query_as::<_, crate::models::pack::Pack>(
@@ -122,9 +124,10 @@ pub async fn get_pack_history(State(state): State<AppState>) -> AppResult<Json<V
pub async fn post_open_pack(
State(state): State<AppState>,
game: GameId,
Path(pack_id): Path<String>,
) -> AppResult<Json<PackOpenResult>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = pack_svc::open_pack(
@@ -140,12 +143,8 @@ pub async fn post_open_pack(
objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1)
.await?;
let _ = crate::services::achievement::check_and_unlock(
&state.pool,
&state.achievement_defs,
&profile.id,
&club.id,
)
.await;
&state.pool, &state.achievement_defs, &profile.id, &club.id,
).await;
Ok(Json(result))
}
+3 -2
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use crate::{
app::AppState,
error::AppResult,
@@ -7,8 +8,8 @@ use crate::{
use axum::{extract::State, Json};
use serde_json::{json, Value};
pub async fn get_profile(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
pub async fn get_profile(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let computed_level = level_for_xp(profile.xp);
let next_level = computed_level + 1;
+5 -7
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -29,9 +30,10 @@ pub async fn get_sbc(
pub async fn post_sbc_submit(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SubmitSbcRequest>,
) -> AppResult<Json<SbcResult>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = sbc_svc::submit_sbc(
@@ -47,12 +49,8 @@ pub async fn post_sbc_submit(
if result.passed {
let _ = crate::services::achievement::check_and_unlock(
&state.pool,
&state.achievement_defs,
&profile.id,
&club.id,
)
.await;
&state.pool, &state.achievement_defs, &profile.id, &club.id,
).await;
}
Ok(Json(result))
+13 -9
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -11,8 +12,8 @@ use crate::{
services::{club as club_svc, profile as profile_svc, squad as squad_svc},
};
pub async fn get_squad(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?;
@@ -21,8 +22,8 @@ pub async fn get_squad(State(state): State<AppState>) -> AppResult<Json<Value>>
Ok(Json(squad_response(&squad, &players, chemistry)))
}
pub async fn get_squads(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
pub async fn get_squads(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let squads = squad_svc::list_squads(&state.pool, &club.id).await?;
Ok(Json(json!({ "squads": squads })))
@@ -30,9 +31,10 @@ pub async fn get_squads(State(state): State<AppState>) -> AppResult<Json<Value>>
pub async fn get_squad_by_id(
State(state): State<AppState>,
game: GameId,
Path(squad_id): Path<String>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let (squad, players) = squad_svc::get_squad_by_id(&state.pool, &club.id, &squad_id).await?;
@@ -43,24 +45,26 @@ pub async fn get_squad_by_id(
pub async fn post_squad(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SaveSquadRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
if !req.players.is_empty() {
squad_svc::validate_formation(&state.pool, &state.card_db, &req.players).await?;
squad_svc::validate_formation(&state.pool, &state.card_db, &club.id, &req.players).await?;
}
let squad = squad_svc::save_squad(&state.pool, &state.card_db, &club.id, &req).await?;
let squad = squad_svc::save_squad(&state.pool, &club.id, &req).await?;
Ok(Json(json!({ "squad": squad })))
}
pub async fn delete_squad(
State(state): State<AppState>,
game: GameId,
Path(squad_id): Path<String>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
squad_svc::delete_squad(&state.pool, &club.id, &squad_id).await?;
Ok(Json(json!({ "deleted": squad_id })))
+5 -3
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{
extract::{Query, State},
Json,
@@ -12,8 +13,8 @@ use crate::{
services::{profile as profile_svc, statistics as stats_svc},
};
pub async fn get_statistics(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
pub async fn get_statistics(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
let pos_goals = stats_svc::get_position_goals(&state.pool, &profile.id).await?;
@@ -36,9 +37,10 @@ pub struct HistoryQuery {
pub async fn get_statistics_history(
State(state): State<AppState>,
game: GameId,
Query(query): Query<HistoryQuery>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let limit = query.limit.unwrap_or(20).clamp(1, 100);
let matches = sqlx::query_as::<_, Match>(
+14 -5
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -27,10 +28,11 @@ pub struct ApplyChemStyleRequest {
/// POST /collection/:owned_card_id/chemistry-style
pub async fn post_apply_chemistry_style(
State(state): State<AppState>,
game: GameId,
Path(owned_card_id): Path<String>,
Json(req): Json<ApplyChemStyleRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated = upgrade_svc::apply_chemistry_style(
@@ -63,14 +65,20 @@ pub struct ChangePositionRequest {
/// POST /collection/:owned_card_id/position — costs 500 coins.
pub async fn post_change_position(
State(state): State<AppState>,
game: GameId,
Path(owned_card_id): Path<String>,
Json(req): Json<ChangePositionRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).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 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);
@@ -92,10 +100,11 @@ pub struct ApplyTrainingRequest {
/// 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>,
game: GameId,
Path(owned_card_id): Path<String>,
Json(req): Json<ApplyTrainingRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated =
+69 -76
View File
@@ -29,83 +29,79 @@ pub fn load_achievement_definitions(data_dir: &str) -> anyhow::Result<Vec<Achiev
}
/// 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)
}
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)
}
"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)
}
"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)
}
"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)
}
"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?
}
"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),
"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?,
"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?,
"drafts_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
)
.bind(profile_id)
.fetch_one(pool)
.await?,
_ => 0,
};
_ => 0,
};
Ok(v)
}
@@ -169,14 +165,11 @@ pub async fn check_and_unlock(
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;
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());
}
-11
View File
@@ -42,17 +42,6 @@ impl CardDb {
self.cards.get(id)
}
#[allow(dead_code)]
pub fn by_rarity(&self, rarity: &str) -> Vec<&CardDefinition> {
self.cards
.values()
.filter(|c| {
let r = format!("{:?}", c.rarity).to_lowercase();
r == rarity || rarity == "any"
})
.collect()
}
pub fn all(&self) -> Vec<&CardDefinition> {
self.cards.values().collect()
}
+8 -11
View File
@@ -1,8 +1,4 @@
use crate::{
db::Pool,
error::AppResult,
services::{club, pack},
};
use crate::{db::Pool, error::AppResult, services::{club, pack}};
use uuid::Uuid;
const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000];
@@ -12,7 +8,7 @@ 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 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>,
@@ -60,7 +56,11 @@ pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatu
}
}
pub async fn claim(pool: &Pool, profile_id: &str, club_id: &str) -> AppResult<CheckinResult> {
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",
@@ -82,10 +82,7 @@ pub async fn claim(pool: &Pool, profile_id: &str, club_id: &str) -> AppResult<Ch
}
}
let last_streak = row
.as_ref()
.map(|(s, last_at)| compute_next_streak(*s, last_at))
.unwrap_or(1);
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 };
+17 -13
View File
@@ -88,13 +88,18 @@ pub async fn start_draft(
let first_position = &pick_order[0];
let candidates = pick_candidates(card_db, first_position, min_overall, CANDIDATES_PER_SLOT);
let pick_order_json = serde_json::to_string(&pick_order)
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
let candidates_json = serde_json::to_string(&candidates)
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
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(),
pick_order: pick_order_json,
picks: "[]".to_string(),
current_candidates: Some(serde_json::to_string(&candidates).unwrap()),
current_candidates: Some(candidates_json),
status: "active".to_string(),
reward_coins: 0,
reward_pack_id: None,
@@ -179,30 +184,28 @@ pub async fn pick_card(
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()),
)
(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);
{
let candidates_json = serde_json::to_string(&next_candidates)
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
(
Some(serde_json::to_string(&next_candidates).unwrap()),
Some(candidates_json),
"active".to_string(),
0,
None,
0,
None,
)
}
};
let picks_json = serde_json::to_string(&picks).unwrap();
let picks_json = serde_json::to_string(&picks)
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
sqlx::query(
"UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \
@@ -291,7 +294,8 @@ async fn fetch_session(pool: &Pool, profile_id: &str, session_id: &str) -> AppRe
}
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 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
+22 -10
View File
@@ -26,11 +26,7 @@ pub async fn get_active_session(
.map_err(Into::into)
}
pub async fn get_session(
pool: &Pool,
session_id: &str,
profile_id: &str,
) -> AppResult<FutChampsSession> {
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 = ?"
))
@@ -243,16 +239,30 @@ pub async fn claim_rivals_reward(
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 = ?",
let row: Option<(i64, i64, i64, Option<String>)> = sqlx::query_as(
"SELECT division, rivals_week_claimed, rivals_total_points, rivals_last_claimed_at \
FROM seasons WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?;
let (division, week_claimed, total_pts) =
let (division, week_claimed, total_pts, last_claimed_at) =
row.ok_or_else(|| AppError::NotFound("no season found — play a match first".into()))?;
// Enforce 24-hour cooldown between weekly reward claims
if let Some(ref last_claimed) = last_claimed_at {
if let Ok(last_time) = chrono::DateTime::parse_from_rfc3339(last_claimed) {
let elapsed = chrono::Utc::now() - last_time.with_timezone(&chrono::Utc);
if elapsed < chrono::Duration::hours(24) {
let hours_remaining = 24 - elapsed.num_hours();
return Err(AppError::Conflict(format!(
"rivals weekly reward already claimed; try again in ~{hours_remaining}h"
)));
}
}
}
let next_week = week_claimed + 1;
let coins = rivals_weekly_coins(division);
let new_balance = club_svc::add_coins(pool, club_id, coins).await?;
@@ -275,11 +285,13 @@ pub async fn claim_rivals_reward(
None
};
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100 \
WHERE profile_id = ?",
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100, \
rivals_last_claimed_at = ? WHERE profile_id = ?",
)
.bind(next_week)
.bind(&now)
.bind(profile_id)
.execute(pool)
.await?;
-291
View File
@@ -1,291 +0,0 @@
//! Game-independent owned-inventory query: semantic filtering, deterministic
//! ordering, and offset/limit pagination over a club's owned items.
//!
//! This layer is deliberately free of any game-specific concepts. It never sees
//! raw FIFA (or any other game's) numeric entity ids — a game adapter is
//! responsible for translating its wire query into the *semantic* values here
//! (quality tier, entity **names**, semantic offset/limit). The canonical
//! ordering is imposed by Core so pagination is correct and repeatable
//! regardless of what (if any) sort the client requests; see the module tests
//! and `docs`/vault for why the client's `sort` key is treated as UNKNOWN.
//!
//! Order of operations is load-bearing: **filter → order → paginate**. Paginating
//! before filtering is the production bug this replaces (a client that pages an
//! unfiltered/unsorted set re-reads page one forever and amplifies requests).
use serde::Deserialize;
use crate::models::card::Quality;
/// Semantic owned-inventory query. All values are game-independent: a quality
/// tier, entity **names** (not ids), and semantic offset/limit. Every filter is
/// optional; combined filters are ANDed. Absent field = no constraint.
#[derive(Debug, Default, Deserialize)]
pub struct OwnedItemQuery {
/// Quality tier (gold/silver/bronze). Serialized lowercase.
#[serde(default)]
pub quality: Option<Quality>,
/// Playing position, e.g. "ST" (matched case-insensitively).
#[serde(default)]
pub position: Option<String>,
/// Nation name, e.g. "Argentina" (matched case-insensitively).
#[serde(default)]
pub nation: Option<String>,
/// League name, e.g. "Premier League" (matched case-insensitively).
#[serde(default)]
pub league: Option<String>,
/// Club name, e.g. "Chelsea" (matched case-insensitively).
#[serde(default)]
pub club: Option<String>,
/// Number of leading items to skip after filtering + ordering.
#[serde(default)]
pub offset: Option<i64>,
/// Maximum number of items to return in the page.
#[serde(default)]
pub limit: Option<i64>,
}
/// One owned item projected to the attributes needed for querying, plus the
/// response body to hand back verbatim once it survives the filter+page.
pub struct OwnedItemView {
pub owned_card_id: String,
/// Base card overall (drives quality tier).
pub base_overall: u8,
/// Effective overall (base + training bonus); drives ordering.
pub effective_overall: i64,
pub position: String,
pub nation: String,
pub league: String,
pub club: String,
pub body: serde_json::Value,
}
impl OwnedItemView {
fn quality(&self) -> Quality {
Quality::from_overall(self.base_overall)
}
}
/// Result of applying a query: the requested page plus the count of items that
/// matched the filter **before** pagination (what a client needs to page).
pub struct QueryPage {
pub items: Vec<serde_json::Value>,
pub total: usize,
pub offset: usize,
pub limit: Option<usize>,
}
/// Does an item satisfy every present filter (AND semantics)?
fn matches(item: &OwnedItemView, q: &OwnedItemQuery) -> bool {
let quality_ok = q.quality.map(|want| item.quality() == want).unwrap_or(true);
let pos_ok = q
.position
.as_ref()
.map(|p| item.position.eq_ignore_ascii_case(p))
.unwrap_or(true);
let nation_ok = q
.nation
.as_ref()
.map(|n| item.nation.eq_ignore_ascii_case(n))
.unwrap_or(true);
let league_ok = q
.league
.as_ref()
.map(|l| item.league.eq_ignore_ascii_case(l))
.unwrap_or(true);
let club_ok = q
.club
.as_ref()
.map(|c| item.club.eq_ignore_ascii_case(c))
.unwrap_or(true);
quality_ok && pos_ok && nation_ok && league_ok && club_ok
}
/// Apply the query: filter (AND) → deterministic order → paginate.
///
/// Ordering is `(effective_overall DESC, owned_card_id ASC)` — a total order, so
/// pages never overlap or repeat. `offset`/`limit` are clamped to sane
/// non-negative values (the wire never sends negatives; clamping keeps a
/// malformed request from panicking).
pub fn apply_query(mut items: Vec<OwnedItemView>, q: &OwnedItemQuery) -> QueryPage {
// 1. filter
items.retain(|it| matches(it, q));
let total = items.len();
// 2. deterministic total order (independent of input/DB order)
items.sort_by(|a, b| {
b.effective_overall
.cmp(&a.effective_overall)
.then_with(|| a.owned_card_id.cmp(&b.owned_card_id))
});
// 3. paginate
let offset = q.offset.unwrap_or(0).max(0) as usize;
let limit = q.limit.map(|l| l.max(0) as usize);
let page: Vec<serde_json::Value> = items
.into_iter()
.skip(offset)
.take(limit.unwrap_or(usize::MAX))
.map(|it| it.body)
.collect();
QueryPage {
items: page,
total,
offset,
limit,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn view(
id: &str,
overall: u8,
position: &str,
nation: &str,
league: &str,
club: &str,
) -> OwnedItemView {
OwnedItemView {
owned_card_id: id.to_string(),
base_overall: overall,
effective_overall: overall as i64,
position: position.to_string(),
nation: nation.to_string(),
league: league.to_string(),
club: club.to_string(),
body: json!({ "owned_card_id": id, "overall": overall }),
}
}
fn ids(page: &QueryPage) -> Vec<String> {
page.items
.iter()
.map(|b| b["owned_card_id"].as_str().unwrap().to_string())
.collect()
}
fn fixture() -> Vec<OwnedItemView> {
vec![
view("a", 84, "ST", "England", "Premier League", "Northgate"),
view("b", 86, "CDM", "Ghana", "Premier League", "Chelsea"),
view("c", 72, "ST", "Brazil", "Brasileirao", "Santos"),
view("d", 60, "CM", "Italy", "Serie B", "Modena"),
view("e", 89, "LW", "Argentina", "Primera Division", "Boca"),
]
}
#[test]
fn no_filter_returns_all_in_overall_desc_order() {
let p = apply_query(fixture(), &OwnedItemQuery::default());
assert_eq!(p.total, 5);
assert_eq!(ids(&p), ["e", "b", "a", "c", "d"]); // 89,86,84,72,60
}
#[test]
fn quality_gold_selects_overall_75_plus() {
let q = OwnedItemQuery {
quality: Some(Quality::Gold),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(ids(&p), ["e", "b", "a"]);
}
#[test]
fn filters_are_anded() {
let q = OwnedItemQuery {
league: Some("Premier League".into()),
position: Some("ST".into()),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(ids(&p), ["a"]); // only the PL ST, not the PL CDM
}
#[test]
fn case_insensitive_name_match() {
let q = OwnedItemQuery {
club: Some("chelsea".into()),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(ids(&p), ["b"]);
}
#[test]
fn empty_when_nothing_matches() {
let q = OwnedItemQuery {
nation: Some("Argentina".into()),
club: Some("Chelsea".into()),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(p.total, 0);
assert!(p.items.is_empty());
}
#[test]
fn filter_runs_before_pagination() {
// Gold set is [e,b,a]; page (offset 1, limit 1) over the FILTERED set is [b].
// If pagination ran first, offset/limit would slice the full 5-item set.
let q = OwnedItemQuery {
quality: Some(Quality::Gold),
offset: Some(1),
limit: Some(1),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(p.total, 3, "total is the filtered count, not the page size");
assert_eq!(ids(&p), ["b"]);
}
#[test]
fn pages_do_not_overlap_and_advance() {
let page = |off| {
apply_query(
fixture(),
&OwnedItemQuery {
offset: Some(off),
limit: Some(2),
..Default::default()
},
)
};
let p0 = page(0);
let p1 = page(2);
assert_eq!(ids(&p0), ["e", "b"]);
assert_eq!(ids(&p1), ["a", "c"]);
// start advancing must NOT re-serve page one
assert_ne!(ids(&p0), ids(&p1));
assert_eq!(p0.total, 5);
assert_eq!(p1.total, 5);
}
#[test]
fn offset_past_end_is_empty_not_wrapped() {
let q = OwnedItemQuery {
offset: Some(100),
limit: Some(11),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert!(p.items.is_empty());
assert_eq!(p.total, 5);
}
#[test]
fn ordering_is_stable_on_overall_ties() {
let items = vec![
view("z", 80, "ST", "N", "L", "C"),
view("a", 80, "ST", "N", "L", "C"),
view("m", 80, "ST", "N", "L", "C"),
];
let p = apply_query(items, &OwnedItemQuery::default());
assert_eq!(ids(&p), ["a", "m", "z"]); // tie broken by owned id asc
}
}
+13 -11
View File
@@ -43,11 +43,12 @@ pub async fn refresh_npc_listings(
card_db: &CardDb,
event_defs: &[EventDefinition],
) -> AppResult<usize> {
// Clean up expired and unsold listings
// Clean up expired listings and previous NPC listings.
// Player-posted listings (is_npc = 0) are intentionally preserved.
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
.execute(pool)
.await?;
sqlx::query("DELETE FROM market_listings WHERE sold = 0")
sqlx::query("DELETE FROM market_listings WHERE sold = 0 AND is_npc = 1")
.execute(pool)
.await?;
@@ -107,8 +108,8 @@ pub async fn refresh_npc_listings(
for listing in &listings_to_insert {
sqlx::query(
"INSERT INTO market_listings \
(id, card_id, seller_name, price, listed_at, expires_at, sold) \
VALUES (?, ?, ?, ?, ?, ?, 0)",
(id, card_id, seller_name, price, listed_at, expires_at, sold, is_npc) \
VALUES (?, ?, ?, ?, ?, ?, 0, 1)",
)
.bind(&listing.id)
.bind(&listing.card_id)
@@ -190,6 +191,10 @@ pub async fn sell_card(
club_id: &str,
req: &SellCardRequest,
) -> AppResult<i64> {
if req.price < 0 {
return Err(AppError::BadRequest("price must be non-negative".into()));
}
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 \
@@ -285,10 +290,9 @@ pub async fn get_listings_by_seller(
let with_cards = listings
.into_iter()
.filter_map(|l| {
card_db.get(&l.card_id).map(|card| MarketListingWithCard {
listing: l,
card: card.clone(),
})
card_db
.get(&l.card_id)
.map(|card| MarketListingWithCard { listing: l, card: card.clone() })
})
.collect();
Ok(with_cards)
@@ -304,9 +308,7 @@ pub async fn cancel_listing(pool: &Pool, club_id: &str, listing_id: &str) -> App
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| {
AppError::NotFound(format!("listing '{listing_id}' not found or already sold"))
})?;
.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)
+32 -75
View File
@@ -7,14 +7,13 @@ use crate::{
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
objective::ObjectiveDefinition,
},
services::{
achievement, card_db::CardDb, club, notification, objective, profile, season as season_svc,
statistics,
},
services::{achievement, card_db::CardDb, club, notification, objective, profile, season as season_svc, statistics},
};
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"];
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.
///
@@ -28,53 +27,23 @@ pub fn generate_opponent(card_db: &CardDb, difficulty: &str) -> serde_json::Valu
let (min_overall, names): (u8, &[&str]) = match difficulty {
"professional" => (
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" => (
78,
&[
"Elite Stars FC",
"Champions Select",
"Premier XI",
"Galaxy United",
"Titan FC",
],
&["Elite Stars FC", "Champions Select", "Premier XI", "Galaxy United", "Titan FC"],
),
"legendary" => (
85,
&[
"Legends United",
"Ultimate XI",
"Gold Standard FC",
"The Icons",
"Heritage FC",
],
&["Legends United", "Ultimate XI", "Gold Standard FC", "The Icons", "Heritage FC"],
),
"ultimate" => (
90,
&[
"Apex XI",
"Pantheon FC",
"Gods of FUT",
"Invincibles Select",
"Eternal XI",
],
&["Apex XI", "Pantheon FC", "Gods of FUT", "Invincibles Select", "Eternal XI"],
),
_ => (
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"],
),
};
@@ -90,11 +59,7 @@ pub fn generate_opponent(card_db: &CardDb, difficulty: &str) -> serde_json::Valu
let mut indices: Vec<usize> = (0..pool.len()).collect();
indices.shuffle(&mut rng);
let cards: Vec<_> = indices
.into_iter()
.take(11)
.map(|i| pool[i].clone())
.collect();
let cards: Vec<_> = indices.into_iter().take(11).map(|i| pool[i].clone()).collect();
let squad_rating = if cards.is_empty() {
0
@@ -129,6 +94,12 @@ pub async fn process_match(
obj_defs: &[ObjectiveDefinition],
ach_defs: &[AchievementDefinition],
) -> AppResult<MatchRewardResult> {
if req.goals_for < 0 || req.goals_against < 0 || req.goals_for > 99 || req.goals_against > 99 {
return Err(crate::error::AppError::BadRequest(
"goals_for and goals_against must each be between 0 and 99".into(),
));
}
let outcome = if req.goals_for > req.goals_against {
"win"
} else if req.goals_for == req.goals_against {
@@ -176,18 +147,11 @@ pub async fn process_match(
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
)
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
)
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;
let _ = notification::create(pool, "level_up", &format!("Level {}!", ev.new_level), &body).await;
}
statistics::record_match(
@@ -226,20 +190,20 @@ pub async fn process_match(
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;
let display_name = obj_defs
.iter()
.find(|d| &d.id == obj_id)
.map(|d| d.title.as_str())
.unwrap_or(obj_id.as_str());
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", display_name);
let _ = notification::create(pool, "objective_complete", "Objective complete!", &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 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;
}
@@ -254,21 +218,14 @@ pub async fn process_match(
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 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();
let achievements_unlocked =
achievement::check_and_unlock(pool, ach_defs, profile_id, club_id)
.await
.unwrap_or_default();
Ok(MatchRewardResult {
match_record,
+2 -4
View File
@@ -2,20 +2,18 @@ pub mod achievement;
pub mod card_db;
pub mod checkin;
pub mod club;
pub mod notification;
pub mod draft;
pub mod event;
pub mod fut_champs;
pub mod inventory;
pub mod season;
pub mod market;
pub mod match_service;
pub mod notification;
pub mod objective;
pub mod pack;
pub mod profile;
pub mod sbc;
pub mod season;
pub mod settings;
pub mod squad;
pub mod squad_rules;
pub mod statistics;
pub mod upgrades;
+1 -1
View File
@@ -69,7 +69,7 @@ pub async fn increment_metric(
for def in defs
.iter()
.filter(|d| format!("{:?}", d.metric).to_lowercase() == metric)
.filter(|d| d.metric.as_str() == metric)
{
let existing = sqlx::query_as::<_, ObjectiveProgress>(
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
+4 -4
View File
@@ -90,8 +90,8 @@ pub async fn open_pack(
.all()
.into_iter()
.filter(|c| {
let r = format!("{:?}", c.rarity).to_lowercase();
rarities.contains(&r)
let r = c.rarity.as_str();
rarities.contains(&r.to_string())
})
.cloned()
.collect()
@@ -124,8 +124,8 @@ pub async fn open_pack(
}
}
let card_ids_json =
serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>()).unwrap_or_default();
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 = ?")
+23 -13
View File
@@ -5,33 +5,41 @@ use crate::{
};
use chrono::Utc;
pub async fn get_active_profile(pool: &Pool) -> AppResult<Profile> {
/// Fetch the active profile for a game. Single-profile-per-game: there is exactly
/// one profile row per `game_id`, so we take the earliest for that game.
pub async fn get_active_profile(pool: &Pool, game_id: &str) -> AppResult<Profile> {
sqlx::query_as::<_, Profile>(
"SELECT id, username, level, xp, created_at, updated_at FROM profiles ORDER BY created_at ASC LIMIT 1"
"SELECT id, username, level, xp, game_id, created_at, updated_at \
FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
)
.bind(game_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into()))
}
pub async fn create_profile(pool: &Pool, username: &str) -> AppResult<Profile> {
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles")
pub async fn create_profile(pool: &Pool, username: &str, game_id: &str) -> AppResult<Profile> {
// Single-player per game: one profile per game_id, not one globally.
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
.bind(game_id)
.fetch_one(pool)
.await?;
if existing > 0 {
return Err(AppError::Conflict(
"a profile already exists; OpenFUT is single-player only".into(),
"a profile already exists for this game; OpenFUT is single-player per game".into(),
));
}
let profile = Profile::new(username);
let profile = Profile::new(username, game_id);
sqlx::query(
"INSERT INTO profiles (id, username, level, xp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(&profile.id)
.bind(&profile.username)
.bind(profile.level)
.bind(profile.xp)
.bind(&profile.game_id)
.bind(profile.created_at)
.bind(profile.updated_at)
.execute(pool)
@@ -59,7 +67,13 @@ pub async fn add_xp_with_levelup(
club_id: &str,
xp_to_add: i64,
) -> AppResult<Vec<LevelUpEvent>> {
let profile = get_active_profile(pool).await?;
let profile = sqlx::query_as::<_, Profile>(
"SELECT id, username, level, xp, game_id, created_at, updated_at FROM profiles WHERE id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("profile '{profile_id}' not found")))?;
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);
@@ -86,11 +100,7 @@ pub async fn add_xp_with_levelup(
}
tracing::info!(profile_id, new_level = lvl, coins, "level up");
events.push(LevelUpEvent {
new_level: lvl,
coins_granted: coins,
pack_granted: pack,
});
events.push(LevelUpEvent { new_level: lvl, coins_granted: coins, pack_granted: pack });
}
Ok(events)
-3
View File
@@ -101,9 +101,6 @@ pub async fn record_match(
// 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?;
}
+61 -509
View File
@@ -3,19 +3,10 @@ use crate::{
error::{AppError, AppResult},
models::{
card::{CardDefinition, OwnedCard},
squad::{
SaveSquadRequest, SlotAssignment, Squad, SquadPlayer, SquadPlayerInput, SquadReplaced,
SquadReplacement,
},
},
services::{
card_db::CardDb,
squad_rules::{
ClientReportedEvaluation, DefaultSquadRules, SquadPlayerCard, SquadRules, SquadSnapshot,
},
squad::{SaveSquadRequest, Squad, SquadPlayer, SquadPlayerInput},
},
services::card_db::CardDb,
};
use std::collections::HashSet;
use uuid::Uuid;
pub async fn get_squad(pool: &Pool, club_id: &str) -> AppResult<(Squad, Vec<SquadPlayer>)> {
@@ -72,6 +63,7 @@ async fn get_players(pool: &Pool, squad_id: &str) -> AppResult<Vec<SquadPlayer>>
pub async fn validate_formation(
pool: &Pool,
card_db: &CardDb,
club_id: &str,
players: &[SquadPlayerInput],
) -> AppResult<()> {
let starters: Vec<&SquadPlayerInput> = players.iter().filter(|p| !p.is_on_bench).collect();
@@ -86,12 +78,13 @@ pub async fn validate_formation(
let mut gk_count = 0usize;
for sp in &starters {
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 = ?",
"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(&sp.owned_card_id)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found", sp.owned_card_id)))?;
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found or does not belong to this club", sp.owned_card_id)))?;
if let Some(card) = card_db.get(&owned.card_id) {
if card.position == "GK" {
@@ -200,153 +193,68 @@ pub async fn calculate_chemistry(
}))
}
/// Replace a squad's entire slot assignment, atomically.
///
/// # Why this exists alongside `save_squad`
///
/// `save_squad` wrote outside a transaction: it UPDATEd the squad, DELETEd every
/// row from `squad_players`, then INSERTed the new ones one at a time. A failure
/// part-way through left a squad with some of its old players deleted and only
/// some of its new ones written — a state no client asked for and none can
/// detect. It also never checked that the cards being placed belonged to the
/// club, and happily accepted the same card in two slots.
///
/// Those are acceptable in a single-user REST toy and not acceptable under a
/// real client, so this is the one write path now and `save_squad` delegates to
/// it.
///
/// # Order of work
///
/// Validation happens BEFORE any write, so a rejected replacement leaves the
/// existing squad exactly as it was. Everything that does write happens inside
/// one transaction.
pub async fn replace_squad(
pool: &Pool,
card_db: &CardDb,
rules: &dyn SquadRules,
club_id: &str,
squad_id: Option<&str>,
replacement: &SquadReplacement,
client_reported: &ClientReportedEvaluation,
) -> AppResult<SquadReplaced> {
// ── validate before touching anything ────────────────────────────────
let mut seen: HashSet<&str> = HashSet::new();
let mut slots_seen: HashSet<i64> = HashSet::new();
for s in &replacement.slots {
if s.slot < 0 {
return Err(AppError::BadRequest(format!(
"slot index must not be negative, got {}",
s.slot
)));
}
if !slots_seen.insert(s.slot) {
return Err(AppError::BadRequest(format!(
"slot {} assigned more than once",
s.slot
)));
}
if !seen.insert(s.owned_card_id.as_str()) {
return Err(AppError::BadRequest(format!(
"card {} assigned to more than one slot",
s.owned_card_id
)));
}
}
// Ownership: every card must belong to THIS club. Without this a client
// could place a card it does not own, and the squad would read back as
// though it did.
let mut resolved: Vec<(SlotAssignmentRef, OwnedCard)> = Vec::new();
for s in &replacement.slots {
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 = ?",
)
.bind(&s.owned_card_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found", s.owned_card_id)))?;
if owned.club_id != club_id {
// Deliberately the same message as "not found": whether a card
// exists in someone else's club is not this caller's business.
return Err(AppError::NotFound(format!(
"owned card {} not found",
s.owned_card_id
)));
}
resolved.push((
SlotAssignmentRef {
slot: s.slot,
is_captain: s.is_captain,
is_on_bench: s.is_on_bench,
},
owned,
));
}
// ── one transaction for every write ──────────────────────────────────
pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> AppResult<Squad> {
let now = chrono::Utc::now().to_rfc3339();
let mut tx = pool.begin().await?;
let squad_id = match squad_id {
Some(id) => {
let verified = sqlx::query_scalar::<_, String>(
"SELECT id FROM squads WHERE id = ? AND club_id = ?",
)
.bind(id)
.bind(club_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| AppError::NotFound(format!("squad '{id}' not found")))?;
let squad_id = if let Some(ref id) = req.squad_id {
// Update existing squad — verify ownership
let verified =
sqlx::query_scalar::<_, String>("SELECT id FROM squads WHERE id = ? AND club_id = ?")
.bind(id)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("squad '{id}' not found")))?;
sqlx::query(
"UPDATE squads SET name = COALESCE(?, name), formation = COALESCE(?, formation), updated_at = ? WHERE id = ?",
)
.bind(replacement.name.as_deref())
.bind(replacement.formation.as_deref())
.bind(&now)
.bind(&verified)
.execute(&mut *tx)
.await?;
verified
}
None => {
let squad = Squad::new(
club_id,
replacement.name.as_deref().unwrap_or("My Squad"),
replacement.formation.as_deref().unwrap_or("4-4-2"),
);
sqlx::query(
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&squad.id)
.bind(&squad.club_id)
.bind(&squad.name)
.bind(&squad.formation)
.bind(&squad.created_at)
.bind(&squad.updated_at)
.execute(&mut *tx)
.await?;
squad.id
}
};
sqlx::query("DELETE FROM squad_players WHERE squad_id = ?")
.bind(&squad_id)
.execute(&mut *tx)
sqlx::query(
"UPDATE squads SET name = COALESCE(?, name), formation = COALESCE(?, formation), updated_at = ? WHERE id = ?",
)
.bind(req.name.as_deref())
.bind(req.formation.as_deref())
.bind(&now)
.bind(&verified)
.execute(pool)
.await?;
for (slot, owned) in &resolved {
sqlx::query("DELETE FROM squad_players WHERE squad_id = ?")
.bind(&verified)
.execute(pool)
.await?;
verified
} else {
// Create a new squad
let squad = Squad::new(
club_id,
req.name.as_deref().unwrap_or("My Squad"),
req.formation.as_deref().unwrap_or("4-4-2"),
);
sqlx::query(
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&squad.id)
.bind(&squad.club_id)
.bind(&squad.name)
.bind(&squad.formation)
.bind(&squad.created_at)
.bind(&squad.updated_at)
.execute(pool)
.await?;
squad.id
};
for player in &req.players {
let sp_id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(Uuid::new_v4().to_string())
.bind(&sp_id)
.bind(&squad_id)
.bind(&owned.id)
.bind(slot.slot)
.bind(slot.is_captain)
.bind(slot.is_on_bench)
.execute(&mut *tx)
.bind(&player.owned_card_id)
.bind(player.position_index)
.bind(player.is_captain)
.bind(player.is_on_bench)
.execute(pool)
.await?;
}
@@ -354,87 +262,10 @@ pub async fn replace_squad(
"SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE id = ?",
)
.bind(&squad_id)
.fetch_one(&mut *tx)
.fetch_one(pool)
.await?;
tx.commit().await?;
// ── evaluate with the game's rules, never with the client's numbers ──
let snapshot = SquadSnapshot {
formation: squad.formation.clone(),
players: resolved
.iter()
.filter_map(|(slot, owned)| {
card_db.get(&owned.card_id).map(|card| SquadPlayerCard {
owned_card_id: owned.id.clone(),
card_id: card.id.clone(),
name: card.name.clone(),
overall: card.overall,
position: card.position.clone(),
nation: card.nation.clone(),
league: card.league.clone(),
club: card.club.clone(),
slot: slot.slot,
on_bench: slot.is_on_bench,
})
})
.collect(),
};
let evaluation = rules.evaluate(&snapshot);
let client_disagreements = client_reported.compare(&evaluation);
Ok(SquadReplaced {
squad,
slots_written: resolved.len(),
evaluation,
client_disagreements,
})
}
struct SlotAssignmentRef {
slot: i64,
is_captain: bool,
is_on_bench: bool,
}
/// Compatibility wrapper over [`replace_squad`].
///
/// Kept so the existing Core REST route keeps working, but it no longer has its
/// own write path. That means this route now also validates ownership and
/// rejects duplicate cards — a deliberate tightening, not an accident: those
/// were bugs, and having two write paths with different guarantees is how the
/// stricter one gets bypassed.
pub async fn save_squad(
pool: &Pool,
card_db: &CardDb,
club_id: &str,
req: &SaveSquadRequest,
) -> AppResult<Squad> {
let replacement = SquadReplacement {
name: req.name.clone(),
formation: req.formation.clone(),
slots: req
.players
.iter()
.map(|p| SlotAssignment {
owned_card_id: p.owned_card_id.clone(),
slot: p.position_index,
is_captain: p.is_captain,
is_on_bench: p.is_on_bench,
})
.collect(),
};
let out = replace_squad(
pool,
card_db,
&DefaultSquadRules,
club_id,
req.squad_id.as_deref(),
&replacement,
&ClientReportedEvaluation::default(),
)
.await?;
Ok(out.squad)
Ok(squad)
}
pub async fn delete_squad(pool: &Pool, club_id: &str, squad_id: &str) -> AppResult<()> {
@@ -449,282 +280,3 @@ pub async fn delete_squad(pool: &Pool, club_id: &str, squad_id: &str) -> AppResu
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::squad::SlotAssignment;
/// A pool with the real schema, plus two clubs that own one card each.
///
/// Two clubs specifically: the guarantee under test is that a card
/// belonging to somebody else cannot be placed, and that cannot be
/// expressed with one club.
const TS: &str = "2026-01-01T00:00:00Z";
async fn fixture() -> (Pool, CardDb) {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.expect("in-memory sqlite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations");
for (profile, club) in [("prof-a", "club-a"), ("prof-b", "club-b")] {
sqlx::query(
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)",
)
.bind(profile)
.bind(profile)
.bind(TS)
.bind(TS)
.execute(&pool)
.await
.expect("profile");
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
.bind(club).bind(profile).bind(club).bind(1000i64).bind(TS).bind(TS)
.execute(&pool).await.expect("club");
}
// club-a owns card-1 and card-2; club-b owns card-foreign.
for (id, club) in [
("card-1", "club-a"),
("card-2", "club-a"),
("card-foreign", "club-b"),
] {
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
.bind(id).bind(club).bind("def-1").bind(TS)
.execute(&pool).await.expect("owned card");
}
// An empty card database is enough: none of these guarantees consult it.
(
pool,
CardDb::load("/nonexistent-card-dir").expect("empty card db"),
)
}
fn slot(card: &str, n: i64) -> SlotAssignment {
SlotAssignment {
owned_card_id: card.into(),
slot: n,
is_captain: false,
is_on_bench: false,
}
}
async fn replace(
pool: &Pool,
db: &CardDb,
club: &str,
id: Option<&str>,
slots: Vec<SlotAssignment>,
) -> AppResult<SquadReplaced> {
replace_squad(
pool,
db,
&DefaultSquadRules,
club,
id,
&SquadReplacement {
name: Some("S".into()),
formation: Some("4-4-2".into()),
slots,
},
&ClientReportedEvaluation::default(),
)
.await
}
async fn slots_of(pool: &Pool, squad_id: &str) -> Vec<(String, i64)> {
sqlx::query_as::<_, (String, i64)>(
"SELECT owned_card_id, position_index FROM squad_players WHERE squad_id = ? ORDER BY position_index",
).bind(squad_id).fetch_all(pool).await.unwrap()
}
#[tokio::test]
async fn a_card_owned_by_another_club_cannot_be_placed() {
let (pool, db) = fixture().await;
let err = replace(&pool, &db, "club-a", None, vec![slot("card-foreign", 0)])
.await
.unwrap_err();
// Same message as a missing card: whether it exists elsewhere is not
// this caller's business.
assert!(
matches!(err, AppError::NotFound(ref m) if m.contains("card-foreign")),
"{err:?}"
);
}
#[tokio::test]
async fn the_same_card_cannot_occupy_two_slots() {
let (pool, db) = fixture().await;
let err = replace(
&pool,
&db,
"club-a",
None,
vec![slot("card-1", 0), slot("card-1", 1)],
)
.await
.unwrap_err();
assert!(
matches!(err, AppError::BadRequest(ref m) if m.contains("more than one slot")),
"{err:?}"
);
}
#[tokio::test]
async fn two_cards_cannot_occupy_the_same_slot() {
let (pool, db) = fixture().await;
let err = replace(
&pool,
&db,
"club-a",
None,
vec![slot("card-1", 3), slot("card-2", 3)],
)
.await
.unwrap_err();
assert!(
matches!(err, AppError::BadRequest(ref m) if m.contains("slot 3")),
"{err:?}"
);
}
#[tokio::test]
async fn a_negative_slot_is_refused() {
let (pool, db) = fixture().await;
let err = replace(&pool, &db, "club-a", None, vec![slot("card-1", -1)])
.await
.unwrap_err();
assert!(
matches!(err, AppError::BadRequest(ref m) if m.contains("negative")),
"{err:?}"
);
}
/// The atomicity guarantee, and the reason this operation exists.
///
/// The old implementation deleted every squad player before inserting the
/// new ones, outside a transaction. A replacement rejected part-way through
/// therefore destroyed the squad it failed to replace.
#[tokio::test]
async fn a_rejected_replacement_leaves_the_previous_squad_untouched() {
let (pool, db) = fixture().await;
let first = replace(
&pool,
&db,
"club-a",
None,
vec![slot("card-1", 0), slot("card-2", 1)],
)
.await
.expect("first save");
let before = slots_of(&pool, &first.squad.id).await;
assert_eq!(before.len(), 2);
// Valid card in slot 0, then one owned by another club.
let err = replace(
&pool,
&db,
"club-a",
Some(&first.squad.id),
vec![slot("card-1", 0), slot("card-foreign", 1)],
)
.await
.unwrap_err();
assert!(matches!(err, AppError::NotFound(_)), "{err:?}");
assert_eq!(
slots_of(&pool, &first.squad.id).await,
before,
"a rejected replacement must not disturb the stored squad"
);
}
/// Replacement means replacement: slots present before and absent from the
/// new assignment must be gone, not merged.
#[tokio::test]
async fn replacement_removes_slots_absent_from_the_new_assignment() {
let (pool, db) = fixture().await;
let first = replace(
&pool,
&db,
"club-a",
None,
vec![slot("card-1", 0), slot("card-2", 1)],
)
.await
.expect("first");
let second = replace(
&pool,
&db,
"club-a",
Some(&first.squad.id),
vec![slot("card-2", 5)],
)
.await
.expect("second");
assert_eq!(second.slots_written, 1);
assert_eq!(
slots_of(&pool, &first.squad.id).await,
vec![("card-2".to_string(), 5)]
);
}
#[tokio::test]
async fn a_squad_belonging_to_another_club_cannot_be_replaced() {
let (pool, db) = fixture().await;
let mine = replace(&pool, &db, "club-a", None, vec![slot("card-1", 0)])
.await
.expect("mine");
let err = replace(&pool, &db, "club-b", Some(&mine.squad.id), vec![])
.await
.unwrap_err();
assert!(matches!(err, AppError::NotFound(_)), "{err:?}");
assert_eq!(slots_of(&pool, &mine.squad.id).await.len(), 1);
}
/// The client's numbers must never become the server's.
#[tokio::test]
async fn client_reported_values_are_reported_as_disagreement_not_stored() {
let (pool, db) = fixture().await;
let out = replace_squad(
&pool,
&db,
&DefaultSquadRules,
"club-a",
None,
&SquadReplacement {
name: Some("S".into()),
formation: Some("4-4-2".into()),
slots: vec![slot("card-1", 0)],
},
&ClientReportedEvaluation {
client_reported_chemistry: Some(52),
client_reported_rating: Some(99),
client_reported_star_rating: None,
},
)
.await
.expect("save");
// The card db is empty, so the server derives nothing: 0.
assert_eq!(out.evaluation.chemistry, 0);
assert_eq!(out.evaluation.rating, 0);
// And the disagreement is surfaced rather than reconciled.
let fields: Vec<&str> = out
.client_disagreements
.iter()
.map(|d| d.field.as_str())
.collect();
assert_eq!(
fields,
vec!["chemistry", "rating"],
"{:?}",
out.client_disagreements
);
assert_eq!(out.evaluation.rules, "openfut-default-v2");
}
}
-380
View File
@@ -1,380 +0,0 @@
//! Squad evaluation, behind a game-rules boundary.
//!
//! # Why this is a trait and not a function in `squad.rs`
//!
//! Chemistry, rating and star rating are **game-specific**. FUT chemistry
//! changed substantially between FIFA generations, so a single formula
//! compiled into generic Core would quietly make Core a FIFA-something server.
//! Core is allowed to understand that a squad *has* an evaluation; it is not
//! allowed to know how any particular game computes one.
//!
//! ```text
//! Core owns the squad, slots, items, persistence
//! | and the SEMANTIC concept of an evaluation
//! v
//! SquadRules how a specific game computes it
//! |
//! +-- DefaultSquadRules OpenFUT's own rules (the implementation that
//! | already existed in Core)
//! +-- Fifa17SquadRules NOT YET WRITTEN. The FIFA 17 algorithm is not
//! proven, and inventing one would be worse than
//! having none.
//! ```
//!
//! # Pure data in, evaluation out
//!
//! Rules take a [`SquadSnapshot`] — already resolved by Core from the database
//! — rather than a pool and a card database. That keeps every rules
//! implementation synchronous, dependency-free and testable without fixtures,
//! and it stops a game's rules from reaching into Core's storage.
//!
//! # Client-reported values are not evaluations
//!
//! FIFA 17 sends its own `chemistry`, `rating` and `starRating` on every squad
//! save. Those are observations about what the client believes, captured for
//! shadow validation, and they are deliberately a *different type* from
//! [`SquadEvaluation`] so no later code can pass one where the other belongs.
use serde::{Deserialize, Serialize};
/// One player in a squad, reduced to the attributes rules are allowed to see.
///
/// Deliberately not `OwnedCard` + `CardDefinition`: rules should not be able to
/// reach storage identifiers, loan state or acquisition history.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SquadPlayerCard {
/// Core's owned-card id. Present so an evaluation can attribute per-player
/// results; rules must not interpret its contents.
pub owned_card_id: String,
pub card_id: String,
pub name: String,
pub overall: u8,
pub position: String,
pub nation: String,
pub league: String,
pub club: String,
/// Slot this player occupies, in Core's numbering.
pub slot: i64,
pub on_bench: bool,
}
/// Everything a rules implementation may consider.
#[derive(Debug, Clone, Default)]
pub struct SquadSnapshot {
pub formation: String,
pub players: Vec<SquadPlayerCard>,
}
impl SquadSnapshot {
pub fn starters(&self) -> impl Iterator<Item = &SquadPlayerCard> {
self.players.iter().filter(|p| !p.on_bench)
}
}
/// The semantic result Core understands.
///
/// `chemistry` has no fixed scale here on purpose — `chemistry_max` travels
/// with it, because a later game may not use 100.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SquadEvaluation {
pub chemistry: i64,
pub chemistry_max: i64,
pub rating: i64,
pub star_rating: i64,
/// Per-player detail, for UIs and for diagnosing a rules mismatch.
pub players: Vec<PlayerEvaluation>,
/// Which rules produced this, so a stored or logged evaluation is never
/// ambiguous about its own provenance.
pub rules: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerEvaluation {
pub owned_card_id: String,
pub chemistry: i64,
pub detail: Vec<(String, i64)>,
}
/// What a game client claimed about a squad it sent.
///
/// **Never canonical.** A separate type from [`SquadEvaluation`] specifically so
/// that assigning one to the other does not compile. A modified client can put
/// anything here; OpenFUT has no independent knowledge of what it means until
/// its own rules run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ClientReportedEvaluation {
pub client_reported_chemistry: Option<i64>,
pub client_reported_rating: Option<i64>,
pub client_reported_star_rating: Option<i64>,
}
/// Result of comparing what the client claimed against what the server derived.
///
/// A mismatch is **not** silently reconciled in either direction: the server's
/// value stands as canonical and the disagreement is reported so the rules
/// model can be investigated against the exact squad that produced it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvaluationComparison {
pub field: String,
pub client: i64,
pub server: i64,
}
impl ClientReportedEvaluation {
/// Fields where the client and the server disagree. Empty means agreement
/// on every field the client actually sent.
pub fn compare(&self, server: &SquadEvaluation) -> Vec<EvaluationComparison> {
let mut out = Vec::new();
let mut check = |field: &str, client: Option<i64>, srv: i64| {
if let Some(c) = client {
if c != srv {
out.push(EvaluationComparison {
field: field.to_string(),
client: c,
server: srv,
});
}
}
};
check(
"chemistry",
self.client_reported_chemistry,
server.chemistry,
);
check("rating", self.client_reported_rating, server.rating);
check(
"star_rating",
self.client_reported_star_rating,
server.star_rating,
);
out
}
}
/// How a specific game evaluates a squad.
pub trait SquadRules: Send + Sync {
/// Stable identifier recorded in [`SquadEvaluation::rules`].
fn name(&self) -> &'static str;
fn evaluate(&self, snapshot: &SquadSnapshot) -> SquadEvaluation;
}
/// OpenFUT's own rules — the implementation that already lived in Core.
///
/// Moved here unchanged in behaviour rather than rewritten: it is the default
/// for clients that have no game-specific rules, and changing its numbers while
/// relocating it would have made the move unreviewable.
///
/// Link scoring: club +3 each (max 6), league +1 each (max 4), nation +1 each
/// (max 3), per player capped at 10, team total capped at 100.
pub struct DefaultSquadRules;
impl SquadRules for DefaultSquadRules {
fn name(&self) -> &'static str {
"openfut-default-v2"
}
fn evaluate(&self, snapshot: &SquadSnapshot) -> SquadEvaluation {
let starters: Vec<&SquadPlayerCard> = snapshot.starters().collect();
let mut players = Vec::with_capacity(starters.len());
let mut total: i64 = 0;
for (i, p) in starters.iter().enumerate() {
let count = |f: fn(&SquadPlayerCard) -> &String, v: &String| {
starters
.iter()
.enumerate()
.filter(|(j, o)| *j != i && f(o) == v)
.count() as i64
};
let club_links = count(|c| &c.club, &p.club);
let league_links = count(|c| &c.league, &p.league);
let nation_links = count(|c| &c.nation, &p.nation);
let club_pts = (club_links * 3).min(6);
let league_pts = league_links.min(4);
let nation_pts = nation_links.min(3);
let chem = (club_pts + league_pts + nation_pts).min(10);
total += chem;
players.push(PlayerEvaluation {
owned_card_id: p.owned_card_id.clone(),
chemistry: chem,
detail: vec![
("club_links".into(), club_links),
("league_links".into(), league_links),
("nation_links".into(), nation_links),
("club_pts".into(), club_pts),
("league_pts".into(), league_pts),
("nation_pts".into(), nation_pts),
],
});
}
// Mean overall of the starters, rounded down. Empty squad rates 0
// rather than dividing by zero.
let rating = if starters.is_empty() {
0
} else {
starters.iter().map(|p| p.overall as i64).sum::<i64>() / starters.len() as i64
};
SquadEvaluation {
chemistry: total.min(100),
chemistry_max: 100,
rating,
// 0-5 from the rating band. Coarse on purpose: this is OpenFUT's
// own presentation value, not a reconstruction of any game's.
star_rating: match rating {
0 => 0,
1..=64 => 1,
65..=74 => 2,
75..=81 => 3,
82..=87 => 4,
_ => 5,
},
players,
rules: self.name().to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn p(slot: i64, club: &str, league: &str, nation: &str, overall: u8) -> SquadPlayerCard {
SquadPlayerCard {
owned_card_id: format!("owned-{slot}"),
card_id: format!("card-{slot}"),
name: format!("P{slot}"),
overall,
position: "ST".into(),
nation: nation.into(),
league: league.into(),
club: club.into(),
slot,
on_bench: false,
}
}
#[test]
fn an_empty_squad_evaluates_without_dividing_by_zero() {
let e = DefaultSquadRules.evaluate(&SquadSnapshot::default());
assert_eq!(e.chemistry, 0);
assert_eq!(e.rating, 0);
assert_eq!(e.star_rating, 0);
assert!(e.players.is_empty());
}
#[test]
fn bench_players_do_not_contribute() {
let mut snap = SquadSnapshot {
formation: "4-4-2".into(),
players: vec![p(0, "A", "L", "N", 80), p(1, "A", "L", "N", 80)],
};
let with_both = DefaultSquadRules.evaluate(&snap);
snap.players[1].on_bench = true;
let with_bench = DefaultSquadRules.evaluate(&snap);
assert!(
with_bench.chemistry < with_both.chemistry,
"a benched team-mate must not create links: {with_bench:?}"
);
assert_eq!(with_bench.players.len(), 1);
}
#[test]
fn links_are_capped_per_category_and_per_player() {
// Eleven identical players: club links alone would be 30 pts uncapped.
let players: Vec<_> = (0..11).map(|i| p(i, "A", "L", "N", 90)).collect();
let e = DefaultSquadRules.evaluate(&SquadSnapshot {
formation: "4-4-2".into(),
players,
});
for pe in &e.players {
assert_eq!(pe.chemistry, 10, "per-player cap is 10: {pe:?}");
}
assert_eq!(e.chemistry, 100);
assert_eq!(e.chemistry_max, 100);
}
#[test]
fn team_chemistry_is_capped_at_the_maximum() {
// 15 starters would total 150 uncapped.
let players: Vec<_> = (0..15).map(|i| p(i, "A", "L", "N", 90)).collect();
let e = DefaultSquadRules.evaluate(&SquadSnapshot {
formation: "x".into(),
players,
});
assert_eq!(e.chemistry, 100);
}
#[test]
fn unrelated_players_earn_no_chemistry() {
let players = vec![
p(0, "A", "L1", "N1", 80),
p(1, "B", "L2", "N2", 80),
p(2, "C", "L3", "N3", 80),
];
let e = DefaultSquadRules.evaluate(&SquadSnapshot {
formation: "x".into(),
players,
});
assert_eq!(e.chemistry, 0);
assert_eq!(e.rating, 80);
}
#[test]
fn the_evaluation_names_the_rules_that_produced_it() {
let e = DefaultSquadRules.evaluate(&SquadSnapshot::default());
assert_eq!(e.rules, "openfut-default-v2");
assert_eq!(DefaultSquadRules.name(), "openfut-default-v2");
}
/// The comparison must report disagreement rather than reconcile it.
#[test]
fn a_client_that_disagrees_is_reported_not_reconciled() {
let server = DefaultSquadRules.evaluate(&SquadSnapshot {
formation: "x".into(),
players: vec![p(0, "A", "L", "N", 80)],
});
let claimed = ClientReportedEvaluation {
client_reported_chemistry: Some(52),
client_reported_rating: Some(server.rating),
client_reported_star_rating: None,
};
let diff = claimed.compare(&server);
assert_eq!(diff.len(), 1, "{diff:?}");
assert_eq!(diff[0].field, "chemistry");
assert_eq!(diff[0].client, 52);
assert_eq!(diff[0].server, server.chemistry);
// And the server's own value is untouched by the comparison.
assert_eq!(server.chemistry, 0);
}
/// A field the client did not send cannot disagree.
#[test]
fn absent_client_fields_are_not_treated_as_zero() {
let server = DefaultSquadRules.evaluate(&SquadSnapshot {
formation: "x".into(),
players: vec![p(0, "A", "L", "N", 80)],
});
assert!(ClientReportedEvaluation::default()
.compare(&server)
.is_empty());
}
#[test]
fn agreement_reports_nothing() {
let server = DefaultSquadRules.evaluate(&SquadSnapshot {
formation: "x".into(),
players: vec![p(0, "A", "L", "N", 80)],
});
let claimed = ClientReportedEvaluation {
client_reported_chemistry: Some(server.chemistry),
client_reported_rating: Some(server.rating),
client_reported_star_rating: Some(server.star_rating),
};
assert!(claimed.compare(&server).is_empty());
}
}
+10 -8
View File
@@ -16,12 +16,14 @@ pub const MAX_TRAINING_BONUS: i64 = 3;
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()))
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.
@@ -62,8 +64,8 @@ pub async fn change_position(
new_position: &str,
) -> AppResult<OwnedCard> {
let valid_positions = [
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW", "CF",
"ST",
"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!(
+178 -834
View File
File diff suppressed because it is too large Load Diff