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
-1
@@ -29,7 +29,7 @@ thiserror = "1"
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
rand = "0.8"
|
rand = { version = "0.8", features = ["small_rng"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
axum-macros = "0.4"
|
axum-macros = "0.4"
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
@@ -190,6 +190,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/market", get(routes::market::get_market))
|
.route("/market", get(routes::market::get_market))
|
||||||
.route("/market/buy", post(routes::market::post_market_buy))
|
.route("/market/buy", post(routes::market::post_market_buy))
|
||||||
.route("/market/sell", post(routes::market::post_market_sell))
|
.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/refresh", post(routes::market::post_market_refresh))
|
||||||
.route("/market/my-listings", get(routes::market::get_my_listings))
|
.route("/market/my-listings", get(routes::market::get_my_listings))
|
||||||
.route(
|
.route(
|
||||||
@@ -205,6 +206,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/settings", put(routes::settings::put_settings))
|
.route("/settings", put(routes::settings::put_settings))
|
||||||
.route("/division", get(routes::division::get_division))
|
.route("/division", get(routes::division::get_division))
|
||||||
.route("/division/history", get(routes::division::get_division_history))
|
.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("/achievements", get(routes::achievements::get_achievements))
|
||||||
.route("/notifications", get(routes::notifications::get_notifications))
|
.route("/notifications", get(routes::notifications::get_notifications))
|
||||||
.route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read))
|
.route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
|
use rand::{Rng, SeedableRng};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::{
|
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?;
|
let history = season_svc::get_history(&state.pool, &profile.id).await?;
|
||||||
Ok(Json(json!({ "history": history, "total": history.len() })))
|
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},
|
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)]
|
#[derive(Deserialize)]
|
||||||
pub struct MarketQuery {
|
pub struct MarketQuery {
|
||||||
@@ -63,7 +70,7 @@ pub async fn post_market_sell(
|
|||||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).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(
|
Ok(Json(
|
||||||
json!({ "new_coin_balance": new_balance, "message": "Card sold to NPC market" }),
|
json!({ "new_coin_balance": new_balance, "message": "Card sold to NPC market" }),
|
||||||
))
|
))
|
||||||
|
|||||||
+72
-4
@@ -160,14 +160,37 @@ pub async fn buy_listing(
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
card_db
|
let card = card_db
|
||||||
.get(&listing.card_id)
|
.get(&listing.card_id)
|
||||||
.cloned()
|
.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> {
|
pub async fn sell_card(
|
||||||
let _owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
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, \
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
||||||
chemistry_style, position_override, training_bonus \
|
chemistry_style, position_override, training_bonus \
|
||||||
FROM owned_cards WHERE id = ? AND club_id = ?",
|
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 coins = (req.price as f64 * 0.4) as i64;
|
||||||
let new_balance = club::add_coins(pool, club_id, coins).await?;
|
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)
|
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 {
|
fn price_for_card(overall: u8) -> i64 {
|
||||||
match overall {
|
match overall {
|
||||||
85..=u8::MAX => 50_000,
|
85..=u8::MAX => 50_000,
|
||||||
|
|||||||
@@ -1850,3 +1850,50 @@ async fn test_milestones_endpoint_structure() {
|
|||||||
assert!(json["sbcs_completed"].is_number());
|
assert!(json["sbcs_completed"].is_number());
|
||||||
assert!(json["total_checkins"].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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user