Phase 25: division leaderboard, market trade history
CI / Build, lint & test (push) Failing after 2m10s

- Market: record buy/sell history in market_history table; expose via
  GET /market/trade-history (last 30 events, newest first)
- Division: GET /division/leaderboard returns 10-club table with 9 seeded
  NPC entries + player row, sorted by pts; stable within a season
- rand feature small_rng enabled in Cargo.toml for SmallRng use
- 3 new integration tests (leaderboard count, sort order, empty trade history)
- Core: 96 tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 19:13:10 -07:00
parent 956bfe7a73
commit e438b58d88
8 changed files with 253 additions and 6 deletions
+91
View File
@@ -1,4 +1,5 @@
use axum::{extract::State, Json};
use rand::{Rng, SeedableRng};
use serde_json::{json, Value};
use crate::{
@@ -40,3 +41,93 @@ pub async fn get_division_history(State(state): State<AppState>) -> AppResult<Js
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>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).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.20.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
})))
}
+8 -1
View File
@@ -12,6 +12,13 @@ use crate::{
services::{club as club_svc, market as market_svc, profile as profile_svc},
};
pub async fn get_trade_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let trades = market_svc::get_trade_history(&state.pool, &club.id).await?;
Ok(Json(json!({ "trades": trades, "total": trades.len() })))
}
#[derive(Deserialize)]
pub struct MarketQuery {
@@ -63,7 +70,7 @@ pub async fn post_market_sell(
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let new_balance = market_svc::sell_card(&state.pool, &club.id, &req).await?;
let new_balance = market_svc::sell_card(&state.pool, &state.card_db, &club.id, &req).await?;
Ok(Json(
json!({ "new_coin_balance": new_balance, "message": "Card sold to NPC market" }),
))