1 Commits

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