Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33b5ac1908 | |||
| eab522a1eb |
@@ -1,5 +0,0 @@
|
||||
-- Distinguish NPC-generated listings from player-posted ones so that the
|
||||
-- periodic NPC refresh does not accidentally wipe player listings.
|
||||
ALTER TABLE market_listings ADD COLUMN is_npc INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_market_npc ON market_listings(is_npc, sold);
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Track when the player last claimed their rivals weekly reward to enforce a
|
||||
-- 24-hour cooldown between claims.
|
||||
ALTER TABLE seasons ADD COLUMN rivals_last_claimed_at TEXT;
|
||||
@@ -1,14 +0,0 @@
|
||||
-- Multi-game support. Every profile (and therefore all of its downstream state,
|
||||
-- which hangs off profiles(id) via profile_id / club_id foreign keys) is scoped to
|
||||
-- a game. Bridges identify their game with the X-OpenFUT-Game request header.
|
||||
--
|
||||
-- Default 'fifa23' preserves the existing single-game behaviour: the current bridge
|
||||
-- and the integration tests send no game header, so they keep operating on the same
|
||||
-- (now fifa23-tagged) profile with zero behaviour change. The FIFA 17 bridge sends
|
||||
-- X-OpenFUT-Game: fifa17 and therefore gets its own isolated profile/club/state.
|
||||
--
|
||||
-- Only profiles needs the column: get_active_profile becomes game-scoped, and because
|
||||
-- all other tables reference a profile (directly via profile_id or via
|
||||
-- club_id -> clubs.profile_id), scoping the active profile isolates the whole tree.
|
||||
ALTER TABLE profiles ADD COLUMN game_id TEXT NOT NULL DEFAULT 'fifa23';
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_game ON profiles(game_id);
|
||||
@@ -5,6 +5,7 @@ pub struct Config {
|
||||
pub listen_addr: String,
|
||||
pub database_url: String,
|
||||
pub data_dir: String,
|
||||
#[allow(dead_code)]
|
||||
pub max_connections: u32,
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ use tracing::info;
|
||||
|
||||
pub type Pool = SqlitePool;
|
||||
|
||||
pub async fn init_pool(database_url: &str, max_connections: u32) -> Result<Pool> {
|
||||
pub async fn init_pool(database_url: &str) -> Result<Pool> {
|
||||
info!("Connecting to database: {}", database_url);
|
||||
let opts = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.max_connections(5)
|
||||
.connect_with(opts)
|
||||
.await?;
|
||||
sqlx::query("PRAGMA journal_mode=WAL")
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
//! Request extractors shared across routes.
|
||||
|
||||
use axum::{
|
||||
async_trait,
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, HeaderName},
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
|
||||
/// The game a request belongs to, read from the `X-OpenFUT-Game` header.
|
||||
///
|
||||
/// Multi-game support: each game bridge tags its requests with its own id
|
||||
/// (e.g. `fifa17`, `fifa23`) so core can scope the active profile - and thus all
|
||||
/// downstream club/card/squad/market state - to that game. Defaults to `fifa23`
|
||||
/// when the header is absent, so the existing FIFA 23 bridge and the integration
|
||||
/// tests (which send no header) keep operating on their game unchanged.
|
||||
///
|
||||
/// Extraction never fails: a missing or malformed header falls back to the default.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameId(pub String);
|
||||
|
||||
/// The game assumed when no `X-OpenFUT-Game` header is present.
|
||||
pub const DEFAULT_GAME: &str = "fifa23";
|
||||
|
||||
static HEADER: HeaderName = HeaderName::from_static("x-openfut-game");
|
||||
|
||||
impl GameId {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for GameId
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Infallible;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let game = parts
|
||||
.headers
|
||||
.get(&HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(DEFAULT_GAME)
|
||||
.to_string();
|
||||
Ok(GameId(game))
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ pub mod app;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod extractors;
|
||||
pub mod middleware;
|
||||
pub mod modding;
|
||||
pub mod models;
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ async fn main() -> Result<()> {
|
||||
let cfg = config::Config::from_env()?;
|
||||
info!("OpenFUT Core starting on {}", cfg.listen_addr);
|
||||
|
||||
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
|
||||
let pool = db::init_pool(&cfg.database_url).await?;
|
||||
db::run_migrations(&pool).await?;
|
||||
|
||||
seed::maybe_seed(&pool).await?;
|
||||
|
||||
+24
-1
@@ -1 +1,24 @@
|
||||
// Reserved for future modding loader utilities.
|
||||
use anyhow::{Context, Result};
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::path::Path;
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// Generic loader for JSON arrays from a directory.
|
||||
pub fn load_json_dir<T: DeserializeOwned>(dir: &Path) -> Result<Vec<T>> {
|
||||
let mut items = Vec::new();
|
||||
if !dir.exists() {
|
||||
return Ok(items);
|
||||
}
|
||||
for entry in std::fs::read_dir(dir).with_context(|| format!("reading dir {dir:?}"))? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
let content =
|
||||
std::fs::read_to_string(&path).with_context(|| format!("reading {path:?}"))?;
|
||||
let batch: Vec<T> =
|
||||
serde_json::from_str(&content).with_context(|| format!("parsing {path:?}"))?;
|
||||
items.extend(batch);
|
||||
}
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
//! Modding support: load JSON data files from the data/ directory.
|
||||
//! All game content (cards, packs, objectives, SBCs) is data-driven.
|
||||
|
||||
pub mod loader;
|
||||
|
||||
+23
-10
@@ -13,16 +13,29 @@ pub enum Rarity {
|
||||
Icon,
|
||||
}
|
||||
|
||||
impl Rarity {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Rarity::Bronze => "bronze",
|
||||
Rarity::Silver => "silver",
|
||||
Rarity::Gold => "gold",
|
||||
Rarity::RareGold => "raregold",
|
||||
Rarity::Totw => "totw",
|
||||
Rarity::Hero => "hero",
|
||||
Rarity::Icon => "icon",
|
||||
/// Visual card quality tier (gold/silver/bronze).
|
||||
///
|
||||
/// Game-independent semantic dimension, kept distinct from `Rarity` (which also
|
||||
/// carries special-card programs like TOTW/Hero/Icon). Derived from a card's base
|
||||
/// overall using FIFA 17's proven tier convention: gold >= 75, silver >= 65,
|
||||
/// otherwise bronze (evidence: `fifa17-recon/tools/fut_cards.py` `tier()`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Quality {
|
||||
Bronze,
|
||||
Silver,
|
||||
Gold,
|
||||
}
|
||||
|
||||
impl Quality {
|
||||
/// Classify a base overall rating into its quality tier.
|
||||
pub fn from_overall(overall: u8) -> Self {
|
||||
if overall >= 75 {
|
||||
Quality::Gold
|
||||
} else if overall >= 65 {
|
||||
Quality::Silver
|
||||
} else {
|
||||
Quality::Bronze
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,19 +21,6 @@ pub enum ObjectiveMetric {
|
||||
CoinsEarned,
|
||||
}
|
||||
|
||||
impl ObjectiveMetric {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ObjectiveMetric::MatchesWon => "matcheswon",
|
||||
ObjectiveMetric::MatchesPlayed => "matchesplayed",
|
||||
ObjectiveMetric::GoalsScored => "goalsscored",
|
||||
ObjectiveMetric::PacksOpened => "packsopened",
|
||||
ObjectiveMetric::SbcsCompleted => "sbcscompleted",
|
||||
ObjectiveMetric::CoinsEarned => "coinsearned",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Objective definition from data/objectives/*.json
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObjectiveDefinition {
|
||||
|
||||
@@ -8,22 +8,18 @@ pub struct Profile {
|
||||
pub username: String,
|
||||
pub level: i64,
|
||||
pub xp: i64,
|
||||
/// The game this profile belongs to (e.g. "fifa17", "fifa23"). Scopes all of
|
||||
/// this profile's downstream state so multiple games share one core + DB.
|
||||
pub game_id: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn new(username: impl Into<String>, game_id: impl Into<String>) -> Self {
|
||||
pub fn new(username: impl Into<String>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
username: username.into(),
|
||||
level: 1,
|
||||
xp: 0,
|
||||
game_id: game_id.into(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
|
||||
@@ -54,3 +54,46 @@ pub struct SquadPlayerInput {
|
||||
pub is_captain: bool,
|
||||
pub is_on_bench: bool,
|
||||
}
|
||||
|
||||
/// A complete squad, as a game client sends it.
|
||||
///
|
||||
/// # Why replacement rather than edits
|
||||
///
|
||||
/// Retail FIFA 17 sends the WHOLE squad on every save — roughly 2 KB carrying
|
||||
/// every slot, item id and kit number — and a user swapping two players
|
||||
/// produced nine changed slots across two saves. Slot deltas therefore do not
|
||||
/// describe what the user did, and any attempt to derive `swap_players` or
|
||||
/// `move_player` from them would be inventing intent the wire never carried.
|
||||
///
|
||||
/// So the only honest semantic operation is: *this is the squad now*.
|
||||
///
|
||||
/// Empty slots are simply absent from `slots`; a client that models an empty
|
||||
/// slot as a zero item id must drop it at the adapter boundary rather than
|
||||
/// sending a player Core would have to special-case.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SquadReplacement {
|
||||
pub name: Option<String>,
|
||||
pub formation: Option<String>,
|
||||
pub slots: Vec<SlotAssignment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SlotAssignment {
|
||||
pub owned_card_id: String,
|
||||
/// Core's slot numbering. The adapter maps the game's numbering onto it.
|
||||
pub slot: i64,
|
||||
pub is_captain: bool,
|
||||
pub is_on_bench: bool,
|
||||
}
|
||||
|
||||
/// Outcome of a replacement.
|
||||
///
|
||||
/// Carries the server's own evaluation and, separately, any disagreement with
|
||||
/// what the client claimed — never a merged value.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SquadReplaced {
|
||||
pub squad: Squad,
|
||||
pub slots_written: usize,
|
||||
pub evaluation: crate::services::squad_rules::SquadEvaluation,
|
||||
pub client_disagreements: Vec<crate::services::squad_rules::EvaluationComparison>,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{extract::State, Json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -8,8 +7,8 @@ use crate::{
|
||||
services::{achievement as ach_svc, club as club_svc, profile as profile_svc},
|
||||
};
|
||||
|
||||
pub async fn get_achievements(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_achievements(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id).await;
|
||||
let achievements = ach_svc::list_with_status(&state.pool, &state.achievement_defs).await?;
|
||||
|
||||
+28
-85
@@ -1,11 +1,9 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::{AppError, AppResult},
|
||||
extractors::GameId,
|
||||
error::AppResult,
|
||||
models::{club::Club, profile::CreateProfileRequest},
|
||||
seed,
|
||||
services::{club as club_svc, profile as profile_svc},
|
||||
@@ -13,12 +11,11 @@ use crate::{
|
||||
|
||||
pub async fn post_auth_local(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<CreateProfileRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let username = req.username.unwrap_or_else(|| "Player 1".into());
|
||||
|
||||
let profile = profile_svc::create_profile(&state.pool, &username, game.as_str()).await?;
|
||||
let profile = profile_svc::create_profile(&state.pool, &username).await?;
|
||||
|
||||
let club = Club::new(&profile.id, "OpenFUT FC", 5000);
|
||||
club_svc::create_club(&state.pool, &club).await?;
|
||||
@@ -34,105 +31,51 @@ pub async fn post_auth_local(
|
||||
|
||||
/// GET /auth/status — lightweight check: does a profile exist?
|
||||
/// Returns 200 `{ "has_profile": true/false }` without erroring.
|
||||
pub async fn get_auth_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
|
||||
.bind(game.as_str())
|
||||
pub async fn get_auth_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles")
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
Ok(Json(json!({ "has_profile": count > 0 })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetRequest {
|
||||
pub confirm: Option<String>,
|
||||
}
|
||||
|
||||
/// POST /auth/reset — wipe all game data and start fresh.
|
||||
/// Requires `{"confirm":"reset"}` in the request body as a safeguard against
|
||||
/// accidental or unauthenticated calls. Deletes every user-data table in
|
||||
/// dependency order; schema (migrations) is preserved.
|
||||
pub async fn post_auth_reset(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<ResetRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
if req.confirm.as_deref() != Some("reset") {
|
||||
return Err(AppError::BadRequest(
|
||||
r#"include {"confirm":"reset"} in the request body to confirm data wipe"#.into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Multi-game: reset ONLY this game's profile subtree so a FIFA 17 reset never
|
||||
// wipes FIFA 23 (and vice versa). Resolve the game's profile + its clubs, then
|
||||
// delete their dependent rows in reverse-dependency order.
|
||||
let profile_id: Option<String> = sqlx::query_scalar(
|
||||
"SELECT id FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
|
||||
)
|
||||
.bind(game.as_str())
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let Some(profile_id) = profile_id else {
|
||||
return Ok(Json(json!({
|
||||
"reset": true,
|
||||
"message": "nothing to reset for this game"
|
||||
})));
|
||||
};
|
||||
|
||||
let club_ids: Vec<String> = sqlx::query_scalar("SELECT id FROM clubs WHERE profile_id = ?")
|
||||
.bind(&profile_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
for club_id in &club_ids {
|
||||
sqlx::query(
|
||||
"DELETE FROM squad_players WHERE squad_id IN (SELECT id FROM squads WHERE club_id = ?)",
|
||||
)
|
||||
.bind(club_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
for table in ["squads", "owned_cards", "packs", "market_history"] {
|
||||
sqlx::query(&format!("DELETE FROM {table} WHERE club_id = ?"))
|
||||
.bind(club_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
for table in [
|
||||
/// Deletes every user-data table in dependency order. The schema tables
|
||||
/// (migrations) are left intact; calling POST /auth/local afterwards
|
||||
/// creates a new profile.
|
||||
pub async fn post_auth_reset(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
// Delete in reverse-dependency order to satisfy FK constraints
|
||||
// (SQLite FK enforcement is opt-in, but we follow the order anyway)
|
||||
let tables = [
|
||||
"player_achievements",
|
||||
"notifications",
|
||||
"fut_champs_sessions",
|
||||
"sbc_submissions",
|
||||
"objective_progress",
|
||||
"position_goals",
|
||||
"statistics",
|
||||
"seasons",
|
||||
"season_history",
|
||||
"matches",
|
||||
"market_listings",
|
||||
"squad_players",
|
||||
"squads",
|
||||
"owned_cards",
|
||||
"packs",
|
||||
"events",
|
||||
"draft_sessions",
|
||||
"daily_checkins",
|
||||
] {
|
||||
sqlx::query(&format!("DELETE FROM {table} WHERE profile_id = ?"))
|
||||
.bind(&profile_id)
|
||||
"settings",
|
||||
"clubs",
|
||||
"profiles",
|
||||
];
|
||||
|
||||
for table in &tables {
|
||||
sqlx::query(&format!("DELETE FROM {table}"))
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM clubs WHERE profile_id = ?")
|
||||
.bind(&profile_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM profiles WHERE id = ?")
|
||||
.bind(&profile_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// NOTE (follow-up): settings (global key/value), events (shared content),
|
||||
// market_listings (keyed by seller_name, includes the shared NPC market),
|
||||
// notifications and player_achievements are not yet game-scoped and are left
|
||||
// intact. They need a game/profile key before a per-game reset can cover them.
|
||||
tracing::info!(game = %game.as_str(), "Per-game reset performed");
|
||||
tracing::info!("Full game reset performed");
|
||||
Ok(Json(json!({
|
||||
"reset": true,
|
||||
"message": "All progress for this game has been wiped. Call POST /auth/local to start a new club."
|
||||
"message": "All progress has been wiped. Call POST /auth/local to start a new club."
|
||||
})))
|
||||
}
|
||||
|
||||
+34
-13
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
@@ -10,7 +9,11 @@ use crate::{
|
||||
app::AppState,
|
||||
error::{AppError, AppResult},
|
||||
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.
|
||||
@@ -57,7 +60,7 @@ pub async fn get_cards(
|
||||
let rarity_ok = query
|
||||
.rarity
|
||||
.as_ref()
|
||||
.map(|r| c.rarity.as_str().eq_ignore_ascii_case(r))
|
||||
.map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase())
|
||||
.unwrap_or(true);
|
||||
let pos_ok = query
|
||||
.position
|
||||
@@ -94,8 +97,11 @@ pub async fn get_cards(
|
||||
Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() })))
|
||||
}
|
||||
|
||||
pub async fn get_collection(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_collection(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<OwnedItemQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
||||
@@ -105,14 +111,14 @@ pub async fn get_collection(State(state): State<AppState>, game: GameId) -> AppR
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let with_defs: Vec<Value> = owned
|
||||
let views: Vec<OwnedItemView> = owned
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
state.card_db.get(&o.card_id).map(|def| {
|
||||
let effective_overall = def.overall as i64 + o.training_bonus;
|
||||
let effective_position =
|
||||
o.position_override.as_deref().unwrap_or(&def.position);
|
||||
json!({
|
||||
let body = json!({
|
||||
"owned_card_id": o.id,
|
||||
"is_loan": o.is_loan,
|
||||
"loan_matches_remaining": o.loan_matches_remaining,
|
||||
@@ -123,23 +129,38 @@ pub async fn get_collection(State(state): State<AppState>, game: GameId) -> AppR
|
||||
"effective_overall": effective_overall,
|
||||
"effective_position": effective_position,
|
||||
"card": def,
|
||||
})
|
||||
});
|
||||
OwnedItemView {
|
||||
owned_card_id: o.id.clone(),
|
||||
base_overall: def.overall,
|
||||
effective_overall,
|
||||
position: effective_position.to_string(),
|
||||
nation: def.nation.clone(),
|
||||
league: def.league.clone(),
|
||||
club: def.club.clone(),
|
||||
body,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(
|
||||
json!({ "collection": with_defs, "total": with_defs.len() }),
|
||||
))
|
||||
let page = inventory::apply_query(views, &query);
|
||||
let returned = page.items.len();
|
||||
Ok(Json(json!({
|
||||
"collection": page.items,
|
||||
"total": page.total,
|
||||
"returned": returned,
|
||||
"offset": page.offset,
|
||||
"limit": page.limit,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Quick-sell an owned card for instant coins. The card is removed from the collection.
|
||||
pub async fn delete_owned_card(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(owned_card_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
||||
|
||||
+9
-11
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
@@ -9,8 +8,8 @@ use axum::{extract::State, Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn get_club(State(state): State<AppState>, game: GameId) -> AppResult<Json<Club>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_club(State(state): State<AppState>) -> AppResult<Json<Club>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
Ok(Json(club))
|
||||
}
|
||||
@@ -23,10 +22,9 @@ pub struct UpdateClubRequest {
|
||||
|
||||
pub async fn put_club(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<UpdateClubRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let updated = club_svc::update_club(
|
||||
&state.pool,
|
||||
@@ -38,8 +36,8 @@ pub async fn put_club(
|
||||
Ok(Json(json!({ "club": updated })))
|
||||
}
|
||||
|
||||
pub async fn get_checkin_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_checkin_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
|
||||
Ok(Json(json!({
|
||||
"available": status.available,
|
||||
@@ -50,8 +48,8 @@ pub async fn get_checkin_status(State(state): State<AppState>, game: GameId) ->
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn post_checkin(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn post_checkin(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?;
|
||||
Ok(Json(json!({
|
||||
@@ -62,8 +60,8 @@ pub async fn post_checkin(State(state): State<AppState>, game: GameId) -> AppRes
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_milestones(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{extract::State, Json};
|
||||
use rand::{Rng, SeedableRng};
|
||||
use serde_json::{json, Value};
|
||||
@@ -10,8 +9,8 @@ use crate::{
|
||||
services::{club as club_svc, profile as profile_svc, season as season_svc},
|
||||
};
|
||||
|
||||
pub async fn get_division(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_division(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let _club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||
|
||||
@@ -37,14 +36,14 @@ pub async fn get_division(State(state): State<AppState>, game: GameId) -> AppRes
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_division_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_division_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let history = season_svc::get_history(&state.pool, &profile.id).await?;
|
||||
Ok(Json(json!({ "history": history, "total": history.len() })))
|
||||
}
|
||||
|
||||
pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_division_leaderboard(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||
|
||||
|
||||
+4
-9
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
@@ -40,10 +39,9 @@ pub async fn get_draft_squad(
|
||||
/// until all 11 slots are filled.
|
||||
pub async fn post_draft_start(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Query(query): Query<DraftQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let difficulty = query.difficulty.as_deref().unwrap_or("professional");
|
||||
let session = draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
|
||||
Ok(Json(session))
|
||||
@@ -52,10 +50,9 @@ pub async fn post_draft_start(
|
||||
/// Get the current state of a draft session.
|
||||
pub async fn get_draft_session(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(session_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let session = draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?;
|
||||
Ok(Json(session))
|
||||
}
|
||||
@@ -72,11 +69,10 @@ pub struct PickRequest {
|
||||
/// and rewards (coins + optional pack) are granted automatically.
|
||||
pub async fn post_draft_pick(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(session_id): Path<String>,
|
||||
Json(req): Json<PickRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let session = draft_svc::pick_card(
|
||||
&state.pool,
|
||||
@@ -93,10 +89,9 @@ pub async fn post_draft_pick(
|
||||
/// Abandon an active draft session. No rewards are granted.
|
||||
pub async fn post_draft_abandon(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(session_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let result = draft_svc::abandon_draft(&state.pool, &profile.id, &session_id).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
+10
-13
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
@@ -13,8 +12,8 @@ use crate::{
|
||||
};
|
||||
|
||||
/// GET /fut-champs — current active session, or null if none.
|
||||
pub async fn get_fut_champs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let session = champs_svc::get_active_session(&state.pool, &profile.id).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -24,8 +23,8 @@ pub async fn get_fut_champs(State(state): State<AppState>, game: GameId) -> AppR
|
||||
}
|
||||
|
||||
/// POST /fut-champs/start — open a new FUT Champions week.
|
||||
pub async fn post_start_fut_champs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn post_start_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let session = champs_svc::start_session(&state.pool, &profile.id).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -43,11 +42,10 @@ pub struct ChampsMatchRequest {
|
||||
/// POST /fut-champs/:session_id/result — record a match in this session.
|
||||
pub async fn post_champs_result(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(session_id): Path<String>,
|
||||
Json(req): Json<ChampsMatchRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
|
||||
let session = champs_svc::record_match(
|
||||
&state.pool,
|
||||
@@ -77,10 +75,9 @@ pub async fn post_champs_result(
|
||||
/// POST /fut-champs/:session_id/claim — claim end-of-week rewards.
|
||||
pub async fn post_claim_champs_rewards(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(session_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let result = champs_svc::claim_rewards(
|
||||
@@ -96,8 +93,8 @@ pub async fn post_claim_champs_rewards(
|
||||
}
|
||||
|
||||
/// GET /fut-champs/history — past sessions, newest first.
|
||||
pub async fn get_champs_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_champs_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let history = champs_svc::get_history(&state.pool, &profile.id).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -107,8 +104,8 @@ pub async fn get_champs_history(State(state): State<AppState>, game: GameId) ->
|
||||
}
|
||||
|
||||
/// POST /rivals/claim-weekly — claim weekly Division Rivals reward.
|
||||
pub async fn post_claim_rivals_reward(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn post_claim_rivals_reward(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
// Ensure a season row exists
|
||||
|
||||
+7
-11
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
@@ -13,8 +12,8 @@ use crate::{
|
||||
services::{club as club_svc, market as market_svc, profile as profile_svc},
|
||||
};
|
||||
|
||||
pub async fn get_trade_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_trade_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let trades = market_svc::get_trade_history(&state.pool, &club.id).await?;
|
||||
Ok(Json(json!({ "trades": trades, "total": trades.len() })))
|
||||
@@ -53,10 +52,9 @@ pub async fn get_market(
|
||||
|
||||
pub async fn post_market_buy(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<BuyListingRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?;
|
||||
@@ -67,10 +65,9 @@ pub async fn post_market_buy(
|
||||
|
||||
pub async fn post_market_sell(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<SellCardRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let new_balance = market_svc::sell_card(&state.pool, &state.card_db, &club.id, &req).await?;
|
||||
@@ -86,8 +83,8 @@ pub async fn post_market_refresh(State(state): State<AppState>) -> AppResult<Jso
|
||||
}
|
||||
|
||||
/// Return all active market listings posted by the current player's club.
|
||||
pub async fn get_my_listings(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_my_listings(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let listings = market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
|
||||
Ok(Json(json!({ "listings": listings, "total": listings.len() })))
|
||||
@@ -96,10 +93,9 @@ pub async fn get_my_listings(State(state): State<AppState>, game: GameId) -> App
|
||||
/// Cancel a player-posted listing and return the card to the collection.
|
||||
pub async fn delete_market_listing(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(listing_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
market_svc::cancel_listing(&state.pool, &club.id, &listing_id).await?;
|
||||
Ok(Json(json!({ "cancelled": listing_id })))
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
@@ -21,10 +20,9 @@ pub struct MatchHistoryQuery {
|
||||
|
||||
pub async fn get_matches(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Query(query): Query<MatchHistoryQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||
|
||||
let matches = if let Some(mode) = &query.mode {
|
||||
@@ -65,10 +63,9 @@ pub async fn get_opponent(
|
||||
|
||||
pub async fn post_match_result(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<SubmitMatchRequest>,
|
||||
) -> AppResult<Json<MatchRewardResult>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let result =
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
@@ -17,8 +16,8 @@ use crate::{
|
||||
/// notifications (unclaimed objectives, expiring loans, season ending soon).
|
||||
/// Persistent notifications carry an `id` and `is_read` flag; dynamic ones
|
||||
/// have `id: null` and are always considered unread.
|
||||
pub async fn get_notifications(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_notifications(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
// ── Persistent notifications ─────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
@@ -12,8 +11,8 @@ use crate::{
|
||||
services::{club as club_svc, objective as obj_svc, profile as profile_svc},
|
||||
};
|
||||
|
||||
pub async fn get_objectives(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_objectives(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let objectives =
|
||||
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
||||
Ok(Json(json!({ "objectives": objectives })))
|
||||
@@ -21,10 +20,9 @@ pub async fn get_objectives(State(state): State<AppState>, game: GameId) -> AppR
|
||||
|
||||
pub async fn get_objective(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(objective_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let all =
|
||||
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
||||
let obj = all
|
||||
@@ -36,10 +34,9 @@ pub async fn get_objective(
|
||||
|
||||
pub async fn post_claim_objective_by_id(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(objective_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let reward = obj_svc::claim_objective(
|
||||
&state.pool,
|
||||
@@ -59,10 +56,9 @@ pub struct ClaimRequest {
|
||||
|
||||
pub async fn post_claim_objective(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<ClaimRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let reward = obj_svc::claim_objective(
|
||||
&state.pool,
|
||||
|
||||
+6
-9
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
@@ -20,10 +19,9 @@ pub struct BuyPackRequest {
|
||||
|
||||
pub async fn post_buy_pack(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<BuyPackRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let pack = pack_svc::buy_pack(
|
||||
&state.pool,
|
||||
@@ -56,8 +54,8 @@ pub async fn get_pack_store(State(state): State<AppState>) -> AppResult<Json<Val
|
||||
Ok(Json(json!({ "packs": store })))
|
||||
}
|
||||
|
||||
pub async fn get_packs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?;
|
||||
|
||||
@@ -79,8 +77,8 @@ pub async fn get_packs(State(state): State<AppState>, game: GameId) -> AppResult
|
||||
}
|
||||
|
||||
/// Return recently opened packs with the card IDs they contained.
|
||||
pub async fn get_pack_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_pack_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let opened = sqlx::query_as::<_, crate::models::pack::Pack>(
|
||||
@@ -124,10 +122,9 @@ pub async fn get_pack_history(State(state): State<AppState>, game: GameId) -> Ap
|
||||
|
||||
pub async fn post_open_pack(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(pack_id): Path<String>,
|
||||
) -> AppResult<Json<PackOpenResult>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let result = pack_svc::open_pack(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
@@ -8,8 +7,8 @@ use crate::{
|
||||
use axum::{extract::State, Json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn get_profile(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_profile(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let computed_level = level_for_xp(profile.xp);
|
||||
|
||||
let next_level = computed_level + 1;
|
||||
|
||||
+1
-3
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
@@ -30,10 +29,9 @@ pub async fn get_sbc(
|
||||
|
||||
pub async fn post_sbc_submit(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<SubmitSbcRequest>,
|
||||
) -> AppResult<Json<SbcResult>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let result = sbc_svc::submit_sbc(
|
||||
|
||||
+9
-13
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
@@ -12,8 +11,8 @@ use crate::{
|
||||
services::{club as club_svc, profile as profile_svc, squad as squad_svc},
|
||||
};
|
||||
|
||||
pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_squad(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?;
|
||||
@@ -22,8 +21,8 @@ pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult
|
||||
Ok(Json(squad_response(&squad, &players, chemistry)))
|
||||
}
|
||||
|
||||
pub async fn get_squads(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_squads(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let squads = squad_svc::list_squads(&state.pool, &club.id).await?;
|
||||
Ok(Json(json!({ "squads": squads })))
|
||||
@@ -31,10 +30,9 @@ pub async fn get_squads(State(state): State<AppState>, game: GameId) -> AppResul
|
||||
|
||||
pub async fn get_squad_by_id(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(squad_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let (squad, players) = squad_svc::get_squad_by_id(&state.pool, &club.id, &squad_id).await?;
|
||||
@@ -45,26 +43,24 @@ pub async fn get_squad_by_id(
|
||||
|
||||
pub async fn post_squad(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<SaveSquadRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
if !req.players.is_empty() {
|
||||
squad_svc::validate_formation(&state.pool, &state.card_db, &club.id, &req.players).await?;
|
||||
squad_svc::validate_formation(&state.pool, &state.card_db, &req.players).await?;
|
||||
}
|
||||
|
||||
let squad = squad_svc::save_squad(&state.pool, &club.id, &req).await?;
|
||||
let squad = squad_svc::save_squad(&state.pool, &state.card_db, &club.id, &req).await?;
|
||||
Ok(Json(json!({ "squad": squad })))
|
||||
}
|
||||
|
||||
pub async fn delete_squad(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(squad_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
squad_svc::delete_squad(&state.pool, &club.id, &squad_id).await?;
|
||||
Ok(Json(json!({ "deleted": squad_id })))
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
@@ -13,8 +12,8 @@ use crate::{
|
||||
services::{profile as profile_svc, statistics as stats_svc},
|
||||
};
|
||||
|
||||
pub async fn get_statistics(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_statistics(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||
let pos_goals = stats_svc::get_position_goals(&state.pool, &profile.id).await?;
|
||||
|
||||
@@ -37,10 +36,9 @@ pub struct HistoryQuery {
|
||||
|
||||
pub async fn get_statistics_history(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Query(query): Query<HistoryQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||
|
||||
let matches = sqlx::query_as::<_, Match>(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
@@ -28,11 +27,10 @@ pub struct ApplyChemStyleRequest {
|
||||
/// POST /collection/:owned_card_id/chemistry-style
|
||||
pub async fn post_apply_chemistry_style(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(owned_card_id): Path<String>,
|
||||
Json(req): Json<ApplyChemStyleRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let updated = upgrade_svc::apply_chemistry_style(
|
||||
@@ -65,11 +63,10 @@ pub struct ChangePositionRequest {
|
||||
/// POST /collection/:owned_card_id/position — costs 500 coins.
|
||||
pub async fn post_change_position(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(owned_card_id): Path<String>,
|
||||
Json(req): Json<ChangePositionRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let updated = upgrade_svc::change_position(
|
||||
@@ -100,11 +97,10 @@ pub struct ApplyTrainingRequest {
|
||||
/// POST /collection/:owned_card_id/training — applies a training boost (up to +3 OVR total).
|
||||
pub async fn post_apply_training(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(owned_card_id): Path<String>,
|
||||
Json(req): Json<ApplyTrainingRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let updated =
|
||||
|
||||
@@ -42,6 +42,17 @@ impl CardDb {
|
||||
self.cards.get(id)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn by_rarity(&self, rarity: &str) -> Vec<&CardDefinition> {
|
||||
self.cards
|
||||
.values()
|
||||
.filter(|c| {
|
||||
let r = format!("{:?}", c.rarity).to_lowercase();
|
||||
r == rarity || rarity == "any"
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn all(&self) -> Vec<&CardDefinition> {
|
||||
self.cards.values().collect()
|
||||
}
|
||||
|
||||
+4
-14
@@ -88,18 +88,13 @@ pub async fn start_draft(
|
||||
let first_position = &pick_order[0];
|
||||
let candidates = pick_candidates(card_db, first_position, min_overall, CANDIDATES_PER_SLOT);
|
||||
|
||||
let pick_order_json = serde_json::to_string(&pick_order)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
||||
let candidates_json = serde_json::to_string(&candidates)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
||||
|
||||
let session = DraftSession {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
profile_id: profile_id.to_string(),
|
||||
difficulty: difficulty.to_string(),
|
||||
pick_order: pick_order_json,
|
||||
pick_order: serde_json::to_string(&pick_order).unwrap(),
|
||||
picks: "[]".to_string(),
|
||||
current_candidates: Some(candidates_json),
|
||||
current_candidates: Some(serde_json::to_string(&candidates).unwrap()),
|
||||
status: "active".to_string(),
|
||||
reward_coins: 0,
|
||||
reward_pack_id: None,
|
||||
@@ -190,22 +185,17 @@ pub async fn pick_card(
|
||||
let next_pos = &pick_order[next_index];
|
||||
let next_candidates =
|
||||
pick_candidates(card_db, next_pos, min_overall, CANDIDATES_PER_SLOT);
|
||||
{
|
||||
let candidates_json = serde_json::to_string(&next_candidates)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
||||
(
|
||||
Some(candidates_json),
|
||||
Some(serde_json::to_string(&next_candidates).unwrap()),
|
||||
"active".to_string(),
|
||||
0,
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let picks_json = serde_json::to_string(&picks)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
||||
let picks_json = serde_json::to_string(&picks).unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \
|
||||
|
||||
@@ -239,30 +239,16 @@ pub async fn claim_rivals_reward(
|
||||
pack_defs: &[PackDefinition],
|
||||
) -> AppResult<serde_json::Value> {
|
||||
// Fetch current season row (must exist)
|
||||
let row: Option<(i64, i64, i64, Option<String>)> = sqlx::query_as(
|
||||
"SELECT division, rivals_week_claimed, rivals_total_points, rivals_last_claimed_at \
|
||||
FROM seasons WHERE profile_id = ?",
|
||||
let row: Option<(i64, i64, i64)> = sqlx::query_as(
|
||||
"SELECT division, rivals_week_claimed, rivals_total_points FROM seasons WHERE profile_id = ?",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let (division, week_claimed, total_pts, last_claimed_at) =
|
||||
let (division, week_claimed, total_pts) =
|
||||
row.ok_or_else(|| AppError::NotFound("no season found — play a match first".into()))?;
|
||||
|
||||
// Enforce 24-hour cooldown between weekly reward claims
|
||||
if let Some(ref last_claimed) = last_claimed_at {
|
||||
if let Ok(last_time) = chrono::DateTime::parse_from_rfc3339(last_claimed) {
|
||||
let elapsed = chrono::Utc::now() - last_time.with_timezone(&chrono::Utc);
|
||||
if elapsed < chrono::Duration::hours(24) {
|
||||
let hours_remaining = 24 - elapsed.num_hours();
|
||||
return Err(AppError::Conflict(format!(
|
||||
"rivals weekly reward already claimed; try again in ~{hours_remaining}h"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let next_week = week_claimed + 1;
|
||||
let coins = rivals_weekly_coins(division);
|
||||
let new_balance = club_svc::add_coins(pool, club_id, coins).await?;
|
||||
@@ -285,13 +271,11 @@ pub async fn claim_rivals_reward(
|
||||
None
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100, \
|
||||
rivals_last_claimed_at = ? WHERE profile_id = ?",
|
||||
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100 \
|
||||
WHERE profile_id = ?",
|
||||
)
|
||||
.bind(next_week)
|
||||
.bind(&now)
|
||||
.bind(profile_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Executable
+291
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -43,12 +43,11 @@ pub async fn refresh_npc_listings(
|
||||
card_db: &CardDb,
|
||||
event_defs: &[EventDefinition],
|
||||
) -> AppResult<usize> {
|
||||
// Clean up expired listings and previous NPC listings.
|
||||
// Player-posted listings (is_npc = 0) are intentionally preserved.
|
||||
// Clean up expired and unsold listings
|
||||
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM market_listings WHERE sold = 0 AND is_npc = 1")
|
||||
sqlx::query("DELETE FROM market_listings WHERE sold = 0")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
@@ -108,8 +107,8 @@ pub async fn refresh_npc_listings(
|
||||
for listing in &listings_to_insert {
|
||||
sqlx::query(
|
||||
"INSERT INTO market_listings \
|
||||
(id, card_id, seller_name, price, listed_at, expires_at, sold, is_npc) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, 1)",
|
||||
(id, card_id, seller_name, price, listed_at, expires_at, sold) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0)",
|
||||
)
|
||||
.bind(&listing.id)
|
||||
.bind(&listing.card_id)
|
||||
@@ -191,10 +190,6 @@ pub async fn sell_card(
|
||||
club_id: &str,
|
||||
req: &SellCardRequest,
|
||||
) -> AppResult<i64> {
|
||||
if req.price < 0 {
|
||||
return Err(AppError::BadRequest("price must be non-negative".into()));
|
||||
}
|
||||
|
||||
let owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
||||
chemistry_style, position_override, training_bonus \
|
||||
|
||||
@@ -94,12 +94,6 @@ pub async fn process_match(
|
||||
obj_defs: &[ObjectiveDefinition],
|
||||
ach_defs: &[AchievementDefinition],
|
||||
) -> AppResult<MatchRewardResult> {
|
||||
if req.goals_for < 0 || req.goals_against < 0 || req.goals_for > 99 || req.goals_against > 99 {
|
||||
return Err(crate::error::AppError::BadRequest(
|
||||
"goals_for and goals_against must each be between 0 and 99".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let outcome = if req.goals_for > req.goals_against {
|
||||
"win"
|
||||
} else if req.goals_for == req.goals_against {
|
||||
@@ -190,13 +184,9 @@ pub async fn process_match(
|
||||
objectives_updated.append(&mut c);
|
||||
|
||||
for obj_id in &objectives_updated {
|
||||
let display_name = obj_defs
|
||||
.iter()
|
||||
.find(|d| &d.id == obj_id)
|
||||
.map(|d| d.title.as_str())
|
||||
.unwrap_or(obj_id.as_str());
|
||||
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", display_name);
|
||||
let _ = notification::create(pool, "objective_complete", "Objective complete!", &body).await;
|
||||
let title = "Objective complete!";
|
||||
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", obj_id);
|
||||
let _ = notification::create(pool, "objective_complete", title, &body).await;
|
||||
}
|
||||
|
||||
// Decrement loan matches remaining for squad starters; collect expired loan IDs.
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod notification;
|
||||
pub mod draft;
|
||||
pub mod event;
|
||||
pub mod fut_champs;
|
||||
pub mod inventory;
|
||||
pub mod season;
|
||||
pub mod market;
|
||||
pub mod match_service;
|
||||
@@ -15,5 +16,6 @@ pub mod profile;
|
||||
pub mod sbc;
|
||||
pub mod settings;
|
||||
pub mod squad;
|
||||
pub mod squad_rules;
|
||||
pub mod statistics;
|
||||
pub mod upgrades;
|
||||
|
||||
@@ -69,7 +69,7 @@ pub async fn increment_metric(
|
||||
|
||||
for def in defs
|
||||
.iter()
|
||||
.filter(|d| d.metric.as_str() == metric)
|
||||
.filter(|d| format!("{:?}", d.metric).to_lowercase() == metric)
|
||||
{
|
||||
let existing = sqlx::query_as::<_, ObjectiveProgress>(
|
||||
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
|
||||
|
||||
@@ -90,8 +90,8 @@ pub async fn open_pack(
|
||||
.all()
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
let r = c.rarity.as_str();
|
||||
rarities.contains(&r.to_string())
|
||||
let r = format!("{:?}", c.rarity).to_lowercase();
|
||||
rarities.contains(&r)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
|
||||
+8
-22
@@ -5,41 +5,33 @@ use crate::{
|
||||
};
|
||||
use chrono::Utc;
|
||||
|
||||
/// Fetch the active profile for a game. Single-profile-per-game: there is exactly
|
||||
/// one profile row per `game_id`, so we take the earliest for that game.
|
||||
pub async fn get_active_profile(pool: &Pool, game_id: &str) -> AppResult<Profile> {
|
||||
pub async fn get_active_profile(pool: &Pool) -> AppResult<Profile> {
|
||||
sqlx::query_as::<_, Profile>(
|
||||
"SELECT id, username, level, xp, game_id, created_at, updated_at \
|
||||
FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
|
||||
"SELECT id, username, level, xp, created_at, updated_at FROM profiles ORDER BY created_at ASC LIMIT 1"
|
||||
)
|
||||
.bind(game_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into()))
|
||||
}
|
||||
|
||||
pub async fn create_profile(pool: &Pool, username: &str, game_id: &str) -> AppResult<Profile> {
|
||||
// Single-player per game: one profile per game_id, not one globally.
|
||||
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
|
||||
.bind(game_id)
|
||||
pub async fn create_profile(pool: &Pool, username: &str) -> AppResult<Profile> {
|
||||
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if existing > 0 {
|
||||
return Err(AppError::Conflict(
|
||||
"a profile already exists for this game; OpenFUT is single-player per game".into(),
|
||||
"a profile already exists; OpenFUT is single-player only".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let profile = Profile::new(username, game_id);
|
||||
let profile = Profile::new(username);
|
||||
sqlx::query(
|
||||
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO profiles (id, username, level, xp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&profile.id)
|
||||
.bind(&profile.username)
|
||||
.bind(profile.level)
|
||||
.bind(profile.xp)
|
||||
.bind(&profile.game_id)
|
||||
.bind(profile.created_at)
|
||||
.bind(profile.updated_at)
|
||||
.execute(pool)
|
||||
@@ -67,13 +59,7 @@ pub async fn add_xp_with_levelup(
|
||||
club_id: &str,
|
||||
xp_to_add: i64,
|
||||
) -> AppResult<Vec<LevelUpEvent>> {
|
||||
let profile = sqlx::query_as::<_, Profile>(
|
||||
"SELECT id, username, level, xp, game_id, created_at, updated_at FROM profiles WHERE id = ?",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("profile '{profile_id}' not found")))?;
|
||||
let profile = get_active_profile(pool).await?;
|
||||
let old_level = level_for_xp(profile.xp);
|
||||
let new_total_xp = profile.xp + xp_to_add;
|
||||
let new_level = level_for_xp(new_total_xp);
|
||||
|
||||
@@ -101,6 +101,9 @@ pub async fn record_match(
|
||||
// Grant rewards
|
||||
club::add_coins(pool, club_id, coins).await?;
|
||||
if let Some(pack_def) = pack_id {
|
||||
let dummy_pack_id = Uuid::new_v4().to_string();
|
||||
// Grant via pack system so it shows in inventory
|
||||
let _ = dummy_pack_id; // will use grant_pack instead
|
||||
pack::grant_pack(pool, club_id, pack_def).await?;
|
||||
}
|
||||
|
||||
|
||||
+507
-59
@@ -3,10 +3,19 @@ use crate::{
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
card::{CardDefinition, OwnedCard},
|
||||
squad::{SaveSquadRequest, Squad, SquadPlayer, SquadPlayerInput},
|
||||
squad::{
|
||||
SaveSquadRequest, SlotAssignment, Squad, SquadPlayer, SquadPlayerInput, SquadReplaced,
|
||||
SquadReplacement,
|
||||
},
|
||||
},
|
||||
services::{
|
||||
card_db::CardDb,
|
||||
squad_rules::{
|
||||
ClientReportedEvaluation, DefaultSquadRules, SquadPlayerCard, SquadRules, SquadSnapshot,
|
||||
},
|
||||
},
|
||||
services::card_db::CardDb,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn get_squad(pool: &Pool, club_id: &str) -> AppResult<(Squad, Vec<SquadPlayer>)> {
|
||||
@@ -63,7 +72,6 @@ async fn get_players(pool: &Pool, squad_id: &str) -> AppResult<Vec<SquadPlayer>>
|
||||
pub async fn validate_formation(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
club_id: &str,
|
||||
players: &[SquadPlayerInput],
|
||||
) -> AppResult<()> {
|
||||
let starters: Vec<&SquadPlayerInput> = players.iter().filter(|p| !p.is_on_bench).collect();
|
||||
@@ -78,13 +86,12 @@ pub async fn validate_formation(
|
||||
let mut gk_count = 0usize;
|
||||
for sp in &starters {
|
||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ? AND club_id = ?",
|
||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ?",
|
||||
)
|
||||
.bind(&sp.owned_card_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found or does not belong to this club", sp.owned_card_id)))?;
|
||||
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found", sp.owned_card_id)))?;
|
||||
|
||||
if let Some(card) = card_db.get(&owned.card_id) {
|
||||
if card.position == "GK" {
|
||||
@@ -193,68 +200,153 @@ pub async fn calculate_chemistry(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> AppResult<Squad> {
|
||||
/// Replace a squad's entire slot assignment, atomically.
|
||||
///
|
||||
/// # Why this exists alongside `save_squad`
|
||||
///
|
||||
/// `save_squad` wrote outside a transaction: it UPDATEd the squad, DELETEd every
|
||||
/// row from `squad_players`, then INSERTed the new ones one at a time. A failure
|
||||
/// part-way through left a squad with some of its old players deleted and only
|
||||
/// some of its new ones written — a state no client asked for and none can
|
||||
/// detect. It also never checked that the cards being placed belonged to the
|
||||
/// club, and happily accepted the same card in two slots.
|
||||
///
|
||||
/// Those are acceptable in a single-user REST toy and not acceptable under a
|
||||
/// real client, so this is the one write path now and `save_squad` delegates to
|
||||
/// it.
|
||||
///
|
||||
/// # Order of work
|
||||
///
|
||||
/// Validation happens BEFORE any write, so a rejected replacement leaves the
|
||||
/// existing squad exactly as it was. Everything that does write happens inside
|
||||
/// one transaction.
|
||||
pub async fn replace_squad(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
rules: &dyn SquadRules,
|
||||
club_id: &str,
|
||||
squad_id: Option<&str>,
|
||||
replacement: &SquadReplacement,
|
||||
client_reported: &ClientReportedEvaluation,
|
||||
) -> AppResult<SquadReplaced> {
|
||||
// ── validate before touching anything ────────────────────────────────
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
let mut slots_seen: HashSet<i64> = HashSet::new();
|
||||
for s in &replacement.slots {
|
||||
if s.slot < 0 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"slot index must not be negative, got {}",
|
||||
s.slot
|
||||
)));
|
||||
}
|
||||
if !slots_seen.insert(s.slot) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"slot {} assigned more than once",
|
||||
s.slot
|
||||
)));
|
||||
}
|
||||
if !seen.insert(s.owned_card_id.as_str()) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"card {} assigned to more than one slot",
|
||||
s.owned_card_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Ownership: every card must belong to THIS club. Without this a client
|
||||
// could place a card it does not own, and the squad would read back as
|
||||
// though it did.
|
||||
let mut resolved: Vec<(SlotAssignmentRef, OwnedCard)> = Vec::new();
|
||||
for s in &replacement.slots {
|
||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ?",
|
||||
)
|
||||
.bind(&s.owned_card_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found", s.owned_card_id)))?;
|
||||
|
||||
if owned.club_id != club_id {
|
||||
// Deliberately the same message as "not found": whether a card
|
||||
// exists in someone else's club is not this caller's business.
|
||||
return Err(AppError::NotFound(format!(
|
||||
"owned card {} not found",
|
||||
s.owned_card_id
|
||||
)));
|
||||
}
|
||||
resolved.push((
|
||||
SlotAssignmentRef {
|
||||
slot: s.slot,
|
||||
is_captain: s.is_captain,
|
||||
is_on_bench: s.is_on_bench,
|
||||
},
|
||||
owned,
|
||||
));
|
||||
}
|
||||
|
||||
// ── one transaction for every write ──────────────────────────────────
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let squad_id = if let Some(ref id) = req.squad_id {
|
||||
// Update existing squad — verify ownership
|
||||
let verified =
|
||||
sqlx::query_scalar::<_, String>("SELECT id FROM squads WHERE id = ? AND club_id = ?")
|
||||
.bind(id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("squad '{id}' not found")))?;
|
||||
let squad_id = match squad_id {
|
||||
Some(id) => {
|
||||
let verified = sqlx::query_scalar::<_, String>(
|
||||
"SELECT id FROM squads WHERE id = ? AND club_id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("squad '{id}' not found")))?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE squads SET name = COALESCE(?, name), formation = COALESCE(?, formation), updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(req.name.as_deref())
|
||||
.bind(req.formation.as_deref())
|
||||
.bind(&now)
|
||||
.bind(&verified)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query("DELETE FROM squad_players WHERE squad_id = ?")
|
||||
sqlx::query(
|
||||
"UPDATE squads SET name = COALESCE(?, name), formation = COALESCE(?, formation), updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(replacement.name.as_deref())
|
||||
.bind(replacement.formation.as_deref())
|
||||
.bind(&now)
|
||||
.bind(&verified)
|
||||
.execute(pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
verified
|
||||
} else {
|
||||
// Create a new squad
|
||||
let squad = Squad::new(
|
||||
club_id,
|
||||
req.name.as_deref().unwrap_or("My Squad"),
|
||||
req.formation.as_deref().unwrap_or("4-4-2"),
|
||||
);
|
||||
sqlx::query(
|
||||
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&squad.id)
|
||||
.bind(&squad.club_id)
|
||||
.bind(&squad.name)
|
||||
.bind(&squad.formation)
|
||||
.bind(&squad.created_at)
|
||||
.bind(&squad.updated_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
squad.id
|
||||
verified
|
||||
}
|
||||
None => {
|
||||
let squad = Squad::new(
|
||||
club_id,
|
||||
replacement.name.as_deref().unwrap_or("My Squad"),
|
||||
replacement.formation.as_deref().unwrap_or("4-4-2"),
|
||||
);
|
||||
sqlx::query(
|
||||
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&squad.id)
|
||||
.bind(&squad.club_id)
|
||||
.bind(&squad.name)
|
||||
.bind(&squad.formation)
|
||||
.bind(&squad.created_at)
|
||||
.bind(&squad.updated_at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
squad.id
|
||||
}
|
||||
};
|
||||
|
||||
for player in &req.players {
|
||||
let sp_id = Uuid::new_v4().to_string();
|
||||
sqlx::query("DELETE FROM squad_players WHERE squad_id = ?")
|
||||
.bind(&squad_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for (slot, owned) in &resolved {
|
||||
sqlx::query(
|
||||
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&sp_id)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(&squad_id)
|
||||
.bind(&player.owned_card_id)
|
||||
.bind(player.position_index)
|
||||
.bind(player.is_captain)
|
||||
.bind(player.is_on_bench)
|
||||
.execute(pool)
|
||||
.bind(&owned.id)
|
||||
.bind(slot.slot)
|
||||
.bind(slot.is_captain)
|
||||
.bind(slot.is_on_bench)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -262,10 +354,87 @@ pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> A
|
||||
"SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE id = ?",
|
||||
)
|
||||
.bind(&squad_id)
|
||||
.fetch_one(pool)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
Ok(squad)
|
||||
tx.commit().await?;
|
||||
|
||||
// ── evaluate with the game's rules, never with the client's numbers ──
|
||||
let snapshot = SquadSnapshot {
|
||||
formation: squad.formation.clone(),
|
||||
players: resolved
|
||||
.iter()
|
||||
.filter_map(|(slot, owned)| {
|
||||
card_db.get(&owned.card_id).map(|card| SquadPlayerCard {
|
||||
owned_card_id: owned.id.clone(),
|
||||
card_id: card.id.clone(),
|
||||
name: card.name.clone(),
|
||||
overall: card.overall,
|
||||
position: card.position.clone(),
|
||||
nation: card.nation.clone(),
|
||||
league: card.league.clone(),
|
||||
club: card.club.clone(),
|
||||
slot: slot.slot,
|
||||
on_bench: slot.is_on_bench,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let evaluation = rules.evaluate(&snapshot);
|
||||
let client_disagreements = client_reported.compare(&evaluation);
|
||||
|
||||
Ok(SquadReplaced {
|
||||
squad,
|
||||
slots_written: resolved.len(),
|
||||
evaluation,
|
||||
client_disagreements,
|
||||
})
|
||||
}
|
||||
|
||||
struct SlotAssignmentRef {
|
||||
slot: i64,
|
||||
is_captain: bool,
|
||||
is_on_bench: bool,
|
||||
}
|
||||
|
||||
/// Compatibility wrapper over [`replace_squad`].
|
||||
///
|
||||
/// Kept so the existing Core REST route keeps working, but it no longer has its
|
||||
/// own write path. That means this route now also validates ownership and
|
||||
/// rejects duplicate cards — a deliberate tightening, not an accident: those
|
||||
/// were bugs, and having two write paths with different guarantees is how the
|
||||
/// stricter one gets bypassed.
|
||||
pub async fn save_squad(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
club_id: &str,
|
||||
req: &SaveSquadRequest,
|
||||
) -> AppResult<Squad> {
|
||||
let replacement = SquadReplacement {
|
||||
name: req.name.clone(),
|
||||
formation: req.formation.clone(),
|
||||
slots: req
|
||||
.players
|
||||
.iter()
|
||||
.map(|p| SlotAssignment {
|
||||
owned_card_id: p.owned_card_id.clone(),
|
||||
slot: p.position_index,
|
||||
is_captain: p.is_captain,
|
||||
is_on_bench: p.is_on_bench,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let out = replace_squad(
|
||||
pool,
|
||||
card_db,
|
||||
&DefaultSquadRules,
|
||||
club_id,
|
||||
req.squad_id.as_deref(),
|
||||
&replacement,
|
||||
&ClientReportedEvaluation::default(),
|
||||
)
|
||||
.await?;
|
||||
Ok(out.squad)
|
||||
}
|
||||
|
||||
pub async fn delete_squad(pool: &Pool, club_id: &str, squad_id: &str) -> AppResult<()> {
|
||||
@@ -280,3 +449,282 @@ pub async fn delete_squad(pool: &Pool, club_id: &str, squad_id: &str) -> AppResu
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::squad::SlotAssignment;
|
||||
|
||||
/// A pool with the real schema, plus two clubs that own one card each.
|
||||
///
|
||||
/// Two clubs specifically: the guarantee under test is that a card
|
||||
/// belonging to somebody else cannot be placed, and that cannot be
|
||||
/// expressed with one club.
|
||||
const TS: &str = "2026-01-01T00:00:00Z";
|
||||
|
||||
async fn fixture() -> (Pool, CardDb) {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("in-memory sqlite");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrations");
|
||||
|
||||
for (profile, club) in [("prof-a", "club-a"), ("prof-b", "club-b")] {
|
||||
sqlx::query(
|
||||
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(profile)
|
||||
.bind(profile)
|
||||
.bind(TS)
|
||||
.bind(TS)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("profile");
|
||||
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(club).bind(profile).bind(club).bind(1000i64).bind(TS).bind(TS)
|
||||
.execute(&pool).await.expect("club");
|
||||
}
|
||||
// club-a owns card-1 and card-2; club-b owns card-foreign.
|
||||
for (id, club) in [
|
||||
("card-1", "club-a"),
|
||||
("card-2", "club-a"),
|
||||
("card-foreign", "club-b"),
|
||||
] {
|
||||
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
|
||||
.bind(id).bind(club).bind("def-1").bind(TS)
|
||||
.execute(&pool).await.expect("owned card");
|
||||
}
|
||||
// An empty card database is enough: none of these guarantees consult it.
|
||||
(
|
||||
pool,
|
||||
CardDb::load("/nonexistent-card-dir").expect("empty card db"),
|
||||
)
|
||||
}
|
||||
|
||||
fn slot(card: &str, n: i64) -> SlotAssignment {
|
||||
SlotAssignment {
|
||||
owned_card_id: card.into(),
|
||||
slot: n,
|
||||
is_captain: false,
|
||||
is_on_bench: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn replace(
|
||||
pool: &Pool,
|
||||
db: &CardDb,
|
||||
club: &str,
|
||||
id: Option<&str>,
|
||||
slots: Vec<SlotAssignment>,
|
||||
) -> AppResult<SquadReplaced> {
|
||||
replace_squad(
|
||||
pool,
|
||||
db,
|
||||
&DefaultSquadRules,
|
||||
club,
|
||||
id,
|
||||
&SquadReplacement {
|
||||
name: Some("S".into()),
|
||||
formation: Some("4-4-2".into()),
|
||||
slots,
|
||||
},
|
||||
&ClientReportedEvaluation::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn slots_of(pool: &Pool, squad_id: &str) -> Vec<(String, i64)> {
|
||||
sqlx::query_as::<_, (String, i64)>(
|
||||
"SELECT owned_card_id, position_index FROM squad_players WHERE squad_id = ? ORDER BY position_index",
|
||||
).bind(squad_id).fetch_all(pool).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_card_owned_by_another_club_cannot_be_placed() {
|
||||
let (pool, db) = fixture().await;
|
||||
let err = replace(&pool, &db, "club-a", None, vec![slot("card-foreign", 0)])
|
||||
.await
|
||||
.unwrap_err();
|
||||
// Same message as a missing card: whether it exists elsewhere is not
|
||||
// this caller's business.
|
||||
assert!(
|
||||
matches!(err, AppError::NotFound(ref m) if m.contains("card-foreign")),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_same_card_cannot_occupy_two_slots() {
|
||||
let (pool, db) = fixture().await;
|
||||
let err = replace(
|
||||
&pool,
|
||||
&db,
|
||||
"club-a",
|
||||
None,
|
||||
vec![slot("card-1", 0), slot("card-1", 1)],
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, AppError::BadRequest(ref m) if m.contains("more than one slot")),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_cards_cannot_occupy_the_same_slot() {
|
||||
let (pool, db) = fixture().await;
|
||||
let err = replace(
|
||||
&pool,
|
||||
&db,
|
||||
"club-a",
|
||||
None,
|
||||
vec![slot("card-1", 3), slot("card-2", 3)],
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, AppError::BadRequest(ref m) if m.contains("slot 3")),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_negative_slot_is_refused() {
|
||||
let (pool, db) = fixture().await;
|
||||
let err = replace(&pool, &db, "club-a", None, vec![slot("card-1", -1)])
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, AppError::BadRequest(ref m) if m.contains("negative")),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The atomicity guarantee, and the reason this operation exists.
|
||||
///
|
||||
/// The old implementation deleted every squad player before inserting the
|
||||
/// new ones, outside a transaction. A replacement rejected part-way through
|
||||
/// therefore destroyed the squad it failed to replace.
|
||||
#[tokio::test]
|
||||
async fn a_rejected_replacement_leaves_the_previous_squad_untouched() {
|
||||
let (pool, db) = fixture().await;
|
||||
let first = replace(
|
||||
&pool,
|
||||
&db,
|
||||
"club-a",
|
||||
None,
|
||||
vec![slot("card-1", 0), slot("card-2", 1)],
|
||||
)
|
||||
.await
|
||||
.expect("first save");
|
||||
let before = slots_of(&pool, &first.squad.id).await;
|
||||
assert_eq!(before.len(), 2);
|
||||
|
||||
// Valid card in slot 0, then one owned by another club.
|
||||
let err = replace(
|
||||
&pool,
|
||||
&db,
|
||||
"club-a",
|
||||
Some(&first.squad.id),
|
||||
vec![slot("card-1", 0), slot("card-foreign", 1)],
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::NotFound(_)), "{err:?}");
|
||||
|
||||
assert_eq!(
|
||||
slots_of(&pool, &first.squad.id).await,
|
||||
before,
|
||||
"a rejected replacement must not disturb the stored squad"
|
||||
);
|
||||
}
|
||||
|
||||
/// Replacement means replacement: slots present before and absent from the
|
||||
/// new assignment must be gone, not merged.
|
||||
#[tokio::test]
|
||||
async fn replacement_removes_slots_absent_from_the_new_assignment() {
|
||||
let (pool, db) = fixture().await;
|
||||
let first = replace(
|
||||
&pool,
|
||||
&db,
|
||||
"club-a",
|
||||
None,
|
||||
vec![slot("card-1", 0), slot("card-2", 1)],
|
||||
)
|
||||
.await
|
||||
.expect("first");
|
||||
let second = replace(
|
||||
&pool,
|
||||
&db,
|
||||
"club-a",
|
||||
Some(&first.squad.id),
|
||||
vec![slot("card-2", 5)],
|
||||
)
|
||||
.await
|
||||
.expect("second");
|
||||
assert_eq!(second.slots_written, 1);
|
||||
assert_eq!(
|
||||
slots_of(&pool, &first.squad.id).await,
|
||||
vec![("card-2".to_string(), 5)]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_squad_belonging_to_another_club_cannot_be_replaced() {
|
||||
let (pool, db) = fixture().await;
|
||||
let mine = replace(&pool, &db, "club-a", None, vec![slot("card-1", 0)])
|
||||
.await
|
||||
.expect("mine");
|
||||
let err = replace(&pool, &db, "club-b", Some(&mine.squad.id), vec![])
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AppError::NotFound(_)), "{err:?}");
|
||||
assert_eq!(slots_of(&pool, &mine.squad.id).await.len(), 1);
|
||||
}
|
||||
|
||||
/// The client's numbers must never become the server's.
|
||||
#[tokio::test]
|
||||
async fn client_reported_values_are_reported_as_disagreement_not_stored() {
|
||||
let (pool, db) = fixture().await;
|
||||
let out = replace_squad(
|
||||
&pool,
|
||||
&db,
|
||||
&DefaultSquadRules,
|
||||
"club-a",
|
||||
None,
|
||||
&SquadReplacement {
|
||||
name: Some("S".into()),
|
||||
formation: Some("4-4-2".into()),
|
||||
slots: vec![slot("card-1", 0)],
|
||||
},
|
||||
&ClientReportedEvaluation {
|
||||
client_reported_chemistry: Some(52),
|
||||
client_reported_rating: Some(99),
|
||||
client_reported_star_rating: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("save");
|
||||
|
||||
// The card db is empty, so the server derives nothing: 0.
|
||||
assert_eq!(out.evaluation.chemistry, 0);
|
||||
assert_eq!(out.evaluation.rating, 0);
|
||||
// And the disagreement is surfaced rather than reconciled.
|
||||
let fields: Vec<&str> = out
|
||||
.client_disagreements
|
||||
.iter()
|
||||
.map(|d| d.field.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
fields,
|
||||
vec!["chemistry", "rating"],
|
||||
"{:?}",
|
||||
out.client_disagreements
|
||||
);
|
||||
assert_eq!(out.evaluation.rules, "openfut-default-v2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+382
-8
@@ -1404,14 +1404,10 @@ async fn test_rivals_reward_increments_week_counter() {
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
})).await;
|
||||
|
||||
// First claim succeeds
|
||||
json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
|
||||
let (s, json) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
|
||||
assert_eq!(s, StatusCode::OK, "{json}");
|
||||
assert_eq!(json["week_number"], 1, "first claim should be week 1");
|
||||
|
||||
// Immediate re-claim is blocked by the 24-hour cooldown
|
||||
let (s, _) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
|
||||
assert_eq!(s, StatusCode::CONFLICT, "re-claim within 24h should be rejected");
|
||||
assert_eq!(json["week_number"], 2, "second claim should be week 2");
|
||||
}
|
||||
|
||||
// ── Phase 15: Pack Store ──────────────────────────────────────────────────────
|
||||
@@ -1715,7 +1711,7 @@ async fn test_auth_reset_clears_profile() {
|
||||
})).await;
|
||||
|
||||
// Reset
|
||||
let (status, json) = json_post(&app, "/auth/reset", serde_json::json!({ "confirm": "reset" })).await;
|
||||
let (status, json) = json_post(&app, "/auth/reset", serde_json::json!({})).await;
|
||||
assert_eq!(status, StatusCode::OK, "{json}");
|
||||
assert_eq!(json["reset"], true);
|
||||
|
||||
@@ -1733,7 +1729,7 @@ async fn test_auth_reset_allows_new_profile() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "FirstProfile").await;
|
||||
|
||||
json_post(&app, "/auth/reset", serde_json::json!({ "confirm": "reset" })).await;
|
||||
json_post(&app, "/auth/reset", serde_json::json!({})).await;
|
||||
|
||||
// Should be able to create a new profile after reset
|
||||
let (status, json) = json_post(&app, "/auth/local",
|
||||
@@ -1901,3 +1897,381 @@ async fn test_trade_history_empty_initially() {
|
||||
assert!(json["trades"].is_array());
|
||||
assert_eq!(json["trades"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
// ── Owned-item query: filtering, deterministic order, pagination ──────────────
|
||||
//
|
||||
// Regression coverage for the FIFA17 "My Squad" owned-player search. Retail
|
||||
// evidence (sha256-identical response bodies) proved the Python oracle applies
|
||||
// ONLY league+team and ignores level/rare/position/nation/start/count, which
|
||||
// re-serves page one forever and amplifies requests. Core intentionally fixes
|
||||
// this: filter (AND) -> deterministic order -> paginate. Semantics only — no raw
|
||||
// FIFA ids reach Core (the adapter resolves ids to the names asserted here).
|
||||
|
||||
/// Build an app AND keep the pool, so tests can seed a deterministic inventory.
|
||||
async fn build_test_app_with_pool() -> (axum::Router, sqlx::SqlitePool) {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("in-memory sqlite");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrations");
|
||||
let app = openfut_core::build_app(pool.clone(), "data")
|
||||
.await
|
||||
.expect("app build");
|
||||
(app, pool)
|
||||
}
|
||||
|
||||
/// A deliberately cluttered but KNOWN owned inventory drawn from committed card
|
||||
/// data. Spans qualities (gold/silver/bronze), several leagues/nations/positions
|
||||
/// and includes irrelevant "junk" that must disappear under a filter. Ids are
|
||||
/// `oc_NN` in listed order so the `(overall desc, owned_id asc)` order is fixed.
|
||||
const CLUTTERED_FIXTURE: &[(&str, &str)] = &[
|
||||
("oc_00", "card_raregold_008"), // 86 CDM Ghana / Premier League / Chelsea
|
||||
("oc_01", "card_hero_004"), // 85 CDM Nigeria / Premier League / Chelsea
|
||||
("oc_02", "card_raregold_010"), // 87 LB Russia / Premier League / Arsenal
|
||||
("oc_03", "card_pl_001"), // 84 ST England / Premier League / Northgate
|
||||
("oc_04", "card_ll_008"), // 82 ST Argentina / La Liga / Valencia Azul
|
||||
("oc_05", "card_raregold_004"), // 89 LW Argentina / Primera Division / Boca
|
||||
("oc_06", "card_totw_004"), // 92 LW Argentina / Primera Division / Boca
|
||||
("oc_07", "card_silver_001"), // 72 ST Brazil / Brasileirao / Athletico
|
||||
("oc_08", "card_silver_002"), // 70 CM Italy / Serie B / Frosinone
|
||||
("oc_09", "card_bronze_001"), // 62 ST Brazil / Serie B / Santos
|
||||
("oc_10", "card_bronze_002"), // 60 CM Italy / Serie C / Modena
|
||||
("oc_11", "card_raregold_001"), // 88 ST Brazil / Brasileirao / Flamengo
|
||||
("oc_12", "card_raregold_002"), // 86 CAM Italy / Serie A / AS Roma
|
||||
("oc_13", "card_raregold_003"), // 87 CB Germany / Bundesliga / Bayer
|
||||
];
|
||||
|
||||
async fn seed_cluttered(app: &axum::Router, pool: &sqlx::SqlitePool) {
|
||||
auth(app, "ClutterClub").await;
|
||||
let club_id: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("club exists after auth");
|
||||
// Replace the auto-granted starter pack with the deterministic fixture.
|
||||
sqlx::query("DELETE FROM owned_cards WHERE club_id = ?")
|
||||
.bind(&club_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
for (oc_id, card_id) in CLUTTERED_FIXTURE {
|
||||
sqlx::query(
|
||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus) \
|
||||
VALUES (?, ?, ?, 0, NULL, '2026-01-01T00:00:00Z', 'basic', NULL, 0)",
|
||||
)
|
||||
.bind(oc_id)
|
||||
.bind(&club_id)
|
||||
.bind(card_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn coll_card_ids(v: &Value) -> Vec<String> {
|
||||
v["collection"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e["card"]["id"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sorted(mut v: Vec<String>) -> Vec<String> {
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_owned_query_no_filter_returns_all() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_cluttered(&app, &pool).await;
|
||||
let (s, j) = json_get(&app, "/collection").await;
|
||||
assert_eq!(s, StatusCode::OK, "{j}");
|
||||
assert_eq!(j["total"], 14);
|
||||
assert_eq!(j["returned"], 14);
|
||||
assert_eq!(coll_card_ids(&j).len(), 14);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_owned_query_quality_gold() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_cluttered(&app, &pool).await;
|
||||
let (_, j) = json_get(&app, "/collection?quality=gold").await;
|
||||
assert_eq!(j["total"], 10);
|
||||
assert_eq!(
|
||||
sorted(coll_card_ids(&j)),
|
||||
sorted(
|
||||
vec![
|
||||
"card_raregold_008",
|
||||
"card_hero_004",
|
||||
"card_raregold_010",
|
||||
"card_pl_001",
|
||||
"card_ll_008",
|
||||
"card_raregold_004",
|
||||
"card_totw_004",
|
||||
"card_raregold_001",
|
||||
"card_raregold_002",
|
||||
"card_raregold_003",
|
||||
]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_owned_query_position() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_cluttered(&app, &pool).await;
|
||||
let (_, j) = json_get(&app, "/collection?position=ST").await;
|
||||
assert_eq!(
|
||||
sorted(coll_card_ids(&j)),
|
||||
sorted(
|
||||
[
|
||||
"card_pl_001",
|
||||
"card_ll_008",
|
||||
"card_silver_001",
|
||||
"card_bronze_001",
|
||||
"card_raregold_001"
|
||||
]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_owned_query_nation() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_cluttered(&app, &pool).await;
|
||||
let (_, j) = json_get(&app, "/collection?nation=Argentina").await;
|
||||
assert_eq!(
|
||||
sorted(coll_card_ids(&j)),
|
||||
sorted(
|
||||
["card_ll_008", "card_raregold_004", "card_totw_004"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_owned_query_league() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_cluttered(&app, &pool).await;
|
||||
let (_, j) = json_get(&app, "/collection?league=Premier%20League").await;
|
||||
assert_eq!(
|
||||
sorted(coll_card_ids(&j)),
|
||||
sorted(
|
||||
[
|
||||
"card_raregold_008",
|
||||
"card_hero_004",
|
||||
"card_raregold_010",
|
||||
"card_pl_001"
|
||||
]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_owned_query_club() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_cluttered(&app, &pool).await;
|
||||
let (_, j) = json_get(&app, "/collection?club=Chelsea").await;
|
||||
assert_eq!(
|
||||
sorted(coll_card_ids(&j)),
|
||||
sorted(
|
||||
["card_raregold_008", "card_hero_004"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_owned_query_league_and_club() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_cluttered(&app, &pool).await;
|
||||
let (_, j) = json_get(&app, "/collection?league=Premier%20League&club=Chelsea").await;
|
||||
assert_eq!(
|
||||
sorted(coll_card_ids(&j)),
|
||||
sorted(
|
||||
["card_raregold_008", "card_hero_004"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_owned_query_league_and_position() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_cluttered(&app, &pool).await;
|
||||
let (_, j) = json_get(&app, "/collection?league=Premier%20League&position=ST").await;
|
||||
assert_eq!(j["total"], 1);
|
||||
assert_eq!(coll_card_ids(&j), ["card_pl_001"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_owned_query_quality_and_position() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_cluttered(&app, &pool).await;
|
||||
let (_, j) = json_get(&app, "/collection?quality=gold&position=ST").await;
|
||||
assert_eq!(
|
||||
sorted(coll_card_ids(&j)),
|
||||
sorted(
|
||||
["card_pl_001", "card_ll_008", "card_raregold_001"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_owned_query_no_results() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_cluttered(&app, &pool).await;
|
||||
let (s, j) = json_get(&app, "/collection?nation=Argentina&club=Chelsea").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(j["total"], 0);
|
||||
assert!(coll_card_ids(&j).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_owned_query_deterministic_order_overall_desc() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_cluttered(&app, &pool).await;
|
||||
let (_, j) = json_get(&app, "/collection").await;
|
||||
let overalls: Vec<i64> = j["collection"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e["effective_overall"].as_i64().unwrap())
|
||||
.collect();
|
||||
let mut sorted_desc = overalls.clone();
|
||||
sorted_desc.sort_by(|a, b| b.cmp(a));
|
||||
assert_eq!(
|
||||
overalls, sorted_desc,
|
||||
"collection must be overall-descending"
|
||||
);
|
||||
assert_eq!(
|
||||
coll_card_ids(&j)[0],
|
||||
"card_totw_004",
|
||||
"highest overall (92) first"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_owned_query_offset_and_limit() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_cluttered(&app, &pool).await;
|
||||
// gold set ordered: totw_004, raregold_004, raregold_001, raregold_010, ...
|
||||
let (_, j) = json_get(&app, "/collection?quality=gold&limit=3").await;
|
||||
assert_eq!(
|
||||
j["total"], 10,
|
||||
"total is the filtered count, not the page size"
|
||||
);
|
||||
assert_eq!(j["returned"], 3);
|
||||
assert_eq!(
|
||||
coll_card_ids(&j),
|
||||
["card_totw_004", "card_raregold_004", "card_raregold_001"]
|
||||
);
|
||||
|
||||
let (_, j2) = json_get(&app, "/collection?quality=gold&offset=3&limit=3").await;
|
||||
assert_eq!(j2["total"], 10);
|
||||
assert_eq!(
|
||||
coll_card_ids(&j2)[0],
|
||||
"card_raregold_010",
|
||||
"offset advances past page one"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_owned_query_pagination_no_repeated_first_page() {
|
||||
// THE production regression: paging must advance and never re-serve page one,
|
||||
// with filters retained on every page. A mutation that ignores `offset` (the
|
||||
// Python bug) makes every page identical and fails here.
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_cluttered(&app, &pool).await;
|
||||
|
||||
let page = |off: u32| {
|
||||
let app = app.clone();
|
||||
async move {
|
||||
let (_, j) = json_get(
|
||||
&app,
|
||||
&format!("/collection?quality=gold&limit=4&start_ignored=0&offset={off}"),
|
||||
)
|
||||
.await;
|
||||
j
|
||||
}
|
||||
};
|
||||
let p0 = page(0).await;
|
||||
let p1 = page(4).await;
|
||||
let p2 = page(8).await;
|
||||
|
||||
let ids0 = coll_card_ids(&p0);
|
||||
let ids1 = coll_card_ids(&p1);
|
||||
let ids2 = coll_card_ids(&p2);
|
||||
|
||||
// sizes: 4, 4, 2 over the 10-item gold set
|
||||
assert_eq!(ids0.len(), 4);
|
||||
assert_eq!(ids1.len(), 4);
|
||||
assert_eq!(ids2.len(), 2);
|
||||
|
||||
// page one is NOT repeated on later pages
|
||||
assert_ne!(ids0, ids1, "offset advance must not re-serve page one");
|
||||
assert_ne!(ids0, ids2);
|
||||
|
||||
// pairwise disjoint (no duplicates across pages)
|
||||
for a in &ids0 {
|
||||
assert!(
|
||||
!ids1.contains(a) && !ids2.contains(a),
|
||||
"pages overlap on {a}"
|
||||
);
|
||||
}
|
||||
for a in &ids1 {
|
||||
assert!(!ids2.contains(a), "pages overlap on {a}");
|
||||
}
|
||||
|
||||
// union == the full filtered set, each page still all-gold, no dupes
|
||||
let mut union: Vec<String> = ids0.iter().chain(&ids1).chain(&ids2).cloned().collect();
|
||||
let count = union.len();
|
||||
union.sort();
|
||||
union.dedup();
|
||||
assert_eq!(union.len(), count, "no duplicate items across pages");
|
||||
assert_eq!(union.len(), 10, "pages cover the whole filtered set");
|
||||
// filter retained across pages: every id on every page is a gold card
|
||||
let (_, all_gold) = json_get(&app, "/collection?quality=gold").await;
|
||||
let gold_set = sorted(coll_card_ids(&all_gold));
|
||||
assert_eq!(sorted(union), gold_set);
|
||||
// total constant across pages
|
||||
assert_eq!(p0["total"], 10);
|
||||
assert_eq!(p1["total"], 10);
|
||||
assert_eq!(p2["total"], 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_owned_query_parameter_order_invariance() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_cluttered(&app, &pool).await;
|
||||
let (_, a) = json_get(&app, "/collection?league=Premier%20League&position=ST").await;
|
||||
let (_, b) = json_get(&app, "/collection?position=ST&league=Premier%20League").await;
|
||||
assert_eq!(
|
||||
coll_card_ids(&a),
|
||||
coll_card_ids(&b),
|
||||
"HTTP param order must not change the result"
|
||||
);
|
||||
assert_eq!(a["total"], b["total"]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user