diff --git a/migrations/0014_market_npc_flag.sql b/migrations/0014_market_npc_flag.sql new file mode 100644 index 0000000..9ca674a --- /dev/null +++ b/migrations/0014_market_npc_flag.sql @@ -0,0 +1,5 @@ +-- Distinguish NPC-generated listings from player-posted ones so that the +-- periodic NPC refresh does not accidentally wipe player listings. +ALTER TABLE market_listings ADD COLUMN is_npc INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS idx_market_npc ON market_listings(is_npc, sold); diff --git a/migrations/0015_rivals_cooldown.sql b/migrations/0015_rivals_cooldown.sql new file mode 100644 index 0000000..e127049 --- /dev/null +++ b/migrations/0015_rivals_cooldown.sql @@ -0,0 +1,3 @@ +-- Track when the player last claimed their rivals weekly reward to enforce a +-- 24-hour cooldown between claims. +ALTER TABLE seasons ADD COLUMN rivals_last_claimed_at TEXT; diff --git a/migrations/0016_game_dimension.sql b/migrations/0016_game_dimension.sql new file mode 100644 index 0000000..d4c072c --- /dev/null +++ b/migrations/0016_game_dimension.sql @@ -0,0 +1,14 @@ +-- Multi-game support. Every profile (and therefore all of its downstream state, +-- which hangs off profiles(id) via profile_id / club_id foreign keys) is scoped to +-- a game. Bridges identify their game with the X-OpenFUT-Game request header. +-- +-- Default 'fifa23' preserves the existing single-game behaviour: the current bridge +-- and the integration tests send no game header, so they keep operating on the same +-- (now fifa23-tagged) profile with zero behaviour change. The FIFA 17 bridge sends +-- X-OpenFUT-Game: fifa17 and therefore gets its own isolated profile/club/state. +-- +-- Only profiles needs the column: get_active_profile becomes game-scoped, and because +-- all other tables reference a profile (directly via profile_id or via +-- club_id -> clubs.profile_id), scoping the active profile isolates the whole tree. +ALTER TABLE profiles ADD COLUMN game_id TEXT NOT NULL DEFAULT 'fifa23'; +CREATE INDEX IF NOT EXISTS idx_profiles_game ON profiles(game_id); diff --git a/src/config.rs b/src/config.rs index 96c6da7..c0698ea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,7 +5,6 @@ pub struct Config { pub listen_addr: String, pub database_url: String, pub data_dir: String, - #[allow(dead_code)] pub max_connections: u32, } diff --git a/src/db.rs b/src/db.rs index 99e86c2..7fad7c8 100644 --- a/src/db.rs +++ b/src/db.rs @@ -8,11 +8,11 @@ use tracing::info; pub type Pool = SqlitePool; -pub async fn init_pool(database_url: &str) -> Result { +pub async fn init_pool(database_url: &str, max_connections: u32) -> Result { info!("Connecting to database: {}", database_url); let opts = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true); let pool = SqlitePoolOptions::new() - .max_connections(5) + .max_connections(max_connections) .connect_with(opts) .await?; sqlx::query("PRAGMA journal_mode=WAL") diff --git a/src/extractors.rs b/src/extractors.rs new file mode 100644 index 0000000..4e38a2b --- /dev/null +++ b/src/extractors.rs @@ -0,0 +1,51 @@ +//! Request extractors shared across routes. + +use axum::{ + async_trait, + extract::FromRequestParts, + http::{request::Parts, HeaderName}, +}; +use std::convert::Infallible; + +/// The game a request belongs to, read from the `X-OpenFUT-Game` header. +/// +/// Multi-game support: each game bridge tags its requests with its own id +/// (e.g. `fifa17`, `fifa23`) so core can scope the active profile - and thus all +/// downstream club/card/squad/market state - to that game. Defaults to `fifa23` +/// when the header is absent, so the existing FIFA 23 bridge and the integration +/// tests (which send no header) keep operating on their game unchanged. +/// +/// Extraction never fails: a missing or malformed header falls back to the default. +#[derive(Debug, Clone)] +pub struct GameId(pub String); + +/// The game assumed when no `X-OpenFUT-Game` header is present. +pub const DEFAULT_GAME: &str = "fifa23"; + +static HEADER: HeaderName = HeaderName::from_static("x-openfut-game"); + +impl GameId { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[async_trait] +impl FromRequestParts for GameId +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + 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)) + } +} diff --git a/src/lib.rs b/src/lib.rs index 68dddea..cedb2c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod app; pub mod config; pub mod db; pub mod error; +pub mod extractors; pub mod middleware; pub mod modding; pub mod models; diff --git a/src/main.rs b/src/main.rs index 87d8719..0d558c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ async fn main() -> Result<()> { let cfg = config::Config::from_env()?; info!("OpenFUT Core starting on {}", cfg.listen_addr); - let pool = db::init_pool(&cfg.database_url).await?; + let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?; db::run_migrations(&pool).await?; seed::maybe_seed(&pool).await?; diff --git a/src/modding/loader.rs b/src/modding/loader.rs index 9bde1f9..98e182e 100644 --- a/src/modding/loader.rs +++ b/src/modding/loader.rs @@ -1,24 +1 @@ -use anyhow::{Context, Result}; -use serde::de::DeserializeOwned; -use std::path::Path; - -#[allow(dead_code)] -/// Generic loader for JSON arrays from a directory. -pub fn load_json_dir(dir: &Path) -> Result> { - 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 = - serde_json::from_str(&content).with_context(|| format!("parsing {path:?}"))?; - items.extend(batch); - } - } - Ok(items) -} +// Reserved for future modding loader utilities. diff --git a/src/modding/mod.rs b/src/modding/mod.rs index b7c2862..261771a 100644 --- a/src/modding/mod.rs +++ b/src/modding/mod.rs @@ -1,4 +1,2 @@ //! Modding support: load JSON data files from the data/ directory. //! All game content (cards, packs, objectives, SBCs) is data-driven. - -pub mod loader; diff --git a/src/models/card.rs b/src/models/card.rs index 4422b3f..804409a 100644 --- a/src/models/card.rs +++ b/src/models/card.rs @@ -13,6 +13,20 @@ 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", + } + } +} + /// A card definition loaded from JSON data files. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CardDefinition { diff --git a/src/models/objective.rs b/src/models/objective.rs index 30f345e..68c8170 100644 --- a/src/models/objective.rs +++ b/src/models/objective.rs @@ -21,6 +21,19 @@ pub enum ObjectiveMetric { CoinsEarned, } +impl ObjectiveMetric { + pub fn as_str(&self) -> &'static str { + match self { + ObjectiveMetric::MatchesWon => "matcheswon", + ObjectiveMetric::MatchesPlayed => "matchesplayed", + ObjectiveMetric::GoalsScored => "goalsscored", + ObjectiveMetric::PacksOpened => "packsopened", + ObjectiveMetric::SbcsCompleted => "sbcscompleted", + ObjectiveMetric::CoinsEarned => "coinsearned", + } + } +} + /// Objective definition from data/objectives/*.json #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObjectiveDefinition { diff --git a/src/models/profile.rs b/src/models/profile.rs index 300ea5f..571353e 100644 --- a/src/models/profile.rs +++ b/src/models/profile.rs @@ -8,18 +8,22 @@ pub struct Profile { pub username: String, pub level: i64, pub xp: i64, + /// The game this profile belongs to (e.g. "fifa17", "fifa23"). Scopes all of + /// this profile's downstream state so multiple games share one core + DB. + pub game_id: String, pub created_at: DateTime, pub updated_at: DateTime, } impl Profile { - pub fn new(username: impl Into) -> Self { + pub fn new(username: impl Into, game_id: impl Into) -> 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, } diff --git a/src/routes/achievements.rs b/src/routes/achievements.rs index c48093a..dd6e18b 100644 --- a/src/routes/achievements.rs +++ b/src/routes/achievements.rs @@ -1,3 +1,4 @@ +use crate::extractors::GameId; use axum::{extract::State, Json}; use serde_json::{json, Value}; @@ -7,8 +8,8 @@ use crate::{ services::{achievement as ach_svc, club as club_svc, profile as profile_svc}, }; -pub async fn get_achievements(State(state): State) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_achievements(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id).await; let achievements = ach_svc::list_with_status(&state.pool, &state.achievement_defs).await?; diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 1c451a7..83afbd2 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -1,9 +1,11 @@ use axum::{extract::State, Json}; +use serde::Deserialize; use serde_json::{json, Value}; use crate::{ app::AppState, - error::AppResult, + error::{AppError, AppResult}, + extractors::GameId, models::{club::Club, profile::CreateProfileRequest}, seed, services::{club as club_svc, profile as profile_svc}, @@ -11,11 +13,12 @@ use crate::{ pub async fn post_auth_local( State(state): State, + game: GameId, Json(req): Json, ) -> AppResult> { let username = req.username.unwrap_or_else(|| "Player 1".into()); - let profile = profile_svc::create_profile(&state.pool, &username).await?; + let profile = profile_svc::create_profile(&state.pool, &username, game.as_str()).await?; let club = Club::new(&profile.id, "OpenFUT FC", 5000); club_svc::create_club(&state.pool, &club).await?; @@ -31,51 +34,105 @@ pub async fn post_auth_local( /// GET /auth/status — lightweight check: does a profile exist? /// Returns 200 `{ "has_profile": true/false }` without erroring. -pub async fn get_auth_status(State(state): State) -> AppResult> { - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles") +pub async fn get_auth_status(State(state): State, game: GameId) -> AppResult> { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles WHERE game_id = ?") + .bind(game.as_str()) .fetch_one(&state.pool) .await?; Ok(Json(json!({ "has_profile": count > 0 }))) } +#[derive(Deserialize)] +pub struct ResetRequest { + pub confirm: Option, +} + /// POST /auth/reset — wipe all game data and start fresh. -/// Deletes every user-data table in dependency order. The schema tables -/// (migrations) are left intact; calling POST /auth/local afterwards -/// creates a new profile. -pub async fn post_auth_reset(State(state): State) -> AppResult> { - // Delete in reverse-dependency order to satisfy FK constraints - // (SQLite FK enforcement is opt-in, but we follow the order anyway) - let tables = [ - "player_achievements", - "notifications", +/// Requires `{"confirm":"reset"}` in the request body as a safeguard against +/// accidental or unauthenticated calls. Deletes every user-data table in +/// dependency order; schema (migrations) is preserved. +pub async fn post_auth_reset( + State(state): State, + game: GameId, + Json(req): Json, +) -> AppResult> { + 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 = 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 = sqlx::query_scalar("SELECT id FROM clubs WHERE profile_id = ?") + .bind(&profile_id) + .fetch_all(&state.pool) + .await?; + + for club_id in &club_ids { + sqlx::query( + "DELETE FROM squad_players WHERE squad_id IN (SELECT id FROM squads WHERE club_id = ?)", + ) + .bind(club_id) + .execute(&state.pool) + .await?; + for table in ["squads", "owned_cards", "packs", "market_history"] { + sqlx::query(&format!("DELETE FROM {table} WHERE club_id = ?")) + .bind(club_id) + .execute(&state.pool) + .await?; + } + } + + for table in [ "fut_champs_sessions", "sbc_submissions", "objective_progress", "position_goals", "statistics", "seasons", + "season_history", "matches", - "market_listings", - "squad_players", - "squads", - "owned_cards", - "packs", - "events", "draft_sessions", - "settings", - "clubs", - "profiles", - ]; - - for table in &tables { - sqlx::query(&format!("DELETE FROM {table}")) + "daily_checkins", + ] { + sqlx::query(&format!("DELETE FROM {table} WHERE profile_id = ?")) + .bind(&profile_id) .execute(&state.pool) .await?; } - tracing::info!("Full game reset performed"); + sqlx::query("DELETE FROM clubs WHERE profile_id = ?") + .bind(&profile_id) + .execute(&state.pool) + .await?; + sqlx::query("DELETE FROM profiles WHERE id = ?") + .bind(&profile_id) + .execute(&state.pool) + .await?; + + // NOTE (follow-up): settings (global key/value), events (shared content), + // market_listings (keyed by seller_name, includes the shared NPC market), + // notifications and player_achievements are not yet game-scoped and are left + // intact. They need a game/profile key before a per-game reset can cover them. + tracing::info!(game = %game.as_str(), "Per-game reset performed"); Ok(Json(json!({ "reset": true, - "message": "All progress has been wiped. Call POST /auth/local to start a new club." + "message": "All progress for this game has been wiped. Call POST /auth/local to start a new club." }))) } diff --git a/src/routes/cards.rs b/src/routes/cards.rs index adc2a27..e2af7c3 100644 --- a/src/routes/cards.rs +++ b/src/routes/cards.rs @@ -1,3 +1,4 @@ +use crate::extractors::GameId; use axum::{ extract::{Path, Query, State}, Json, @@ -56,7 +57,7 @@ pub async fn get_cards( let rarity_ok = query .rarity .as_ref() - .map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase()) + .map(|r| c.rarity.as_str().eq_ignore_ascii_case(r)) .unwrap_or(true); let pos_ok = query .position @@ -93,8 +94,8 @@ pub async fn get_cards( Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() }))) } -pub async fn get_collection(State(state): State) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_collection(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let owned = sqlx::query_as::<_, OwnedCard>( @@ -135,9 +136,10 @@ pub async fn get_collection(State(state): State) -> AppResult, + game: GameId, Path(owned_card_id): Path, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let owned = sqlx::query_as::<_, OwnedCard>( diff --git a/src/routes/club.rs b/src/routes/club.rs index 59bd7ae..531726e 100644 --- a/src/routes/club.rs +++ b/src/routes/club.rs @@ -1,3 +1,4 @@ +use crate::extractors::GameId; use crate::{ app::AppState, error::AppResult, @@ -8,8 +9,8 @@ use axum::{extract::State, Json}; use serde::Deserialize; use serde_json::{json, Value}; -pub async fn get_club(State(state): State) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_club(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; Ok(Json(club)) } @@ -22,9 +23,10 @@ pub struct UpdateClubRequest { pub async fn put_club( State(state): State, + game: GameId, Json(req): Json, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let updated = club_svc::update_club( &state.pool, @@ -36,8 +38,8 @@ pub async fn put_club( Ok(Json(json!({ "club": updated }))) } -pub async fn get_checkin_status(State(state): State) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_checkin_status(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let status = checkin_svc::get_status(&state.pool, &profile.id).await?; Ok(Json(json!({ "available": status.available, @@ -48,8 +50,8 @@ pub async fn get_checkin_status(State(state): State) -> AppResult) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn post_checkin(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?; Ok(Json(json!({ @@ -60,8 +62,8 @@ pub async fn post_checkin(State(state): State) -> AppResult) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_milestones(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?; diff --git a/src/routes/division.rs b/src/routes/division.rs index a9c2fc1..9982009 100644 --- a/src/routes/division.rs +++ b/src/routes/division.rs @@ -1,3 +1,4 @@ +use crate::extractors::GameId; use axum::{extract::State, Json}; use rand::{Rng, SeedableRng}; use serde_json::{json, Value}; @@ -9,8 +10,8 @@ use crate::{ services::{club as club_svc, profile as profile_svc, season as season_svc}, }; -pub async fn get_division(State(state): State) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_division(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let _club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let season = season_svc::get_or_create(&state.pool, &profile.id).await?; @@ -36,14 +37,14 @@ pub async fn get_division(State(state): State) -> AppResult) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_division_history(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let history = season_svc::get_history(&state.pool, &profile.id).await?; Ok(Json(json!({ "history": history, "total": history.len() }))) } -pub async fn get_division_leaderboard(State(state): State) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_division_leaderboard(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let season = season_svc::get_or_create(&state.pool, &profile.id).await?; diff --git a/src/routes/draft.rs b/src/routes/draft.rs index 1c058c7..5fb9eed 100644 --- a/src/routes/draft.rs +++ b/src/routes/draft.rs @@ -1,3 +1,4 @@ +use crate::extractors::GameId; use axum::{ extract::{Path, Query, State}, Json, @@ -39,9 +40,10 @@ pub async fn get_draft_squad( /// until all 11 slots are filled. pub async fn post_draft_start( State(state): State, + game: GameId, Query(query): Query, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let difficulty = query.difficulty.as_deref().unwrap_or("professional"); let session = draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?; Ok(Json(session)) @@ -50,9 +52,10 @@ pub async fn post_draft_start( /// Get the current state of a draft session. pub async fn get_draft_session( State(state): State, + game: GameId, Path(session_id): Path, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let session = draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?; Ok(Json(session)) } @@ -69,10 +72,11 @@ pub struct PickRequest { /// and rewards (coins + optional pack) are granted automatically. pub async fn post_draft_pick( State(state): State, + game: GameId, Path(session_id): Path, Json(req): Json, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let session = draft_svc::pick_card( &state.pool, @@ -89,9 +93,10 @@ pub async fn post_draft_pick( /// Abandon an active draft session. No rewards are granted. pub async fn post_draft_abandon( State(state): State, + game: GameId, Path(session_id): Path, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let result = draft_svc::abandon_draft(&state.pool, &profile.id, &session_id).await?; Ok(Json(result)) } diff --git a/src/routes/fut_champs.rs b/src/routes/fut_champs.rs index fcf39f1..774a58d 100644 --- a/src/routes/fut_champs.rs +++ b/src/routes/fut_champs.rs @@ -1,3 +1,4 @@ +use crate::extractors::GameId; use axum::{ extract::{Path, State}, Json, @@ -12,8 +13,8 @@ use crate::{ }; /// GET /fut-champs — current active session, or null if none. -pub async fn get_fut_champs(State(state): State) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_fut_champs(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let session = champs_svc::get_active_session(&state.pool, &profile.id).await?; Ok(Json(json!({ @@ -23,8 +24,8 @@ pub async fn get_fut_champs(State(state): State) -> AppResult) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn post_start_fut_champs(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let session = champs_svc::start_session(&state.pool, &profile.id).await?; Ok(Json(json!({ @@ -42,10 +43,11 @@ pub struct ChampsMatchRequest { /// POST /fut-champs/:session_id/result — record a match in this session. pub async fn post_champs_result( State(state): State, + game: GameId, Path(session_id): Path, Json(req): Json, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let session = champs_svc::record_match( &state.pool, @@ -75,9 +77,10 @@ pub async fn post_champs_result( /// POST /fut-champs/:session_id/claim — claim end-of-week rewards. pub async fn post_claim_champs_rewards( State(state): State, + game: GameId, Path(session_id): Path, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let result = champs_svc::claim_rewards( @@ -93,8 +96,8 @@ pub async fn post_claim_champs_rewards( } /// GET /fut-champs/history — past sessions, newest first. -pub async fn get_champs_history(State(state): State) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_champs_history(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let history = champs_svc::get_history(&state.pool, &profile.id).await?; Ok(Json(json!({ @@ -104,8 +107,8 @@ pub async fn get_champs_history(State(state): State) -> AppResult) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn post_claim_rivals_reward(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; // Ensure a season row exists diff --git a/src/routes/market.rs b/src/routes/market.rs index c6bd966..a915fb1 100644 --- a/src/routes/market.rs +++ b/src/routes/market.rs @@ -1,3 +1,4 @@ +use crate::extractors::GameId; use axum::{ extract::{Path, Query, State}, Json, @@ -12,8 +13,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) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_trade_history(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let trades = market_svc::get_trade_history(&state.pool, &club.id).await?; Ok(Json(json!({ "trades": trades, "total": trades.len() }))) @@ -52,9 +53,10 @@ pub async fn get_market( pub async fn post_market_buy( State(state): State, + game: GameId, Json(req): Json, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?; @@ -65,9 +67,10 @@ pub async fn post_market_buy( pub async fn post_market_sell( State(state): State, + game: GameId, Json(req): Json, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let new_balance = market_svc::sell_card(&state.pool, &state.card_db, &club.id, &req).await?; @@ -83,8 +86,8 @@ pub async fn post_market_refresh(State(state): State) -> AppResult) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_my_listings(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let listings = market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?; Ok(Json(json!({ "listings": listings, "total": listings.len() }))) @@ -93,9 +96,10 @@ pub async fn get_my_listings(State(state): State) -> AppResult, + game: GameId, Path(listing_id): Path, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; market_svc::cancel_listing(&state.pool, &club.id, &listing_id).await?; Ok(Json(json!({ "cancelled": listing_id }))) diff --git a/src/routes/matches.rs b/src/routes/matches.rs index e266670..5a8b25f 100644 --- a/src/routes/matches.rs +++ b/src/routes/matches.rs @@ -1,3 +1,4 @@ +use crate::extractors::GameId; use axum::{ extract::{Query, State}, Json, @@ -20,9 +21,10 @@ pub struct MatchHistoryQuery { pub async fn get_matches( State(state): State, + game: GameId, Query(query): Query, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let limit = query.limit.unwrap_or(20).clamp(1, 100); let matches = if let Some(mode) = &query.mode { @@ -63,9 +65,10 @@ pub async fn get_opponent( pub async fn post_match_result( State(state): State, + game: GameId, Json(req): Json, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let result = diff --git a/src/routes/notifications.rs b/src/routes/notifications.rs index bfe6752..8805ae2 100644 --- a/src/routes/notifications.rs +++ b/src/routes/notifications.rs @@ -1,3 +1,4 @@ +use crate::extractors::GameId; use axum::{ extract::{Path, State}, Json, @@ -16,8 +17,8 @@ use crate::{ /// notifications (unclaimed objectives, expiring loans, season ending soon). /// Persistent notifications carry an `id` and `is_read` flag; dynamic ones /// have `id: null` and are always considered unread. -pub async fn get_notifications(State(state): State) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_notifications(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; // ── Persistent notifications ───────────────────────────────────────────── diff --git a/src/routes/objectives.rs b/src/routes/objectives.rs index ec6e215..bf56aa6 100644 --- a/src/routes/objectives.rs +++ b/src/routes/objectives.rs @@ -1,3 +1,4 @@ +use crate::extractors::GameId; use axum::{ extract::{Path, State}, Json, @@ -11,8 +12,8 @@ use crate::{ services::{club as club_svc, objective as obj_svc, profile as profile_svc}, }; -pub async fn get_objectives(State(state): State) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_objectives(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let objectives = obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?; Ok(Json(json!({ "objectives": objectives }))) @@ -20,9 +21,10 @@ pub async fn get_objectives(State(state): State) -> AppResult, + game: GameId, Path(objective_id): Path, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let all = obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?; let obj = all @@ -34,9 +36,10 @@ pub async fn get_objective( pub async fn post_claim_objective_by_id( State(state): State, + game: GameId, Path(objective_id): Path, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let reward = obj_svc::claim_objective( &state.pool, @@ -56,9 +59,10 @@ pub struct ClaimRequest { pub async fn post_claim_objective( State(state): State, + game: GameId, Json(req): Json, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let reward = obj_svc::claim_objective( &state.pool, diff --git a/src/routes/packs.rs b/src/routes/packs.rs index 002168d..14e03b7 100644 --- a/src/routes/packs.rs +++ b/src/routes/packs.rs @@ -1,3 +1,4 @@ +use crate::extractors::GameId; use axum::{ extract::{Path, State}, Json, @@ -19,9 +20,10 @@ pub struct BuyPackRequest { pub async fn post_buy_pack( State(state): State, + game: GameId, Json(req): Json, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let pack = pack_svc::buy_pack( &state.pool, @@ -54,8 +56,8 @@ pub async fn get_pack_store(State(state): State) -> AppResult) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_packs(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?; @@ -77,8 +79,8 @@ pub async fn get_packs(State(state): State) -> AppResult> } /// Return recently opened packs with the card IDs they contained. -pub async fn get_pack_history(State(state): State) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_pack_history(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let opened = sqlx::query_as::<_, crate::models::pack::Pack>( @@ -122,9 +124,10 @@ pub async fn get_pack_history(State(state): State) -> AppResult, + game: GameId, Path(pack_id): Path, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let result = pack_svc::open_pack( diff --git a/src/routes/profile.rs b/src/routes/profile.rs index eeb2822..627bf26 100644 --- a/src/routes/profile.rs +++ b/src/routes/profile.rs @@ -1,3 +1,4 @@ +use crate::extractors::GameId; use crate::{ app::AppState, error::AppResult, @@ -7,8 +8,8 @@ use crate::{ use axum::{extract::State, Json}; use serde_json::{json, Value}; -pub async fn get_profile(State(state): State) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_profile(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let computed_level = level_for_xp(profile.xp); let next_level = computed_level + 1; diff --git a/src/routes/sbc.rs b/src/routes/sbc.rs index 2690246..cc86e33 100644 --- a/src/routes/sbc.rs +++ b/src/routes/sbc.rs @@ -1,3 +1,4 @@ +use crate::extractors::GameId; use axum::{ extract::{Path, State}, Json, @@ -29,9 +30,10 @@ pub async fn get_sbc( pub async fn post_sbc_submit( State(state): State, + game: GameId, Json(req): Json, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let result = sbc_svc::submit_sbc( diff --git a/src/routes/squad.rs b/src/routes/squad.rs index c206e81..81823a7 100644 --- a/src/routes/squad.rs +++ b/src/routes/squad.rs @@ -1,3 +1,4 @@ +use crate::extractors::GameId; use axum::{ extract::{Path, State}, Json, @@ -11,8 +12,8 @@ use crate::{ services::{club as club_svc, profile as profile_svc, squad as squad_svc}, }; -pub async fn get_squad(State(state): State) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_squad(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?; @@ -21,8 +22,8 @@ pub async fn get_squad(State(state): State) -> AppResult> Ok(Json(squad_response(&squad, &players, chemistry))) } -pub async fn get_squads(State(state): State) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_squads(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let squads = squad_svc::list_squads(&state.pool, &club.id).await?; Ok(Json(json!({ "squads": squads }))) @@ -30,9 +31,10 @@ pub async fn get_squads(State(state): State) -> AppResult> pub async fn get_squad_by_id( State(state): State, + game: GameId, Path(squad_id): Path, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let (squad, players) = squad_svc::get_squad_by_id(&state.pool, &club.id, &squad_id).await?; @@ -43,13 +45,14 @@ pub async fn get_squad_by_id( pub async fn post_squad( State(state): State, + game: GameId, Json(req): Json, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; if !req.players.is_empty() { - squad_svc::validate_formation(&state.pool, &state.card_db, &req.players).await?; + squad_svc::validate_formation(&state.pool, &state.card_db, &club.id, &req.players).await?; } let squad = squad_svc::save_squad(&state.pool, &club.id, &req).await?; @@ -58,9 +61,10 @@ pub async fn post_squad( pub async fn delete_squad( State(state): State, + game: GameId, Path(squad_id): Path, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; squad_svc::delete_squad(&state.pool, &club.id, &squad_id).await?; Ok(Json(json!({ "deleted": squad_id }))) diff --git a/src/routes/statistics.rs b/src/routes/statistics.rs index c839919..9136bc4 100644 --- a/src/routes/statistics.rs +++ b/src/routes/statistics.rs @@ -1,3 +1,4 @@ +use crate::extractors::GameId; use axum::{ extract::{Query, State}, Json, @@ -12,8 +13,8 @@ use crate::{ services::{profile as profile_svc, statistics as stats_svc}, }; -pub async fn get_statistics(State(state): State) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; +pub async fn get_statistics(State(state): State, game: GameId) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?; let pos_goals = stats_svc::get_position_goals(&state.pool, &profile.id).await?; @@ -36,9 +37,10 @@ pub struct HistoryQuery { pub async fn get_statistics_history( State(state): State, + game: GameId, Query(query): Query, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let limit = query.limit.unwrap_or(20).clamp(1, 100); let matches = sqlx::query_as::<_, Match>( diff --git a/src/routes/upgrades.rs b/src/routes/upgrades.rs index a77f33a..9979fe0 100644 --- a/src/routes/upgrades.rs +++ b/src/routes/upgrades.rs @@ -1,3 +1,4 @@ +use crate::extractors::GameId; use axum::{ extract::{Path, State}, Json, @@ -27,10 +28,11 @@ pub struct ApplyChemStyleRequest { /// POST /collection/:owned_card_id/chemistry-style pub async fn post_apply_chemistry_style( State(state): State, + game: GameId, Path(owned_card_id): Path, Json(req): Json, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let updated = upgrade_svc::apply_chemistry_style( @@ -63,10 +65,11 @@ pub struct ChangePositionRequest { /// POST /collection/:owned_card_id/position — costs 500 coins. pub async fn post_change_position( State(state): State, + game: GameId, Path(owned_card_id): Path, Json(req): Json, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let updated = upgrade_svc::change_position( @@ -97,10 +100,11 @@ pub struct ApplyTrainingRequest { /// POST /collection/:owned_card_id/training — applies a training boost (up to +3 OVR total). pub async fn post_apply_training( State(state): State, + game: GameId, Path(owned_card_id): Path, Json(req): Json, ) -> AppResult> { - let profile = profile_svc::get_active_profile(&state.pool).await?; + let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let updated = diff --git a/src/services/card_db.rs b/src/services/card_db.rs index 7cb609d..de006b4 100644 --- a/src/services/card_db.rs +++ b/src/services/card_db.rs @@ -42,17 +42,6 @@ impl CardDb { self.cards.get(id) } - #[allow(dead_code)] - pub fn by_rarity(&self, rarity: &str) -> Vec<&CardDefinition> { - self.cards - .values() - .filter(|c| { - let r = format!("{:?}", c.rarity).to_lowercase(); - r == rarity || rarity == "any" - }) - .collect() - } - pub fn all(&self) -> Vec<&CardDefinition> { self.cards.values().collect() } diff --git a/src/services/draft.rs b/src/services/draft.rs index e3e6f0a..eef64e3 100644 --- a/src/services/draft.rs +++ b/src/services/draft.rs @@ -88,13 +88,18 @@ pub async fn start_draft( let first_position = &pick_order[0]; let candidates = pick_candidates(card_db, first_position, min_overall, CANDIDATES_PER_SLOT); + let pick_order_json = serde_json::to_string(&pick_order) + .map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?; + let candidates_json = serde_json::to_string(&candidates) + .map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?; + let session = DraftSession { id: Uuid::new_v4().to_string(), profile_id: profile_id.to_string(), difficulty: difficulty.to_string(), - pick_order: serde_json::to_string(&pick_order).unwrap(), + pick_order: pick_order_json, picks: "[]".to_string(), - current_candidates: Some(serde_json::to_string(&candidates).unwrap()), + current_candidates: Some(candidates_json), status: "active".to_string(), reward_coins: 0, reward_pack_id: None, @@ -185,17 +190,22 @@ 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(serde_json::to_string(&next_candidates).unwrap()), + Some(candidates_json), "active".to_string(), 0, None, 0, None, ) + } }; - let picks_json = serde_json::to_string(&picks).unwrap(); + let picks_json = serde_json::to_string(&picks) + .map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?; sqlx::query( "UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \ diff --git a/src/services/fut_champs.rs b/src/services/fut_champs.rs index c834d3f..0c2d1e3 100644 --- a/src/services/fut_champs.rs +++ b/src/services/fut_champs.rs @@ -239,16 +239,30 @@ pub async fn claim_rivals_reward( pack_defs: &[PackDefinition], ) -> AppResult { // Fetch current season row (must exist) - let row: Option<(i64, i64, i64)> = sqlx::query_as( - "SELECT division, rivals_week_claimed, rivals_total_points FROM seasons WHERE profile_id = ?", + let row: Option<(i64, i64, i64, Option)> = sqlx::query_as( + "SELECT division, rivals_week_claimed, rivals_total_points, rivals_last_claimed_at \ + FROM seasons WHERE profile_id = ?", ) .bind(profile_id) .fetch_optional(pool) .await?; - let (division, week_claimed, total_pts) = + let (division, week_claimed, total_pts, last_claimed_at) = row.ok_or_else(|| AppError::NotFound("no season found — play a match first".into()))?; + // Enforce 24-hour cooldown between weekly reward claims + if let Some(ref last_claimed) = last_claimed_at { + if let Ok(last_time) = chrono::DateTime::parse_from_rfc3339(last_claimed) { + let elapsed = chrono::Utc::now() - last_time.with_timezone(&chrono::Utc); + if elapsed < chrono::Duration::hours(24) { + let hours_remaining = 24 - elapsed.num_hours(); + return Err(AppError::Conflict(format!( + "rivals weekly reward already claimed; try again in ~{hours_remaining}h" + ))); + } + } + } + let next_week = week_claimed + 1; let coins = rivals_weekly_coins(division); let new_balance = club_svc::add_coins(pool, club_id, coins).await?; @@ -271,11 +285,13 @@ pub async fn claim_rivals_reward( None }; + let now = chrono::Utc::now().to_rfc3339(); sqlx::query( - "UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100 \ - WHERE profile_id = ?", + "UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100, \ + rivals_last_claimed_at = ? WHERE profile_id = ?", ) .bind(next_week) + .bind(&now) .bind(profile_id) .execute(pool) .await?; diff --git a/src/services/market.rs b/src/services/market.rs index 4866ce7..83074b5 100644 --- a/src/services/market.rs +++ b/src/services/market.rs @@ -43,11 +43,12 @@ pub async fn refresh_npc_listings( card_db: &CardDb, event_defs: &[EventDefinition], ) -> AppResult { - // Clean up expired and unsold listings + // Clean up expired listings and previous NPC listings. + // Player-posted listings (is_npc = 0) are intentionally preserved. sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')") .execute(pool) .await?; - sqlx::query("DELETE FROM market_listings WHERE sold = 0") + sqlx::query("DELETE FROM market_listings WHERE sold = 0 AND is_npc = 1") .execute(pool) .await?; @@ -107,8 +108,8 @@ pub async fn refresh_npc_listings( for listing in &listings_to_insert { sqlx::query( "INSERT INTO market_listings \ - (id, card_id, seller_name, price, listed_at, expires_at, sold) \ - VALUES (?, ?, ?, ?, ?, ?, 0)", + (id, card_id, seller_name, price, listed_at, expires_at, sold, is_npc) \ + VALUES (?, ?, ?, ?, ?, ?, 0, 1)", ) .bind(&listing.id) .bind(&listing.card_id) @@ -190,6 +191,10 @@ pub async fn sell_card( club_id: &str, req: &SellCardRequest, ) -> AppResult { + 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 \ diff --git a/src/services/match_service.rs b/src/services/match_service.rs index c1b0ce1..6ba4738 100644 --- a/src/services/match_service.rs +++ b/src/services/match_service.rs @@ -94,6 +94,12 @@ pub async fn process_match( obj_defs: &[ObjectiveDefinition], ach_defs: &[AchievementDefinition], ) -> AppResult { + 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 { @@ -184,9 +190,13 @@ pub async fn process_match( objectives_updated.append(&mut c); for obj_id in &objectives_updated { - let title = "Objective complete!"; - let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", obj_id); - let _ = notification::create(pool, "objective_complete", title, &body).await; + let display_name = obj_defs + .iter() + .find(|d| &d.id == obj_id) + .map(|d| d.title.as_str()) + .unwrap_or(obj_id.as_str()); + let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", display_name); + let _ = notification::create(pool, "objective_complete", "Objective complete!", &body).await; } // Decrement loan matches remaining for squad starters; collect expired loan IDs. diff --git a/src/services/objective.rs b/src/services/objective.rs index fe7d76c..ca310a0 100644 --- a/src/services/objective.rs +++ b/src/services/objective.rs @@ -69,7 +69,7 @@ pub async fn increment_metric( for def in defs .iter() - .filter(|d| format!("{:?}", d.metric).to_lowercase() == metric) + .filter(|d| d.metric.as_str() == metric) { let existing = sqlx::query_as::<_, ObjectiveProgress>( "SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?" diff --git a/src/services/pack.rs b/src/services/pack.rs index 1e3199f..6348ad3 100644 --- a/src/services/pack.rs +++ b/src/services/pack.rs @@ -90,8 +90,8 @@ pub async fn open_pack( .all() .into_iter() .filter(|c| { - let r = format!("{:?}", c.rarity).to_lowercase(); - rarities.contains(&r) + let r = c.rarity.as_str(); + rarities.contains(&r.to_string()) }) .cloned() .collect() diff --git a/src/services/profile.rs b/src/services/profile.rs index 1b077a1..df01af6 100644 --- a/src/services/profile.rs +++ b/src/services/profile.rs @@ -5,33 +5,41 @@ use crate::{ }; use chrono::Utc; -pub async fn get_active_profile(pool: &Pool) -> AppResult { +/// 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 { sqlx::query_as::<_, Profile>( - "SELECT id, username, level, xp, created_at, updated_at FROM profiles ORDER BY created_at ASC LIMIT 1" + "SELECT id, username, level, xp, game_id, created_at, updated_at \ + FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1", ) + .bind(game_id) .fetch_optional(pool) .await? .ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into())) } -pub async fn create_profile(pool: &Pool, username: &str) -> AppResult { - let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles") +pub async fn create_profile(pool: &Pool, username: &str, game_id: &str) -> AppResult { + // Single-player per game: one profile per game_id, not one globally. + let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles WHERE game_id = ?") + .bind(game_id) .fetch_one(pool) .await?; if existing > 0 { return Err(AppError::Conflict( - "a profile already exists; OpenFUT is single-player only".into(), + "a profile already exists for this game; OpenFUT is single-player per game".into(), )); } - let profile = Profile::new(username); + let profile = Profile::new(username, game_id); sqlx::query( - "INSERT INTO profiles (id, username, level, xp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" + "INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(&profile.id) .bind(&profile.username) .bind(profile.level) .bind(profile.xp) + .bind(&profile.game_id) .bind(profile.created_at) .bind(profile.updated_at) .execute(pool) @@ -59,7 +67,13 @@ pub async fn add_xp_with_levelup( club_id: &str, xp_to_add: i64, ) -> AppResult> { - let profile = get_active_profile(pool).await?; + let profile = sqlx::query_as::<_, Profile>( + "SELECT id, username, level, xp, game_id, created_at, updated_at FROM profiles WHERE id = ?", + ) + .bind(profile_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("profile '{profile_id}' not found")))?; let old_level = level_for_xp(profile.xp); let new_total_xp = profile.xp + xp_to_add; let new_level = level_for_xp(new_total_xp); diff --git a/src/services/season.rs b/src/services/season.rs index ed6d7a7..b67a5af 100644 --- a/src/services/season.rs +++ b/src/services/season.rs @@ -101,9 +101,6 @@ pub async fn record_match( // Grant rewards club::add_coins(pool, club_id, coins).await?; if let Some(pack_def) = pack_id { - let dummy_pack_id = Uuid::new_v4().to_string(); - // Grant via pack system so it shows in inventory - let _ = dummy_pack_id; // will use grant_pack instead pack::grant_pack(pool, club_id, pack_def).await?; } diff --git a/src/services/squad.rs b/src/services/squad.rs index 2d550a2..5737694 100644 --- a/src/services/squad.rs +++ b/src/services/squad.rs @@ -63,6 +63,7 @@ async fn get_players(pool: &Pool, squad_id: &str) -> AppResult> 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(); @@ -77,12 +78,13 @@ pub async fn validate_formation( let mut gk_count = 0usize; for sp in &starters { let owned = sqlx::query_as::<_, OwnedCard>( - "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ?", + "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ? AND club_id = ?", ) .bind(&sp.owned_card_id) + .bind(club_id) .fetch_optional(pool) .await? - .ok_or_else(|| AppError::NotFound(format!("owned card {} not found", sp.owned_card_id)))?; + .ok_or_else(|| AppError::NotFound(format!("owned card {} not found or does not belong to this club", sp.owned_card_id)))?; if let Some(card) = card_db.get(&owned.card_id) { if card.position == "GK" { diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 9bdf60a..0533867 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1404,10 +1404,14 @@ async fn test_rivals_reward_increments_week_counter() { "goals_for": 1, "goals_against": 0, "mode": "squad_battles" })).await; - json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await; + // First claim succeeds let (s, json) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await; assert_eq!(s, StatusCode::OK, "{json}"); - assert_eq!(json["week_number"], 2, "second claim should be week 2"); + assert_eq!(json["week_number"], 1, "first claim should be week 1"); + + // Immediate re-claim is blocked by the 24-hour cooldown + let (s, _) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await; + assert_eq!(s, StatusCode::CONFLICT, "re-claim within 24h should be rejected"); } // ── Phase 15: Pack Store ────────────────────────────────────────────────────── @@ -1711,7 +1715,7 @@ async fn test_auth_reset_clears_profile() { })).await; // Reset - let (status, json) = json_post(&app, "/auth/reset", serde_json::json!({})).await; + let (status, json) = json_post(&app, "/auth/reset", serde_json::json!({ "confirm": "reset" })).await; assert_eq!(status, StatusCode::OK, "{json}"); assert_eq!(json["reset"], true); @@ -1729,7 +1733,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!({})).await; + json_post(&app, "/auth/reset", serde_json::json!({ "confirm": "reset" })).await; // Should be able to create a new profile after reset let (status, json) = json_post(&app, "/auth/local",