From 28d74905554ffc53f70be6b549ab102b20d829ae Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 25 Jun 2026 18:38:02 -0700 Subject: [PATCH] Phase 23: season history + division zone data + new route - migrations/0011_season_history.sql: persist one row per completed season - models/season.rs: SeasonHistoryEntry struct; public SEASON_LENGTH / PROMOTION_PTS / RELEGATION_PTS consts; pts_above_safe, can_be_relegated, promotion_achievable helpers - services/season.rs: write history entry on season rollover; get_history() returns last 20 seasons newest-first - routes/division.rs: GET /division now includes promotion_pts, relegation_pts, season_length, pts_above_safe, promotion_achievable, can_be_relegated; new GET /division/history endpoint - 3 new integration tests: history empty, history records after promotion, division response has zone fields Co-Authored-By: Claude Sonnet 4.6 --- migrations/0011_season_history.sql | 18 ++++ src/app.rs | 1 + src/models/season.rs | 127 ++++++++++++++++++++++ src/routes/division.rs | 18 +++- src/services/season.rs | 162 +++++++++++++++++++++++++++++ tests/integration_test.rs | 51 +++++++++ 6 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 migrations/0011_season_history.sql create mode 100644 src/models/season.rs create mode 100644 src/services/season.rs diff --git a/migrations/0011_season_history.sql b/migrations/0011_season_history.sql new file mode 100644 index 0000000..ee6b480 --- /dev/null +++ b/migrations/0011_season_history.sql @@ -0,0 +1,18 @@ +-- Season history: one row per completed season +CREATE TABLE IF NOT EXISTS season_history ( + id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL, + season_number INTEGER NOT NULL, + division INTEGER NOT NULL, + season_points INTEGER NOT NULL, + wins INTEGER NOT NULL, + draws INTEGER NOT NULL, + losses INTEGER NOT NULL, + result TEXT NOT NULL, -- 'promoted' | 'maintained' | 'relegated' + new_division INTEGER NOT NULL, + coins_awarded INTEGER NOT NULL, + pack_awarded TEXT, -- nullable + ended_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_season_history_profile ON season_history (profile_id, season_number DESC); diff --git a/src/app.rs b/src/app.rs index 1c1ffb9..4191bb9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -201,6 +201,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .route("/settings", get(routes::settings::get_settings)) .route("/settings", put(routes::settings::put_settings)) .route("/division", get(routes::division::get_division)) + .route("/division/history", get(routes::division::get_division_history)) .route("/achievements", get(routes::achievements::get_achievements)) .route("/notifications", get(routes::notifications::get_notifications)) .route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read)) diff --git a/src/models/season.rs b/src/models/season.rs new file mode 100644 index 0000000..978e5f6 --- /dev/null +++ b/src/models/season.rs @@ -0,0 +1,127 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Season { + pub profile_id: String, + pub division: i64, + pub season_number: i64, + pub season_points: i64, + pub matches_played: i64, + pub wins: i64, + pub draws: i64, + pub losses: i64, + pub started_at: String, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct SeasonHistoryEntry { + pub id: String, + pub profile_id: String, + pub season_number: i64, + pub division: i64, + pub season_points: i64, + pub wins: i64, + pub draws: i64, + pub losses: i64, + pub result: String, + pub new_division: i64, + pub coins_awarded: i64, + pub pack_awarded: Option, + pub ended_at: String, +} + +pub const SEASON_LENGTH: i64 = 10; +pub const PROMOTION_PTS: i64 = 21; +pub const RELEGATION_PTS: i64 = 3; +const MIN_DIVISION: i64 = 1; +const MAX_DIVISION: i64 = 10; + +impl Season { + /// Whether this season's match quota is complete. + pub fn is_complete(&self) -> bool { + self.matches_played >= SEASON_LENGTH + } + + /// Season end result: promote / maintain / relegate. + pub fn end_result(&self) -> SeasonResult { + if self.season_points >= PROMOTION_PTS && self.division > MIN_DIVISION { + SeasonResult::Promoted + } else if self.season_points <= RELEGATION_PTS && self.division < MAX_DIVISION { + SeasonResult::Relegated + } else { + SeasonResult::Maintained + } + } + + /// Coin reward for ending the season in this division. + pub fn season_reward_coins(&self) -> i64 { + match self.division { + 1 => 5000, + 2 => 4000, + 3 => 3000, + 4 => 2000, + 5 => 1500, + 6 | 7 => 1000, + _ => 500, + } + } + + /// Pack reward (if any) for ending the season in this division. + pub fn season_reward_pack(&self) -> Option<&'static str> { + match self.division { + 1 | 2 => Some("gold_pack"), + 3 | 4 => Some("silver_pack"), + _ => None, + } + } + + /// Matches left in the current season. + pub fn matches_remaining(&self) -> i64 { + (SEASON_LENGTH - self.matches_played).max(0) + } + + /// Points needed for promotion (None if already at div 1). + pub fn pts_for_promotion(&self) -> Option { + if self.division <= MIN_DIVISION { + None + } else { + Some((PROMOTION_PTS - self.season_points).max(0)) + } + } + + /// Points above the relegation threshold (always ≥ 0 means safe). + pub fn pts_above_safe(&self) -> i64 { + self.season_points - RELEGATION_PTS + } + + /// Whether relegation is still possible (false if at max division already). + pub fn can_be_relegated(&self) -> bool { + self.division < MAX_DIVISION + } + + /// Whether promotion is still achievable given matches remaining. + pub fn promotion_achievable(&self) -> bool { + self.division > MIN_DIVISION + && self.season_points + self.matches_remaining() * 3 >= PROMOTION_PTS + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum SeasonResult { + Promoted, + Maintained, + Relegated, +} + +/// Returned in match result when a season ends. +#[derive(Debug, Clone, Serialize)] +pub struct SeasonEndSummary { + pub result: SeasonResult, + pub old_division: i64, + pub new_division: i64, + pub new_season_number: i64, + pub coins_awarded: i64, + pub pack_awarded: Option, +} diff --git a/src/routes/division.rs b/src/routes/division.rs index fa593ef..546c5f6 100644 --- a/src/routes/division.rs +++ b/src/routes/division.rs @@ -4,15 +4,13 @@ use serde_json::{json, Value}; use crate::{ app::AppState, error::AppResult, + models::season::{PROMOTION_PTS, RELEGATION_PTS, SEASON_LENGTH}, services::{club as club_svc, profile as profile_svc, season as season_svc}, }; pub async fn get_division(State(state): State) -> AppResult> { let profile = profile_svc::get_active_profile(&state.pool).await?; - let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; - - // Ensure a season row exists (idempotent) - let _ = club; // suppress unused warning; we need club_id later if we extend this + 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?; Ok(Json(json!({ @@ -21,6 +19,12 @@ pub async fn get_division(State(state): State) -> AppResult) -> AppResult) -> AppResult> { + let profile = profile_svc::get_active_profile(&state.pool).await?; + let history = season_svc::get_history(&state.pool, &profile.id).await?; + Ok(Json(json!({ "history": history, "total": history.len() }))) +} diff --git a/src/services/season.rs b/src/services/season.rs new file mode 100644 index 0000000..ed6d7a7 --- /dev/null +++ b/src/services/season.rs @@ -0,0 +1,162 @@ +use crate::{ + db::Pool, + error::AppResult, + models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult}, + services::{club, pack}, +}; +use uuid::Uuid; + +/// Get the current season for a profile, creating it if it doesn't exist. +pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult { + if let Some(s) = fetch(pool, profile_id).await? { + return Ok(s); + } + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO seasons (profile_id, division, season_number, season_points, \ + matches_played, wins, draws, losses, started_at) VALUES (?, 5, 1, 0, 0, 0, 0, 0, ?)", + ) + .bind(profile_id) + .bind(&now) + .execute(pool) + .await?; + Ok(fetch(pool, profile_id).await?.expect("just inserted")) +} + +async fn fetch(pool: &Pool, profile_id: &str) -> AppResult> { + let s = sqlx::query_as::<_, Season>( + "SELECT profile_id, division, season_number, season_points, matches_played, \ + wins, draws, losses, started_at FROM seasons WHERE profile_id = ?", + ) + .bind(profile_id) + .fetch_optional(pool) + .await?; + Ok(s) +} + +/// Record a match result in the season; end the season if the quota is met. +/// +/// Returns the updated season and an optional end-of-season summary. +pub async fn record_match( + pool: &Pool, + club_id: &str, + profile_id: &str, + outcome: &str, +) -> AppResult<(Season, Option)> { + let points = match outcome { + "win" => 3, + "draw" => 1, + _ => 0, + }; + + sqlx::query( + "UPDATE seasons SET \ + season_points = season_points + ?, \ + matches_played = matches_played + 1, \ + wins = wins + CASE WHEN ? = 'win' THEN 1 ELSE 0 END, \ + draws = draws + CASE WHEN ? = 'draw' THEN 1 ELSE 0 END, \ + losses = losses + CASE WHEN ? = 'loss' THEN 1 ELSE 0 END \ + WHERE profile_id = ?", + ) + .bind(points) + .bind(outcome) + .bind(outcome) + .bind(outcome) + .bind(profile_id) + .execute(pool) + .await?; + + let season = fetch(pool, profile_id).await?.expect("season must exist"); + + if !season.is_complete() { + return Ok((season, None)); + } + + // Season complete — calculate result and start next + let result = season.end_result(); + let old_div = season.division; + let coins = season.season_reward_coins(); + let pack_id = season.season_reward_pack(); + + let new_div = match result { + SeasonResult::Promoted => (old_div - 1).max(1), + SeasonResult::Relegated => (old_div + 1).min(10), + SeasonResult::Maintained => old_div, + }; + let new_season = season.season_number + 1; + let now = chrono::Utc::now().to_rfc3339(); + + sqlx::query( + "UPDATE seasons SET division = ?, season_number = ?, season_points = 0, \ + matches_played = 0, wins = 0, draws = 0, losses = 0, started_at = ? \ + WHERE profile_id = ?", + ) + .bind(new_div) + .bind(new_season) + .bind(&now) + .bind(profile_id) + .execute(pool) + .await?; + + // Grant rewards + club::add_coins(pool, club_id, coins).await?; + if let Some(pack_def) = pack_id { + let dummy_pack_id = Uuid::new_v4().to_string(); + // Grant via pack system so it shows in inventory + let _ = dummy_pack_id; // will use grant_pack instead + pack::grant_pack(pool, club_id, pack_def).await?; + } + + // Persist history entry before rolling over + let result_str = match result { + SeasonResult::Promoted => "promoted", + SeasonResult::Maintained => "maintained", + SeasonResult::Relegated => "relegated", + }; + let history_id = Uuid::new_v4().to_string(); + let _ = sqlx::query( + "INSERT INTO season_history (id, profile_id, season_number, division, season_points, \ + wins, draws, losses, result, new_division, coins_awarded, pack_awarded, ended_at) \ + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + ) + .bind(&history_id) + .bind(profile_id) + .bind(season.season_number) + .bind(old_div) + .bind(season.season_points) + .bind(season.wins) + .bind(season.draws) + .bind(season.losses) + .bind(result_str) + .bind(new_div) + .bind(coins) + .bind(pack_id) + .bind(&now) + .execute(pool) + .await; + + let summary = SeasonEndSummary { + result, + old_division: old_div, + new_division: new_div, + new_season_number: new_season, + coins_awarded: coins, + pack_awarded: pack_id.map(String::from), + }; + + let updated = fetch(pool, profile_id).await?.expect("season must exist"); + Ok((updated, Some(summary))) +} + +/// Return past seasons for a profile, newest first (max 20). +pub async fn get_history(pool: &Pool, profile_id: &str) -> AppResult> { + let rows = sqlx::query_as::<_, SeasonHistoryEntry>( + "SELECT id, profile_id, season_number, division, season_points, wins, draws, losses, \ + result, new_division, coins_awarded, pack_awarded, ended_at \ + FROM season_history WHERE profile_id = ? ORDER BY season_number DESC LIMIT 20", + ) + .bind(profile_id) + .fetch_all(pool) + .await?; + Ok(rows) +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 1f3d320..fa2945f 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1738,3 +1738,54 @@ async fn test_auth_reset_allows_new_profile() { assert_eq!(status, StatusCode::OK, "{json}"); assert_eq!(json["profile"]["username"], "NewProfile"); } + +#[tokio::test] +async fn test_division_history_empty_initially() { + let app = build_test_app().await; + auth(&app, "HistoryZero").await; + + let (status, json) = json_get(&app, "/division/history").await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert!(json["history"].is_array()); + assert_eq!(json["history"].as_array().unwrap().len(), 0); + assert_eq!(json["total"], 0); +} + +#[tokio::test] +async fn test_division_history_records_after_season_end() { + let app = build_test_app().await; + auth(&app, "HistoryRecorder").await; + + // Win all 10 matches to complete and promote + for _ in 0..10 { + json_post(&app, "/matches/result", serde_json::json!({ + "squad_id": "dummy", "opponent_name": "Bot", + "goals_for": 3, "goals_against": 0, "mode": "squad_battles" + })).await; + } + + let (status, json) = json_get(&app, "/division/history").await; + assert_eq!(status, StatusCode::OK, "{json}"); + let history = json["history"].as_array().unwrap(); + assert_eq!(history.len(), 1, "one completed season"); + assert_eq!(history[0]["result"], "promoted"); + assert_eq!(history[0]["division"], 5); + assert_eq!(history[0]["new_division"], 4); + assert_eq!(history[0]["season_number"], 1); +} + +#[tokio::test] +async fn test_division_response_has_zone_fields() { + let app = build_test_app().await; + auth(&app, "ZoneChecker").await; + + let (status, json) = json_get(&app, "/division").await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert!(json["promotion_pts"].is_number()); + assert!(json["relegation_pts"].is_number()); + assert!(json["season_length"].is_number()); + assert!(json["pts_above_safe"].is_number()); + assert_eq!(json["promotion_pts"], 21); + assert_eq!(json["relegation_pts"], 3); + assert_eq!(json["season_length"], 10); +}