a034e74c16
CI / Build, lint & test (push) Successful in 2m19s
Pure rustfmt reflow (import grouping, array/match-arm/call-arg wrapping, alphabetized module decls, comment realignment). No semantic change: full-diff and `git diff -w` both confirm logic byte-identical to 271c363; workspace builds and all 74 core tests pass. Retained pre-existing WIP brought forward after verification.
159 lines
5.6 KiB
Rust
159 lines
5.6 KiB
Rust
use crate::extractors::GameId;
|
||
use axum::{extract::State, Json};
|
||
use rand::{Rng, SeedableRng};
|
||
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<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||
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 season = season_svc::get_or_create(&state.pool, &profile.id).await?;
|
||
|
||
Ok(Json(json!({
|
||
"division": season.division,
|
||
"season_number": season.season_number,
|
||
"season_points": season.season_points,
|
||
"matches_played": season.matches_played,
|
||
"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": {
|
||
"wins": season.wins,
|
||
"draws": season.draws,
|
||
"losses": season.losses,
|
||
},
|
||
"pts_for_promotion": season.pts_for_promotion(),
|
||
"started_at": season.started_at,
|
||
})))
|
||
}
|
||
|
||
pub async fn get_division_history(
|
||
State(state): State<AppState>,
|
||
game: GameId,
|
||
) -> AppResult<Json<Value>> {
|
||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||
let history = season_svc::get_history(&state.pool, &profile.id).await?;
|
||
Ok(Json(json!({ "history": history, "total": history.len() })))
|
||
}
|
||
|
||
pub async fn get_division_leaderboard(
|
||
State(state): State<AppState>,
|
||
game: GameId,
|
||
) -> AppResult<Json<Value>> {
|
||
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 season = season_svc::get_or_create(&state.pool, &profile.id).await?;
|
||
|
||
// Seed from division + season_number so the NPC table is stable within a season
|
||
let seed = (season.division as u64) * 1000 + season.season_number as u64;
|
||
let mut rng = rand::rngs::SmallRng::seed_from_u64(seed);
|
||
|
||
const NPC_NAMES: &[&str] = &[
|
||
"Riverside FC",
|
||
"City Athletic",
|
||
"County United",
|
||
"Valley Rangers",
|
||
"Harbor Town FC",
|
||
"Mountside City",
|
||
"Lakewood Athletic",
|
||
"Eastbrook United",
|
||
"Westfield Rovers",
|
||
"Northgate FC",
|
||
"Southport Athletic",
|
||
"Ironbridge City",
|
||
"Milldale United",
|
||
"Hillcrest Rangers",
|
||
"Bayside FC",
|
||
"Thornfield Athletic",
|
||
"Greenhill United",
|
||
"Coldwater City",
|
||
"Redbury Rangers",
|
||
"Ashdown FC",
|
||
];
|
||
|
||
// Pick 9 NPC names without repetition using the seeded RNG
|
||
let mut name_indices: Vec<usize> = (0..NPC_NAMES.len()).collect();
|
||
name_indices.sort_by_key(|&_i| rng.gen::<u64>());
|
||
let npc_names: Vec<&str> = name_indices[..9].iter().map(|&i| NPC_NAMES[i]).collect();
|
||
|
||
// Generate NPC records: clubs in top of table have more wins, bottom have more losses
|
||
let matches_played = season.matches_played;
|
||
let npc_matches = matches_played.max(1); // NPC clubs play same number of matches as player
|
||
let mut table: Vec<serde_json::Value> = npc_names
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(idx, name)| {
|
||
// Quality bias: index 0-2 = stronger, 6-8 = weaker
|
||
let quality: f64 = 1.0 - (idx as f64 / 8.0); // 1.0 → 0.0
|
||
let expected_win_rate = 0.2 + quality * 0.6; // 0.2–0.8
|
||
let wins =
|
||
(npc_matches as f64 * expected_win_rate * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
|
||
let losses = (npc_matches as f64
|
||
* (1.0 - expected_win_rate)
|
||
* (0.8 + rng.gen::<f64>() * 0.4)) as i64;
|
||
let draws = (npc_matches - wins - losses).max(0);
|
||
let pts = wins * 3 + draws;
|
||
json!({
|
||
"club_name": name,
|
||
"wins": wins,
|
||
"draws": draws,
|
||
"losses": losses,
|
||
"pts": pts,
|
||
"matches_played": npc_matches,
|
||
"is_player": false,
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
// Add player club row
|
||
let player_pts = season.wins * 3 + season.draws;
|
||
table.push(json!({
|
||
"club_name": club.name,
|
||
"wins": season.wins,
|
||
"draws": season.draws,
|
||
"losses": season.losses,
|
||
"pts": player_pts,
|
||
"matches_played": season.matches_played,
|
||
"is_player": true,
|
||
}));
|
||
|
||
// Sort by pts desc, then wins desc
|
||
table.sort_by(|a, b| {
|
||
let pts_a = a["pts"].as_i64().unwrap_or(0);
|
||
let pts_b = b["pts"].as_i64().unwrap_or(0);
|
||
pts_b.cmp(&pts_a).then_with(|| {
|
||
let w_b = b["wins"].as_i64().unwrap_or(0);
|
||
let w_a = a["wins"].as_i64().unwrap_or(0);
|
||
w_b.cmp(&w_a)
|
||
})
|
||
});
|
||
|
||
// Add position numbers
|
||
let table_with_pos: Vec<serde_json::Value> = table
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(i, mut entry)| {
|
||
entry["position"] = json!(i as i64 + 1);
|
||
entry
|
||
})
|
||
.collect();
|
||
|
||
Ok(Json(json!({
|
||
"leaderboard": table_with_pos,
|
||
"division": season.division,
|
||
"season_number": season.season_number,
|
||
"promotion_threshold": 3, // top 3 promote
|
||
"relegation_threshold": 8, // bottom 2 relegate
|
||
})))
|
||
}
|