From e438b58d88ce8051d264110223d8795a6eb37d94 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 25 Jun 2026 19:13:10 -0700 Subject: [PATCH] Phase 25: division leaderboard, market trade history - 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 --- Cargo.toml | 2 +- migrations/0006_seasons_loans_packs.sql | 19 ++++++ migrations/0013_market_history.sql | 13 ++++ src/app.rs | 2 + src/routes/division.rs | 91 +++++++++++++++++++++++++ src/routes/market.rs | 9 ++- src/services/market.rs | 76 +++++++++++++++++++-- tests/integration_test.rs | 47 +++++++++++++ 8 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 migrations/0006_seasons_loans_packs.sql create mode 100644 migrations/0013_market_history.sql diff --git a/Cargo.toml b/Cargo.toml index 3548c6e..b41ad37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/migrations/0006_seasons_loans_packs.sql b/migrations/0006_seasons_loans_packs.sql new file mode 100644 index 0000000..093c100 --- /dev/null +++ b/migrations/0006_seasons_loans_packs.sql @@ -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'; diff --git a/migrations/0013_market_history.sql b/migrations/0013_market_history.sql new file mode 100644 index 0000000..b15c71b --- /dev/null +++ b/migrations/0013_market_history.sql @@ -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); diff --git a/src/app.rs b/src/app.rs index 48efc7f..e78a4a0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -190,6 +190,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { .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 { .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)) diff --git a/src/routes/division.rs b/src/routes/division.rs index 546c5f6..a9c2fc1 100644 --- a/src/routes/division.rs +++ b/src/routes/division.rs @@ -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) -> AppResult) -> AppResult> { + 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 = (0..NPC_NAMES.len()).collect(); + name_indices.sort_by_key(|&_i| rng.gen::()); + 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 = 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::() * 0.4)) as i64; + let losses = (npc_matches as f64 * (1.0 - expected_win_rate) * (0.8 + rng.gen::() * 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 = 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 + }))) +} diff --git a/src/routes/market.rs b/src/routes/market.rs index e75f4dc..c6bd966 100644 --- a/src/routes/market.rs +++ b/src/routes/market.rs @@ -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) -> AppResult> { + 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" }), )) diff --git a/src/services/market.rs b/src/services/market.rs index cb24e2d..4866ce7 100644 --- a/src/services/market.rs +++ b/src/services/market.rs @@ -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 { - 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 { + 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> { + 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::("card_name"), + "card_overall": r.get::("card_overall"), + "trade_type": r.get::("trade_type"), + "price": r.get::("price"), + "traded_at": r.get::("traded_at"), + }) + }) + .collect(); + Ok(trades) +} + fn price_for_card(overall: u8) -> i64 { match overall { 85..=u8::MAX => 50_000, diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 56c7fd8..9bdf60a 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -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 = 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); +}