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
+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>,
}