diff --git a/data/objectives/weekly.json b/data/objectives/weekly.json new file mode 100644 index 0000000..589669d --- /dev/null +++ b/data/objectives/weekly.json @@ -0,0 +1,46 @@ +[ + { + "id": "weekly_win_3", + "title": "Weekly Warrior", + "description": "Win 3 matches this week.", + "objective_type": "weekly", + "metric": "matcheswon", + "target": 3, + "reward_coins": 2000, + "reward_pack_id": "silver_pack", + "reward_xp": 500 + }, + { + "id": "weekly_goals_10", + "title": "Goal Machine", + "description": "Score 10 goals this week.", + "objective_type": "weekly", + "metric": "goalsscored", + "target": 10, + "reward_coins": 1500, + "reward_pack_id": null, + "reward_xp": 400 + }, + { + "id": "weekly_play_5", + "title": "Dedicated Player", + "description": "Play 5 matches this week.", + "objective_type": "weekly", + "metric": "matchesplayed", + "target": 5, + "reward_coins": 1000, + "reward_pack_id": null, + "reward_xp": 300 + }, + { + "id": "weekly_sbc_2", + "title": "SBC Specialist", + "description": "Complete 2 SBCs this week.", + "objective_type": "weekly", + "metric": "sbcscompleted", + "target": 2, + "reward_coins": 2500, + "reward_pack_id": "gold_pack", + "reward_xp": 600 + } +] diff --git a/migrations/0002_win_streaks.sql b/migrations/0002_win_streaks.sql new file mode 100644 index 0000000..cd06fc5 --- /dev/null +++ b/migrations/0002_win_streaks.sql @@ -0,0 +1,3 @@ +-- Add win streak tracking to statistics +ALTER TABLE statistics ADD COLUMN win_streak INTEGER NOT NULL DEFAULT 0; +ALTER TABLE statistics ADD COLUMN best_win_streak INTEGER NOT NULL DEFAULT 0; diff --git a/src/app.rs b/src/app.rs index ba76c19..90bc321 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,13 @@ use crate::{ config::Config, db::Pool, - models::{objective::ObjectiveDefinition, pack::PackDefinition, sbc::SbcDefinition}, + models::objective::{ObjectiveDefinition, ObjectiveType}, + models::pack::PackDefinition, + models::sbc::SbcDefinition, routes, services::{ - card_db::CardDb, objective::load_objective_definitions, pack::load_pack_definitions, - sbc::load_sbc_definitions, + card_db::CardDb, market, objective::load_objective_definitions, + pack::load_pack_definitions, sbc::load_sbc_definitions, }, }; use anyhow::Result; @@ -32,20 +34,73 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?); tracing::info!( - "Loaded {} packs, {} objectives, {} SBCs", + "Loaded {} cards, {} packs, {} objectives, {} SBCs", + card_db.cards.len(), pack_defs.len(), obj_defs.len(), - sbc_defs.len() + sbc_defs.len(), ); let state = AppState { - pool, - card_db, + pool: pool.clone(), + card_db: card_db.clone(), pack_defs, - obj_defs, + obj_defs: obj_defs.clone(), sbc_defs, }; + // Background task: refresh NPC market at startup and every 24 hours + { + let pool = pool.clone(); + let card_db = card_db.clone(); + tokio::spawn(async move { + if let Err(e) = market::refresh_npc_listings(&pool, &card_db).await { + tracing::warn!("initial market refresh failed: {e}"); + } + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(86_400)).await; + if let Err(e) = market::refresh_npc_listings(&pool, &card_db).await { + tracing::warn!("market refresh failed: {e}"); + } + } + }); + } + + // Background task: reset daily objective progress at midnight UTC + { + let pool = pool.clone(); + let daily_ids: Vec = obj_defs + .iter() + .filter(|d| matches!(d.objective_type, ObjectiveType::Daily)) + .map(|d| d.id.clone()) + .collect(); + tokio::spawn(async move { + loop { + let now = chrono::Utc::now(); + let tomorrow = now.date_naive() + chrono::Days::new(1); + let midnight = tomorrow + .and_hms_opt(0, 0, 0) + .map(|dt| dt.and_utc()) + .unwrap_or_else(|| now + chrono::Duration::hours(24)); + let secs = (midnight - now).num_seconds().max(1) as u64; + tokio::time::sleep(tokio::time::Duration::from_secs(secs)).await; + + for id in &daily_ids { + if let Err(e) = sqlx::query( + "UPDATE objective_progress SET current = 0, completed = 0, claimed = 0 WHERE objective_id = ?", + ) + .bind(id) + .execute(&pool) + .await + { + tracing::warn!("daily reset failed for {id}: {e}"); + } + } + tracing::info!("Daily objectives reset ({} objectives)", daily_ids.len()); + } + }); + } + let router = Router::new() .route("/health", get(routes::health::get_health)) .route("/auth/local", post(routes::auth::post_auth_local)) @@ -65,6 +120,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { post(routes::objectives::post_claim_objective), ) .route("/matches", get(routes::matches::get_matches)) + .route("/matches/opponent", get(routes::matches::get_opponent)) .route("/matches/result", post(routes::matches::post_match_result)) .route("/sbc", get(routes::sbc::get_sbcs)) .route("/sbc/:sbc_id", get(routes::sbc::get_sbc)) @@ -74,6 +130,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/market/sell", post(routes::market::post_market_sell)) .route("/market/refresh", post(routes::market::post_market_refresh)) .route("/statistics", get(routes::statistics::get_statistics)) + .route( + "/statistics/history", + get(routes::statistics::get_statistics_history), + ) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) .with_state(state); diff --git a/src/models/statistics.rs b/src/models/statistics.rs index 858c019..71e52c6 100644 --- a/src/models/statistics.rs +++ b/src/models/statistics.rs @@ -12,6 +12,8 @@ pub struct Statistics { pub packs_opened: i64, pub sbcs_completed: i64, pub total_coins_earned: i64, + pub win_streak: i64, + pub best_win_streak: i64, pub updated_at: String, } @@ -28,6 +30,8 @@ impl Statistics { packs_opened: 0, sbcs_completed: 0, total_coins_earned: 0, + win_streak: 0, + best_win_streak: 0, updated_at: chrono::Utc::now().to_rfc3339(), } } diff --git a/src/routes/market.rs b/src/routes/market.rs index e86f69d..4e9e553 100644 --- a/src/routes/market.rs +++ b/src/routes/market.rs @@ -1,4 +1,8 @@ -use axum::{extract::State, Json}; +use axum::{ + extract::{Query, State}, + Json, +}; +use serde::Deserialize; use serde_json::{json, Value}; use crate::{ @@ -8,8 +12,31 @@ use crate::{ services::{club as club_svc, market as market_svc, profile as profile_svc}, }; -pub async fn get_market(State(state): State) -> AppResult> { - let listings = market_svc::get_active_listings(&state.pool, &state.card_db).await?; +#[derive(Deserialize)] +pub struct MarketQuery { + pub min_overall: Option, + pub position: Option, +} + +pub async fn get_market( + State(state): State, + Query(query): Query, +) -> AppResult> { + let all = market_svc::get_active_listings(&state.pool, &state.card_db).await?; + let listings: Vec<_> = all + .into_iter() + .filter(|l| { + query + .min_overall + .map(|min| l.card.overall >= min) + .unwrap_or(true) + && query + .position + .as_ref() + .map(|p| l.card.position.eq_ignore_ascii_case(p)) + .unwrap_or(true) + }) + .collect(); Ok(Json( json!({ "listings": listings, "total": listings.len() }), )) diff --git a/src/routes/matches.rs b/src/routes/matches.rs index 0eea2f3..0a592ca 100644 --- a/src/routes/matches.rs +++ b/src/routes/matches.rs @@ -47,6 +47,20 @@ pub async fn get_matches( Ok(Json(json!({ "matches": matches, "total": matches.len() }))) } +#[derive(Deserialize)] +pub struct OpponentQuery { + pub difficulty: Option, +} + +pub async fn get_opponent( + State(state): State, + Query(query): Query, +) -> AppResult> { + let difficulty = query.difficulty.as_deref().unwrap_or("beginner"); + let opponent = match_service::generate_opponent(&state.card_db, difficulty); + Ok(Json(opponent)) +} + pub async fn post_match_result( State(state): State, Json(req): Json, diff --git a/src/routes/squad.rs b/src/routes/squad.rs index d0ee15a..b3a414b 100644 --- a/src/routes/squad.rs +++ b/src/routes/squad.rs @@ -13,6 +13,7 @@ pub async fn get_squad(State(state): State) -> AppResult> 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 chemistry = squad_svc::calculate_chemistry(&state.pool, &state.card_db, &players).await?; let enriched: Vec = players .iter() @@ -34,6 +35,7 @@ pub async fn get_squad(State(state): State) -> AppResult> "formation": squad.formation, }, "players": enriched, + "chemistry": chemistry, }))) } @@ -44,6 +46,10 @@ pub async fn post_squad( let profile = profile_svc::get_active_profile(&state.pool).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; + if !req.players.is_empty() { + squad_svc::validate_formation(&state.pool, &state.card_db, &req.players).await?; + } + let squad = squad_svc::save_squad(&state.pool, &club.id, &req).await?; Ok(Json(json!({ "squad": squad }))) } diff --git a/src/routes/statistics.rs b/src/routes/statistics.rs index 6e9c019..6212aa2 100644 --- a/src/routes/statistics.rs +++ b/src/routes/statistics.rs @@ -1,9 +1,14 @@ -use axum::{extract::State, Json}; +use axum::{ + extract::{Query, State}, + Json, +}; +use serde::Deserialize; +use serde_json::{json, Value}; use crate::{ app::AppState, error::AppResult, - models::statistics::Statistics, + models::{match_result::Match, statistics::Statistics}, services::{profile as profile_svc, statistics as stats_svc}, }; @@ -12,3 +17,55 @@ pub async fn get_statistics(State(state): State) -> AppResult, +} + +pub async fn get_statistics_history( + State(state): State, + Query(query): Query, +) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let limit = query.limit.unwrap_or(20).clamp(1, 100); + + let matches = sqlx::query_as::<_, Match>( + "SELECT id, profile_id, squad_id, opponent_name, goals_for, goals_against, outcome, coins_awarded, xp_awarded, mode, played_at FROM matches WHERE profile_id = ? ORDER BY played_at DESC LIMIT ?", + ) + .bind(&profile.id) + .bind(limit) + .fetch_all(&state.pool) + .await?; + + let total = matches.len(); + let wins = matches.iter().filter(|m| m.outcome == "win").count(); + let draws = matches.iter().filter(|m| m.outcome == "draw").count(); + let losses = matches.iter().filter(|m| m.outcome == "loss").count(); + let goals_for: i64 = matches.iter().map(|m| m.goals_for).sum(); + let goals_against: i64 = matches.iter().map(|m| m.goals_against).sum(); + let win_rate = if total > 0 { + wins as f64 / total as f64 + } else { + 0.0 + }; + let avg_goals = if total > 0 { + goals_for as f64 / total as f64 + } else { + 0.0 + }; + + Ok(Json(json!({ + "matches": matches, + "summary": { + "total": total, + "wins": wins, + "draws": draws, + "losses": losses, + "win_rate": (win_rate * 100.0).round() / 100.0, + "total_goals_for": goals_for, + "total_goals_against": goals_against, + "avg_goals_per_game": (avg_goals * 100.0).round() / 100.0, + } + }))) +} diff --git a/src/services/market.rs b/src/services/market.rs index 44235d8..3c57a8b 100644 --- a/src/services/market.rs +++ b/src/services/market.rs @@ -35,6 +35,10 @@ pub async fn get_active_listings( /// Refresh NPC market with random listings from the card pool. pub async fn refresh_npc_listings(pool: &Pool, card_db: &CardDb) -> AppResult { + // Clean up expired listings first + sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')") + .execute(pool) + .await?; sqlx::query("DELETE FROM market_listings WHERE sold = 0") .execute(pool) .await?; diff --git a/src/services/match_service.rs b/src/services/match_service.rs index ee895db..0c0ea72 100644 --- a/src/services/match_service.rs +++ b/src/services/match_service.rs @@ -5,8 +5,80 @@ use crate::{ match_result::{Match, MatchRewardResult, SubmitMatchRequest}, objective::ObjectiveDefinition, }, - services::{club, objective, profile, statistics}, + services::{card_db::CardDb, club, objective, profile, statistics}, }; +use rand::{seq::SliceRandom, Rng}; + +/// Generate a random AI opponent squad for Squad Battles. +pub fn generate_opponent(card_db: &CardDb, difficulty: &str) -> serde_json::Value { + let (min_overall, names): (u8, &[&str]) = match difficulty { + "professional" => ( + 70, + &[ + "Athletic CF", + "City Wanderers", + "The Rovers", + "United Select", + "Blue Stars FC", + ], + ), + "world_class" => ( + 78, + &[ + "Elite Stars FC", + "Champions Select", + "Premier XI", + "Galaxy United", + "Titan FC", + ], + ), + "legendary" => ( + 85, + &[ + "Legends United", + "Ultimate XI", + "Gold Standard FC", + "The Icons", + "Heritage FC", + ], + ), + _ => ( + 58, + &[ + "Amateur Town FC", + "Sunday League XI", + "Park FC", + "Village Stars", + "Reserve XI", + ], + ), + }; + + let mut rng = rand::thread_rng(); + let all = card_db.by_min_overall(min_overall); + let mut indices: Vec = (0..all.len()).collect(); + indices.shuffle(&mut rng); + let cards: Vec<_> = indices + .into_iter() + .take(11) + .map(|i| all[i].clone()) + .collect(); + + let squad_rating = if cards.is_empty() { + 0 + } else { + cards.iter().map(|c| c.overall as i64).sum::() / cards.len() as i64 + }; + let name = names[rng.gen_range(0..names.len())]; + + serde_json::json!({ + "opponent_name": name, + "difficulty": difficulty, + "squad_rating": squad_rating, + "formation": "4-4-2", + "cards": cards, + }) +} const COINS_WIN: i64 = 400; const COINS_DRAW: i64 = 150; diff --git a/src/services/sbc.rs b/src/services/sbc.rs index fdc3eb6..4c260d8 100644 --- a/src/services/sbc.rs +++ b/src/services/sbc.rs @@ -130,14 +130,23 @@ fn validate_sbc(def: &SbcDefinition, cards: &[CardDefinition]) -> (bool, Vec() / cards.len() as i64; if avg < min as i64 { - failures.push(format!("average overall {avg} < required {min}")); + failures.push(format!("average overall {avg} < required minimum {min}")); + } + } + + if let Some(max) = req.max_overall { + if let Some(card) = cards.iter().find(|c| c.overall > max) { + failures.push(format!( + "'{}' has overall {} which exceeds maximum {max}", + card.name, card.overall + )); } } if !req.required_leagues.is_empty() { for league in &req.required_leagues { if !cards.iter().any(|c| &c.league == league) { - failures.push(format!("need at least one player from league {league}")); + failures.push(format!("need at least one player from league '{league}'")); } } } @@ -145,22 +154,57 @@ fn validate_sbc(def: &SbcDefinition, cards: &[CardDefinition]) -> (bool, Vec = - cards.iter().fold(Default::default(), |mut m, c| { - *m.entry(c.league.as_str()).or_default() += 1; - m - }); - let max = league_counts.values().copied().max().unwrap_or(0); - if max < min_same_league as usize { - failures.push(format!("need {min_same_league} players from same league")); + let counts = count_by(cards, |c| c.league.as_str()); + if counts.values().copied().max().unwrap_or(0) < min_same_league as usize { + failures.push(format!( + "need at least {min_same_league} players from the same league" + )); + } + } + + if let Some(min_same_nation) = req.min_players_from_same_nation { + let counts = count_by(cards, |c| c.nation.as_str()); + if counts.values().copied().max().unwrap_or(0) < min_same_nation as usize { + failures.push(format!( + "need at least {min_same_nation} players from the same nation" + )); + } + } + + if let Some(min_same_club) = req.min_players_from_same_club { + let counts = count_by(cards, |c| c.club.as_str()); + if counts.values().copied().max().unwrap_or(0) < min_same_club as usize { + failures.push(format!( + "need at least {min_same_club} players from the same club" + )); } } (failures.is_empty(), failures) } + +fn count_by<'a, F>(cards: &'a [CardDefinition], key: F) -> std::collections::HashMap<&'a str, usize> +where + F: Fn(&'a CardDefinition) -> &'a str, +{ + cards + .iter() + .fold(std::collections::HashMap::new(), |mut m, c| { + *m.entry(key(c)).or_default() += 1; + m + }) +} diff --git a/src/services/squad.rs b/src/services/squad.rs index 61d02ad..f7cf1d5 100644 --- a/src/services/squad.rs +++ b/src/services/squad.rs @@ -1,7 +1,11 @@ use crate::{ db::Pool, error::{AppError, AppResult}, - models::squad::{SaveSquadRequest, Squad, SquadPlayer}, + models::{ + card::{CardDefinition, OwnedCard}, + squad::{SaveSquadRequest, Squad, SquadPlayer, SquadPlayerInput}, + }, + services::card_db::CardDb, }; use uuid::Uuid; @@ -24,6 +28,112 @@ pub async fn get_squad(pool: &Pool, club_id: &str) -> AppResult<(Squad, Vec AppResult<()> { + let starters: Vec<&SquadPlayerInput> = players.iter().filter(|p| !p.is_on_bench).collect(); + + if starters.len() != 11 { + return Err(AppError::BadRequest(format!( + "need exactly 11 starters, got {}", + starters.len() + ))); + } + + 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 FROM owned_cards WHERE id = ?", + ) + .bind(&sp.owned_card_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("owned card {} not found", sp.owned_card_id)))?; + + if let Some(card) = card_db.get(&owned.card_id) { + if card.position == "GK" { + gk_count += 1; + } + } + } + + if gk_count == 0 { + return Err(AppError::BadRequest( + "squad must include exactly one goalkeeper (GK)".into(), + )); + } + if gk_count > 1 { + return Err(AppError::BadRequest( + "squad cannot have more than one starting goalkeeper".into(), + )); + } + + Ok(()) +} + +/// Calculate chemistry for the starting XI. Returns total and per-player scores. +pub async fn calculate_chemistry( + pool: &Pool, + card_db: &CardDb, + players: &[SquadPlayer], +) -> AppResult { + let starters: Vec<&SquadPlayer> = players.iter().filter(|p| !p.is_on_bench).collect(); + + let mut cards: Vec = Vec::new(); + for sp in &starters { + let owned = sqlx::query_as::<_, OwnedCard>( + "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ?", + ) + .bind(&sp.owned_card_id) + .fetch_optional(pool) + .await?; + + if let Some(o) = owned { + if let Some(card) = card_db.get(&o.card_id) { + cards.push(card.clone()); + } + } + } + + // Per-player chemistry: club links (max 4) + league links (max 3) + nation links (max 3), capped at 10 + let player_chems: Vec = cards + .iter() + .enumerate() + .map(|(i, c)| { + let same_club = cards + .iter() + .enumerate() + .filter(|(j, o)| *j != i && o.club == c.club) + .count() + .min(4) as i64; + let same_league = cards + .iter() + .enumerate() + .filter(|(j, o)| *j != i && o.league == c.league) + .count() + .min(3) as i64; + let same_nation = cards + .iter() + .enumerate() + .filter(|(j, o)| *j != i && o.nation == c.nation) + .count() + .min(3) as i64; + (same_club + same_league + same_nation).min(10) + }) + .collect(); + + let total: i64 = player_chems.iter().sum::().min(100); + + Ok(serde_json::json!({ + "total": total, + "max": 100, + "player_chemistries": player_chems, + })) +} + pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> AppResult { let now = chrono::Utc::now().to_rfc3339(); @@ -54,7 +164,7 @@ pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> A req.formation.as_deref().unwrap_or("4-4-2"), ); sqlx::query( - "INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" + "INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", ) .bind(&squad.id) .bind(&squad.club_id) @@ -70,7 +180,7 @@ pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> A for player in &req.players { let sp_id = Uuid::new_v4().to_string(); sqlx::query( - "INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, ?)" + "INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, ?)", ) .bind(&sp_id) .bind(&squad_id) diff --git a/src/services/statistics.rs b/src/services/statistics.rs index 84633e3..64c8741 100644 --- a/src/services/statistics.rs +++ b/src/services/statistics.rs @@ -1,20 +1,19 @@ use crate::{db::Pool, error::AppResult, models::statistics::Statistics}; -pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult { - let existing = sqlx::query_as::<_, Statistics>( - "SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, updated_at FROM statistics WHERE profile_id = ?" - ) - .bind(profile_id) - .fetch_optional(pool) - .await?; +const SELECT_STATS: &str = "SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, win_streak, best_win_streak, updated_at FROM statistics WHERE profile_id = ?"; - if let Some(s) = existing { +pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult { + if let Some(s) = sqlx::query_as::<_, Statistics>(SELECT_STATS) + .bind(profile_id) + .fetch_optional(pool) + .await? + { return Ok(s); } let stats = Statistics::new(profile_id); sqlx::query( - "INSERT INTO statistics (profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, updated_at) VALUES (?, 0, 0, 0, 0, 0, 0, 0, 0, 0, ?)" + "INSERT INTO statistics (profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, win_streak, best_win_streak, updated_at) VALUES (?, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ?)", ) .bind(profile_id) .bind(&stats.updated_at) @@ -32,7 +31,7 @@ pub async fn record_match( goals_against: i64, coins: i64, ) -> AppResult<()> { - get_or_create(pool, profile_id).await?; + let current = get_or_create(pool, profile_id).await?; let now = chrono::Utc::now().to_rfc3339(); let (w, d, l) = match outcome { @@ -41,16 +40,25 @@ pub async fn record_match( _ => (0, 0, 1), }; + let new_streak = if outcome == "win" { + current.win_streak + 1 + } else { + 0 + }; + let new_best = new_streak.max(current.best_win_streak); + sqlx::query( "UPDATE statistics SET - matches_played = matches_played + 1, - matches_won = matches_won + ?, - matches_drawn = matches_drawn + ?, - matches_lost = matches_lost + ?, - goals_scored = goals_scored + ?, - goals_conceded = goals_conceded + ?, + matches_played = matches_played + 1, + matches_won = matches_won + ?, + matches_drawn = matches_drawn + ?, + matches_lost = matches_lost + ?, + goals_scored = goals_scored + ?, + goals_conceded = goals_conceded + ?, total_coins_earned = total_coins_earned + ?, - updated_at = ? + win_streak = ?, + best_win_streak = ?, + updated_at = ? WHERE profile_id = ?", ) .bind(w) @@ -59,6 +67,8 @@ pub async fn record_match( .bind(goals_for) .bind(goals_against) .bind(coins) + .bind(new_streak) + .bind(new_best) .bind(&now) .bind(profile_id) .execute(pool) @@ -70,21 +80,25 @@ pub async fn record_match( pub async fn increment_packs_opened(pool: &Pool, profile_id: &str) -> AppResult<()> { get_or_create(pool, profile_id).await?; let now = chrono::Utc::now().to_rfc3339(); - sqlx::query("UPDATE statistics SET packs_opened = packs_opened + 1, updated_at = ? WHERE profile_id = ?") - .bind(&now) - .bind(profile_id) - .execute(pool) - .await?; + sqlx::query( + "UPDATE statistics SET packs_opened = packs_opened + 1, updated_at = ? WHERE profile_id = ?", + ) + .bind(&now) + .bind(profile_id) + .execute(pool) + .await?; Ok(()) } pub async fn increment_sbcs_completed(pool: &Pool, profile_id: &str) -> AppResult<()> { get_or_create(pool, profile_id).await?; let now = chrono::Utc::now().to_rfc3339(); - sqlx::query("UPDATE statistics SET sbcs_completed = sbcs_completed + 1, updated_at = ? WHERE profile_id = ?") - .bind(&now) - .bind(profile_id) - .execute(pool) - .await?; + sqlx::query( + "UPDATE statistics SET sbcs_completed = sbcs_completed + 1, updated_at = ? WHERE profile_id = ?", + ) + .bind(&now) + .bind(profile_id) + .execute(pool) + .await?; Ok(()) }