Phase 25: division leaderboard, market trade history
CI / Build, lint & test (push) Failing after 2m10s
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:
@@ -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.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
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -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" }),
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user