1 Commits

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

Closes #1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:10:40 -07:00
54 changed files with 362 additions and 3566 deletions
-546
View File
@@ -1,546 +0,0 @@
[
{
"id": "fifa17_101490",
"name": "Conor Casey",
"overall": 64,
"position": "ST",
"nation": "United States",
"league": "MLS",
"club": "Columbus Crew SC",
"pace": 43,
"shooting": 65,
"passing": 52,
"dribbling": 60,
"defending": 33,
"physical": 72,
"rarity": "bronze",
"image_path": null
},
{
"id": "fifa17_101880",
"name": "Rob Green",
"overall": 74,
"position": "GK",
"nation": "England",
"league": "EFL Championship",
"club": "Leeds United",
"pace": 78,
"shooting": 70,
"passing": 62,
"dribbling": 77,
"defending": 47,
"physical": 71,
"rarity": "silver",
"image_path": null
},
{
"id": "fifa17_102356",
"name": "Markus Feulner",
"overall": 74,
"position": "CM",
"nation": "Germany",
"league": "Bundesliga",
"club": "Augsburg",
"pace": 58,
"shooting": 70,
"passing": 75,
"dribbling": 71,
"defending": 66,
"physical": 71,
"rarity": "silver",
"image_path": null
},
{
"id": "fifa17_102593",
"name": "Craig Woodman",
"overall": 64,
"position": "LB",
"nation": "England",
"league": "EFL League Two",
"club": "Exeter City",
"pace": 66,
"shooting": 45,
"passing": 58,
"dribbling": 60,
"defending": 62,
"physical": 65,
"rarity": "bronze",
"image_path": null
},
{
"id": "fifa17_105046",
"name": "Anders Østli",
"overall": 64,
"position": "CB",
"nation": "Norway",
"league": "Tippeligaen",
"club": "Sarpsborg 08 FF",
"pace": 54,
"shooting": 46,
"passing": 54,
"dribbling": 52,
"defending": 62,
"physical": 75,
"rarity": "bronze",
"image_path": null
},
{
"id": "fifa17_107298",
"name": "Yohann Pelé",
"overall": 74,
"position": "GK",
"nation": "France",
"league": "Ligue 1",
"club": "O. de Marseille",
"pace": 75,
"shooting": 74,
"passing": 72,
"dribbling": 70,
"defending": 49,
"physical": 76,
"rarity": "silver",
"image_path": null
},
{
"id": "fifa17_107713",
"name": "Tom Starke",
"overall": 74,
"position": "GK",
"nation": "Germany",
"league": "Bundesliga",
"club": "Bayern",
"pace": 76,
"shooting": 73,
"passing": 59,
"dribbling": 72,
"defending": 39,
"physical": 76,
"rarity": "silver",
"image_path": null
},
{
"id": "fifa17_110020",
"name": "Sergio Pelegrín",
"overall": 74,
"position": "CB",
"nation": "Spain",
"league": "LaLiga 1 I 2 I 3",
"club": "Elche CF",
"pace": 45,
"shooting": 32,
"passing": 52,
"dribbling": 49,
"defending": 75,
"physical": 76,
"rarity": "silver",
"image_path": null
},
{
"id": "fifa17_110026",
"name": "Cani",
"overall": 74,
"position": "LM",
"nation": "Spain",
"league": "LaLiga 1 I 2 I 3",
"club": "Real Zaragoza",
"pace": 67,
"shooting": 72,
"passing": 73,
"dribbling": 78,
"defending": 45,
"physical": 61,
"rarity": "silver",
"image_path": null
},
{
"id": "fifa17_11811",
"name": "Paul Green",
"overall": 64,
"position": "CM",
"nation": "Republic of Ireland",
"league": "EFL League One",
"club": "Oldham Athletic",
"pace": 65,
"shooting": 58,
"passing": 62,
"dribbling": 63,
"defending": 62,
"physical": 68,
"rarity": "bronze",
"image_path": null
},
{
"id": "fifa17_139720",
"name": "Vincent Kompany",
"overall": 86,
"position": "CB",
"nation": "Belgium",
"league": "Premier League",
"club": "Manchester City",
"pace": 69,
"shooting": 54,
"passing": 62,
"dribbling": 65,
"defending": 86,
"physical": 81,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_146562",
"name": "Santi Cazorla",
"overall": 86,
"position": "CAM",
"nation": "Spain",
"league": "Premier League",
"club": "Arsenal",
"pace": 71,
"shooting": 78,
"passing": 85,
"dribbling": 86,
"defending": 57,
"physical": 64,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_153079",
"name": "Sergio Agüero",
"overall": 89,
"position": "ST",
"nation": "Argentina",
"league": "Premier League",
"club": "Manchester City",
"pace": 89,
"shooting": 88,
"passing": 75,
"dribbling": 89,
"defending": 23,
"physical": 70,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_158023",
"name": "Lionel Messi",
"overall": 93,
"position": "RW",
"nation": "Argentina",
"league": "LaLiga Santander",
"club": "FC Barcelona",
"pace": 89,
"shooting": 90,
"passing": 86,
"dribbling": 96,
"defending": 26,
"physical": 61,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_162895",
"name": "Cesc Fàbregas",
"overall": 86,
"position": "CM",
"nation": "Spain",
"league": "Premier League",
"club": "Chelsea",
"pace": 63,
"shooting": 77,
"passing": 89,
"dribbling": 81,
"defending": 61,
"physical": 64,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_163705",
"name": "Steve Mandanda",
"overall": 85,
"position": "GK",
"nation": "France",
"league": "Premier League",
"club": "Crystal Palace",
"pace": 86,
"shooting": 80,
"passing": 79,
"dribbling": 85,
"defending": 49,
"physical": 81,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_165229",
"name": "Laurent Koscielny",
"overall": 85,
"position": "CB",
"nation": "France",
"league": "Premier League",
"club": "Arsenal",
"pace": 78,
"shooting": 40,
"passing": 62,
"dribbling": 65,
"defending": 85,
"physical": 78,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_167948",
"name": "Hugo Lloris",
"overall": 88,
"position": "GK",
"nation": "France",
"league": "Premier League",
"club": "Spurs",
"pace": 87,
"shooting": 87,
"passing": 68,
"dribbling": 90,
"defending": 64,
"physical": 82,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_168542",
"name": "David Silva",
"overall": 87,
"position": "CAM",
"nation": "Spain",
"league": "Premier League",
"club": "Manchester City",
"pace": 68,
"shooting": 72,
"passing": 87,
"dribbling": 87,
"defending": 32,
"physical": 58,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_176580",
"name": "Luis Suárez",
"overall": 92,
"position": "ST",
"nation": "Uruguay",
"league": "LaLiga Santander",
"club": "FC Barcelona",
"pace": 82,
"shooting": 90,
"passing": 79,
"dribbling": 87,
"defending": 42,
"physical": 79,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_176635",
"name": "Mesut Özil",
"overall": 89,
"position": "CAM",
"nation": "Germany",
"league": "Premier League",
"club": "Arsenal",
"pace": 72,
"shooting": 74,
"passing": 86,
"dribbling": 86,
"defending": 24,
"physical": 58,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_177388",
"name": "Dimitri Payet",
"overall": 86,
"position": "LM",
"nation": "France",
"league": "Premier League",
"club": "West Ham",
"pace": 77,
"shooting": 78,
"passing": 87,
"dribbling": 87,
"defending": 42,
"physical": 70,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_183277",
"name": "Eden Hazard",
"overall": 88,
"position": "LM",
"nation": "Belgium",
"league": "Premier League",
"club": "Chelsea",
"pace": 90,
"shooting": 81,
"passing": 82,
"dribbling": 91,
"defending": 32,
"physical": 64,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_184941",
"name": "Alexis Sánchez",
"overall": 87,
"position": "LW",
"nation": "Chile",
"league": "Premier League",
"club": "Arsenal",
"pace": 86,
"shooting": 82,
"passing": 79,
"dribbling": 88,
"defending": 39,
"physical": 74,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_190871",
"name": "Neymar",
"overall": 92,
"position": "LW",
"nation": "Brazil",
"league": "LaLiga Santander",
"club": "FC Barcelona",
"pace": 91,
"shooting": 84,
"passing": 78,
"dribbling": 95,
"defending": 30,
"physical": 56,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_192119",
"name": "Thibaut Courtois",
"overall": 89,
"position": "GK",
"nation": "Belgium",
"league": "Premier League",
"club": "Chelsea",
"pace": 84,
"shooting": 91,
"passing": 69,
"dribbling": 89,
"defending": 48,
"physical": 86,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_192985",
"name": "Kevin De Bruyne",
"overall": 88,
"position": "CAM",
"nation": "Belgium",
"league": "Premier League",
"club": "Manchester City",
"pace": 77,
"shooting": 83,
"passing": 86,
"dribbling": 84,
"defending": 40,
"physical": 75,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_193080",
"name": "David De Gea",
"overall": 90,
"position": "GK",
"nation": "Spain",
"league": "Premier League",
"club": "Manchester Utd",
"pace": 88,
"shooting": 85,
"passing": 87,
"dribbling": 90,
"defending": 56,
"physical": 85,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_195864",
"name": "Paul Pogba",
"overall": 88,
"position": "CM",
"nation": "France",
"league": "Premier League",
"club": "Manchester Utd",
"pace": 77,
"shooting": 80,
"passing": 83,
"dribbling": 87,
"defending": 72,
"physical": 87,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_20801",
"name": "Cristiano Ronaldo",
"overall": 94,
"position": "LW",
"nation": "Portugal",
"league": "LaLiga Santander",
"club": "Real Madrid",
"pace": 92,
"shooting": 92,
"passing": 81,
"dribbling": 91,
"defending": 33,
"physical": 80,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_41236",
"name": "Zlatan Ibrahimović",
"overall": 90,
"position": "ST",
"nation": "Sweden",
"league": "Premier League",
"club": "Manchester Utd",
"pace": 72,
"shooting": 90,
"passing": 81,
"dribbling": 85,
"defending": 31,
"physical": 86,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_48940",
"name": "Petr Čech",
"overall": 88,
"position": "GK",
"nation": "Czech Republic",
"league": "Premier League",
"club": "Arsenal",
"pace": 83,
"shooting": 90,
"passing": 77,
"dribbling": 85,
"defending": 48,
"physical": 85,
"rarity": "gold",
"image_path": null
}
]
-5
View File
@@ -1,5 +0,0 @@
-- 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
@@ -1,3 +0,0 @@
-- 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
@@ -1,14 +0,0 @@
-- 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);
-23
View File
@@ -1,23 +0,0 @@
-- Generic, game-scoped OPAQUE extension storage.
--
-- Core persists, versions, associates (to a canonical entity + a server-computed
-- fingerprint), and enforces generic safety bounds on these bytes — but NEVER
-- interprets them. A game adapter owns the payload's schema and meaning. This is
-- how a game keeps wire-only round-trip state (e.g. FIFA 17 squad custom[]/
-- kicktakers/kitNumber) durable and atomic with its canonical entity without
-- leaking game-specific columns into generic Core.
--
-- Scope key: (game_id, entity_kind, entity_id, namespace). `namespace` is an
-- opaque adapter key (e.g. "fifa17.squad.v1"); `schema_version` is the adapter's
-- payload version (distinct from this table's storage schema).
CREATE TABLE IF NOT EXISTS game_entity_ext (
game_id TEXT NOT NULL,
entity_kind TEXT NOT NULL,
entity_id TEXT NOT NULL,
namespace TEXT NOT NULL,
schema_version INTEGER NOT NULL,
canonical_fingerprint TEXT NOT NULL,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (game_id, entity_kind, entity_id, namespace)
);
+1 -5
View File
@@ -42,11 +42,7 @@ pub struct AppState {
}
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
let mut card_db = CardDb::load(&cfg.data_dir)?;
for game in &cfg.dev_content_games {
card_db.load_game_dev(&cfg.data_dir, game)?;
}
let card_db = Arc::new(card_db);
let card_db = Arc::new(CardDb::load(&cfg.data_dir)?);
let pack_defs = Arc::new(load_pack_definitions(&cfg.data_dir)?);
let obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?);
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
+1 -14
View File
@@ -5,11 +5,8 @@ pub struct Config {
pub listen_addr: String,
pub database_url: String,
pub data_dir: String,
#[allow(dead_code)]
pub max_connections: u32,
/// Games whose opt-in development content pack (`data/games/<game>/dev/`) is
/// loaded IN ADDITION to the default `data/cards` catalog. Empty by default —
/// default/test content is never affected unless a game is named here.
pub dev_content_games: Vec<String>,
}
impl Config {
@@ -23,16 +20,6 @@ impl Config {
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5),
dev_content_games: std::env::var("OPENFUT_DEV_CONTENT_GAMES")
.ok()
.map(|v| {
v.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect()
})
.unwrap_or_default(),
})
}
}
+2 -2
View File
@@ -8,11 +8,11 @@ use tracing::info;
pub type Pool = SqlitePool;
pub async fn init_pool(database_url: &str, max_connections: u32) -> Result<Pool> {
pub async fn init_pool(database_url: &str) -> 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(max_connections)
.max_connections(5)
.connect_with(opts)
.await?;
sqlx::query("PRAGMA journal_mode=WAL")
-51
View File
@@ -1,51 +0,0 @@
//! 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))
}
}
-2
View File
@@ -2,7 +2,6 @@ pub mod app;
pub mod config;
pub mod db;
pub mod error;
pub mod extractors;
pub mod middleware;
pub mod modding;
pub mod models;
@@ -21,7 +20,6 @@ pub async fn build_app(pool: db::Pool, data_dir: &str) -> Result<Router> {
database_url: "sqlite::memory:".into(),
data_dir: data_dir.to_string(),
max_connections: 1,
dev_content_games: Vec::new(),
};
app::build(pool, cfg).await
}
+1 -15
View File
@@ -16,23 +16,9 @@ async fn main() -> Result<()> {
.init();
let cfg = config::Config::from_env()?;
// Opt-in dev subcommand: `openfut-core seed-dev` seeds the FIFA 17 dev
// profile/club from the dev content pack, prints a coverage report, and
// exits. Normal server startup NEVER seeds dev inventory.
if std::env::args().nth(1).as_deref() == Some("seed-dev") {
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
db::run_migrations(&pool).await?;
let mut card_db = openfut_core::services::card_db::CardDb::load(&cfg.data_dir)?;
card_db.load_game_dev(&cfg.data_dir, seed::FIFA17_GAME)?;
let report = seed::seed_fifa17_dev(&pool, &card_db).await?;
println!("{}", serde_json::to_string_pretty(&report)?);
return Ok(());
}
info!("OpenFUT Core starting on {}", cfg.listen_addr);
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
let pool = db::init_pool(&cfg.database_url).await?;
db::run_migrations(&pool).await?;
seed::maybe_seed(&pool).await?;
+24 -1
View File
@@ -1 +1,24 @@
// Reserved for future modding loader utilities.
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)
}
+2
View File
@@ -1,2 +1,4 @@
//! Modding support: load JSON data files from the data/ directory.
//! All game content (cards, packs, objectives, SBCs) is data-driven.
pub mod loader;
-41
View File
@@ -13,47 +13,6 @@ pub enum Rarity {
Icon,
}
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",
}
}
}
/// 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
}
}
}
/// A card definition loaded from JSON data files.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardDefinition {
-60
View File
@@ -1,60 +0,0 @@
//! Generic, game-scoped **opaque** extension state.
//!
//! Core stores and versions these bytes and associates them with a canonical
//! entity + a server-computed fingerprint, but never interprets them. A game
//! adapter owns the payload schema/meaning. This keeps game-only wire round-trip
//! state (e.g. a FIFA 17 squad's `custom[]`/`kicktakers`/`kitNumber`) durable and
//! atomic with its canonical entity without leaking game concepts into Core.
use serde::{Deserialize, Serialize};
/// Generic safety bounds Core enforces without interpreting the payload.
pub const MAX_EXT_PAYLOAD_BYTES: usize = 64 * 1024;
pub const MAX_EXT_NAMESPACE_LEN: usize = 64;
/// An opaque extension payload a game adapter asks Core to persist atomically
/// alongside a canonical entity. `payload` is uninterpreted bytes-as-text.
#[derive(Debug, Clone, Deserialize)]
pub struct OpaqueExtensionWrite {
/// Opaque adapter key, e.g. `"fifa17.squad.v1"`. Core treats it as a string.
pub namespace: String,
/// Adapter's payload schema version (distinct from the DB storage schema).
pub schema_version: i64,
/// Uninterpreted payload (the adapter's serialized game-only state).
pub payload: String,
}
impl OpaqueExtensionWrite {
/// Generic bounds check — namespace non-empty/length, payload size. Semantic
/// validation of the payload is the adapter's job; Core only guards size.
pub fn validate(&self) -> Result<(), String> {
if self.namespace.is_empty() || self.namespace.len() > MAX_EXT_NAMESPACE_LEN {
return Err(format!(
"namespace length {} out of bounds (1..={MAX_EXT_NAMESPACE_LEN})",
self.namespace.len()
));
}
if self.payload.len() > MAX_EXT_PAYLOAD_BYTES {
return Err(format!(
"extension payload {} bytes exceeds max {MAX_EXT_PAYLOAD_BYTES}",
self.payload.len()
));
}
Ok(())
}
}
/// A stored opaque extension row (read side). `canonical_fingerprint` is the
/// server-computed fingerprint of the canonical entity at write time; a reader
/// compares it against the entity's *current* fingerprint to detect staleness.
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct GameEntityExt {
pub game_id: String,
pub entity_kind: String,
pub entity_id: String,
pub namespace: String,
pub schema_version: i64,
pub canonical_fingerprint: String,
pub payload: String,
pub updated_at: String,
}
-1
View File
@@ -5,7 +5,6 @@ pub mod notification;
pub mod club;
pub mod draft;
pub mod fut_champs;
pub mod game_ext;
pub mod event;
pub mod season;
pub mod market;
-13
View File
@@ -21,19 +21,6 @@ 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 {
+1 -5
View File
@@ -8,22 +8,18 @@ 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>, game_id: impl Into<String>) -> Self {
pub fn new(username: impl Into<String>) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
username: username.into(),
level: 1,
xp: 0,
game_id: game_id.into(),
created_at: now,
updated_at: now,
}
-46
View File
@@ -54,49 +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>,
/// Server-computed deterministic fingerprint of the committed canonical squad
/// (anchors any opaque game extension against stale projection).
pub canonical_fingerprint: String,
}
+2 -3
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{extract::State, Json};
use serde_json::{json, Value};
@@ -8,8 +7,8 @@ use crate::{
services::{achievement as ach_svc, club as club_svc, profile as profile_svc},
};
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?;
pub async fn get_achievements(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id).await;
let achievements = ach_svc::list_with_status(&state.pool, &state.achievement_defs).await?;
+28 -85
View File
@@ -1,11 +1,9 @@
use axum::{extract::State, Json};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::{
app::AppState,
error::{AppError, AppResult},
extractors::GameId,
error::AppResult,
models::{club::Club, profile::CreateProfileRequest},
seed,
services::{club as club_svc, profile as profile_svc},
@@ -13,12 +11,11 @@ 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, game.as_str()).await?;
let profile = profile_svc::create_profile(&state.pool, &username).await?;
let club = Club::new(&profile.id, "OpenFUT FC", 5000);
club_svc::create_club(&state.pool, &club).await?;
@@ -34,105 +31,51 @@ 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>, game: GameId) -> AppResult<Json<Value>> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
.bind(game.as_str())
pub async fn get_auth_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles")
.fetch_one(&state.pool)
.await?;
Ok(Json(json!({ "has_profile": count > 0 })))
}
#[derive(Deserialize)]
pub struct ResetRequest {
pub confirm: Option<String>,
}
/// POST /auth/reset — wipe all game data and start fresh.
/// 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 [
/// Deletes every user-data table in dependency order. The schema tables
/// (migrations) are left intact; calling POST /auth/local afterwards
/// creates a new profile.
pub async fn post_auth_reset(State(state): State<AppState>) -> AppResult<Json<Value>> {
// Delete in reverse-dependency order to satisfy FK constraints
// (SQLite FK enforcement is opt-in, but we follow the order anyway)
let tables = [
"player_achievements",
"notifications",
"fut_champs_sessions",
"sbc_submissions",
"objective_progress",
"position_goals",
"statistics",
"seasons",
"season_history",
"matches",
"market_listings",
"squad_players",
"squads",
"owned_cards",
"packs",
"events",
"draft_sessions",
"daily_checkins",
] {
sqlx::query(&format!("DELETE FROM {table} WHERE profile_id = ?"))
.bind(&profile_id)
"settings",
"clubs",
"profiles",
];
for table in &tables {
sqlx::query(&format!("DELETE FROM {table}"))
.execute(&state.pool)
.await?;
}
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");
tracing::info!("Full game reset performed");
Ok(Json(json!({
"reset": true,
"message": "All progress for this game has been wiped. Call POST /auth/local to start a new club."
"message": "All progress has been wiped. Call POST /auth/local to start a new club."
})))
}
+11 -37
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, Query, State},
Json,
@@ -10,11 +9,7 @@ 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.
@@ -61,7 +56,7 @@ pub async fn get_cards(
let rarity_ok = query
.rarity
.as_ref()
.map(|r| c.rarity.as_str().eq_ignore_ascii_case(r))
.map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase())
.unwrap_or(true);
let pos_ok = query
.position
@@ -98,12 +93,8 @@ pub async fn get_cards(
Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() })))
}
pub async fn get_collection(
State(state): State<AppState>,
game: GameId,
Query(query): Query<OwnedItemQuery>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let owned = sqlx::query_as::<_, OwnedCard>(
@@ -113,14 +104,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!({
json!({
"owned_card_id": o.id,
"is_loan": o.is_loan,
"loan_matches_remaining": o.loan_matches_remaining,
@@ -131,39 +122,22 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let owned = sqlx::query_as::<_, OwnedCard>(
+9 -11
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use crate::{
app::AppState,
error::AppResult,
@@ -9,8 +8,8 @@ use axum::{extract::State, Json};
use serde::Deserialize;
use serde_json::{json, Value};
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?;
pub async fn get_club(State(state): State<AppState>) -> AppResult<Json<Club>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
Ok(Json(club))
}
@@ -23,10 +22,9 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated = club_svc::update_club(
&state.pool,
@@ -38,8 +36,8 @@ pub async fn put_club(
Ok(Json(json!({ "club": updated })))
}
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?;
pub async fn get_checkin_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
Ok(Json(json!({
"available": status.available,
@@ -50,8 +48,8 @@ pub async fn get_checkin_status(State(state): State<AppState>, game: GameId) ->
})))
}
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?;
pub async fn post_checkin(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?;
Ok(Json(json!({
@@ -62,8 +60,8 @@ pub async fn post_checkin(State(state): State<AppState>, game: GameId) -> AppRes
})))
}
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?;
pub async fn get_milestones(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
+6 -7
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{extract::State, Json};
use rand::{Rng, SeedableRng};
use serde_json::{json, Value};
@@ -10,8 +9,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>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_division(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let _club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
@@ -37,14 +36,14 @@ pub async fn get_division(State(state): State<AppState>, game: GameId) -> AppRes
})))
}
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?;
pub async fn get_division_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let history = season_svc::get_history(&state.pool, &profile.id).await?;
Ok(Json(json!({ "history": history, "total": history.len() })))
}
pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_division_leaderboard(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
+4 -9
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, Query, State},
Json,
@@ -40,10 +39,9 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let difficulty = query.difficulty.as_deref().unwrap_or("professional");
let session = draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
Ok(Json(session))
@@ -52,10 +50,9 @@ pub async fn post_draft_start(
/// 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let session = draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?;
Ok(Json(session))
}
@@ -72,11 +69,10 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let session = draft_svc::pick_card(
&state.pool,
@@ -93,10 +89,9 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let result = draft_svc::abandon_draft(&state.pool, &profile.id, &session_id).await?;
Ok(Json(result))
}
+10 -13
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -13,8 +12,8 @@ use crate::{
};
/// GET /fut-champs — current active session, or null if none.
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?;
pub async fn get_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let session = champs_svc::get_active_session(&state.pool, &profile.id).await?;
Ok(Json(json!({
@@ -24,8 +23,8 @@ pub async fn get_fut_champs(State(state): State<AppState>, game: GameId) -> AppR
}
/// POST /fut-champs/start — open a new FUT Champions week.
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?;
pub async fn post_start_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let session = champs_svc::start_session(&state.pool, &profile.id).await?;
Ok(Json(json!({
@@ -43,11 +42,10 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let session = champs_svc::record_match(
&state.pool,
@@ -77,10 +75,9 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = champs_svc::claim_rewards(
@@ -96,8 +93,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>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_champs_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let history = champs_svc::get_history(&state.pool, &profile.id).await?;
Ok(Json(json!({
@@ -107,8 +104,8 @@ pub async fn get_champs_history(State(state): State<AppState>, game: GameId) ->
}
/// POST /rivals/claim-weekly — claim weekly Division Rivals reward.
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?;
pub async fn post_claim_rivals_reward(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
// Ensure a season row exists
+7 -11
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, Query, State},
Json,
@@ -13,8 +12,8 @@ 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>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_trade_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let trades = market_svc::get_trade_history(&state.pool, &club.id).await?;
Ok(Json(json!({ "trades": trades, "total": trades.len() })))
@@ -53,10 +52,9 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?;
@@ -67,10 +65,9 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let new_balance = market_svc::sell_card(&state.pool, &state.card_db, &club.id, &req).await?;
@@ -86,8 +83,8 @@ 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>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_my_listings(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let listings = market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
Ok(Json(json!({ "listings": listings, "total": listings.len() })))
@@ -96,10 +93,9 @@ pub async fn get_my_listings(State(state): State<AppState>, game: GameId) -> App
/// 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
market_svc::cancel_listing(&state.pool, &club.id, &listing_id).await?;
Ok(Json(json!({ "cancelled": listing_id })))
+2 -5
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Query, State},
Json,
@@ -21,10 +20,9 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let limit = query.limit.unwrap_or(20).clamp(1, 100);
let matches = if let Some(mode) = &query.mode {
@@ -65,10 +63,9 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result =
+2 -3
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -17,8 +16,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>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_notifications(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
// ── Persistent notifications ─────────────────────────────────────────────
+5 -9
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -12,8 +11,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>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_objectives(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let objectives =
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
Ok(Json(json!({ "objectives": objectives })))
@@ -21,10 +20,9 @@ pub async fn get_objectives(State(state): State<AppState>, game: GameId) -> AppR
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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let all =
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
let obj = all
@@ -36,10 +34,9 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let reward = obj_svc::claim_objective(
&state.pool,
@@ -59,10 +56,9 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let reward = obj_svc::claim_objective(
&state.pool,
+6 -9
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -20,10 +19,9 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let pack = pack_svc::buy_pack(
&state.pool,
@@ -56,8 +54,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>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?;
@@ -79,8 +77,8 @@ pub async fn get_packs(State(state): State<AppState>, game: GameId) -> AppResult
}
/// Return recently opened packs with the card IDs they contained.
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?;
pub async fn get_pack_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let opened = sqlx::query_as::<_, crate::models::pack::Pack>(
@@ -124,10 +122,9 @@ pub async fn get_pack_history(State(state): State<AppState>, game: GameId) -> Ap
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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = pack_svc::open_pack(
+2 -3
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use crate::{
app::AppState,
error::AppResult,
@@ -8,8 +7,8 @@ use crate::{
use axum::{extract::State, Json};
use serde_json::{json, Value};
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?;
pub async fn get_profile(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let computed_level = level_for_xp(profile.xp);
let next_level = computed_level + 1;
+1 -3
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -30,10 +29,9 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = sbc_svc::submit_sbc(
+9 -13
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -12,8 +11,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>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
pub async fn get_squad(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?;
@@ -22,8 +21,8 @@ pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult
Ok(Json(squad_response(&squad, &players, chemistry)))
}
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?;
pub async fn get_squads(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let squads = squad_svc::list_squads(&state.pool, &club.id).await?;
Ok(Json(json!({ "squads": squads })))
@@ -31,10 +30,9 @@ pub async fn get_squads(State(state): State<AppState>, game: GameId) -> AppResul
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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let (squad, players) = squad_svc::get_squad_by_id(&state.pool, &club.id, &squad_id).await?;
@@ -45,26 +43,24 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).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, &club.id, &req.players).await?;
squad_svc::validate_formation(&state.pool, &state.card_db, &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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).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 })))
+3 -5
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Query, State},
Json,
@@ -13,8 +12,8 @@ use crate::{
services::{profile as profile_svc, statistics as stats_svc},
};
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?;
pub async fn get_statistics(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).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?;
@@ -37,10 +36,9 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let limit = query.limit.unwrap_or(20).clamp(1, 100);
let matches = sqlx::query_as::<_, Match>(
+3 -7
View File
@@ -1,4 +1,3 @@
use crate::extractors::GameId;
use axum::{
extract::{Path, State},
Json,
@@ -28,11 +27,10 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated = upgrade_svc::apply_chemistry_style(
@@ -65,11 +63,10 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated = upgrade_svc::change_position(
@@ -100,11 +97,10 @@ 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, game.as_str()).await?;
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated =
+1 -186
View File
@@ -1,23 +1,6 @@
use crate::{
db::Pool,
error::AppResult,
models::{card::Quality, club::Club, pack::PackDefinition},
services::{card_db::CardDb, club as club_svc, pack as pack_svc, profile as profile_svc},
};
use serde::Serialize;
use std::collections::BTreeMap;
use crate::{db::Pool, error::AppResult, models::pack::PackDefinition, services::pack as pack_svc};
use tracing::info;
/// The game whose dev content + inventory this seeds.
pub const FIFA17_GAME: &str = "fifa17";
/// Deterministic owned-instance id prefix, so re-running the seed is idempotent
/// (INSERT OR IGNORE on a stable id) rather than minting duplicate ownership.
const DEV_OWNED_PREFIX: &str = "fdev-";
/// Fixed grant timestamp — the seed is deterministic, not wall-clock dependent.
const DEV_ACQUIRED_AT: &str = "2026-08-11T00:00:00Z";
/// The client's My Squad page size (evidence: request `count=11`).
const MY_SQUAD_PAGE: usize = 11;
/// Seeds the market with NPC listings if empty.
pub async fn maybe_seed(_pool: &Pool) -> AppResult<()> {
// Any one-time startup seeds go here.
@@ -46,171 +29,3 @@ pub async fn grant_starter_pack(
Ok(())
}
// ───────────────────────────── FIFA 17 dev seed ─────────────────────────────
/// Coverage of the seeded FIFA 17 development inventory. Game-independent: it
/// counts quality tiers, positions and distinct entities, and whether the Gold
/// filter spans more than one page — everything the retail `/club` UI must
/// exercise. It carries NO FIFA wire ids (those are the adapter/host's runtime
/// concern; the seed never allocates them).
#[derive(Debug, Serialize)]
pub struct DevSeedReport {
pub game_id: String,
/// True if the fifa17 club already owned dev cards (no new grants made).
pub already_seeded: bool,
pub definitions_available: usize,
pub owned_total: usize,
pub unique_definitions: usize,
pub gold: usize,
pub silver: usize,
pub bronze: usize,
pub positions: BTreeMap<String, usize>,
pub distinct_nations: usize,
pub distinct_leagues: usize,
pub distinct_clubs: usize,
pub max_same_club: usize,
/// Gold owned items exceed one page → the client must request a 2nd page.
pub gold_over_one_page: bool,
}
/// Opt-in development seed: create (if absent) a `game_id=fifa17` profile + club
/// and grant Core-owned instances of every dev-pack `CardDefinition` (ids
/// `fifa17_*`), plus one deliberate duplicate of a single definition (to exercise
/// two-copies-of-one-card identity later).
///
/// **Ownership only — no FIFA wire ids.** The FIFA 17 integer item id is minted
/// lazily by `Fifa17IdentityResolver` at request time, never here. This keeps the
/// boundary clean: Core owns "this profile owns this card"; the adapter owns
/// "this owned item is wire id N".
///
/// Idempotent: owned ids are deterministic (`fdev-<card_id>`), inserted with
/// `INSERT OR IGNORE`, so re-running grants nothing new. The default profile
/// (`fifa23`/no-header) and any existing synthetic inventory are never touched.
pub async fn seed_fifa17_dev(pool: &Pool, card_db: &CardDb) -> AppResult<DevSeedReport> {
// The dev definitions are exactly the game-namespaced ids in the catalog.
let mut defs: Vec<&crate::models::card::CardDefinition> = card_db
.cards
.values()
.filter(|c| c.id.starts_with("fifa17_"))
.collect();
defs.sort_by(|a, b| a.id.cmp(&b.id));
// Ensure the fifa17-scoped profile + club exist (single-profile-per-game).
let profile = match profile_svc::get_active_profile(pool, FIFA17_GAME).await {
Ok(p) => p,
Err(_) => profile_svc::create_profile(pool, "OpenFUT Dev (FIFA17)", FIFA17_GAME).await?,
};
let club = match club_svc::get_club_by_profile(pool, &profile.id).await {
Ok(c) => c,
Err(_) => {
let c = Club::new(&profile.id, "OpenFUT Dev FC", 100_000);
club_svc::create_club(pool, &c).await?;
c
}
};
let prior: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ? AND card_id LIKE 'fifa17_%'",
)
.bind(&club.id)
.fetch_one(pool)
.await?;
let already_seeded = prior > 0;
// Grant one instance per definition; INSERT OR IGNORE keeps reruns idempotent.
for def in &defs {
grant_owned(
pool,
&format!("{DEV_OWNED_PREFIX}{}", def.id),
&club.id,
&def.id,
)
.await?;
}
// One deliberate duplicate of the first (lexicographic) definition → two
// owned copies of one card sharing a definition but distinct owned ids.
if let Some(first) = defs.first() {
grant_owned(
pool,
&format!("{DEV_OWNED_PREFIX}{}-b", first.id),
&club.id,
&first.id,
)
.await?;
}
let report = dev_coverage(pool, card_db, &club.id, already_seeded, defs.len()).await?;
info!(
"seeded fifa17 dev inventory: {} owned ({} gold) over club {}",
report.owned_total, report.gold, club.id
);
Ok(report)
}
async fn grant_owned(pool: &Pool, owned_id: &str, club_id: &str, card_id: &str) -> AppResult<()> {
sqlx::query(
"INSERT OR IGNORE INTO owned_cards \
(id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
VALUES (?, ?, ?, 0, NULL, ?)",
)
.bind(owned_id)
.bind(club_id)
.bind(card_id)
.bind(DEV_ACQUIRED_AT)
.execute(pool)
.await?;
Ok(())
}
/// Build the coverage report from the club's owned dev cards joined to `card_db`.
async fn dev_coverage(
pool: &Pool,
card_db: &CardDb,
club_id: &str,
already_seeded: bool,
definitions_available: usize,
) -> AppResult<DevSeedReport> {
let card_ids: Vec<String> = sqlx::query_scalar(
"SELECT card_id FROM owned_cards WHERE club_id = ? AND card_id LIKE 'fifa17_%'",
)
.bind(club_id)
.fetch_all(pool)
.await?;
let (mut gold, mut silver, mut bronze) = (0usize, 0usize, 0usize);
let mut positions: BTreeMap<String, usize> = BTreeMap::new();
let mut nations = std::collections::BTreeSet::new();
let mut leagues = std::collections::BTreeSet::new();
let mut club_counts: BTreeMap<String, usize> = BTreeMap::new();
let mut unique = std::collections::BTreeSet::new();
for card_id in &card_ids {
unique.insert(card_id.clone());
if let Some(def) = card_db.get(card_id) {
match Quality::from_overall(def.overall) {
Quality::Gold => gold += 1,
Quality::Silver => silver += 1,
Quality::Bronze => bronze += 1,
}
*positions.entry(def.position.clone()).or_default() += 1;
nations.insert(def.nation.clone());
leagues.insert(def.league.clone());
*club_counts.entry(def.club.clone()).or_default() += 1;
}
}
Ok(DevSeedReport {
game_id: FIFA17_GAME.to_string(),
already_seeded,
definitions_available,
owned_total: card_ids.len(),
unique_definitions: unique.len(),
gold,
silver,
bronze,
positions,
distinct_nations: nations.len(),
distinct_leagues: leagues.len(),
distinct_clubs: club_counts.len(),
max_same_club: club_counts.values().copied().max().unwrap_or(0),
gold_over_one_page: gold > MY_SQUAD_PAGE,
})
}
+11 -24
View File
@@ -38,34 +38,21 @@ impl CardDb {
Ok(Self { cards })
}
/// Merge a game's **opt-in development content pack** from
/// `{data_dir}/games/{game}/dev/cards.json` (a single `CardDefinition[]`).
/// This is NOT read by [`CardDb::load`]; it is loaded only when a game is
/// explicitly named in `Config::dev_content_games`, so default content stays
/// untouched. Returns the number of definitions merged. A missing file is an
/// error (opt-in means the pack is expected to exist).
pub fn load_game_dev(&mut self, data_dir: &str, game: &str) -> Result<usize> {
let path = Path::new(data_dir)
.join("games")
.join(game)
.join("dev")
.join("cards.json");
let content = std::fs::read_to_string(&path)
.with_context(|| format!("reading dev content pack {path:?}"))?;
let batch: Vec<CardDefinition> =
serde_json::from_str(&content).with_context(|| format!("parsing {path:?}"))?;
let n = batch.len();
for card in batch {
self.cards.insert(card.id.clone(), card);
}
tracing::info!("Loaded {} dev card definitions for game '{}'", n, game);
Ok(n)
}
pub fn get(&self, id: &str) -> Option<&CardDefinition> {
self.cards.get(id)
}
#[allow(dead_code)]
pub fn by_rarity(&self, rarity: &str) -> Vec<&CardDefinition> {
self.cards
.values()
.filter(|c| {
let r = format!("{:?}", c.rarity).to_lowercase();
r == rarity || rarity == "any"
})
.collect()
}
pub fn all(&self) -> Vec<&CardDefinition> {
self.cards.values().collect()
}
+115
View File
@@ -0,0 +1,115 @@
use crate::models::card::CardDefinition;
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChemistryResult {
/// Sum of all player chemistry values, max 33 (11 × 3).
pub total: u8,
/// Per-player chemistry in the same order as the input slice (13 each).
pub per_player: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq)]
enum LinkStrength {
/// Same club AND same nationality.
Full,
/// Same league OR same nationality (but not Full).
Half,
None,
}
fn link_strength(a: &CardDefinition, b: &CardDefinition) -> LinkStrength {
if a.club == b.club && a.nation == b.nation {
return LinkStrength::Full;
}
if a.league == b.league || a.nation == b.nation {
return LinkStrength::Half;
}
LinkStrength::None
}
/// Compute team chemistry for a squad given the resolved `CardDefinition`s.
///
/// Each player starts at 1 chemistry. Full links add 2 to both players;
/// half links add 1. Each player is clamped to [1, 3].
/// Squad chemistry is the sum of all player chemistry values (max 33).
pub fn calculate_chemistry(cards: &[CardDefinition]) -> ChemistryResult {
let n = cards.len();
let mut per_player: Vec<i16> = vec![1; n];
for i in 0..n {
for j in (i + 1)..n {
let bonus = match link_strength(&cards[i], &cards[j]) {
LinkStrength::Full => 2,
LinkStrength::Half => 1,
LinkStrength::None => 0,
};
if bonus > 0 {
per_player[i] += bonus;
per_player[j] += bonus;
}
}
}
let per_player: Vec<u8> = per_player
.into_iter()
.map(|c| c.clamp(1, 3) as u8)
.collect();
let total = per_player.iter().map(|&c| c as u16).sum::<u16>() as u8;
ChemistryResult { total, per_player }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::card::Rarity;
fn card(club: &str, league: &str, nation: &str) -> CardDefinition {
CardDefinition {
id: "x".into(),
name: "Player".into(),
overall: 75,
position: "CM".into(),
nation: nation.into(),
league: league.into(),
club: club.into(),
pace: 75,
shooting: 75,
passing: 75,
dribbling: 75,
defending: 75,
physical: 75,
rarity: Rarity::Gold,
image_path: None,
}
}
#[test]
fn all_same_club_and_nationality_max_chem() {
let cards: Vec<_> = (0..11).map(|_| card("FC Test", "LaLiga", "ESP")).collect();
let result = calculate_chemistry(&cards);
assert_eq!(result.total, 33);
assert!(result.per_player.iter().all(|&c| c == 3));
}
#[test]
fn all_different_attributes_min_chem() {
let cards: Vec<_> = (0..11)
.map(|i| card(&format!("Club{i}"), &format!("League{i}"), &format!("N{i}")))
.collect();
let result = calculate_chemistry(&cards);
assert_eq!(result.total, 11);
assert!(result.per_player.iter().all(|&c| c == 1));
}
#[test]
fn same_league_gives_half_links() {
// All same league but different club and nation → half links only.
let cards: Vec<_> = (0..11)
.map(|i| card(&format!("Club{i}"), "PremierLeague", &format!("N{i}")))
.collect();
let result = calculate_chemistry(&cards);
// Each player has half links to all 10 others → +10, clamped to 3.
assert!(result.per_player.iter().all(|&c| c == 3));
}
}
+4 -14
View File
@@ -88,18 +88,13 @@ 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: pick_order_json,
pick_order: serde_json::to_string(&pick_order).unwrap(),
picks: "[]".to_string(),
current_candidates: Some(candidates_json),
current_candidates: Some(serde_json::to_string(&candidates).unwrap()),
status: "active".to_string(),
reward_coins: 0,
reward_pack_id: None,
@@ -190,22 +185,17 @@ pub async fn pick_card(
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(candidates_json),
Some(serde_json::to_string(&next_candidates).unwrap()),
"active".to_string(),
0,
None,
0,
None,
)
}
};
let picks_json = serde_json::to_string(&picks)
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
let picks_json = serde_json::to_string(&picks).unwrap();
sqlx::query(
"UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \
+5 -21
View File
@@ -239,30 +239,16 @@ 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, Option<String>)> = sqlx::query_as(
"SELECT division, rivals_week_claimed, rivals_total_points, rivals_last_claimed_at \
FROM seasons WHERE profile_id = ?",
let row: Option<(i64, i64, i64)> = sqlx::query_as(
"SELECT division, rivals_week_claimed, rivals_total_points FROM seasons WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?;
let (division, week_claimed, total_pts, last_claimed_at) =
let (division, week_claimed, total_pts) =
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?;
@@ -285,13 +271,11 @@ 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, \
rivals_last_claimed_at = ? WHERE profile_id = ?",
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100 \
WHERE profile_id = ?",
)
.bind(next_week)
.bind(&now)
.bind(profile_id)
.execute(pool)
.await?;
-34
View File
@@ -1,34 +0,0 @@
//! Generic read/write for [`crate::models::game_ext`] opaque state.
//!
//! Core never interprets the payload. Writes happen INSIDE the owning entity's
//! transaction (see `squad::replace_squad_with_extension`) so the canonical
//! entity and its opaque extension commit atomically — there is deliberately no
//! standalone "write extension" entry point that could desync the two.
use crate::db::Pool;
use crate::error::AppResult;
use crate::models::game_ext::GameEntityExt;
/// Fetch the stored opaque extension for a scoped entity, or `None`. The caller
/// compares `canonical_fingerprint` against the entity's *current* fingerprint to
/// decide freshness — this layer does not know how to fingerprint any entity.
pub async fn get_ext(
pool: &Pool,
game_id: &str,
entity_kind: &str,
entity_id: &str,
namespace: &str,
) -> AppResult<Option<GameEntityExt>> {
let row = sqlx::query_as::<_, GameEntityExt>(
"SELECT game_id, entity_kind, entity_id, namespace, schema_version, \
canonical_fingerprint, payload, updated_at FROM game_entity_ext \
WHERE game_id = ? AND entity_kind = ? AND entity_id = ? AND namespace = ?",
)
.bind(game_id)
.bind(entity_kind)
.bind(entity_id)
.bind(namespace)
.fetch_optional(pool)
.await?;
Ok(row)
}
-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
}
}
+4 -9
View File
@@ -43,12 +43,11 @@ pub async fn refresh_npc_listings(
card_db: &CardDb,
event_defs: &[EventDefinition],
) -> AppResult<usize> {
// Clean up expired listings and previous NPC listings.
// Player-posted listings (is_npc = 0) are intentionally preserved.
// Clean up expired and unsold listings
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
.execute(pool)
.await?;
sqlx::query("DELETE FROM market_listings WHERE sold = 0 AND is_npc = 1")
sqlx::query("DELETE FROM market_listings WHERE sold = 0")
.execute(pool)
.await?;
@@ -108,8 +107,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, is_npc) \
VALUES (?, ?, ?, ?, ?, ?, 0, 1)",
(id, card_id, seller_name, price, listed_at, expires_at, sold) \
VALUES (?, ?, ?, ?, ?, ?, 0)",
)
.bind(&listing.id)
.bind(&listing.card_id)
@@ -191,10 +190,6 @@ 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 \
+3 -13
View File
@@ -94,12 +94,6 @@ 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 {
@@ -190,13 +184,9 @@ pub async fn process_match(
objectives_updated.append(&mut c);
for obj_id in &objectives_updated {
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;
let title = "Objective complete!";
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", obj_id);
let _ = notification::create(pool, "objective_complete", title, &body).await;
}
// Decrement loan matches remaining for squad starters; collect expired loan IDs.
-3
View File
@@ -6,8 +6,6 @@ pub mod notification;
pub mod draft;
pub mod event;
pub mod fut_champs;
pub mod game_ext;
pub mod inventory;
pub mod season;
pub mod market;
pub mod match_service;
@@ -17,6 +15,5 @@ pub mod profile;
pub mod sbc;
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| d.metric.as_str() == metric)
.filter(|d| format!("{:?}", d.metric).to_lowercase() == metric)
{
let existing = sqlx::query_as::<_, ObjectiveProgress>(
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
+2 -2
View File
@@ -90,8 +90,8 @@ pub async fn open_pack(
.all()
.into_iter()
.filter(|c| {
let r = c.rarity.as_str();
rarities.contains(&r.to_string())
let r = format!("{:?}", c.rarity).to_lowercase();
rarities.contains(&r)
})
.cloned()
.collect()
+8 -22
View File
@@ -5,41 +5,33 @@ use crate::{
};
use chrono::Utc;
/// 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> {
pub async fn get_active_profile(pool: &Pool) -> AppResult<Profile> {
sqlx::query_as::<_, Profile>(
"SELECT id, username, level, xp, game_id, created_at, updated_at \
FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
"SELECT id, username, level, xp, created_at, updated_at FROM profiles 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, 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)
pub async fn create_profile(pool: &Pool, username: &str) -> AppResult<Profile> {
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles")
.fetch_one(pool)
.await?;
if existing > 0 {
return Err(AppError::Conflict(
"a profile already exists for this game; OpenFUT is single-player per game".into(),
"a profile already exists; OpenFUT is single-player only".into(),
));
}
let profile = Profile::new(username, game_id);
let profile = Profile::new(username);
sqlx::query(
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO profiles (id, username, level, xp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
)
.bind(&profile.id)
.bind(&profile.username)
.bind(profile.level)
.bind(profile.xp)
.bind(&profile.game_id)
.bind(profile.created_at)
.bind(profile.updated_at)
.execute(pool)
@@ -67,13 +59,7 @@ pub async fn add_xp_with_levelup(
club_id: &str,
xp_to_add: i64,
) -> AppResult<Vec<LevelUpEvent>> {
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 profile = get_active_profile(pool).await?;
let old_level = level_for_xp(profile.xp);
let new_total_xp = profile.xp + xp_to_add;
let new_level = level_for_xp(new_total_xp);
+3
View File
@@ -101,6 +101,9 @@ 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?;
}
+59 -919
View File
File diff suppressed because it is too large Load Diff
-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());
}
}
-181
View File
@@ -1,181 +0,0 @@
//! FIFA 17 development content pack + ownership seed (Commit 5).
//!
//! Proves: the dev pack is opt-in and isolated from default content; the seed
//! creates a `game_id=fifa17` profile/club and grants real Core `OwnedCard`s
//! (never FIFA wire ids); it is idempotent and leaves the default profile alone;
//! and the seeded inventory can exercise the retail `/club` filter + pagination.
use openfut_core::db::Pool;
use openfut_core::services::card_db::CardDb;
async fn pool() -> Pool {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("in-memory sqlite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations");
pool
}
fn dev_card_db() -> CardDb {
let mut db = CardDb::load("data").expect("default cards");
db.load_game_dev("data", "fifa17").expect("dev pack");
db
}
// ── Content isolation ────────────────────────────────────────────────────────
#[test]
fn default_load_never_contains_dev_pack() {
// The default global loader reads only data/cards — the dev pack under
// data/games/fifa17/dev must be invisible unless explicitly requested.
let default = CardDb::load("data").expect("default cards");
let leaked: Vec<_> = default
.cards
.keys()
.filter(|k| k.starts_with("fifa17_"))
.collect();
assert!(
leaked.is_empty(),
"default content must not include FIFA17 dev cards: {leaked:?}"
);
assert!(
!default.cards.is_empty(),
"default synthetic catalogue still loads"
);
}
#[test]
fn opt_in_load_adds_dev_pack_only() {
let default_n = CardDb::load("data").unwrap().cards.len();
let db = dev_card_db();
let dev: Vec<_> = db
.cards
.keys()
.filter(|k| k.starts_with("fifa17_"))
.collect();
assert_eq!(dev.len(), 32, "the curated dev pack is 32 definitions");
assert_eq!(
db.cards.len(),
default_n + 32,
"dev pack is additive; default content unchanged"
);
}
#[test]
fn dev_definitions_carry_semantic_names_not_raw_ids() {
let db = dev_card_db();
for c in db.cards.values().filter(|c| c.id.starts_with("fifa17_")) {
// Semantic Core fields are names, never raw FIFA numeric entity ids.
assert!(
c.nation.parse::<i64>().is_err(),
"nation must be a name, got {:?}",
c.nation
);
assert!(
c.league.parse::<i64>().is_err(),
"league must be a name: {:?}",
c.league
);
assert!(
c.club.parse::<i64>().is_err(),
"club must be a name: {:?}",
c.club
);
assert!(!c.name.is_empty(), "every dev card has a player name");
}
}
// ── Ownership seed ─────────────────────────────────────────────────────────
#[tokio::test]
async fn seed_grants_game_scoped_inventory_with_filter_coverage() {
let pool = pool().await;
let db = dev_card_db();
let r = openfut_core::seed::seed_fifa17_dev(&pool, &db)
.await
.unwrap();
assert_eq!(r.game_id, "fifa17");
assert!(!r.already_seeded);
assert_eq!(r.definitions_available, 32);
assert_eq!(r.owned_total, 33, "32 defs + 1 deliberate duplicate");
assert_eq!(r.unique_definitions, 32);
assert!(r.gold_over_one_page, "gold spans >1 page (22 > 11)");
assert!(r.gold > 11, "enough gold for pagination");
assert!(r.silver >= 1 && r.bronze >= 1, "quality spread");
assert!(r.positions.contains_key("GK"), "GK present");
assert!(r.positions.contains_key("ST"), "ST present");
assert!(r.distinct_leagues >= 2, "multiple leagues");
assert!(r.distinct_nations >= 2, "multiple nations");
assert!(r.max_same_club >= 2, "a same-club group for team filters");
// The seed created ONLY a fifa17 profile — the default (fifa23) profile and
// any synthetic inventory are untouched.
let games: Vec<(String,)> = sqlx::query_as("SELECT game_id FROM profiles")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(
games,
vec![("fifa17".to_string(),)],
"only the fifa17 profile exists"
);
// Every seeded owned card references a dev-pack definition (all renderable).
let orphans: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM owned_cards o \
WHERE o.card_id LIKE 'fifa17_%' AND o.card_id NOT IN \
(SELECT card_id FROM owned_cards WHERE card_id LIKE 'fifa17_%')",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(orphans, 0);
}
#[tokio::test]
async fn seed_is_idempotent_across_reruns() {
let pool = pool().await;
let db = dev_card_db();
let first = openfut_core::seed::seed_fifa17_dev(&pool, &db)
.await
.unwrap();
let second = openfut_core::seed::seed_fifa17_dev(&pool, &db)
.await
.unwrap();
assert!(!first.already_seeded);
assert!(second.already_seeded, "second run sees existing ownership");
assert_eq!(first.owned_total, second.owned_total, "no duplicate grants");
let n: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE card_id LIKE 'fifa17_%'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(n, 33, "row count stable after rerun");
}
#[tokio::test]
async fn seed_creates_exactly_one_two_copy_definition() {
let pool = pool().await;
let db = dev_card_db();
openfut_core::seed::seed_fifa17_dev(&pool, &db)
.await
.unwrap();
// Exactly one definition is owned twice (distinct owned ids, same card_id):
// the identity foundation for "two copies of one card" later.
let dupes: Vec<(String, i64)> = sqlx::query_as(
"SELECT card_id, COUNT(*) c FROM owned_cards WHERE card_id LIKE 'fifa17_%' \
GROUP BY card_id HAVING c > 1",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(dupes.len(), 1, "exactly one duplicated definition");
assert_eq!(dupes[0].1, 2, "owned twice");
}
+4 -386
View File
@@ -1404,14 +1404,10 @@ async fn test_rivals_reward_increments_week_counter() {
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
})).await;
// First claim succeeds
json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
let (s, json) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
assert_eq!(s, StatusCode::OK, "{json}");
assert_eq!(json["week_number"], 1, "first claim should be week 1");
// Immediate re-claim is blocked by the 24-hour cooldown
let (s, _) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
assert_eq!(s, StatusCode::CONFLICT, "re-claim within 24h should be rejected");
assert_eq!(json["week_number"], 2, "second claim should be week 2");
}
// ── Phase 15: Pack Store ──────────────────────────────────────────────────────
@@ -1715,7 +1711,7 @@ async fn test_auth_reset_clears_profile() {
})).await;
// Reset
let (status, json) = json_post(&app, "/auth/reset", serde_json::json!({ "confirm": "reset" })).await;
let (status, json) = json_post(&app, "/auth/reset", serde_json::json!({})).await;
assert_eq!(status, StatusCode::OK, "{json}");
assert_eq!(json["reset"], true);
@@ -1733,7 +1729,7 @@ async fn test_auth_reset_allows_new_profile() {
let app = build_test_app().await;
auth(&app, "FirstProfile").await;
json_post(&app, "/auth/reset", serde_json::json!({ "confirm": "reset" })).await;
json_post(&app, "/auth/reset", serde_json::json!({})).await;
// Should be able to create a new profile after reset
let (status, json) = json_post(&app, "/auth/local",
@@ -1901,381 +1897,3 @@ async fn test_trade_history_empty_initially() {
assert!(json["trades"].is_array());
assert_eq!(json["trades"].as_array().unwrap().len(), 0);
}
// ── Owned-item query: filtering, deterministic order, pagination ──────────────
//
// Regression coverage for the FIFA17 "My Squad" owned-player search. Retail
// evidence (sha256-identical response bodies) proved the Python oracle applies
// ONLY league+team and ignores level/rare/position/nation/start/count, which
// re-serves page one forever and amplifies requests. Core intentionally fixes
// this: filter (AND) -> deterministic order -> paginate. Semantics only — no raw
// FIFA ids reach Core (the adapter resolves ids to the names asserted here).
/// Build an app AND keep the pool, so tests can seed a deterministic inventory.
async fn build_test_app_with_pool() -> (axum::Router, sqlx::SqlitePool) {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.expect("in-memory sqlite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations");
let app = openfut_core::build_app(pool.clone(), "data")
.await
.expect("app build");
(app, pool)
}
/// A deliberately cluttered but KNOWN owned inventory drawn from committed card
/// data. Spans qualities (gold/silver/bronze), several leagues/nations/positions
/// and includes irrelevant "junk" that must disappear under a filter. Ids are
/// `oc_NN` in listed order so the `(overall desc, owned_id asc)` order is fixed.
const CLUTTERED_FIXTURE: &[(&str, &str)] = &[
("oc_00", "card_raregold_008"), // 86 CDM Ghana / Premier League / Chelsea
("oc_01", "card_hero_004"), // 85 CDM Nigeria / Premier League / Chelsea
("oc_02", "card_raregold_010"), // 87 LB Russia / Premier League / Arsenal
("oc_03", "card_pl_001"), // 84 ST England / Premier League / Northgate
("oc_04", "card_ll_008"), // 82 ST Argentina / La Liga / Valencia Azul
("oc_05", "card_raregold_004"), // 89 LW Argentina / Primera Division / Boca
("oc_06", "card_totw_004"), // 92 LW Argentina / Primera Division / Boca
("oc_07", "card_silver_001"), // 72 ST Brazil / Brasileirao / Athletico
("oc_08", "card_silver_002"), // 70 CM Italy / Serie B / Frosinone
("oc_09", "card_bronze_001"), // 62 ST Brazil / Serie B / Santos
("oc_10", "card_bronze_002"), // 60 CM Italy / Serie C / Modena
("oc_11", "card_raregold_001"), // 88 ST Brazil / Brasileirao / Flamengo
("oc_12", "card_raregold_002"), // 86 CAM Italy / Serie A / AS Roma
("oc_13", "card_raregold_003"), // 87 CB Germany / Bundesliga / Bayer
];
async fn seed_cluttered(app: &axum::Router, pool: &sqlx::SqlitePool) {
auth(app, "ClutterClub").await;
let club_id: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1")
.fetch_one(pool)
.await
.expect("club exists after auth");
// Replace the auto-granted starter pack with the deterministic fixture.
sqlx::query("DELETE FROM owned_cards WHERE club_id = ?")
.bind(&club_id)
.execute(pool)
.await
.unwrap();
for (oc_id, card_id) in CLUTTERED_FIXTURE {
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus) \
VALUES (?, ?, ?, 0, NULL, '2026-01-01T00:00:00Z', 'basic', NULL, 0)",
)
.bind(oc_id)
.bind(&club_id)
.bind(card_id)
.execute(pool)
.await
.unwrap();
}
}
fn coll_card_ids(v: &Value) -> Vec<String> {
v["collection"]
.as_array()
.unwrap()
.iter()
.map(|e| e["card"]["id"].as_str().unwrap().to_string())
.collect()
}
fn sorted(mut v: Vec<String>) -> Vec<String> {
v.sort();
v
}
#[tokio::test]
async fn test_owned_query_no_filter_returns_all() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (s, j) = json_get(&app, "/collection").await;
assert_eq!(s, StatusCode::OK, "{j}");
assert_eq!(j["total"], 14);
assert_eq!(j["returned"], 14);
assert_eq!(coll_card_ids(&j).len(), 14);
}
#[tokio::test]
async fn test_owned_query_quality_gold() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection?quality=gold").await;
assert_eq!(j["total"], 10);
assert_eq!(
sorted(coll_card_ids(&j)),
sorted(
vec![
"card_raregold_008",
"card_hero_004",
"card_raregold_010",
"card_pl_001",
"card_ll_008",
"card_raregold_004",
"card_totw_004",
"card_raregold_001",
"card_raregold_002",
"card_raregold_003",
]
.into_iter()
.map(String::from)
.collect()
)
);
}
#[tokio::test]
async fn test_owned_query_position() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection?position=ST").await;
assert_eq!(
sorted(coll_card_ids(&j)),
sorted(
[
"card_pl_001",
"card_ll_008",
"card_silver_001",
"card_bronze_001",
"card_raregold_001"
]
.into_iter()
.map(String::from)
.collect()
)
);
}
#[tokio::test]
async fn test_owned_query_nation() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection?nation=Argentina").await;
assert_eq!(
sorted(coll_card_ids(&j)),
sorted(
["card_ll_008", "card_raregold_004", "card_totw_004"]
.into_iter()
.map(String::from)
.collect()
)
);
}
#[tokio::test]
async fn test_owned_query_league() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection?league=Premier%20League").await;
assert_eq!(
sorted(coll_card_ids(&j)),
sorted(
[
"card_raregold_008",
"card_hero_004",
"card_raregold_010",
"card_pl_001"
]
.into_iter()
.map(String::from)
.collect()
)
);
}
#[tokio::test]
async fn test_owned_query_club() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection?club=Chelsea").await;
assert_eq!(
sorted(coll_card_ids(&j)),
sorted(
["card_raregold_008", "card_hero_004"]
.into_iter()
.map(String::from)
.collect()
)
);
}
#[tokio::test]
async fn test_owned_query_league_and_club() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection?league=Premier%20League&club=Chelsea").await;
assert_eq!(
sorted(coll_card_ids(&j)),
sorted(
["card_raregold_008", "card_hero_004"]
.into_iter()
.map(String::from)
.collect()
)
);
}
#[tokio::test]
async fn test_owned_query_league_and_position() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection?league=Premier%20League&position=ST").await;
assert_eq!(j["total"], 1);
assert_eq!(coll_card_ids(&j), ["card_pl_001"]);
}
#[tokio::test]
async fn test_owned_query_quality_and_position() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection?quality=gold&position=ST").await;
assert_eq!(
sorted(coll_card_ids(&j)),
sorted(
["card_pl_001", "card_ll_008", "card_raregold_001"]
.into_iter()
.map(String::from)
.collect()
)
);
}
#[tokio::test]
async fn test_owned_query_no_results() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (s, j) = json_get(&app, "/collection?nation=Argentina&club=Chelsea").await;
assert_eq!(s, StatusCode::OK);
assert_eq!(j["total"], 0);
assert!(coll_card_ids(&j).is_empty());
}
#[tokio::test]
async fn test_owned_query_deterministic_order_overall_desc() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection").await;
let overalls: Vec<i64> = j["collection"]
.as_array()
.unwrap()
.iter()
.map(|e| e["effective_overall"].as_i64().unwrap())
.collect();
let mut sorted_desc = overalls.clone();
sorted_desc.sort_by(|a, b| b.cmp(a));
assert_eq!(
overalls, sorted_desc,
"collection must be overall-descending"
);
assert_eq!(
coll_card_ids(&j)[0],
"card_totw_004",
"highest overall (92) first"
);
}
#[tokio::test]
async fn test_owned_query_offset_and_limit() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
// gold set ordered: totw_004, raregold_004, raregold_001, raregold_010, ...
let (_, j) = json_get(&app, "/collection?quality=gold&limit=3").await;
assert_eq!(
j["total"], 10,
"total is the filtered count, not the page size"
);
assert_eq!(j["returned"], 3);
assert_eq!(
coll_card_ids(&j),
["card_totw_004", "card_raregold_004", "card_raregold_001"]
);
let (_, j2) = json_get(&app, "/collection?quality=gold&offset=3&limit=3").await;
assert_eq!(j2["total"], 10);
assert_eq!(
coll_card_ids(&j2)[0],
"card_raregold_010",
"offset advances past page one"
);
}
#[tokio::test]
async fn test_owned_query_pagination_no_repeated_first_page() {
// THE production regression: paging must advance and never re-serve page one,
// with filters retained on every page. A mutation that ignores `offset` (the
// Python bug) makes every page identical and fails here.
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let page = |off: u32| {
let app = app.clone();
async move {
let (_, j) = json_get(
&app,
&format!("/collection?quality=gold&limit=4&start_ignored=0&offset={off}"),
)
.await;
j
}
};
let p0 = page(0).await;
let p1 = page(4).await;
let p2 = page(8).await;
let ids0 = coll_card_ids(&p0);
let ids1 = coll_card_ids(&p1);
let ids2 = coll_card_ids(&p2);
// sizes: 4, 4, 2 over the 10-item gold set
assert_eq!(ids0.len(), 4);
assert_eq!(ids1.len(), 4);
assert_eq!(ids2.len(), 2);
// page one is NOT repeated on later pages
assert_ne!(ids0, ids1, "offset advance must not re-serve page one");
assert_ne!(ids0, ids2);
// pairwise disjoint (no duplicates across pages)
for a in &ids0 {
assert!(
!ids1.contains(a) && !ids2.contains(a),
"pages overlap on {a}"
);
}
for a in &ids1 {
assert!(!ids2.contains(a), "pages overlap on {a}");
}
// union == the full filtered set, each page still all-gold, no dupes
let mut union: Vec<String> = ids0.iter().chain(&ids1).chain(&ids2).cloned().collect();
let count = union.len();
union.sort();
union.dedup();
assert_eq!(union.len(), count, "no duplicate items across pages");
assert_eq!(union.len(), 10, "pages cover the whole filtered set");
// filter retained across pages: every id on every page is a gold card
let (_, all_gold) = json_get(&app, "/collection?quality=gold").await;
let gold_set = sorted(coll_card_ids(&all_gold));
assert_eq!(sorted(union), gold_set);
// total constant across pages
assert_eq!(p0["total"], 10);
assert_eq!(p1["total"], 10);
assert_eq!(p2["total"], 10);
}
#[tokio::test]
async fn test_owned_query_parameter_order_invariance() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, a) = json_get(&app, "/collection?league=Premier%20League&position=ST").await;
let (_, b) = json_get(&app, "/collection?position=ST&league=Premier%20League").await;
assert_eq!(
coll_card_ids(&a),
coll_card_ids(&b),
"HTTP param order must not change the result"
);
assert_eq!(a["total"], b["total"]);
}