diff --git a/.sqlx/query-0d0682c39e29959670da4e313079ab31bc20018074cec257c47028ea7e2ca36a.json b/.sqlx/query-0d0682c39e29959670da4e313079ab31bc20018074cec257c47028ea7e2ca36a.json new file mode 100644 index 0000000..479ec67 --- /dev/null +++ b/.sqlx/query-0d0682c39e29959670da4e313079ab31bc20018074cec257c47028ea7e2ca36a.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO leaderboards (name) VALUES ('mm-test') RETURNING leaderboard_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "leaderboard_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "0d0682c39e29959670da4e313079ab31bc20018074cec257c47028ea7e2ca36a" +} diff --git a/.sqlx/query-837663d70385d9360dbf503a931072bbb6a73b0d8618a6c7b1ca8270982036da.json b/.sqlx/query-837663d70385d9360dbf503a931072bbb6a73b0d8618a6c7b1ca8270982036da.json new file mode 100644 index 0000000..24a2999 --- /dev/null +++ b/.sqlx/query-837663d70385d9360dbf503a931072bbb6a73b0d8618a6c7b1ca8270982036da.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users (external_github_id, github_login, github_access_token)\n VALUES (88001, 'mm-owner', 'test-token')\n RETURNING user_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "837663d70385d9360dbf503a931072bbb6a73b0d8618a6c7b1ca8270982036da" +} diff --git a/.sqlx/query-e9eded8f345a6d2116c9687fad55b5e9b378c8e56d7533b42ea1b3d89494e83f.json b/.sqlx/query-e9eded8f345a6d2116c9687fad55b5e9b378c8e56d7533b42ea1b3d89494e83f.json new file mode 100644 index 0000000..2da4515 --- /dev/null +++ b/.sqlx/query-e9eded8f345a6d2116c9687fad55b5e9b378c8e56d7533b42ea1b3d89494e83f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) as \"size!\"\n FROM leaderboard_games lg\n JOIN game_battlesnakes gb ON gb.game_id = lg.game_id\n WHERE lg.leaderboard_id = $1\n GROUP BY lg.game_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "size!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "e9eded8f345a6d2116c9687fad55b5e9b378c8e56d7533b42ea1b3d89494e83f" +} diff --git a/.sqlx/query-fd07363829985188be9893d7885a5a76ac6f7434f461cbfd79d9eb76b155fe76.json b/.sqlx/query-fd07363829985188be9893d7885a5a76ac6f7434f461cbfd79d9eb76b155fe76.json new file mode 100644 index 0000000..30558eb --- /dev/null +++ b/.sqlx/query-fd07363829985188be9893d7885a5a76ac6f7434f461cbfd79d9eb76b155fe76.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO battlesnakes (user_id, name, url)\n VALUES ($1, $2, 'http://example.com/snake')\n RETURNING battlesnake_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "battlesnake_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fd07363829985188be9893d7885a5a76ac6f7434f461cbfd79d9eb76b155fe76" +} diff --git a/server/src/leaderboard_matchmaker.rs b/server/src/leaderboard_matchmaker.rs index baf3aea..1341a02 100644 --- a/server/src/leaderboard_matchmaker.rs +++ b/server/src/leaderboard_matchmaker.rs @@ -6,7 +6,7 @@ use crate::{ jobs::GameRunnerJob, models::{ game::{self, CreateGame, GameBoardSize, GameType}, - leaderboard::{self, GAMES_PER_DAY, LeaderboardEntry, MATCH_SIZE}, + leaderboard::{self, GAMES_PER_DAY, LeaderboardEntry, MATCH_SIZE, MIN_MATCH_SIZE}, }, state::AppState, }; @@ -43,15 +43,19 @@ async fn run_matchmaker_for_leaderboard( .await .wrap_err("Failed to fetch active entries")?; - if entries.len() < MATCH_SIZE { - tracing::debug!( + // Play short-handed (down to MIN_MATCH_SIZE) rather than freezing the + // ladder when snakes drop out — a health-disabled snake once starved + // matchmaking for 10 days because this was a silent 4-or-nothing check. + if entries.len() < MIN_MATCH_SIZE { + tracing::warn!( leaderboard_id = %leaderboard_id, active_snakes = entries.len(), - "Not enough active snakes for matchmaking (need {})", - MATCH_SIZE + "Matchmaking starved: not enough active snakes (need at least {})", + MIN_MATCH_SIZE ); return Ok(()); } + let match_size = entries.len().min(MATCH_SIZE); // Calculate how many games to create this run // Derived from shared cron interval constant to avoid manual sync bugs @@ -61,13 +65,14 @@ async fn run_matchmaker_for_leaderboard( tracing::info!( leaderboard_id = %leaderboard_id, active_snakes = entries.len(), + match_size, games_to_create = games_per_run, "Running matchmaker" ); for _ in 0..games_per_run { - let selected = select_match(&mut rand::thread_rng(), &entries, MATCH_SIZE); - if selected.len() < MATCH_SIZE { + let selected = select_match(&mut rand::thread_rng(), &entries, match_size); + if selected.len() < match_size { break; } @@ -227,6 +232,21 @@ mod tests { assert!(selected.is_empty()); } + /// The matchmaker passes `min(pool, MATCH_SIZE)` — a 3-snake pool plays + /// 3-snake games instead of freezing the ladder. + #[test] + fn test_select_match_short_handed() { + for pool in MIN_MATCH_SIZE..MATCH_SIZE { + let entries: Vec = + (0..pool).map(|i| make_entry(i as f64 * 5.0)).collect(); + let selected = select_match(&mut seeded_rng(), &entries, pool.min(MATCH_SIZE)); + assert_eq!(selected.len(), pool); + let unique: std::collections::HashSet = + selected.iter().map(|e| e.battlesnake_id).collect(); + assert_eq!(unique.len(), pool, "short-handed picks must be unique"); + } + } + #[test] fn test_select_match_exactly_enough() { let entries: Vec = (0..4).map(|i| make_entry(i as f64 * 5.0)).collect(); @@ -242,4 +262,74 @@ mod tests { let unique: std::collections::HashSet = ids.iter().copied().collect(); assert_eq!(ids.len(), unique.len(), "Selected snakes should be unique"); } + + async fn leaderboard_with_snakes(pool: &sqlx::PgPool, snake_count: usize) -> cja::Result { + let user_id = sqlx::query_scalar!( + "INSERT INTO users (external_github_id, github_login, github_access_token) + VALUES (88001, 'mm-owner', 'test-token') + RETURNING user_id", + ) + .fetch_one(pool) + .await?; + let leaderboard_id = sqlx::query_scalar!( + "INSERT INTO leaderboards (name) VALUES ('mm-test') RETURNING leaderboard_id", + ) + .fetch_one(pool) + .await?; + for i in 0..snake_count { + let battlesnake_id = sqlx::query_scalar!( + "INSERT INTO battlesnakes (user_id, name, url) + VALUES ($1, $2, 'http://example.com/snake') + RETURNING battlesnake_id", + user_id, + format!("mm-snake-{i}"), + ) + .fetch_one(pool) + .await?; + leaderboard::get_or_create_entry(pool, leaderboard_id, battlesnake_id).await?; + } + Ok(leaderboard_id) + } + + async fn game_sizes(pool: &sqlx::PgPool, leaderboard_id: Uuid) -> cja::Result> { + // Matchmaker rows carry leaderboard_entry_id only (battlesnake_id + // stays NULL and is resolved via JOIN), so count rows, not that column. + let sizes = sqlx::query_scalar!( + r#"SELECT COUNT(*) as "size!" + FROM leaderboard_games lg + JOIN game_battlesnakes gb ON gb.game_id = lg.game_id + WHERE lg.leaderboard_id = $1 + GROUP BY lg.game_id"#, + leaderboard_id, + ) + .fetch_all(pool) + .await?; + Ok(sizes) + } + + /// A pool short of MATCH_SIZE still gets games — sized to the pool. + #[sqlx::test(migrations = "../migrations")] + async fn matchmaker_creates_short_handed_games(pool: sqlx::PgPool) -> cja::Result<()> { + let app_state = crate::state::AppState::test_from_pool(pool.clone()); + let leaderboard_id = leaderboard_with_snakes(&pool, 3).await?; + + run_matchmaker_for_leaderboard(&app_state, leaderboard_id).await?; + + let sizes = game_sizes(&pool, leaderboard_id).await?; + assert!(!sizes.is_empty(), "3 enabled snakes must produce games"); + assert!(sizes.iter().all(|&s| s == 3), "games use the whole pool"); + Ok(()) + } + + /// Below MIN_MATCH_SIZE the matchmaker pauses instead of erroring. + #[sqlx::test(migrations = "../migrations")] + async fn matchmaker_pauses_below_min_match_size(pool: sqlx::PgPool) -> cja::Result<()> { + let app_state = crate::state::AppState::test_from_pool(pool.clone()); + let leaderboard_id = leaderboard_with_snakes(&pool, 1).await?; + + run_matchmaker_for_leaderboard(&app_state, leaderboard_id).await?; + + assert!(game_sizes(&pool, leaderboard_id).await?.is_empty()); + Ok(()) + } } diff --git a/server/src/models/leaderboard.rs b/server/src/models/leaderboard.rs index 0a77a69..c546517 100644 --- a/server/src/models/leaderboard.rs +++ b/server/src/models/leaderboard.rs @@ -5,6 +5,10 @@ use uuid::Uuid; /// Application constants for leaderboard configuration pub const MATCH_SIZE: usize = 4; +/// Smallest game the matchmaker will create when fewer than MATCH_SIZE +/// snakes are enabled. Below this the ladder is starved and matchmaking +/// pauses entirely. +pub const MIN_MATCH_SIZE: usize = 2; pub const MIN_GAMES_FOR_RANKING: i32 = 10; pub const GAMES_PER_DAY: i32 = 100; diff --git a/server/src/routes/leaderboard.rs b/server/src/routes/leaderboard.rs index 748d03a..5bcf14d 100644 --- a/server/src/routes/leaderboard.rs +++ b/server/src/routes/leaderboard.rs @@ -173,11 +173,21 @@ pub async fn show_leaderboard( vec![] }; - // Compute next matchmaker run time - let next_run_str = status.last_game_created_at.map(|last| { - let next_run = last + chrono::Duration::seconds(MATCHMAKER_INTERVAL_SECS as i64); - HumanTime::from(next_run).to_string() - }); + // Compute next matchmaker run time. When the ladder is starved the + // matchmaker no-ops, so "last game + interval" would show an ever-staler + // past timestamp — say why matchmaking is paused instead. + let enabled_count = leaderboard::get_active_entries(&state.db, leaderboard_id) + .await + .wrap_err("Failed to fetch active entries")? + .len(); + let next_run_str = if enabled_count < leaderboard::MIN_MATCH_SIZE { + None + } else { + status.last_game_created_at.map(|last| { + let next_run = last + chrono::Duration::seconds(MATCHMAKER_INTERVAL_SECS as i64); + HumanTime::from(next_run).to_string() + }) + }; let rank_start = page * per_page; let sort_param = pagination.sort.as_str(); @@ -259,7 +269,15 @@ pub async fn show_leaderboard( div class="stat" { div class="label" { "Next matchmaker run" } div class="value sm" { - @if let Some(ref next) = next_run_str { + @if enabled_count < leaderboard::MIN_MATCH_SIZE { + // "waiting", not "paused": entry status badges say + // "Paused" and e2e locators match text loosely. + "waiting for at least " + (leaderboard::MIN_MATCH_SIZE) + " healthy snakes (" + (enabled_count) + " enabled)" + } @else if let Some(ref next) = next_run_str { (next) } @else { "waiting for first games"