Phase 23: season history + division zone data + new route
CI / Build, lint & test (push) Failing after 1m19s

- 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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 18:38:02 -07:00
parent c458b8cdbd
commit 28d7490555
6 changed files with 373 additions and 4 deletions
+18
View File
@@ -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);
+1
View File
@@ -201,6 +201,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/settings", get(routes::settings::get_settings)) .route("/settings", get(routes::settings::get_settings))
.route("/settings", put(routes::settings::put_settings)) .route("/settings", put(routes::settings::put_settings))
.route("/division", get(routes::division::get_division)) .route("/division", get(routes::division::get_division))
.route("/division/history", get(routes::division::get_division_history))
.route("/achievements", get(routes::achievements::get_achievements)) .route("/achievements", get(routes::achievements::get_achievements))
.route("/notifications", get(routes::notifications::get_notifications)) .route("/notifications", get(routes::notifications::get_notifications))
.route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read)) .route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read))
+127
View File
@@ -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<String>,
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<i64> {
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<String>,
}
+14 -4
View File
@@ -4,15 +4,13 @@ use serde_json::{json, Value};
use crate::{ use crate::{
app::AppState, app::AppState,
error::AppResult, error::AppResult,
models::season::{PROMOTION_PTS, RELEGATION_PTS, SEASON_LENGTH},
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>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let _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 season = season_svc::get_or_create(&state.pool, &profile.id).await?; let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
Ok(Json(json!({ Ok(Json(json!({
@@ -21,6 +19,12 @@ pub async fn get_division(State(state): State<AppState>) -> AppResult<Json<Value
"season_points": season.season_points, "season_points": season.season_points,
"matches_played": season.matches_played, "matches_played": season.matches_played,
"matches_remaining": season.matches_remaining(), "matches_remaining": season.matches_remaining(),
"season_length": SEASON_LENGTH,
"promotion_pts": PROMOTION_PTS,
"relegation_pts": RELEGATION_PTS,
"pts_above_safe": season.pts_above_safe(),
"promotion_achievable": season.promotion_achievable(),
"can_be_relegated": season.can_be_relegated(),
"record": { "record": {
"wins": season.wins, "wins": season.wins,
"draws": season.draws, "draws": season.draws,
@@ -30,3 +34,9 @@ pub async fn get_division(State(state): State<AppState>) -> AppResult<Json<Value
"started_at": season.started_at, "started_at": season.started_at,
}))) })))
} }
pub async fn get_division_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let history = season_svc::get_history(&state.pool, &profile.id).await?;
Ok(Json(json!({ "history": history, "total": history.len() })))
}
+162
View File
@@ -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<Season> {
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<Option<Season>> {
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<SeasonEndSummary>)> {
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<Vec<SeasonHistoryEntry>> {
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)
}
+51
View File
@@ -1738,3 +1738,54 @@ async fn test_auth_reset_allows_new_profile() {
assert_eq!(status, StatusCode::OK, "{json}"); assert_eq!(status, StatusCode::OK, "{json}");
assert_eq!(json["profile"]["username"], "NewProfile"); 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);
}