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
+2
View File
@@ -190,6 +190,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/market", get(routes::market::get_market))
.route("/market/buy", post(routes::market::post_market_buy))
.route("/market/sell", post(routes::market::post_market_sell))
.route("/market/trade-history", get(routes::market::get_trade_history))
.route("/market/refresh", post(routes::market::post_market_refresh))
.route("/market/my-listings", get(routes::market::get_my_listings))
.route(
@@ -205,6 +206,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/settings", put(routes::settings::put_settings))
.route("/division", get(routes::division::get_division))
.route("/division/history", get(routes::division::get_division_history))
.route("/division/leaderboard", get(routes::division::get_division_leaderboard))
.route("/achievements", get(routes::achievements::get_achievements))
.route("/notifications", get(routes::notifications::get_notifications))
.route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read))
+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" }),
))
+72 -4
View File
@@ -160,14 +160,37 @@ pub async fn buy_listing(
.execute(pool)
.await?;
card_db
let card = card_db
.get(&listing.card_id)
.cloned()
.ok_or_else(|| AppError::NotFound("card definition not found".into()))
.ok_or_else(|| AppError::NotFound("card definition not found".into()))?;
// Record trade history (best-effort — never abort the buy on failure)
let _ = sqlx::query(
"INSERT INTO market_history (id, club_id, card_id, card_name, card_overall, \
trade_type, price, traded_at) VALUES (?,?,?,?,?,?,?,?)",
)
.bind(Uuid::new_v4().to_string())
.bind(club_id)
.bind(&card.id)
.bind(&card.name)
.bind(card.overall as i64)
.bind("buy")
.bind(listing.price)
.bind(chrono::Utc::now().to_rfc3339())
.execute(pool)
.await;
Ok(card)
}
pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> AppResult<i64> {
let _owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
pub async fn sell_card(
pool: &Pool,
card_db: &CardDb,
club_id: &str,
req: &SellCardRequest,
) -> AppResult<i64> {
let owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
chemistry_style, position_override, training_bonus \
FROM owned_cards WHERE id = ? AND club_id = ?",
@@ -185,9 +208,54 @@ pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> App
let coins = (req.price as f64 * 0.4) as i64;
let new_balance = club::add_coins(pool, club_id, coins).await?;
// Record trade history
if let Some(card) = card_db.get(&owned.card_id) {
let _ = sqlx::query(
"INSERT INTO market_history (id, club_id, card_id, card_name, card_overall, \
trade_type, price, traded_at) VALUES (?,?,?,?,?,?,?,?)",
)
.bind(Uuid::new_v4().to_string())
.bind(club_id)
.bind(&card.id)
.bind(&card.name)
.bind(card.overall as i64)
.bind("sell")
.bind(coins)
.bind(chrono::Utc::now().to_rfc3339())
.execute(pool)
.await;
}
Ok(new_balance)
}
/// Return the last 30 buy/sell events for this club, newest first.
pub async fn get_trade_history(pool: &Pool, club_id: &str) -> AppResult<Vec<serde_json::Value>> {
let rows = sqlx::query(
"SELECT card_name, card_overall, trade_type, price, traded_at \
FROM market_history WHERE club_id = ? ORDER BY traded_at DESC LIMIT 30",
)
.bind(club_id)
.fetch_all(pool)
.await?;
use sqlx::Row;
let trades = rows
.iter()
.map(|r| {
serde_json::json!({
"card_name": r.get::<String, _>("card_name"),
"card_overall": r.get::<i64, _>("card_overall"),
"trade_type": r.get::<String, _>("trade_type"),
"price": r.get::<i64, _>("price"),
"traded_at": r.get::<String, _>("traded_at"),
})
})
.collect();
Ok(trades)
}
fn price_for_card(overall: u8) -> i64 {
match overall {
85..=u8::MAX => 50_000,