5 Commits

Author SHA1 Message Date
funman300 615c5fd7a5 feat(squad): generic game-scoped opaque extension + server fingerprint, atomic in replace_squad tx 2026-08-12 01:39:04 +00:00
funman300 36abd4b6fb feat(seed): curated FIFA17 dev content pack + opt-in game-scoped ownership seed 2026-08-11 22:55:02 +00:00
funman300 6acae54f80 feat(club): semantic owned-item query + FIFA17 filter/pagination fix
Game-independent owned-inventory query (services::inventory::{OwnedItemQuery,
apply_query} + a Quality tier) that filters (AND) -> orders deterministically
(effective_overall desc, owned_card_id asc) -> paginates, wired into
GET /collection. Fixes the FIFA17 My Squad search: the Python oracle applied
only league+team and ignored level/rare/position/nation/start/count (proven by
response sha256 identity across pages -> the request-amplification bug); Core
now applies all proven filters and paginates. rare=SP left UNKNOWN.

Tests: +9 inventory unit, +14 /collection integration (full matrix incl. the
repeated-first-page regression); 9 mutations killed.

Isolated from an unrelated dirty working tree via a clean worktree at eab522a;
touches only the 5 slice files, no unrelated reformatting.
2026-08-11 21:38:16 +00:00
funman300 aecbff0de8 squad: transactional replace_squad + a game-rules boundary for evaluation
Driven by a retail FIFA 17 capture: the client sends the WHOLE squad on
every save (~2KB, every slot/item/kit number), and a user swapping two
players produced nine changed slots across two saves. Slot deltas
therefore do not describe intent, so the only honest semantic operation
is "this is the squad now".

replace_squad, and why save_squad no longer has its own write path
------------------------------------------------------------------
save_squad UPDATEd the squad, DELETEd every squad_players row, then
INSERTed the new ones one at a time -- all outside a transaction. A
failure part-way through left a squad with some old players deleted and
only some new ones written: a state nobody asked for and no client can
detect. It also never checked that the cards being placed belonged to the
club, and accepted the same card in two slots.

replace_squad validates BEFORE any write (so a rejection leaves the
stored squad untouched) and performs every write in one transaction:

  - card must exist AND belong to this club
  - a card may occupy at most one slot
  - a slot may hold at most one card
  - slot indices must not be negative
  - the squad being replaced must belong to this club

save_squad is now a thin wrapper over it. That deliberately tightens the
existing Core REST route -- it now validates ownership and rejects
duplicates. Those were bugs, and two write paths with different
guarantees is how the stricter one gets bypassed.

A cross-club card is reported as NotFound, not Forbidden: whether a card
exists in someone else's club is not the caller's business.

Game-rules boundary
-------------------
Core contained calculate_chemistry -- a full FUT-style link-scoring
formula. Chemistry is game-specific and changed between FIFA
generations, so a formula compiled into generic Core quietly makes Core a
FIFA-something server.

It now sits behind SquadRules, with the existing implementation preserved
byte-for-byte in behaviour as DefaultSquadRules ("openfut-default-v2").
Rules take a resolved SquadSnapshot of pure data rather than a pool and a
card database, so they are synchronous, testable without fixtures, and
cannot reach Core's storage. Fifa17SquadRules is deliberately NOT
written: the algorithm is unproven and inventing one is worse than having
none.

Client-reported values
----------------------
FIFA sends its own chemistry/rating/starRating. ClientReportedEvaluation
is a DIFFERENT TYPE from SquadEvaluation, so assigning one where the
other belongs does not compile. Disagreement is reported through
EvaluationComparison and never reconciled in either direction -- the
server's value stands and the mismatch is surfaced for investigation
against the exact squad that produced it.

Evidence
--------
17 unit tests, 113 in the crate, 7/7 mutations killed including
"ownership check removed", "replacement becomes a merge" and
"client-reported chemistry becomes the server value".

