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
+1 -1
View File
@@ -29,7 +29,7 @@ thiserror = "1"
anyhow = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
rand = "0.8"
rand = { version = "0.8", features = ["small_rng"] }
dotenvy = "0.15"
axum-macros = "0.4"
+19
View File
@@ -0,0 +1,19 @@
-- Division / season tracking (one row per profile, reset each season)
CREATE TABLE IF NOT EXISTS seasons (
profile_id TEXT PRIMARY KEY,
division INTEGER NOT NULL DEFAULT 5,
season_number INTEGER NOT NULL DEFAULT 1,
season_points INTEGER NOT NULL DEFAULT 0,
matches_played INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
draws INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
started_at TEXT NOT NULL
);
-- Pack open history: store which card_ids came out of each open
ALTER TABLE packs ADD COLUMN opened_cards TEXT;
ALTER TABLE packs ADD COLUMN opened_at TEXT;
-- Club cosmetic customization
ALTER TABLE clubs ADD COLUMN manager_name TEXT DEFAULT 'Player Manager';
+13
View File
@@ -0,0 +1,13 @@
-- One row per buy or sell event, for trade history display
CREATE TABLE IF NOT EXISTS market_history (
id TEXT PRIMARY KEY,
club_id TEXT NOT NULL,
card_id TEXT NOT NULL, -- definition card id
card_name TEXT NOT NULL,
card_overall INTEGER NOT NULL,
trade_type TEXT NOT NULL, -- 'buy' | 'sell'
price INTEGER NOT NULL,
traded_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_market_history_club ON market_history (club_id, traded_at DESC);
+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,
+47
View File
@@ -1850,3 +1850,50 @@ async fn test_milestones_endpoint_structure() {
assert!(json["sbcs_completed"].is_number());
assert!(json["total_checkins"].is_number());
}
#[tokio::test]
async fn test_leaderboard_has_ten_clubs() {
let app = build_test_app().await;
auth(&app, "LeaderboardPlayer").await;
let (status, json) = json_get(&app, "/division/leaderboard").await;
assert_eq!(status, StatusCode::OK, "{json}");
let table = json["leaderboard"].as_array().unwrap();
assert_eq!(table.len(), 10, "leaderboard should have 10 clubs (9 NPC + player)");
// Exactly one entry should be the player's club
let player_entries: Vec<_> = table.iter().filter(|e| e["is_player"].as_bool() == Some(true)).collect();
assert_eq!(player_entries.len(), 1, "exactly one player club entry");
assert!(json["division"].is_number());
}
#[tokio::test]
async fn test_leaderboard_sorted_by_pts() {
let app = build_test_app().await;
auth(&app, "SortedLeader").await;
let (_, json) = json_get(&app, "/division/leaderboard").await;
let table = json["leaderboard"].as_array().unwrap();
let pts: Vec<i64> = table.iter().map(|e| e["pts"].as_i64().unwrap_or(0)).collect();
let sorted = {
let mut s = pts.clone();
s.sort_by(|a, b| b.cmp(a));
s
};
assert_eq!(pts, sorted, "leaderboard should be sorted by pts desc");
// Positions should be sequential
for (i, entry) in table.iter().enumerate() {
assert_eq!(entry["position"].as_i64().unwrap_or(-1), (i + 1) as i64);
}
}
#[tokio::test]
async fn test_trade_history_empty_initially() {
let app = build_test_app().await;
auth(&app, "TradeHistPlayer").await;
let (status, json) = json_get(&app, "/market/trade-history").await;
assert_eq!(status, StatusCode::OK, "{json}");
assert!(json["trades"].is_array());
assert_eq!(json["trades"].as_array().unwrap().len(), 0);
}