Scope note: `cargo fmt` without -p reformatted ~27 unrelated files; those
were reverted so this commit touches only the squad path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:33:05 +00:00
funman300 8c8a4116bf wip: checkpoint multi-game core work 2026-08-07 12:03:21 -07:00
53 changed files with 3564 additions and 245 deletions
+546
View File
@@ -0,0 +1,546 @@
[
{
"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
@@ -0,0 +1,5 @@
-- Distinguish NPC-generated listings from player-posted ones so that the
-- periodic NPC refresh does not accidentally wipe player listings.
ALTER TABLE market_listings ADD COLUMN is_npc INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_market_npc ON market_listings(is_npc, sold);
+3
View File
@@ -0,0 +1,3 @@
-- Track when the player last claimed their rivals weekly reward to enforce a
-- 24-hour cooldown between claims.
ALTER TABLE seasons ADD COLUMN rivals_last_claimed_at TEXT;
+14
View File
@@ -0,0 +1,14 @@
-- Multi-game support. Every profile (and therefore all of its downstream state,
-- which hangs off profiles(id) via profile_id / club_id foreign keys) is scoped to
-- a game. Bridges identify their game with the X-OpenFUT-Game request header.
--
-- Default 'fifa23' preserves the existing single-game behaviour: the current bridge
-- and the integration tests send no game header, so they keep operating on the same
-- (now fifa23-tagged) profile with zero behaviour change. The FIFA 17 bridge sends
-- X-OpenFUT-Game: fifa17 and therefore gets its own isolated profile/club/state.
--
-- Only profiles needs the column: get_active_profile becomes game-scoped, and because
-- all other tables reference a profile (directly via profile_id or via
-- club_id -> clubs.profile_id), scoping the active profile isolates the whole tree.
ALTER TABLE profiles ADD COLUMN game_id TEXT NOT NULL DEFAULT 'fifa23';
CREATE INDEX IF NOT EXISTS idx_profiles_game ON profiles(game_id);
+23
View File
@@ -0,0 +1,23 @@
-- 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)
);
+5 -1
View File
@@ -42,7 +42,11 @@ pub struct AppState {
} }
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> { pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
let card_db = Arc::new(CardDb::load(&cfg.data_dir)?); 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 pack_defs = Arc::new(load_pack_definitions(&cfg.data_dir)?); let pack_defs = Arc::new(load_pack_definitions(&cfg.data_dir)?);
let obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?); let obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?);
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?); let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
+14 -1
View File
@@ -5,8 +5,11 @@ pub struct Config {
pub listen_addr: String, pub listen_addr: String,
pub database_url: String, pub database_url: String,
pub data_dir: String, pub data_dir: String,
#[allow(dead_code)]
pub max_connections: u32, 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 { impl Config {
@@ -20,6 +23,16 @@ impl Config {
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(5), .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 type Pool = SqlitePool;
pub async fn init_pool(database_url: &str) -> Result<Pool> { pub async fn init_pool(database_url: &str, max_connections: u32) -> Result<Pool> {
info!("Connecting to database: {}", database_url); info!("Connecting to database: {}", database_url);
let opts = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true); let opts = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(5) .max_connections(max_connections)
.connect_with(opts) .connect_with(opts)
.await?; .await?;
sqlx::query("PRAGMA journal_mode=WAL") sqlx::query("PRAGMA journal_mode=WAL")
+51
View File
@@ -0,0 +1,51 @@
//! Request extractors shared across routes.
use axum::{
async_trait,
extract::FromRequestParts,
http::{request::Parts, HeaderName},
};
use std::convert::Infallible;
/// The game a request belongs to, read from the `X-OpenFUT-Game` header.
///
/// Multi-game support: each game bridge tags its requests with its own id
/// (e.g. `fifa17`, `fifa23`) so core can scope the active profile - and thus all
/// downstream club/card/squad/market state - to that game. Defaults to `fifa23`
/// when the header is absent, so the existing FIFA 23 bridge and the integration
/// tests (which send no header) keep operating on their game unchanged.
///
/// Extraction never fails: a missing or malformed header falls back to the default.
#[derive(Debug, Clone)]
pub struct GameId(pub String);
/// The game assumed when no `X-OpenFUT-Game` header is present.
pub const DEFAULT_GAME: &str = "fifa23";
static HEADER: HeaderName = HeaderName::from_static("x-openfut-game");
impl GameId {
pub fn as_str(&self) -> &str {
&self.0
}
}
#[async_trait]
impl<S> FromRequestParts<S> for GameId
where
S: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let game = parts
.headers
.get(&HEADER)
.and_then(|v| v.to_str().ok())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.unwrap_or(DEFAULT_GAME)
.to_string();
Ok(GameId(game))
}
}
+2
View File
@@ -2,6 +2,7 @@ pub mod app;
pub mod config; pub mod config;
pub mod db; pub mod db;
pub mod error; pub mod error;
pub mod extractors;
pub mod middleware; pub mod middleware;
pub mod modding; pub mod modding;
pub mod models; pub mod models;
@@ -20,6 +21,7 @@ pub async fn build_app(pool: db::Pool, data_dir: &str) -> Result<Router> {
database_url: "sqlite::memory:".into(), database_url: "sqlite::memory:".into(),
data_dir: data_dir.to_string(), data_dir: data_dir.to_string(),
max_connections: 1, max_connections: 1,
dev_content_games: Vec::new(),
}; };
app::build(pool, cfg).await app::build(pool, cfg).await
} }
+15 -1
View File
@@ -16,9 +16,23 @@ async fn main() -> Result<()> {
.init(); .init();
let cfg = config::Config::from_env()?; 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); info!("OpenFUT Core starting on {}", cfg.listen_addr);
let pool = db::init_pool(&cfg.database_url).await?; let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
db::run_migrations(&pool).await?; db::run_migrations(&pool).await?;
seed::maybe_seed(&pool).await?; seed::maybe_seed(&pool).await?;
+1 -24
View File
@@ -1,24 +1 @@
use anyhow::{Context, Result}; // Reserved for future modding loader utilities.
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,4 +1,2 @@
//! Modding support: load JSON data files from the data/ directory. //! Modding support: load JSON data files from the data/ directory.
//! All game content (cards, packs, objectives, SBCs) is data-driven. //! All game content (cards, packs, objectives, SBCs) is data-driven.
pub mod loader;
+41
View File
@@ -13,6 +13,47 @@ pub enum Rarity {
Icon, 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. /// A card definition loaded from JSON data files.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardDefinition { pub struct CardDefinition {
+60
View File
@@ -0,0 +1,60 @@
//! 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,6 +5,7 @@ pub mod notification;
pub mod club; pub mod club;
pub mod draft; pub mod draft;
pub mod fut_champs; pub mod fut_champs;
pub mod game_ext;
pub mod event; pub mod event;
pub mod season; pub mod season;
pub mod market; pub mod market;
+13
View File
@@ -21,6 +21,19 @@ pub enum ObjectiveMetric {
CoinsEarned, 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 /// Objective definition from data/objectives/*.json
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectiveDefinition { pub struct ObjectiveDefinition {
+5 -1
View File
@@ -8,18 +8,22 @@ pub struct Profile {
pub username: String, pub username: String,
pub level: i64, pub level: i64,
pub xp: 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 created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
impl Profile { impl Profile {
pub fn new(username: impl Into<String>) -> Self { pub fn new(username: impl Into<String>, game_id: impl Into<String>) -> Self {
let now = Utc::now(); let now = Utc::now();
Self { Self {
id: Uuid::new_v4().to_string(), id: Uuid::new_v4().to_string(),
username: username.into(), username: username.into(),
level: 1, level: 1,
xp: 0, xp: 0,
game_id: game_id.into(),
created_at: now, created_at: now,
updated_at: now, updated_at: now,
} }
+46
View File
@@ -54,3 +54,49 @@ pub struct SquadPlayerInput {
pub is_captain: bool, pub is_captain: bool,
pub is_on_bench: 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,
}
+3 -2
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{extract::State, Json}; use axum::{extract::State, Json};
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -7,8 +8,8 @@ use crate::{
services::{achievement as ach_svc, club as club_svc, profile as profile_svc}, services::{achievement as ach_svc, club as club_svc, profile as profile_svc},
}; };
pub async fn get_achievements(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_achievements(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id).await; let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id).await;
let achievements = ach_svc::list_with_status(&state.pool, &state.achievement_defs).await?; let achievements = ach_svc::list_with_status(&state.pool, &state.achievement_defs).await?;
+85 -28
View File
@@ -1,9 +1,11 @@
use axum::{extract::State, Json}; use axum::{extract::State, Json};
use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::{ use crate::{
app::AppState, app::AppState,
error::AppResult, error::{AppError, AppResult},
extractors::GameId,
models::{club::Club, profile::CreateProfileRequest}, models::{club::Club, profile::CreateProfileRequest},
seed, seed,
services::{club as club_svc, profile as profile_svc}, services::{club as club_svc, profile as profile_svc},
@@ -11,11 +13,12 @@ use crate::{
pub async fn post_auth_local( pub async fn post_auth_local(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<CreateProfileRequest>, Json(req): Json<CreateProfileRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let username = req.username.unwrap_or_else(|| "Player 1".into()); let username = req.username.unwrap_or_else(|| "Player 1".into());
let profile = profile_svc::create_profile(&state.pool, &username).await?; let profile = profile_svc::create_profile(&state.pool, &username, game.as_str()).await?;
let club = Club::new(&profile.id, "OpenFUT FC", 5000); let club = Club::new(&profile.id, "OpenFUT FC", 5000);
club_svc::create_club(&state.pool, &club).await?; club_svc::create_club(&state.pool, &club).await?;
@@ -31,51 +34,105 @@ pub async fn post_auth_local(
/// GET /auth/status — lightweight check: does a profile exist? /// GET /auth/status — lightweight check: does a profile exist?
/// Returns 200 `{ "has_profile": true/false }` without erroring. /// Returns 200 `{ "has_profile": true/false }` without erroring.
pub async fn get_auth_status(State(state): State<AppState>) -> AppResult<Json<Value>> { 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") let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
.bind(game.as_str())
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await?; .await?;
Ok(Json(json!({ "has_profile": count > 0 }))) 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. /// POST /auth/reset — wipe all game data and start fresh.
/// Deletes every user-data table in dependency order. The schema tables /// Requires `{"confirm":"reset"}` in the request body as a safeguard against
/// (migrations) are left intact; calling POST /auth/local afterwards /// accidental or unauthenticated calls. Deletes every user-data table in
/// creates a new profile. /// dependency order; schema (migrations) is preserved.
pub async fn post_auth_reset(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn post_auth_reset(
// Delete in reverse-dependency order to satisfy FK constraints State(state): State<AppState>,
// (SQLite FK enforcement is opt-in, but we follow the order anyway) game: GameId,
let tables = [ Json(req): Json<ResetRequest>,
"player_achievements", ) -> AppResult<Json<Value>> {
"notifications", if req.confirm.as_deref() != Some("reset") {
return Err(AppError::BadRequest(
r#"include {"confirm":"reset"} in the request body to confirm data wipe"#.into(),
));
}
// Multi-game: reset ONLY this game's profile subtree so a FIFA 17 reset never
// wipes FIFA 23 (and vice versa). Resolve the game's profile + its clubs, then
// delete their dependent rows in reverse-dependency order.
let profile_id: Option<String> = sqlx::query_scalar(
"SELECT id FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
)
.bind(game.as_str())
.fetch_optional(&state.pool)
.await?;
let Some(profile_id) = profile_id else {
return Ok(Json(json!({
"reset": true,
"message": "nothing to reset for this game"
})));
};
let club_ids: Vec<String> = sqlx::query_scalar("SELECT id FROM clubs WHERE profile_id = ?")
.bind(&profile_id)
.fetch_all(&state.pool)
.await?;
for club_id in &club_ids {
sqlx::query(
"DELETE FROM squad_players WHERE squad_id IN (SELECT id FROM squads WHERE club_id = ?)",
)
.bind(club_id)
.execute(&state.pool)
.await?;
for table in ["squads", "owned_cards", "packs", "market_history"] {
sqlx::query(&format!("DELETE FROM {table} WHERE club_id = ?"))
.bind(club_id)
.execute(&state.pool)
.await?;
}
}
for table in [
"fut_champs_sessions", "fut_champs_sessions",
"sbc_submissions", "sbc_submissions",
"objective_progress", "objective_progress",
"position_goals", "position_goals",
"statistics", "statistics",
"seasons", "seasons",
"season_history",
"matches", "matches",
"market_listings",
"squad_players",
"squads",
"owned_cards",
"packs",
"events",
"draft_sessions", "draft_sessions",
"settings", "daily_checkins",
"clubs", ] {
"profiles", sqlx::query(&format!("DELETE FROM {table} WHERE profile_id = ?"))
]; .bind(&profile_id)
for table in &tables {
sqlx::query(&format!("DELETE FROM {table}"))
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
} }
tracing::info!("Full game reset performed"); sqlx::query("DELETE FROM clubs WHERE profile_id = ?")
.bind(&profile_id)
.execute(&state.pool)
.await?;
sqlx::query("DELETE FROM profiles WHERE id = ?")
.bind(&profile_id)
.execute(&state.pool)
.await?;
// NOTE (follow-up): settings (global key/value), events (shared content),
// market_listings (keyed by seller_name, includes the shared NPC market),
// notifications and player_achievements are not yet game-scoped and are left
// intact. They need a game/profile key before a per-game reset can cover them.
tracing::info!(game = %game.as_str(), "Per-game reset performed");
Ok(Json(json!({ Ok(Json(json!({
"reset": true, "reset": true,
"message": "All progress has been wiped. Call POST /auth/local to start a new club." "message": "All progress for this game has been wiped. Call POST /auth/local to start a new club."
}))) })))
} }
+37 -11
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
Json, Json,
@@ -9,7 +10,11 @@ use crate::{
app::AppState, app::AppState,
error::{AppError, AppResult}, error::{AppError, AppResult},
models::card::OwnedCard, models::card::OwnedCard,
services::{club as club_svc, profile as profile_svc}, services::{
club as club_svc,
inventory::{self, OwnedItemQuery, OwnedItemView},
profile as profile_svc,
},
}; };
/// Quick-sell value for a card based on overall rating. /// Quick-sell value for a card based on overall rating.
@@ -56,7 +61,7 @@ pub async fn get_cards(
let rarity_ok = query let rarity_ok = query
.rarity .rarity
.as_ref() .as_ref()
.map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase()) .map(|r| c.rarity.as_str().eq_ignore_ascii_case(r))
.unwrap_or(true); .unwrap_or(true);
let pos_ok = query let pos_ok = query
.position .position
@@ -93,8 +98,12 @@ pub async fn get_cards(
Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() }))) Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() })))
} }
pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_collection(
let profile = profile_svc::get_active_profile(&state.pool).await?; 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?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let owned = sqlx::query_as::<_, OwnedCard>( let owned = sqlx::query_as::<_, OwnedCard>(
@@ -104,14 +113,14 @@ pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Val
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?;
let with_defs: Vec<Value> = owned let views: Vec<OwnedItemView> = owned
.iter() .iter()
.filter_map(|o| { .filter_map(|o| {
state.card_db.get(&o.card_id).map(|def| { state.card_db.get(&o.card_id).map(|def| {
let effective_overall = def.overall as i64 + o.training_bonus; let effective_overall = def.overall as i64 + o.training_bonus;
let effective_position = let effective_position =
o.position_override.as_deref().unwrap_or(&def.position); o.position_override.as_deref().unwrap_or(&def.position);
json!({ let body = json!({
"owned_card_id": o.id, "owned_card_id": o.id,
"is_loan": o.is_loan, "is_loan": o.is_loan,
"loan_matches_remaining": o.loan_matches_remaining, "loan_matches_remaining": o.loan_matches_remaining,
@@ -122,22 +131,39 @@ pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Val
"effective_overall": effective_overall, "effective_overall": effective_overall,
"effective_position": effective_position, "effective_position": effective_position,
"card": def, "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(); .collect();
Ok(Json( let page = inventory::apply_query(views, &query);
json!({ "collection": with_defs, "total": with_defs.len() }), let returned = page.items.len();
)) Ok(Json(json!({
"collection": page.items,
"total": page.total,
"returned": returned,
"offset": page.offset,
"limit": page.limit,
})))
} }
/// Quick-sell an owned card for instant coins. The card is removed from the collection. /// Quick-sell an owned card for instant coins. The card is removed from the collection.
pub async fn delete_owned_card( pub async fn delete_owned_card(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(owned_card_id): Path<String>, Path(owned_card_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let owned = sqlx::query_as::<_, OwnedCard>( let owned = sqlx::query_as::<_, OwnedCard>(
+11 -9
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use crate::{ use crate::{
app::AppState, app::AppState,
error::AppResult, error::AppResult,
@@ -8,8 +9,8 @@ use axum::{extract::State, Json};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
pub async fn get_club(State(state): State<AppState>) -> AppResult<Json<Club>> { pub async fn get_club(State(state): State<AppState>, game: GameId) -> AppResult<Json<Club>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
Ok(Json(club)) Ok(Json(club))
} }
@@ -22,9 +23,10 @@ pub struct UpdateClubRequest {
pub async fn put_club( pub async fn put_club(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<UpdateClubRequest>, Json(req): Json<UpdateClubRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated = club_svc::update_club( let updated = club_svc::update_club(
&state.pool, &state.pool,
@@ -36,8 +38,8 @@ pub async fn put_club(
Ok(Json(json!({ "club": updated }))) Ok(Json(json!({ "club": updated })))
} }
pub async fn get_checkin_status(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_checkin_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let status = checkin_svc::get_status(&state.pool, &profile.id).await?; let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
Ok(Json(json!({ Ok(Json(json!({
"available": status.available, "available": status.available,
@@ -48,8 +50,8 @@ pub async fn get_checkin_status(State(state): State<AppState>) -> AppResult<Json
}))) })))
} }
pub async fn post_checkin(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn post_checkin(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?; let r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?;
Ok(Json(json!({ Ok(Json(json!({
@@ -60,8 +62,8 @@ pub async fn post_checkin(State(state): State<AppState>) -> AppResult<Json<Value
}))) })))
} }
pub async fn get_milestones(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?; let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
+7 -6
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{extract::State, Json}; use axum::{extract::State, Json};
use rand::{Rng, SeedableRng}; use rand::{Rng, SeedableRng};
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -9,8 +10,8 @@ use crate::{
services::{club as club_svc, profile as profile_svc, season as season_svc}, services::{club as club_svc, profile as profile_svc, season as season_svc},
}; };
pub async fn get_division(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_division(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let _club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let _club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let season = season_svc::get_or_create(&state.pool, &profile.id).await?; let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
@@ -36,14 +37,14 @@ pub async fn get_division(State(state): State<AppState>) -> AppResult<Json<Value
}))) })))
} }
pub async fn get_division_history(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_division_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let history = season_svc::get_history(&state.pool, &profile.id).await?; let history = season_svc::get_history(&state.pool, &profile.id).await?;
Ok(Json(json!({ "history": history, "total": history.len() }))) Ok(Json(json!({ "history": history, "total": history.len() })))
} }
pub async fn get_division_leaderboard(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let season = season_svc::get_or_create(&state.pool, &profile.id).await?; let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
+9 -4
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
Json, Json,
@@ -39,9 +40,10 @@ pub async fn get_draft_squad(
/// until all 11 slots are filled. /// until all 11 slots are filled.
pub async fn post_draft_start( pub async fn post_draft_start(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Query(query): Query<DraftQuery>, Query(query): Query<DraftQuery>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let difficulty = query.difficulty.as_deref().unwrap_or("professional"); let difficulty = query.difficulty.as_deref().unwrap_or("professional");
let session = draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?; let session = draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
Ok(Json(session)) Ok(Json(session))
@@ -50,9 +52,10 @@ pub async fn post_draft_start(
/// Get the current state of a draft session. /// Get the current state of a draft session.
pub async fn get_draft_session( pub async fn get_draft_session(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>, Path(session_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let session = draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?; let session = draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?;
Ok(Json(session)) Ok(Json(session))
} }
@@ -69,10 +72,11 @@ pub struct PickRequest {
/// and rewards (coins + optional pack) are granted automatically. /// and rewards (coins + optional pack) are granted automatically.
pub async fn post_draft_pick( pub async fn post_draft_pick(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>, Path(session_id): Path<String>,
Json(req): Json<PickRequest>, Json(req): Json<PickRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let session = draft_svc::pick_card( let session = draft_svc::pick_card(
&state.pool, &state.pool,
@@ -89,9 +93,10 @@ pub async fn post_draft_pick(
/// Abandon an active draft session. No rewards are granted. /// Abandon an active draft session. No rewards are granted.
pub async fn post_draft_abandon( pub async fn post_draft_abandon(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>, Path(session_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let result = draft_svc::abandon_draft(&state.pool, &profile.id, &session_id).await?; let result = draft_svc::abandon_draft(&state.pool, &profile.id, &session_id).await?;
Ok(Json(result)) Ok(Json(result))
} }
+13 -10
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
Json, Json,
@@ -12,8 +13,8 @@ use crate::{
}; };
/// GET /fut-champs — current active session, or null if none. /// GET /fut-champs — current active session, or null if none.
pub async fn get_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_fut_champs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let session = champs_svc::get_active_session(&state.pool, &profile.id).await?; let session = champs_svc::get_active_session(&state.pool, &profile.id).await?;
Ok(Json(json!({ Ok(Json(json!({
@@ -23,8 +24,8 @@ pub async fn get_fut_champs(State(state): State<AppState>) -> AppResult<Json<Val
} }
/// POST /fut-champs/start — open a new FUT Champions week. /// POST /fut-champs/start — open a new FUT Champions week.
pub async fn post_start_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> { 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).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let session = champs_svc::start_session(&state.pool, &profile.id).await?; let session = champs_svc::start_session(&state.pool, &profile.id).await?;
Ok(Json(json!({ Ok(Json(json!({
@@ -42,10 +43,11 @@ pub struct ChampsMatchRequest {
/// POST /fut-champs/:session_id/result — record a match in this session. /// POST /fut-champs/:session_id/result — record a match in this session.
pub async fn post_champs_result( pub async fn post_champs_result(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>, Path(session_id): Path<String>,
Json(req): Json<ChampsMatchRequest>, Json(req): Json<ChampsMatchRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let session = champs_svc::record_match( let session = champs_svc::record_match(
&state.pool, &state.pool,
@@ -75,9 +77,10 @@ pub async fn post_champs_result(
/// POST /fut-champs/:session_id/claim — claim end-of-week rewards. /// POST /fut-champs/:session_id/claim — claim end-of-week rewards.
pub async fn post_claim_champs_rewards( pub async fn post_claim_champs_rewards(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>, Path(session_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = champs_svc::claim_rewards( let result = champs_svc::claim_rewards(
@@ -93,8 +96,8 @@ pub async fn post_claim_champs_rewards(
} }
/// GET /fut-champs/history — past sessions, newest first. /// GET /fut-champs/history — past sessions, newest first.
pub async fn get_champs_history(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_champs_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let history = champs_svc::get_history(&state.pool, &profile.id).await?; let history = champs_svc::get_history(&state.pool, &profile.id).await?;
Ok(Json(json!({ Ok(Json(json!({
@@ -104,8 +107,8 @@ pub async fn get_champs_history(State(state): State<AppState>) -> AppResult<Json
} }
/// POST /rivals/claim-weekly — claim weekly Division Rivals reward. /// POST /rivals/claim-weekly — claim weekly Division Rivals reward.
pub async fn post_claim_rivals_reward(State(state): State<AppState>) -> AppResult<Json<Value>> { 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).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
// Ensure a season row exists // Ensure a season row exists
+11 -7
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
Json, Json,
@@ -12,8 +13,8 @@ use crate::{
services::{club as club_svc, market as market_svc, profile as profile_svc}, services::{club as club_svc, market as market_svc, profile as profile_svc},
}; };
pub async fn get_trade_history(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_trade_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let trades = market_svc::get_trade_history(&state.pool, &club.id).await?; let trades = market_svc::get_trade_history(&state.pool, &club.id).await?;
Ok(Json(json!({ "trades": trades, "total": trades.len() }))) Ok(Json(json!({ "trades": trades, "total": trades.len() })))
@@ -52,9 +53,10 @@ pub async fn get_market(
pub async fn post_market_buy( pub async fn post_market_buy(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<BuyListingRequest>, Json(req): Json<BuyListingRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let 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?; let card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?;
@@ -65,9 +67,10 @@ pub async fn post_market_buy(
pub async fn post_market_sell( pub async fn post_market_sell(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<SellCardRequest>, Json(req): Json<SellCardRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let 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?; let new_balance = market_svc::sell_card(&state.pool, &state.card_db, &club.id, &req).await?;
@@ -83,8 +86,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. /// Return all active market listings posted by the current player's club.
pub async fn get_my_listings(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_my_listings(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let 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?; let listings = market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
Ok(Json(json!({ "listings": listings, "total": listings.len() }))) Ok(Json(json!({ "listings": listings, "total": listings.len() })))
@@ -93,9 +96,10 @@ pub async fn get_my_listings(State(state): State<AppState>) -> AppResult<Json<Va
/// Cancel a player-posted listing and return the card to the collection. /// Cancel a player-posted listing and return the card to the collection.
pub async fn delete_market_listing( pub async fn delete_market_listing(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(listing_id): Path<String>, Path(listing_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
market_svc::cancel_listing(&state.pool, &club.id, &listing_id).await?; market_svc::cancel_listing(&state.pool, &club.id, &listing_id).await?;
Ok(Json(json!({ "cancelled": listing_id }))) Ok(Json(json!({ "cancelled": listing_id })))
+5 -2
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Query, State}, extract::{Query, State},
Json, Json,
@@ -20,9 +21,10 @@ pub struct MatchHistoryQuery {
pub async fn get_matches( pub async fn get_matches(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Query(query): Query<MatchHistoryQuery>, Query(query): Query<MatchHistoryQuery>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let limit = query.limit.unwrap_or(20).clamp(1, 100); let limit = query.limit.unwrap_or(20).clamp(1, 100);
let matches = if let Some(mode) = &query.mode { let matches = if let Some(mode) = &query.mode {
@@ -63,9 +65,10 @@ pub async fn get_opponent(
pub async fn post_match_result( pub async fn post_match_result(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<SubmitMatchRequest>, Json(req): Json<SubmitMatchRequest>,
) -> AppResult<Json<MatchRewardResult>> { ) -> AppResult<Json<MatchRewardResult>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = let result =
+3 -2
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
Json, Json,
@@ -16,8 +17,8 @@ use crate::{
/// notifications (unclaimed objectives, expiring loans, season ending soon). /// notifications (unclaimed objectives, expiring loans, season ending soon).
/// Persistent notifications carry an `id` and `is_read` flag; dynamic ones /// Persistent notifications carry an `id` and `is_read` flag; dynamic ones
/// have `id: null` and are always considered unread. /// have `id: null` and are always considered unread.
pub async fn get_notifications(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_notifications(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
// ── Persistent notifications ───────────────────────────────────────────── // ── Persistent notifications ─────────────────────────────────────────────
+9 -5
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
Json, Json,
@@ -11,8 +12,8 @@ use crate::{
services::{club as club_svc, objective as obj_svc, profile as profile_svc}, services::{club as club_svc, objective as obj_svc, profile as profile_svc},
}; };
pub async fn get_objectives(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_objectives(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let objectives = let objectives =
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?; obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
Ok(Json(json!({ "objectives": objectives }))) Ok(Json(json!({ "objectives": objectives })))
@@ -20,9 +21,10 @@ pub async fn get_objectives(State(state): State<AppState>) -> AppResult<Json<Val
pub async fn get_objective( pub async fn get_objective(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(objective_id): Path<String>, Path(objective_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let all = let all =
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?; obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
let obj = all let obj = all
@@ -34,9 +36,10 @@ pub async fn get_objective(
pub async fn post_claim_objective_by_id( pub async fn post_claim_objective_by_id(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(objective_id): Path<String>, Path(objective_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let reward = obj_svc::claim_objective( let reward = obj_svc::claim_objective(
&state.pool, &state.pool,
@@ -56,9 +59,10 @@ pub struct ClaimRequest {
pub async fn post_claim_objective( pub async fn post_claim_objective(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<ClaimRequest>, Json(req): Json<ClaimRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let reward = obj_svc::claim_objective( let reward = obj_svc::claim_objective(
&state.pool, &state.pool,
+9 -6
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
Json, Json,
@@ -19,9 +20,10 @@ pub struct BuyPackRequest {
pub async fn post_buy_pack( pub async fn post_buy_pack(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<BuyPackRequest>, Json(req): Json<BuyPackRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let pack = pack_svc::buy_pack( let pack = pack_svc::buy_pack(
&state.pool, &state.pool,
@@ -54,8 +56,8 @@ pub async fn get_pack_store(State(state): State<AppState>) -> AppResult<Json<Val
Ok(Json(json!({ "packs": store }))) Ok(Json(json!({ "packs": store })))
} }
pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_packs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?; let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?;
@@ -77,8 +79,8 @@ pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>>
} }
/// Return recently opened packs with the card IDs they contained. /// Return recently opened packs with the card IDs they contained.
pub async fn get_pack_history(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_pack_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let opened = sqlx::query_as::<_, crate::models::pack::Pack>( let opened = sqlx::query_as::<_, crate::models::pack::Pack>(
@@ -122,9 +124,10 @@ pub async fn get_pack_history(State(state): State<AppState>) -> AppResult<Json<V
pub async fn post_open_pack( pub async fn post_open_pack(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(pack_id): Path<String>, Path(pack_id): Path<String>,
) -> AppResult<Json<PackOpenResult>> { ) -> AppResult<Json<PackOpenResult>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = pack_svc::open_pack( let result = pack_svc::open_pack(
+3 -2
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use crate::{ use crate::{
app::AppState, app::AppState,
error::AppResult, error::AppResult,
@@ -7,8 +8,8 @@ use crate::{
use axum::{extract::State, Json}; use axum::{extract::State, Json};
use serde_json::{json, Value}; use serde_json::{json, Value};
pub async fn get_profile(State(state): State<AppState>) -> AppResult<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).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let computed_level = level_for_xp(profile.xp); let computed_level = level_for_xp(profile.xp);
let next_level = computed_level + 1; let next_level = computed_level + 1;
+3 -1
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
Json, Json,
@@ -29,9 +30,10 @@ pub async fn get_sbc(
pub async fn post_sbc_submit( pub async fn post_sbc_submit(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<SubmitSbcRequest>, Json(req): Json<SubmitSbcRequest>,
) -> AppResult<Json<SbcResult>> { ) -> AppResult<Json<SbcResult>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = sbc_svc::submit_sbc( let result = sbc_svc::submit_sbc(
+13 -9
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
Json, Json,
@@ -11,8 +12,8 @@ use crate::{
services::{club as club_svc, profile as profile_svc, squad as squad_svc}, services::{club as club_svc, profile as profile_svc, squad as squad_svc},
}; };
pub async fn get_squad(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?; let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?;
@@ -21,8 +22,8 @@ pub async fn get_squad(State(state): State<AppState>) -> AppResult<Json<Value>>
Ok(Json(squad_response(&squad, &players, chemistry))) Ok(Json(squad_response(&squad, &players, chemistry)))
} }
pub async fn get_squads(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_squads(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let squads = squad_svc::list_squads(&state.pool, &club.id).await?; let squads = squad_svc::list_squads(&state.pool, &club.id).await?;
Ok(Json(json!({ "squads": squads }))) Ok(Json(json!({ "squads": squads })))
@@ -30,9 +31,10 @@ pub async fn get_squads(State(state): State<AppState>) -> AppResult<Json<Value>>
pub async fn get_squad_by_id( pub async fn get_squad_by_id(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(squad_id): Path<String>, Path(squad_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let 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?; let (squad, players) = squad_svc::get_squad_by_id(&state.pool, &club.id, &squad_id).await?;
@@ -43,24 +45,26 @@ pub async fn get_squad_by_id(
pub async fn post_squad( pub async fn post_squad(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<SaveSquadRequest>, Json(req): Json<SaveSquadRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
if !req.players.is_empty() { if !req.players.is_empty() {
squad_svc::validate_formation(&state.pool, &state.card_db, &req.players).await?; squad_svc::validate_formation(&state.pool, &state.card_db, &club.id, &req.players).await?;
} }
let squad = squad_svc::save_squad(&state.pool, &club.id, &req).await?; let squad = squad_svc::save_squad(&state.pool, &state.card_db, &club.id, &req).await?;
Ok(Json(json!({ "squad": squad }))) Ok(Json(json!({ "squad": squad })))
} }
pub async fn delete_squad( pub async fn delete_squad(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(squad_id): Path<String>, Path(squad_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
squad_svc::delete_squad(&state.pool, &club.id, &squad_id).await?; squad_svc::delete_squad(&state.pool, &club.id, &squad_id).await?;
Ok(Json(json!({ "deleted": squad_id }))) Ok(Json(json!({ "deleted": squad_id })))
+5 -3
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Query, State}, extract::{Query, State},
Json, Json,
@@ -12,8 +13,8 @@ use crate::{
services::{profile as profile_svc, statistics as stats_svc}, services::{profile as profile_svc, statistics as stats_svc},
}; };
pub async fn get_statistics(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_statistics(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let stats = stats_svc::get_or_create(&state.pool, &profile.id).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?; let pos_goals = stats_svc::get_position_goals(&state.pool, &profile.id).await?;
@@ -36,9 +37,10 @@ pub struct HistoryQuery {
pub async fn get_statistics_history( pub async fn get_statistics_history(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Query(query): Query<HistoryQuery>, Query(query): Query<HistoryQuery>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let limit = query.limit.unwrap_or(20).clamp(1, 100); let limit = query.limit.unwrap_or(20).clamp(1, 100);
let matches = sqlx::query_as::<_, Match>( let matches = sqlx::query_as::<_, Match>(
+7 -3
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
Json, Json,
@@ -27,10 +28,11 @@ pub struct ApplyChemStyleRequest {
/// POST /collection/:owned_card_id/chemistry-style /// POST /collection/:owned_card_id/chemistry-style
pub async fn post_apply_chemistry_style( pub async fn post_apply_chemistry_style(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(owned_card_id): Path<String>, Path(owned_card_id): Path<String>,
Json(req): Json<ApplyChemStyleRequest>, Json(req): Json<ApplyChemStyleRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated = upgrade_svc::apply_chemistry_style( let updated = upgrade_svc::apply_chemistry_style(
@@ -63,10 +65,11 @@ pub struct ChangePositionRequest {
/// POST /collection/:owned_card_id/position — costs 500 coins. /// POST /collection/:owned_card_id/position — costs 500 coins.
pub async fn post_change_position( pub async fn post_change_position(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(owned_card_id): Path<String>, Path(owned_card_id): Path<String>,
Json(req): Json<ChangePositionRequest>, Json(req): Json<ChangePositionRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated = upgrade_svc::change_position( let updated = upgrade_svc::change_position(
@@ -97,10 +100,11 @@ pub struct ApplyTrainingRequest {
/// POST /collection/:owned_card_id/training — applies a training boost (up to +3 OVR total). /// POST /collection/:owned_card_id/training — applies a training boost (up to +3 OVR total).
pub async fn post_apply_training( pub async fn post_apply_training(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(owned_card_id): Path<String>, Path(owned_card_id): Path<String>,
Json(req): Json<ApplyTrainingRequest>, Json(req): Json<ApplyTrainingRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated = let updated =
+186 -1
View File
@@ -1,6 +1,23 @@
use crate::{db::Pool, error::AppResult, models::pack::PackDefinition, services::pack as pack_svc}; 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 tracing::info; 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. /// Seeds the market with NPC listings if empty.
pub async fn maybe_seed(_pool: &Pool) -> AppResult<()> { pub async fn maybe_seed(_pool: &Pool) -> AppResult<()> {
// Any one-time startup seeds go here. // Any one-time startup seeds go here.
@@ -29,3 +46,171 @@ pub async fn grant_starter_pack(
Ok(()) 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,
})
}
+24 -11
View File
@@ -38,19 +38,32 @@ impl CardDb {
Ok(Self { cards }) Ok(Self { cards })
} }
pub fn get(&self, id: &str) -> Option<&CardDefinition> { /// Merge a game's **opt-in development content pack** from
self.cards.get(id) /// `{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)
} }
#[allow(dead_code)] pub fn get(&self, id: &str) -> Option<&CardDefinition> {
pub fn by_rarity(&self, rarity: &str) -> Vec<&CardDefinition> { self.cards.get(id)
self.cards
.values()
.filter(|c| {
let r = format!("{:?}", c.rarity).to_lowercase();
r == rarity || rarity == "any"
})
.collect()
} }
pub fn all(&self) -> Vec<&CardDefinition> { pub fn all(&self) -> Vec<&CardDefinition> {
+14 -4
View File
@@ -88,13 +88,18 @@ pub async fn start_draft(
let first_position = &pick_order[0]; let first_position = &pick_order[0];
let candidates = pick_candidates(card_db, first_position, min_overall, CANDIDATES_PER_SLOT); 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 { let session = DraftSession {
id: Uuid::new_v4().to_string(), id: Uuid::new_v4().to_string(),
profile_id: profile_id.to_string(), profile_id: profile_id.to_string(),
difficulty: difficulty.to_string(), difficulty: difficulty.to_string(),
pick_order: serde_json::to_string(&pick_order).unwrap(), pick_order: pick_order_json,
picks: "[]".to_string(), picks: "[]".to_string(),
current_candidates: Some(serde_json::to_string(&candidates).unwrap()), current_candidates: Some(candidates_json),
status: "active".to_string(), status: "active".to_string(),
reward_coins: 0, reward_coins: 0,
reward_pack_id: None, reward_pack_id: None,
@@ -185,17 +190,22 @@ pub async fn pick_card(
let next_pos = &pick_order[next_index]; let next_pos = &pick_order[next_index];
let next_candidates = let next_candidates =
pick_candidates(card_db, next_pos, min_overall, CANDIDATES_PER_SLOT); pick_candidates(card_db, next_pos, min_overall, CANDIDATES_PER_SLOT);
{
let candidates_json = serde_json::to_string(&next_candidates)
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
( (
Some(serde_json::to_string(&next_candidates).unwrap()), Some(candidates_json),
"active".to_string(), "active".to_string(),
0, 0,
None, None,
0, 0,
None, None,
) )
}
}; };
let picks_json = serde_json::to_string(&picks).unwrap(); let picks_json = serde_json::to_string(&picks)
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
sqlx::query( sqlx::query(
"UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \ "UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \
+21 -5
View File
@@ -239,16 +239,30 @@ pub async fn claim_rivals_reward(
pack_defs: &[PackDefinition], pack_defs: &[PackDefinition],
) -> AppResult<serde_json::Value> { ) -> AppResult<serde_json::Value> {
// Fetch current season row (must exist) // Fetch current season row (must exist)
let row: Option<(i64, i64, i64)> = sqlx::query_as( let row: Option<(i64, i64, i64, Option<String>)> = sqlx::query_as(
"SELECT division, rivals_week_claimed, rivals_total_points FROM seasons WHERE profile_id = ?", "SELECT division, rivals_week_claimed, rivals_total_points, rivals_last_claimed_at \
FROM seasons WHERE profile_id = ?",
) )
.bind(profile_id) .bind(profile_id)
.fetch_optional(pool) .fetch_optional(pool)
.await?; .await?;
let (division, week_claimed, total_pts) = let (division, week_claimed, total_pts, last_claimed_at) =
row.ok_or_else(|| AppError::NotFound("no season found — play a match first".into()))?; 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 next_week = week_claimed + 1;
let coins = rivals_weekly_coins(division); let coins = rivals_weekly_coins(division);
let new_balance = club_svc::add_coins(pool, club_id, coins).await?; let new_balance = club_svc::add_coins(pool, club_id, coins).await?;
@@ -271,11 +285,13 @@ pub async fn claim_rivals_reward(
None None
}; };
let now = chrono::Utc::now().to_rfc3339();
sqlx::query( sqlx::query(
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100 \ "UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100, \
WHERE profile_id = ?", rivals_last_claimed_at = ? WHERE profile_id = ?",
) )
.bind(next_week) .bind(next_week)
.bind(&now)
.bind(profile_id) .bind(profile_id)
.execute(pool) .execute(pool)
.await?; .await?;
+34
View File
@@ -0,0 +1,34 @@
//! 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
@@ -0,0 +1,291 @@
//! 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
}
}
+9 -4
View File
@@ -43,11 +43,12 @@ pub async fn refresh_npc_listings(
card_db: &CardDb, card_db: &CardDb,
event_defs: &[EventDefinition], event_defs: &[EventDefinition],
) -> AppResult<usize> { ) -> AppResult<usize> {
// Clean up expired and unsold listings // Clean up expired listings and previous NPC listings.
// Player-posted listings (is_npc = 0) are intentionally preserved.
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')") sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
.execute(pool) .execute(pool)
.await?; .await?;
sqlx::query("DELETE FROM market_listings WHERE sold = 0") sqlx::query("DELETE FROM market_listings WHERE sold = 0 AND is_npc = 1")
.execute(pool) .execute(pool)
.await?; .await?;
@@ -107,8 +108,8 @@ pub async fn refresh_npc_listings(
for listing in &listings_to_insert { for listing in &listings_to_insert {
sqlx::query( sqlx::query(
"INSERT INTO market_listings \ "INSERT INTO market_listings \
(id, card_id, seller_name, price, listed_at, expires_at, sold) \ (id, card_id, seller_name, price, listed_at, expires_at, sold, is_npc) \
VALUES (?, ?, ?, ?, ?, ?, 0)", VALUES (?, ?, ?, ?, ?, ?, 0, 1)",
) )
.bind(&listing.id) .bind(&listing.id)
.bind(&listing.card_id) .bind(&listing.card_id)
@@ -190,6 +191,10 @@ pub async fn sell_card(
club_id: &str, club_id: &str,
req: &SellCardRequest, req: &SellCardRequest,
) -> AppResult<i64> { ) -> 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>( let owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \ "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
chemistry_style, position_override, training_bonus \ chemistry_style, position_override, training_bonus \
+13 -3
View File
@@ -94,6 +94,12 @@ pub async fn process_match(
obj_defs: &[ObjectiveDefinition], obj_defs: &[ObjectiveDefinition],
ach_defs: &[AchievementDefinition], ach_defs: &[AchievementDefinition],
) -> AppResult<MatchRewardResult> { ) -> 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 { let outcome = if req.goals_for > req.goals_against {
"win" "win"
} else if req.goals_for == req.goals_against { } else if req.goals_for == req.goals_against {
@@ -184,9 +190,13 @@ pub async fn process_match(
objectives_updated.append(&mut c); objectives_updated.append(&mut c);
for obj_id in &objectives_updated { for obj_id in &objectives_updated {
let title = "Objective complete!"; let display_name = obj_defs
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", obj_id); .iter()
let _ = notification::create(pool, "objective_complete", title, &body).await; .find(|d| &d.id == obj_id)
.map(|d| d.title.as_str())
.unwrap_or(obj_id.as_str());
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", display_name);
let _ = notification::create(pool, "objective_complete", "Objective complete!", &body).await;
} }
// Decrement loan matches remaining for squad starters; collect expired loan IDs. // Decrement loan matches remaining for squad starters; collect expired loan IDs.
+3
View File
@@ -6,6 +6,8 @@ pub mod notification;
pub mod draft; pub mod draft;
pub mod event; pub mod event;
pub mod fut_champs; pub mod fut_champs;
pub mod game_ext;
pub mod inventory;
pub mod season; pub mod season;
pub mod market; pub mod market;
pub mod match_service; pub mod match_service;
@@ -15,5 +17,6 @@ pub mod profile;
pub mod sbc; pub mod sbc;
pub mod settings; pub mod settings;
pub mod squad; pub mod squad;
pub mod squad_rules;
pub mod statistics; pub mod statistics;
pub mod upgrades; pub mod upgrades;
+1 -1
View File
@@ -69,7 +69,7 @@ pub async fn increment_metric(
for def in defs for def in defs
.iter() .iter()
.filter(|d| format!("{:?}", d.metric).to_lowercase() == metric) .filter(|d| d.metric.as_str() == metric)
{ {
let existing = sqlx::query_as::<_, ObjectiveProgress>( 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 = ?" "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() .all()
.into_iter() .into_iter()
.filter(|c| { .filter(|c| {
let r = format!("{:?}", c.rarity).to_lowercase(); let r = c.rarity.as_str();
rarities.contains(&r) rarities.contains(&r.to_string())
}) })
.cloned() .cloned()
.collect() .collect()
+22 -8
View File
@@ -5,33 +5,41 @@ use crate::{
}; };
use chrono::Utc; use chrono::Utc;
pub async fn get_active_profile(pool: &Pool) -> AppResult<Profile> { /// Fetch the active profile for a game. Single-profile-per-game: there is exactly
/// one profile row per `game_id`, so we take the earliest for that game.
pub async fn get_active_profile(pool: &Pool, game_id: &str) -> AppResult<Profile> {
sqlx::query_as::<_, Profile>( sqlx::query_as::<_, Profile>(
"SELECT id, username, level, xp, created_at, updated_at FROM profiles ORDER BY created_at ASC LIMIT 1" "SELECT id, username, level, xp, game_id, created_at, updated_at \
FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
) )
.bind(game_id)
.fetch_optional(pool) .fetch_optional(pool)
.await? .await?
.ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into())) .ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into()))
} }
pub async fn create_profile(pool: &Pool, username: &str) -> AppResult<Profile> { pub async fn create_profile(pool: &Pool, username: &str, game_id: &str) -> AppResult<Profile> {
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles") // Single-player per game: one profile per game_id, not one globally.
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
.bind(game_id)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
if existing > 0 { if existing > 0 {
return Err(AppError::Conflict( return Err(AppError::Conflict(
"a profile already exists; OpenFUT is single-player only".into(), "a profile already exists for this game; OpenFUT is single-player per game".into(),
)); ));
} }
let profile = Profile::new(username); let profile = Profile::new(username, game_id);
sqlx::query( sqlx::query(
"INSERT INTO profiles (id, username, level, xp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" "INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
) )
.bind(&profile.id) .bind(&profile.id)
.bind(&profile.username) .bind(&profile.username)
.bind(profile.level) .bind(profile.level)
.bind(profile.xp) .bind(profile.xp)
.bind(&profile.game_id)
.bind(profile.created_at) .bind(profile.created_at)
.bind(profile.updated_at) .bind(profile.updated_at)
.execute(pool) .execute(pool)
@@ -59,7 +67,13 @@ pub async fn add_xp_with_levelup(
club_id: &str, club_id: &str,
xp_to_add: i64, xp_to_add: i64,
) -> AppResult<Vec<LevelUpEvent>> { ) -> AppResult<Vec<LevelUpEvent>> {
let profile = get_active_profile(pool).await?; let profile = sqlx::query_as::<_, Profile>(
"SELECT id, username, level, xp, game_id, created_at, updated_at FROM profiles WHERE id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("profile '{profile_id}' not found")))?;
let old_level = level_for_xp(profile.xp); let old_level = level_for_xp(profile.xp);
let new_total_xp = profile.xp + xp_to_add; let new_total_xp = profile.xp + xp_to_add;
let new_level = level_for_xp(new_total_xp); let new_level = level_for_xp(new_total_xp);
-3
View File
@@ -101,9 +101,6 @@ pub async fn record_match(
// Grant rewards // Grant rewards
club::add_coins(pool, club_id, coins).await?; club::add_coins(pool, club_id, coins).await?;
if let Some(pack_def) = pack_id { 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?; pack::grant_pack(pool, club_id, pack_def).await?;
} }
+895 -35
View File
File diff suppressed because it is too large Load Diff
+380
View File
@@ -0,0 +1,380 @@
//! 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
@@ -0,0 +1,181 @@
//! 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");
}
+386 -4
View File
@@ -1404,10 +1404,14 @@ async fn test_rivals_reward_increments_week_counter() {
"goals_for": 1, "goals_against": 0, "mode": "squad_battles" "goals_for": 1, "goals_against": 0, "mode": "squad_battles"
})).await; })).await;
json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await; // First claim succeeds
let (s, json) = 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!(s, StatusCode::OK, "{json}");
assert_eq!(json["week_number"], 2, "second claim should be week 2"); 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");
} }
// ── Phase 15: Pack Store ────────────────────────────────────────────────────── // ── Phase 15: Pack Store ──────────────────────────────────────────────────────
@@ -1711,7 +1715,7 @@ async fn test_auth_reset_clears_profile() {
})).await; })).await;
// Reset // Reset
let (status, json) = json_post(&app, "/auth/reset", serde_json::json!({})).await; let (status, json) = json_post(&app, "/auth/reset", serde_json::json!({ "confirm": "reset" })).await;
assert_eq!(status, StatusCode::OK, "{json}"); assert_eq!(status, StatusCode::OK, "{json}");
assert_eq!(json["reset"], true); assert_eq!(json["reset"], true);
@@ -1729,7 +1733,7 @@ async fn test_auth_reset_allows_new_profile() {
let app = build_test_app().await; let app = build_test_app().await;
auth(&app, "FirstProfile").await; auth(&app, "FirstProfile").await;
json_post(&app, "/auth/reset", serde_json::json!({})).await; json_post(&app, "/auth/reset", serde_json::json!({ "confirm": "reset" })).await;
// Should be able to create a new profile after reset // Should be able to create a new profile after reset
let (status, json) = json_post(&app, "/auth/local", let (status, json) = json_post(&app, "/auth/local",
@@ -1897,3 +1901,381 @@ async fn test_trade_history_empty_initially() {
assert!(json["trades"].is_array()); assert!(json["trades"].is_array());
assert_eq!(json["trades"].as_array().unwrap().len(), 0); 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"]);
}