diff --git a/Cargo.lock b/Cargo.lock index 84b480b..da550ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -203,6 +203,7 @@ dependencies = [ "serde_json", "sha2", "simple_asn1", + "skillratings", "sqlx", "tabled", "tempfile", @@ -4314,6 +4315,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "skillratings" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a6ee7559737c1adcd9184f168a04dc360c84878907c3ecc5c33c2320be1d47a" + [[package]] name = "slab" version = "0.4.9" diff --git a/e2e/tests/leaderboard.spec.ts b/e2e/tests/leaderboard.spec.ts new file mode 100644 index 0000000..a9e9ece --- /dev/null +++ b/e2e/tests/leaderboard.spec.ts @@ -0,0 +1,327 @@ +import { test, expect, createMockUser } from '../fixtures/test'; +import { query } from '../fixtures/db'; + +test.describe('Leaderboard Pages', () => { + test('leaderboard list page renders with seeded leaderboard', async ({ authenticatedPage }) => { + await authenticatedPage.goto('/leaderboards'); + + await expect(authenticatedPage.getByRole('heading', { name: 'Leaderboards' })).toBeVisible(); + // The seed migration creates "Standard 11x11" + await expect(authenticatedPage.getByText('Standard 11x11')).toBeVisible(); + await expect(authenticatedPage.getByText('Active')).toBeVisible(); + }); + + test('leaderboard detail page shows rankings and placement sections', async ({ authenticatedPage }) => { + // Get the seeded leaderboard ID + const leaderboards = await query<{ leaderboard_id: string }>( + "SELECT leaderboard_id FROM leaderboards WHERE name = 'Standard 11x11'" + ); + expect(leaderboards.length).toBe(1); + const leaderboardId = leaderboards[0].leaderboard_id; + + await authenticatedPage.goto(`/leaderboards/${leaderboardId}`); + + await expect(authenticatedPage.getByRole('heading', { name: /Leaderboard: Standard 11x11/ })).toBeVisible(); + await expect(authenticatedPage.getByRole('heading', { name: 'Rankings' })).toBeVisible(); + // With no participants, should show the minimum games message + await expect(authenticatedPage.getByText(/Minimum: 10 games/)).toBeVisible(); + }); + + test('can join a leaderboard with a public snake', async ({ authenticatedPage }) => { + const snakeName = `LB Join Snake ${Date.now()}`; + + // Create a public battlesnake + await authenticatedPage.goto('/battlesnakes/new'); + await authenticatedPage.getByLabel('Name').fill(snakeName); + await authenticatedPage.getByLabel('URL').fill('https://example.com/lb-join'); + await authenticatedPage.getByLabel('Visibility').selectOption('public'); + await authenticatedPage.getByRole('button', { name: 'Create Battlesnake' }).click(); + + // Get the seeded leaderboard + const leaderboards = await query<{ leaderboard_id: string }>( + "SELECT leaderboard_id FROM leaderboards WHERE name = 'Standard 11x11'" + ); + const leaderboardId = leaderboards[0].leaderboard_id; + + // Visit leaderboard detail page + await authenticatedPage.goto(`/leaderboards/${leaderboardId}`); + + // Should see the "Your Snakes" section with the join form + await expect(authenticatedPage.getByRole('heading', { name: 'Your Snakes' })).toBeVisible(); + + // Select the snake and join + await authenticatedPage.getByRole('button', { name: 'Join' }).click(); + + // After joining, should see the snake listed as Active + await expect(authenticatedPage.getByRole('cell', { name: snakeName })).toBeVisible(); + await expect(authenticatedPage.getByText('Active')).toBeVisible(); + }); + + test('can pause and resume a snake in a leaderboard', async ({ authenticatedPage }) => { + const snakeName = `LB Pause Snake ${Date.now()}`; + + // Create a public battlesnake + await authenticatedPage.goto('/battlesnakes/new'); + await authenticatedPage.getByLabel('Name').fill(snakeName); + await authenticatedPage.getByLabel('URL').fill('https://example.com/lb-pause'); + await authenticatedPage.getByLabel('Visibility').selectOption('public'); + await authenticatedPage.getByRole('button', { name: 'Create Battlesnake' }).click(); + + // Get the seeded leaderboard + const leaderboards = await query<{ leaderboard_id: string }>( + "SELECT leaderboard_id FROM leaderboards WHERE name = 'Standard 11x11'" + ); + const leaderboardId = leaderboards[0].leaderboard_id; + + // Join the leaderboard + await authenticatedPage.goto(`/leaderboards/${leaderboardId}`); + await authenticatedPage.getByRole('button', { name: 'Join' }).click(); + await expect(authenticatedPage.getByRole('cell', { name: snakeName })).toBeVisible(); + + // Pause the snake + await authenticatedPage.getByRole('button', { name: 'Pause' }).click(); + await expect(authenticatedPage.getByText('Paused')).toBeVisible(); + + // Resume the snake + await authenticatedPage.getByRole('button', { name: 'Resume' }).click(); + await expect(authenticatedPage.getByText('Active')).toBeVisible(); + }); + + test('private snakes cannot join leaderboard', async ({ authenticatedPage }) => { + const snakeName = `LB Private Snake ${Date.now()}`; + + // Create a private battlesnake + await authenticatedPage.goto('/battlesnakes/new'); + await authenticatedPage.getByLabel('Name').fill(snakeName); + await authenticatedPage.getByLabel('URL').fill('https://example.com/lb-private'); + await authenticatedPage.getByLabel('Visibility').selectOption('private'); + await authenticatedPage.getByRole('button', { name: 'Create Battlesnake' }).click(); + + // Get the seeded leaderboard + const leaderboards = await query<{ leaderboard_id: string }>( + "SELECT leaderboard_id FROM leaderboards WHERE name = 'Standard 11x11'" + ); + const leaderboardId = leaderboards[0].leaderboard_id; + + // Visit leaderboard detail page - private snake should not appear in the join dropdown + await authenticatedPage.goto(`/leaderboards/${leaderboardId}`); + + // The join form only shows public snakes, so the private snake name should NOT + // appear as an option. The join button might not be visible at all if no public snakes exist. + // We verify by checking the snake name is not in a select option. + const selectOptions = authenticatedPage.locator('select[name="battlesnake_id"] option'); + const count = await selectOptions.count(); + for (let i = 0; i < count; i++) { + const text = await selectOptions.nth(i).textContent(); + expect(text).not.toBe(snakeName); + } + }); + + test('placement entries show games remaining', async ({ authenticatedPage }) => { + const snakeName = `LB Placement Snake ${Date.now()}`; + + // Create a public battlesnake + await authenticatedPage.goto('/battlesnakes/new'); + await authenticatedPage.getByLabel('Name').fill(snakeName); + await authenticatedPage.getByLabel('URL').fill('https://example.com/lb-placement'); + await authenticatedPage.getByLabel('Visibility').selectOption('public'); + await authenticatedPage.getByRole('button', { name: 'Create Battlesnake' }).click(); + + // Get leaderboard and snake IDs + const leaderboards = await query<{ leaderboard_id: string }>( + "SELECT leaderboard_id FROM leaderboards WHERE name = 'Standard 11x11'" + ); + const leaderboardId = leaderboards[0].leaderboard_id; + + // Join via UI + await authenticatedPage.goto(`/leaderboards/${leaderboardId}`); + await authenticatedPage.getByRole('button', { name: 'Join' }).click(); + + // The snake should appear in the "In Placement" section (0 games played) + await expect(authenticatedPage.getByRole('heading', { name: 'In Placement' })).toBeVisible(); + await expect(authenticatedPage.getByRole('cell', { name: snakeName })).toBeVisible(); + // Games remaining should be 10 (MIN_GAMES_FOR_RANKING - 0 games played) + await expect(authenticatedPage.getByRole('cell', { name: '10', exact: true })).toBeVisible(); + }); +}); + +test.describe('Leaderboard API', () => { + test('GET /api/leaderboards returns leaderboard list', async ({ authenticatedPage }) => { + const response = await authenticatedPage.request.get('/api/leaderboards'); + expect(response.status()).toBe(200); + + const leaderboards = await response.json(); + expect(Array.isArray(leaderboards)).toBe(true); + expect(leaderboards.length).toBeGreaterThanOrEqual(1); + + // The seeded leaderboard should be present + const standard = leaderboards.find((lb: { name: string }) => lb.name === 'Standard 11x11'); + expect(standard).toBeDefined(); + expect(standard.active).toBe(true); + }); + + test('GET /api/leaderboards/:id/rankings returns rankings', async ({ authenticatedPage }) => { + // Get the seeded leaderboard + const leaderboards = await query<{ leaderboard_id: string }>( + "SELECT leaderboard_id FROM leaderboards WHERE name = 'Standard 11x11'" + ); + const leaderboardId = leaderboards[0].leaderboard_id; + + const response = await authenticatedPage.request.get(`/api/leaderboards/${leaderboardId}/rankings`); + expect(response.status()).toBe(200); + + const data = await response.json(); + expect(data.leaderboard_id).toBe(leaderboardId); + expect(data.leaderboard_name).toBe('Standard 11x11'); + expect(data.min_games).toBe(10); + expect(Array.isArray(data.ranked)).toBe(true); + expect(Array.isArray(data.placement)).toBe(true); + }); + + test('POST /api/leaderboards/:id/entries opts in a snake', async ({ authenticatedPage }) => { + const snakeName = `API LB Snake ${Date.now()}`; + + // Create a public battlesnake via UI + await authenticatedPage.goto('/battlesnakes/new'); + await authenticatedPage.getByLabel('Name').fill(snakeName); + await authenticatedPage.getByLabel('URL').fill('https://example.com/api-lb'); + await authenticatedPage.getByLabel('Visibility').selectOption('public'); + await authenticatedPage.getByRole('button', { name: 'Create Battlesnake' }).click(); + + // Get snake and leaderboard IDs + const snakes = await query<{ battlesnake_id: string }>( + "SELECT battlesnake_id FROM battlesnakes WHERE name = $1", + [snakeName] + ); + const snakeId = snakes[0].battlesnake_id; + + const leaderboards = await query<{ leaderboard_id: string }>( + "SELECT leaderboard_id FROM leaderboards WHERE name = 'Standard 11x11'" + ); + const leaderboardId = leaderboards[0].leaderboard_id; + + // Opt-in via API + const response = await authenticatedPage.request.post(`/api/leaderboards/${leaderboardId}/entries`, { + data: { battlesnake_id: snakeId } + }); + + expect(response.status()).toBe(201); + const entry = await response.json(); + expect(entry.battlesnake_id).toBe(snakeId); + expect(entry.display_score).toBe(0.0); + expect(entry.games_played).toBe(0); + expect(entry.active).toBe(true); + }); + + test('DELETE /api/leaderboards/:id/entries/:battlesnake_id pauses a snake', async ({ authenticatedPage }) => { + const snakeName = `API LB Delete Snake ${Date.now()}`; + + // Create a public battlesnake + await authenticatedPage.goto('/battlesnakes/new'); + await authenticatedPage.getByLabel('Name').fill(snakeName); + await authenticatedPage.getByLabel('URL').fill('https://example.com/api-lb-delete'); + await authenticatedPage.getByLabel('Visibility').selectOption('public'); + await authenticatedPage.getByRole('button', { name: 'Create Battlesnake' }).click(); + + const snakes = await query<{ battlesnake_id: string }>( + "SELECT battlesnake_id FROM battlesnakes WHERE name = $1", + [snakeName] + ); + const snakeId = snakes[0].battlesnake_id; + + const leaderboards = await query<{ leaderboard_id: string }>( + "SELECT leaderboard_id FROM leaderboards WHERE name = 'Standard 11x11'" + ); + const leaderboardId = leaderboards[0].leaderboard_id; + + // Opt-in first + const optInResponse = await authenticatedPage.request.post(`/api/leaderboards/${leaderboardId}/entries`, { + data: { battlesnake_id: snakeId } + }); + expect(optInResponse.status()).toBe(201); + + // Opt-out (pause) via DELETE + const deleteResponse = await authenticatedPage.request.delete( + `/api/leaderboards/${leaderboardId}/entries/${snakeId}` + ); + expect(deleteResponse.status()).toBe(204); + + // Verify the entry is now disabled in the database + const entries = await query<{ disabled_at: string | null }>( + "SELECT disabled_at FROM leaderboard_entries WHERE leaderboard_id = $1 AND battlesnake_id = $2", + [leaderboardId, snakeId] + ); + expect(entries.length).toBe(1); + expect(entries[0].disabled_at).not.toBeNull(); + }); + + test('cannot opt-in a private snake via API', async ({ authenticatedPage }) => { + const snakeName = `API LB Private Snake ${Date.now()}`; + + // Create a private battlesnake + await authenticatedPage.goto('/battlesnakes/new'); + await authenticatedPage.getByLabel('Name').fill(snakeName); + await authenticatedPage.getByLabel('URL').fill('https://example.com/api-lb-private'); + await authenticatedPage.getByLabel('Visibility').selectOption('private'); + await authenticatedPage.getByRole('button', { name: 'Create Battlesnake' }).click(); + + const snakes = await query<{ battlesnake_id: string }>( + "SELECT battlesnake_id FROM battlesnakes WHERE name = $1", + [snakeName] + ); + const snakeId = snakes[0].battlesnake_id; + + const leaderboards = await query<{ leaderboard_id: string }>( + "SELECT leaderboard_id FROM leaderboards WHERE name = 'Standard 11x11'" + ); + const leaderboardId = leaderboards[0].leaderboard_id; + + const response = await authenticatedPage.request.post(`/api/leaderboards/${leaderboardId}/entries`, { + data: { battlesnake_id: snakeId } + }); + + expect(response.status()).toBe(400); + const body = await response.text(); + expect(body).toContain('public'); + }); + + test('cannot opt-in another user\'s snake via API', async ({ authenticatedPage, loginAsUser }) => { + const snakeName = `API LB Other Snake ${Date.now()}`; + + // First user creates a public battlesnake + await authenticatedPage.goto('/battlesnakes/new'); + await authenticatedPage.getByLabel('Name').fill(snakeName); + await authenticatedPage.getByLabel('URL').fill('https://example.com/api-lb-other'); + await authenticatedPage.getByLabel('Visibility').selectOption('public'); + await authenticatedPage.getByRole('button', { name: 'Create Battlesnake' }).click(); + + const snakes = await query<{ battlesnake_id: string }>( + "SELECT battlesnake_id FROM battlesnakes WHERE name = $1", + [snakeName] + ); + const snakeId = snakes[0].battlesnake_id; + + const leaderboards = await query<{ leaderboard_id: string }>( + "SELECT leaderboard_id FROM leaderboards WHERE name = 'Standard 11x11'" + ); + const leaderboardId = leaderboards[0].leaderboard_id; + + // Logout and login as second user + await authenticatedPage.goto('/auth/logout'); + const secondUser = createMockUser('lb_other_user'); + await loginAsUser(authenticatedPage, secondUser); + + // Try to opt-in first user's snake + const response = await authenticatedPage.request.post(`/api/leaderboards/${leaderboardId}/entries`, { + data: { battlesnake_id: snakeId } + }); + + expect(response.status()).toBe(403); + }); + + test('GET /api/leaderboards/:id/rankings returns 404 for non-existent leaderboard', async ({ authenticatedPage }) => { + const fakeId = '00000000-0000-0000-0000-000000000000'; + const response = await authenticatedPage.request.get(`/api/leaderboards/${fakeId}/rankings`); + expect(response.status()).toBe(404); + }); +}); diff --git a/migrations/20260221122251_create_leaderboards.down.sql b/migrations/20260221122251_create_leaderboards.down.sql new file mode 100644 index 0000000..6c5de66 --- /dev/null +++ b/migrations/20260221122251_create_leaderboards.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS leaderboard_game_results; +DROP TABLE IF EXISTS leaderboard_games; +DROP TABLE IF EXISTS leaderboard_entries; +DROP TABLE IF EXISTS leaderboards; diff --git a/migrations/20260221122251_create_leaderboards.up.sql b/migrations/20260221122251_create_leaderboards.up.sql new file mode 100644 index 0000000..cd28d0c --- /dev/null +++ b/migrations/20260221122251_create_leaderboards.up.sql @@ -0,0 +1,65 @@ +-- Leaderboards table +CREATE TABLE leaderboards ( + leaderboard_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + disabled_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TRIGGER update_leaderboards_updated_at + BEFORE UPDATE ON leaderboards + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Leaderboard entries: one row per snake per leaderboard +CREATE TABLE leaderboard_entries ( + leaderboard_entry_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + leaderboard_id UUID NOT NULL REFERENCES leaderboards(leaderboard_id) ON DELETE CASCADE, + battlesnake_id UUID NOT NULL REFERENCES battlesnakes(battlesnake_id) ON DELETE CASCADE, + mu DOUBLE PRECISION NOT NULL DEFAULT 25.0, + sigma DOUBLE PRECISION NOT NULL DEFAULT 8.333, + display_score DOUBLE PRECISION NOT NULL DEFAULT 0.0, + games_played INT NOT NULL DEFAULT 0, + wins INT NOT NULL DEFAULT 0, + losses INT NOT NULL DEFAULT 0, + disabled_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (leaderboard_id, battlesnake_id) +); + +CREATE TRIGGER update_leaderboard_entries_updated_at + BEFORE UPDATE ON leaderboard_entries + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Partial index for ranking queries on active entries +CREATE INDEX idx_leaderboard_entries_ranking + ON leaderboard_entries (leaderboard_id, display_score DESC) + WHERE disabled_at IS NULL; + +-- Leaderboard games: links a game to a leaderboard +CREATE TABLE leaderboard_games ( + leaderboard_game_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + leaderboard_id UUID NOT NULL REFERENCES leaderboards(leaderboard_id) ON DELETE CASCADE, + game_id UUID NOT NULL UNIQUE REFERENCES games(game_id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Leaderboard game results: per-snake rating changes (audit trail) +CREATE TABLE leaderboard_game_results ( + leaderboard_game_result_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + leaderboard_game_id UUID NOT NULL REFERENCES leaderboard_games(leaderboard_game_id) ON DELETE CASCADE, + leaderboard_entry_id UUID NOT NULL REFERENCES leaderboard_entries(leaderboard_entry_id) ON DELETE CASCADE, + placement INT NOT NULL, + mu_before DOUBLE PRECISION NOT NULL, + mu_after DOUBLE PRECISION NOT NULL, + sigma_before DOUBLE PRECISION NOT NULL, + sigma_after DOUBLE PRECISION NOT NULL, + display_score_change DOUBLE PRECISION NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Seed the initial Standard 11x11 leaderboard +INSERT INTO leaderboards (name) VALUES ('Standard 11x11'); diff --git a/migrations/20260221220000_leaderboard_review_fixes.down.sql b/migrations/20260221220000_leaderboard_review_fixes.down.sql new file mode 100644 index 0000000..43fda29 --- /dev/null +++ b/migrations/20260221220000_leaderboard_review_fixes.down.sql @@ -0,0 +1,7 @@ +ALTER TABLE leaderboard_entries RENAME COLUMN first_place_finishes TO wins; +ALTER TABLE leaderboard_entries RENAME COLUMN non_first_finishes TO losses; + +DROP INDEX IF EXISTS idx_leaderboard_game_results_game_id; + +ALTER TABLE leaderboard_game_results + DROP CONSTRAINT IF EXISTS uq_leaderboard_game_results_game_entry; diff --git a/migrations/20260221220000_leaderboard_review_fixes.up.sql b/migrations/20260221220000_leaderboard_review_fixes.up.sql new file mode 100644 index 0000000..8194660 --- /dev/null +++ b/migrations/20260221220000_leaderboard_review_fixes.up.sql @@ -0,0 +1,14 @@ +-- Add UNIQUE constraint on leaderboard_game_results to prevent duplicate rating applications +-- This guards against idempotency bugs from job retries +ALTER TABLE leaderboard_game_results + ADD CONSTRAINT uq_leaderboard_game_results_game_entry + UNIQUE (leaderboard_game_id, leaderboard_entry_id); + +-- Add index on leaderboard_game_results.leaderboard_game_id for lookups +CREATE INDEX idx_leaderboard_game_results_game_id + ON leaderboard_game_results (leaderboard_game_id); + +-- Rename wins/losses to first_place_finishes/non_first_finishes +-- In a 4-player game, only 1st place counts as a "win" — calling 2nd place a "loss" is misleading +ALTER TABLE leaderboard_entries RENAME COLUMN wins TO first_place_finishes; +ALTER TABLE leaderboard_entries RENAME COLUMN losses TO non_first_finishes; diff --git a/server/Cargo.toml b/server/Cargo.toml index 1fe55fa..6f4997b 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -79,6 +79,7 @@ tabled = "0.17" colored = "2" chrono-humanize = "0.2" +skillratings = "0.28" battlesnake-game-types = { git = "https://github.com/fables-tales/battlesnake-game-types", branch = "main" } tokio-util = { version = "0.7.14", features = ["rt"] } rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/server/src/cron.rs b/server/src/cron.rs index 8727727..ae7b2cb 100644 --- a/server/src/cron.rs +++ b/server/src/cron.rs @@ -3,9 +3,12 @@ use std::time::Duration; use cja::cron::{CronRegistry, Worker}; use tokio_util::sync::CancellationToken; -use crate::jobs::GameBackupJob; +use crate::jobs::{GameBackupJob, LeaderboardMatchmakerJob}; use crate::state::AppState; +/// Matchmaker cron interval in seconds. Shared with the matchmaker to compute games_per_run. +pub const MATCHMAKER_INTERVAL_SECS: u64 = 15 * 60; + fn cron_registry() -> CronRegistry { let mut registry = CronRegistry::new(); @@ -16,6 +19,13 @@ fn cron_registry() -> CronRegistry { Duration::from_secs(60 * 60), ); + // Leaderboard matchmaker: runs every 15 minutes, creates match games + registry.register_job( + LeaderboardMatchmakerJob, + Some("Create leaderboard match games"), + Duration::from_secs(MATCHMAKER_INTERVAL_SECS), + ); + registry } diff --git a/server/src/game_runner.rs b/server/src/game_runner.rs index d82e732..c40aff1 100644 --- a/server/src/game_runner.rs +++ b/server/src/game_runner.rs @@ -268,6 +268,28 @@ pub async fn run_game(app_state: &AppState, game_id: Uuid) -> cja::Result<()> { "game completed" ); + // Check if this is a leaderboard game and enqueue rating update + if let Some(lb_game) = + crate::models::leaderboard::find_leaderboard_game_by_game_id(pool, game_id).await? + { + let job = crate::jobs::LeaderboardRatingUpdateJob { + leaderboard_game_id: lb_game.leaderboard_game_id, + }; + cja::jobs::Job::enqueue( + job, + app_state.clone(), + format!("Rate leaderboard game {game_id}"), + ) + .await + .wrap_err("Failed to enqueue leaderboard rating update job")?; + + tracing::info!( + game_id = %game_id, + leaderboard_game_id = %lb_game.leaderboard_game_id, + "Enqueued leaderboard rating update" + ); + } + // Clean up game channel (will be removed when no subscribers) game_channels.cleanup(game_id).await; diff --git a/server/src/jobs.rs b/server/src/jobs.rs index 454aa10..87ebb19 100644 --- a/server/src/jobs.rs +++ b/server/src/jobs.rs @@ -93,11 +93,44 @@ impl Job for HistoricalBackupDiscoveryJob { } } +/// Cron job to create leaderboard match games. +/// Runs every 15 minutes, creating games for active leaderboards. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LeaderboardMatchmakerJob; + +#[async_trait::async_trait] +impl Job for LeaderboardMatchmakerJob { + const NAME: &'static str = "LeaderboardMatchmakerJob"; + + async fn run(&self, app_state: AppState) -> cja::Result<()> { + crate::leaderboard_matchmaker::run_matchmaker(&app_state).await?; + Ok(()) + } +} + +/// Job to update ratings after a leaderboard game completes. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LeaderboardRatingUpdateJob { + pub leaderboard_game_id: Uuid, +} + +#[async_trait::async_trait] +impl Job for LeaderboardRatingUpdateJob { + const NAME: &'static str = "LeaderboardRatingUpdateJob"; + + async fn run(&self, app_state: AppState) -> cja::Result<()> { + crate::leaderboard_ratings::update_ratings(&app_state, self.leaderboard_game_id).await?; + Ok(()) + } +} + cja::impl_job_registry!( AppState, NoopJob, GameRunnerJob, GameBackupJob, BackupSingleGameJob, - HistoricalBackupDiscoveryJob + HistoricalBackupDiscoveryJob, + LeaderboardMatchmakerJob, + LeaderboardRatingUpdateJob ); diff --git a/server/src/leaderboard_matchmaker.rs b/server/src/leaderboard_matchmaker.rs new file mode 100644 index 0000000..191e5f1 --- /dev/null +++ b/server/src/leaderboard_matchmaker.rs @@ -0,0 +1,234 @@ +use color_eyre::eyre::Context as _; +use uuid::Uuid; + +use crate::{ + cron::MATCHMAKER_INTERVAL_SECS, + jobs::GameRunnerJob, + models::{ + game::{self, CreateGameWithSnakes, GameBoardSize, GameType}, + leaderboard::{self, GAMES_PER_DAY, LeaderboardEntry, MATCH_SIZE}, + }, + state::AppState, +}; + +/// Run the matchmaker for all active leaderboards +pub async fn run_matchmaker(app_state: &AppState) -> cja::Result<()> { + let pool = &app_state.db; + + let leaderboards = leaderboard::get_active_leaderboards(pool) + .await + .wrap_err("Failed to fetch active leaderboards")?; + + for lb in &leaderboards { + if let Err(e) = run_matchmaker_for_leaderboard(app_state, lb.leaderboard_id).await { + tracing::error!( + leaderboard_id = %lb.leaderboard_id, + leaderboard_name = %lb.name, + error = ?e, + "Failed to run matchmaker for leaderboard" + ); + } + } + + Ok(()) +} + +async fn run_matchmaker_for_leaderboard( + app_state: &AppState, + leaderboard_id: Uuid, +) -> cja::Result<()> { + let pool = &app_state.db; + + let entries = leaderboard::get_active_entries(pool, leaderboard_id) + .await + .wrap_err("Failed to fetch active entries")?; + + if entries.len() < MATCH_SIZE { + tracing::debug!( + leaderboard_id = %leaderboard_id, + active_snakes = entries.len(), + "Not enough active snakes for matchmaking (need {})", + MATCH_SIZE + ); + return Ok(()); + } + + // Calculate how many games to create this run + // Derived from shared cron interval constant to avoid manual sync bugs + let runs_per_day = (24 * 60 * 60 / MATCHMAKER_INTERVAL_SECS) as i32; + let games_per_run = ((GAMES_PER_DAY + runs_per_day - 1) / runs_per_day).max(1); + + tracing::info!( + leaderboard_id = %leaderboard_id, + active_snakes = entries.len(), + 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 { + break; + } + + let battlesnake_ids: Vec = selected.iter().map(|e| e.battlesnake_id).collect(); + + // Use a transaction to atomically create the game, link it to the leaderboard, + // and set enqueued_at. This prevents "zombie" games without a leaderboard record. + let mut tx = pool + .begin() + .await + .wrap_err("Failed to start matchmaker transaction")?; + + let game = game::create_game_with_snakes_tx( + &mut tx, + CreateGameWithSnakes { + board_size: GameBoardSize::Medium, // 11x11 + game_type: GameType::Standard, + battlesnake_ids, + }, + ) + .await + .wrap_err("Failed to create leaderboard game")?; + + game::set_game_enqueued_at_tx(&mut tx, game.game_id, chrono::Utc::now()) + .await + .wrap_err("Failed to set enqueued_at")?; + + leaderboard::create_leaderboard_game(&mut *tx, leaderboard_id, game.game_id) + .await + .wrap_err("Failed to create leaderboard game record")?; + + tx.commit() + .await + .wrap_err("Failed to commit matchmaker transaction")?; + + // Enqueue outside the transaction — if this fails, the game + leaderboard record + // still exist (consistent state). The game can be retried or discovered by a poller. + let job = GameRunnerJob { + game_id: game.game_id, + }; + cja::jobs::Job::enqueue( + job, + app_state.clone(), + format!("Leaderboard game {}", game.game_id), + ) + .await + .wrap_err("Failed to enqueue game runner job")?; + + tracing::info!( + leaderboard_id = %leaderboard_id, + game_id = %game.game_id, + "Created leaderboard match game" + ); + } + + Ok(()) +} + +/// Select snakes for a match using skill-band matching with jitter. +/// Picks a random seed snake, then selects nearest neighbors by score. +/// Accepts an RNG parameter for test determinism. +/// +/// TODO: Add recently-matched deprioritization to prevent the same group of snakes +/// from being matched repeatedly in low-volume periods. +fn select_match( + rng: &mut impl rand::Rng, + entries: &[LeaderboardEntry], + match_size: usize, +) -> Vec { + if entries.len() < match_size { + return vec![]; + } + + // Sort by display_score + let mut sorted: Vec<&LeaderboardEntry> = entries.iter().collect(); + sorted.sort_by(|a, b| { + b.display_score + .partial_cmp(&a.display_score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + // Pick a random seed snake + let seed_idx = rng.gen_range(0..sorted.len()); + let seed_score = sorted[seed_idx].display_score; + + // Score each snake by distance to seed, with jitter for variety + let mut candidates: Vec<(usize, f64)> = sorted + .iter() + .enumerate() + .map(|(i, entry)| { + let distance = (entry.display_score - seed_score).abs(); + let jitter: f64 = rng.gen_range(0.0..5.0); + (i, distance + jitter) + }) + .collect(); + + // Sort by jittered distance (closest first) + candidates.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + + // Take the first match_size snakes + candidates + .into_iter() + .take(match_size) + .map(|(i, _)| sorted[i].clone()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + use uuid::Uuid; + + fn make_entry(display_score: f64) -> LeaderboardEntry { + LeaderboardEntry { + leaderboard_entry_id: Uuid::new_v4(), + leaderboard_id: Uuid::new_v4(), + battlesnake_id: Uuid::new_v4(), + mu: 25.0, + sigma: 8.333, + display_score, + games_played: 0, + first_place_finishes: 0, + non_first_finishes: 0, + disabled_at: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + } + } + + fn seeded_rng() -> rand::rngs::StdRng { + rand::rngs::StdRng::seed_from_u64(42) + } + + #[test] + fn test_select_match_returns_correct_size() { + let entries: Vec = (0..10).map(|i| make_entry(i as f64 * 5.0)).collect(); + let selected = select_match(&mut seeded_rng(), &entries, 4); + assert_eq!(selected.len(), 4); + } + + #[test] + fn test_select_match_too_few_entries() { + let entries: Vec = (0..3).map(|i| make_entry(i as f64 * 5.0)).collect(); + let selected = select_match(&mut seeded_rng(), &entries, 4); + assert!(selected.is_empty()); + } + + #[test] + fn test_select_match_exactly_enough() { + let entries: Vec = (0..4).map(|i| make_entry(i as f64 * 5.0)).collect(); + let selected = select_match(&mut seeded_rng(), &entries, 4); + assert_eq!(selected.len(), 4); + } + + #[test] + fn test_select_match_unique_snakes() { + let entries: Vec = (0..20).map(|i| make_entry(i as f64 * 2.0)).collect(); + let selected = select_match(&mut seeded_rng(), &entries, 4); + let ids: Vec = selected.iter().map(|e| e.battlesnake_id).collect(); + let unique: std::collections::HashSet = ids.iter().copied().collect(); + assert_eq!(ids.len(), unique.len(), "Selected snakes should be unique"); + } +} diff --git a/server/src/leaderboard_ratings.rs b/server/src/leaderboard_ratings.rs new file mode 100644 index 0000000..793533c --- /dev/null +++ b/server/src/leaderboard_ratings.rs @@ -0,0 +1,402 @@ +use color_eyre::eyre::Context as _; +use skillratings::MultiTeamOutcome; +use skillratings::weng_lin::{WengLinConfig, WengLinRating, weng_lin_multi_team}; +use uuid::Uuid; + +use crate::{ + models::{ + game_battlesnake, + leaderboard::{self, LeaderboardEntry, LeaderboardGame}, + }, + state::AppState, +}; + +/// Computed rating update for a single snake in a game. +/// Separated from DB logic for testability. +#[derive(Debug)] +pub struct RatingUpdate { + pub leaderboard_entry_id: Uuid, + pub battlesnake_id: Uuid, + pub placement: i32, + pub old_mu: f64, + pub old_sigma: f64, + pub new_mu: f64, + pub new_sigma: f64, + pub new_display_score: f64, + pub display_score_change: f64, + pub is_first_place: bool, +} + +/// Pure computation: calculate new ratings from entries and placements. +/// No DB access — fully testable. +pub fn calculate_rating_updates( + entries_with_placements: &[(LeaderboardEntry, i32)], +) -> Vec { + let config = WengLinConfig::new(); + + let ratings: Vec> = entries_with_placements + .iter() + .map(|(entry, _)| { + vec![WengLinRating { + rating: entry.mu, + uncertainty: entry.sigma, + }] + }) + .collect(); + + let teams_and_ranks: Vec<(&[WengLinRating], MultiTeamOutcome)> = ratings + .iter() + .zip(entries_with_placements.iter()) + .map(|(team, (_, placement))| (team.as_slice(), MultiTeamOutcome::new(*placement as usize))) + .collect(); + + let new_ratings = weng_lin_multi_team(&teams_and_ranks, &config); + + entries_with_placements + .iter() + .enumerate() + .map(|(i, (entry, placement))| { + let new_rating = &new_ratings[i][0]; + let new_mu = new_rating.rating; + let new_sigma = new_rating.uncertainty; + let new_display_score = new_mu - 3.0 * new_sigma; + let old_display_score = entry.mu - 3.0 * entry.sigma; + + RatingUpdate { + leaderboard_entry_id: entry.leaderboard_entry_id, + battlesnake_id: entry.battlesnake_id, + placement: *placement, + old_mu: entry.mu, + old_sigma: entry.sigma, + new_mu, + new_sigma, + new_display_score, + display_score_change: new_display_score - old_display_score, + is_first_place: *placement == 1, + } + }) + .collect() +} + +/// Update ratings for all snakes in a completed leaderboard game. +/// Idempotent: safe to call multiple times (e.g. job retries). +/// Uses a database transaction with row locking (FOR UPDATE) to prevent +/// race conditions when concurrent games finish for the same snakes. +pub async fn update_ratings(app_state: &AppState, leaderboard_game_id: Uuid) -> cja::Result<()> { + let pool = &app_state.db; + + // Idempotency check: bail if ratings were already applied for this game + let existing: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM leaderboard_game_results WHERE leaderboard_game_id = $1", + ) + .bind(leaderboard_game_id) + .fetch_one(pool) + .await + .wrap_err("Failed to check existing game results")?; + + if existing.0 > 0 { + tracing::info!( + leaderboard_game_id = %leaderboard_game_id, + "Ratings already applied for this game, skipping" + ); + return Ok(()); + } + + // Fetch the leaderboard game (outside transaction — immutable data) + let lb_game = sqlx::query_as::<_, LeaderboardGame>( + "SELECT leaderboard_game_id, leaderboard_id, game_id, created_at + FROM leaderboard_games + WHERE leaderboard_game_id = $1", + ) + .bind(leaderboard_game_id) + .fetch_one(pool) + .await + .wrap_err("Failed to fetch leaderboard game")?; + + // Fetch all game_battlesnakes with their placements (outside transaction — immutable after game finishes) + let game_snakes = game_battlesnake::get_battlesnakes_by_game_id(pool, lb_game.game_id).await?; + + if game_snakes.is_empty() { + tracing::warn!( + game_id = %lb_game.game_id, + "No snakes found for leaderboard game" + ); + return Ok(()); + } + + // Start a transaction for the rating update (locks entries to prevent concurrent overwrites) + let mut tx = pool + .begin() + .await + .wrap_err("Failed to start transaction for rating update")?; + + // Authoritative idempotency check INSIDE the transaction. + // The early check above is a fast-path optimization; this is the real guard + // against concurrent job execution (e.g., timeout-triggered retry while original still runs). + let existing_in_tx: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM leaderboard_game_results WHERE leaderboard_game_id = $1", + ) + .bind(leaderboard_game_id) + .fetch_one(&mut *tx) + .await + .wrap_err("Failed to check existing game results inside transaction")?; + + if existing_in_tx.0 > 0 { + tracing::info!( + leaderboard_game_id = %leaderboard_game_id, + "Ratings already applied (detected inside transaction), skipping" + ); + return Ok(()); + } + + // Look up each snake's leaderboard entry with FOR UPDATE to lock the rows + let mut entries_with_placements: Vec<(LeaderboardEntry, i32)> = Vec::new(); + + for gs in &game_snakes { + let placement = gs.placement.unwrap_or(game_snakes.len() as i32); + + let entry = + leaderboard::get_entry_for_update(&mut *tx, lb_game.leaderboard_id, gs.battlesnake_id) + .await + .wrap_err_with(|| { + format!( + "Failed to get leaderboard entry for snake {}", + gs.battlesnake_id + ) + })?; + + if let Some(entry) = entry { + entries_with_placements.push((entry, placement)); + } else { + tracing::warn!( + battlesnake_id = %gs.battlesnake_id, + leaderboard_id = %lb_game.leaderboard_id, + "Snake has no leaderboard entry, skipping" + ); + } + } + + if entries_with_placements.len() < 2 { + tracing::warn!( + game_id = %lb_game.game_id, + "Fewer than 2 snakes with leaderboard entries, skipping rating update" + ); + return Ok(()); + } + + // Calculate new ratings (pure computation, no DB) + let updates = calculate_rating_updates(&entries_with_placements); + + // Apply all updates within the transaction + for update in &updates { + // Record the result (audit trail, ON CONFLICT DO NOTHING for idempotency) + let _result = leaderboard::create_game_result( + &mut *tx, + leaderboard::CreateGameResult { + leaderboard_game_id, + leaderboard_entry_id: update.leaderboard_entry_id, + placement: update.placement, + mu_before: update.old_mu, + mu_after: update.new_mu, + sigma_before: update.old_sigma, + sigma_after: update.new_sigma, + display_score_change: update.display_score_change, + }, + ) + .await + .wrap_err("Failed to create game result record")?; + + // Update the entry's rating + leaderboard::update_rating( + &mut *tx, + update.leaderboard_entry_id, + update.new_mu, + update.new_sigma, + update.new_display_score, + update.is_first_place, + ) + .await + .wrap_err("Failed to update entry rating")?; + + tracing::debug!( + entry_id = %update.leaderboard_entry_id, + battlesnake_id = %update.battlesnake_id, + placement = update.placement, + mu = format!("{:.2} -> {:.2}", update.old_mu, update.new_mu), + sigma = format!("{:.2} -> {:.2}", update.old_sigma, update.new_sigma), + score_change = format!("{:+.2}", update.display_score_change), + "Updated rating" + ); + } + + // Commit the transaction — all rating updates are atomic + tx.commit() + .await + .wrap_err("Failed to commit rating update transaction")?; + + tracing::info!( + leaderboard_game_id = %leaderboard_game_id, + game_id = %lb_game.game_id, + snakes_updated = updates.len(), + "Ratings updated for leaderboard game" + ); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::leaderboard::LeaderboardEntry; + use uuid::Uuid; + + fn make_entry(mu: f64, sigma: f64) -> LeaderboardEntry { + LeaderboardEntry { + leaderboard_entry_id: Uuid::new_v4(), + leaderboard_id: Uuid::new_v4(), + battlesnake_id: Uuid::new_v4(), + mu, + sigma, + display_score: mu - 3.0 * sigma, + games_played: 5, + first_place_finishes: 2, + non_first_finishes: 3, + disabled_at: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + } + } + + #[test] + fn test_calculate_rating_updates_returns_correct_count() { + let entries = vec![ + (make_entry(25.0, 8.333), 1), + (make_entry(25.0, 8.333), 2), + (make_entry(25.0, 8.333), 3), + (make_entry(25.0, 8.333), 4), + ]; + + let updates = calculate_rating_updates(&entries); + assert_eq!(updates.len(), 4); + } + + #[test] + fn test_winner_gains_rating() { + let entries = vec![ + (make_entry(25.0, 8.333), 1), // winner + (make_entry(25.0, 8.333), 2), + (make_entry(25.0, 8.333), 3), + (make_entry(25.0, 8.333), 4), + ]; + + let updates = calculate_rating_updates(&entries); + + // Winner (placement 1) should gain mu + assert!( + updates[0].new_mu > updates[0].old_mu, + "Winner should gain mu: {} -> {}", + updates[0].old_mu, + updates[0].new_mu + ); + assert!(updates[0].is_first_place); + assert!(!updates[1].is_first_place); + } + + #[test] + fn test_last_place_loses_rating() { + let entries = vec![ + (make_entry(25.0, 8.333), 1), + (make_entry(25.0, 8.333), 2), + (make_entry(25.0, 8.333), 3), + (make_entry(25.0, 8.333), 4), // last place + ]; + + let updates = calculate_rating_updates(&entries); + + // Last place should lose mu + assert!( + updates[3].new_mu < updates[3].old_mu, + "Last place should lose mu: {} -> {}", + updates[3].old_mu, + updates[3].new_mu + ); + } + + #[test] + fn test_sigma_decreases_after_game() { + let entries = vec![ + (make_entry(25.0, 8.333), 1), + (make_entry(25.0, 8.333), 2), + (make_entry(25.0, 8.333), 3), + (make_entry(25.0, 8.333), 4), + ]; + + let updates = calculate_rating_updates(&entries); + + // All snakes should have reduced uncertainty after a game + for update in &updates { + assert!( + update.new_sigma < update.old_sigma, + "Sigma should decrease: {} -> {}", + update.old_sigma, + update.new_sigma + ); + } + } + + #[test] + fn test_display_score_calculation() { + let entries = vec![(make_entry(25.0, 8.333), 1), (make_entry(25.0, 8.333), 2)]; + + let updates = calculate_rating_updates(&entries); + + for update in &updates { + let expected_display = update.new_mu - 3.0 * update.new_sigma; + assert!( + (update.new_display_score - expected_display).abs() < f64::EPSILON, + "Display score should equal mu - 3*sigma" + ); + } + } + + #[test] + fn test_higher_rated_snake_loses_less_when_winning() { + // Strong snake beats weak snake — should gain less than if equal + let strong = make_entry(35.0, 5.0); + let weak = make_entry(15.0, 5.0); + + let expected_win = vec![(strong.clone(), 1), (weak.clone(), 2)]; + let updates = calculate_rating_updates(&expected_win); + let strong_gain = updates[0].new_mu - updates[0].old_mu; + + // Upset: weak beats strong — weak should gain more + let upset_win = vec![(weak, 1), (strong, 2)]; + let upset_updates = calculate_rating_updates(&upset_win); + let weak_upset_gain = upset_updates[0].new_mu - upset_updates[0].old_mu; + + assert!( + weak_upset_gain > strong_gain, + "Upset winner should gain more ({:.4}) than expected winner ({:.4})", + weak_upset_gain, + strong_gain + ); + } + + #[test] + fn test_preserves_entry_ids() { + let entries = vec![(make_entry(25.0, 8.333), 1), (make_entry(25.0, 8.333), 3)]; + + let updates = calculate_rating_updates(&entries); + + assert_eq!( + updates[0].leaderboard_entry_id, + entries[0].0.leaderboard_entry_id + ); + assert_eq!( + updates[1].leaderboard_entry_id, + entries[1].0.leaderboard_entry_id + ); + assert_eq!(updates[0].placement, 1); + assert_eq!(updates[1].placement, 3); + } +} diff --git a/server/src/main.rs b/server/src/main.rs index d71f830..c6783ed 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -20,6 +20,8 @@ mod game_channels; mod game_runner; mod github; mod jobs; +mod leaderboard_matchmaker; +mod leaderboard_ratings; mod models; mod routes; mod snake_client; diff --git a/server/src/models/game.rs b/server/src/models/game.rs index d48f6d4..aeeef05 100644 --- a/server/src/models/game.rs +++ b/server/src/models/game.rs @@ -264,6 +264,26 @@ pub async fn delete_game(pool: &PgPool, game_id: Uuid) -> cja::Result<()> { pub async fn create_game_with_snakes( pool: &PgPool, data: CreateGameWithSnakes, +) -> cja::Result { + let mut tx = pool + .begin() + .await + .wrap_err("Failed to start database transaction")?; + + let game = create_game_with_snakes_tx(&mut tx, data).await?; + + tx.commit() + .await + .wrap_err("Failed to commit database transaction")?; + + Ok(game) +} + +/// Create a game with battlesnakes using a mutable connection reference. +/// Use this when you need to compose game creation with other operations in a single transaction. +pub async fn create_game_with_snakes_tx( + conn: &mut sqlx::PgConnection, + data: CreateGameWithSnakes, ) -> cja::Result { // Validate number of battlesnakes if data.battlesnake_ids.is_empty() { @@ -278,76 +298,28 @@ pub async fn create_game_with_snakes( )); } - // Start a transaction - let mut tx = pool - .begin() - .await - .wrap_err("Failed to start database transaction")?; - // Create the game - let board_size_str = data.board_size.as_str(); - let game_type_str = data.game_type.as_str(); - let status_str = GameStatus::Waiting.as_str(); - - let row = sqlx::query!( - r#" - INSERT INTO games ( - board_size, - game_type, - status - ) - VALUES ($1, $2, $3) - RETURNING - game_id, - board_size, - game_type, - status, - enqueued_at, - created_at, - updated_at - "#, - board_size_str, - game_type_str, - status_str + let game = create_game( + &mut *conn, + CreateGame { + board_size: data.board_size, + game_type: data.game_type, + }, ) - .fetch_one(&mut *tx) // Access the connection inside the transaction .await .wrap_err("Failed to create game in database")?; - let game = Game { - game_id: row.game_id, - board_size: data.board_size, - game_type: data.game_type, - status: GameStatus::from_str(&row.status) - .wrap_err_with(|| format!("Invalid game status: {}", row.status))?, - enqueued_at: row.enqueued_at, - created_at: row.created_at, - updated_at: row.updated_at, - }; - // Add each battlesnake to the game for battlesnake_id in data.battlesnake_ids { - sqlx::query!( - r#" - INSERT INTO game_battlesnakes ( - game_id, - battlesnake_id - ) - VALUES ($1, $2) - "#, + add_battlesnake_to_game( + &mut *conn, game.game_id, - battlesnake_id + AddBattlesnakeToGame { battlesnake_id }, ) - .execute(&mut *tx) // Access the connection inside the transaction .await .wrap_err_with(|| format!("Failed to add battlesnake {} to game", battlesnake_id))?; } - // Commit the transaction - tx.commit() - .await - .wrap_err("Failed to commit database transaction")?; - Ok(game) } @@ -493,6 +465,28 @@ pub async fn set_game_enqueued_at( Ok(()) } +/// Set the enqueued_at timestamp using a mutable connection reference (for transaction composition). +pub async fn set_game_enqueued_at_tx( + conn: &mut sqlx::PgConnection, + game_id: Uuid, + enqueued_at: chrono::DateTime, +) -> cja::Result<()> { + sqlx::query!( + r#" + UPDATE games + SET enqueued_at = $2 + WHERE game_id = $1 + "#, + game_id, + enqueued_at + ) + .execute(&mut *conn) + .await + .wrap_err_with(|| format!("Failed to set enqueued_at for game {}", game_id))?; + + Ok(()) +} + // Get all games with their winners (if available) pub async fn get_all_games_with_winners(pool: &PgPool) -> cja::Result)>> { let rows = sqlx::query_as!( diff --git a/server/src/models/leaderboard.rs b/server/src/models/leaderboard.rs new file mode 100644 index 0000000..bd97d19 --- /dev/null +++ b/server/src/models/leaderboard.rs @@ -0,0 +1,499 @@ +use color_eyre::eyre::Context as _; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool, Postgres}; +use uuid::Uuid; + +/// Application constants for leaderboard configuration +pub const MATCH_SIZE: usize = 4; +pub const MIN_GAMES_FOR_RANKING: i32 = 10; +pub const GAMES_PER_DAY: i32 = 100; + +// Leaderboard model +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Leaderboard { + pub leaderboard_id: Uuid, + pub name: String, + pub disabled_at: Option>, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +// Leaderboard entry: one per snake per leaderboard +#[derive(Debug, Serialize, Deserialize, Clone, FromRow)] +pub struct LeaderboardEntry { + pub leaderboard_entry_id: Uuid, + pub leaderboard_id: Uuid, + pub battlesnake_id: Uuid, + pub mu: f64, + pub sigma: f64, + pub display_score: f64, + pub games_played: i32, + pub first_place_finishes: i32, + pub non_first_finishes: i32, + pub disabled_at: Option>, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +// Leaderboard game: links a game to a leaderboard +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct LeaderboardGame { + pub leaderboard_game_id: Uuid, + pub leaderboard_id: Uuid, + pub game_id: Uuid, + pub created_at: chrono::DateTime, +} + +// Leaderboard game result: per-snake rating change audit trail +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct LeaderboardGameResult { + pub leaderboard_game_result_id: Uuid, + pub leaderboard_game_id: Uuid, + pub leaderboard_entry_id: Uuid, + pub placement: i32, + pub mu_before: f64, + pub mu_after: f64, + pub sigma_before: f64, + pub sigma_after: f64, + pub display_score_change: f64, + pub created_at: chrono::DateTime, +} + +// Ranked entry with snake and owner info for display +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct RankedEntry { + pub leaderboard_entry_id: Uuid, + pub battlesnake_id: Uuid, + pub display_score: f64, + pub games_played: i32, + pub first_place_finishes: i32, + pub non_first_finishes: i32, + pub mu: f64, + pub sigma: f64, + pub snake_name: String, + pub owner_login: String, +} + +// --- Leaderboard queries --- +// TODO: Switch to sqlx::query_as! (compile-time checked) macros once the migration +// is merged and the .sqlx offline query cache is updated with `cargo sqlx prepare`. +// Currently using sqlx::query_as (runtime) because these tables are new. + +pub async fn get_all_leaderboards(pool: &PgPool) -> cja::Result> { + let rows = sqlx::query_as::<_, Leaderboard>( + "SELECT leaderboard_id, name, disabled_at, created_at, updated_at + FROM leaderboards + ORDER BY created_at ASC", + ) + .fetch_all(pool) + .await + .wrap_err("Failed to fetch leaderboards")?; + + Ok(rows) +} + +pub async fn get_active_leaderboards(pool: &PgPool) -> cja::Result> { + let rows = sqlx::query_as::<_, Leaderboard>( + "SELECT leaderboard_id, name, disabled_at, created_at, updated_at + FROM leaderboards + WHERE disabled_at IS NULL + ORDER BY created_at ASC", + ) + .fetch_all(pool) + .await + .wrap_err("Failed to fetch active leaderboards")?; + + Ok(rows) +} + +pub async fn get_leaderboard_by_id( + pool: &PgPool, + leaderboard_id: Uuid, +) -> cja::Result> { + let row = sqlx::query_as::<_, Leaderboard>( + "SELECT leaderboard_id, name, disabled_at, created_at, updated_at + FROM leaderboards + WHERE leaderboard_id = $1", + ) + .bind(leaderboard_id) + .fetch_optional(pool) + .await + .wrap_err("Failed to fetch leaderboard")?; + + Ok(row) +} + +// --- Leaderboard entry queries --- + +/// Opt-in a snake to a leaderboard. Returns existing entry if already joined. +pub async fn get_or_create_entry( + pool: &PgPool, + leaderboard_id: Uuid, + battlesnake_id: Uuid, +) -> cja::Result { + let entry = sqlx::query_as::<_, LeaderboardEntry>( + "INSERT INTO leaderboard_entries (leaderboard_id, battlesnake_id) + VALUES ($1, $2) + ON CONFLICT (leaderboard_id, battlesnake_id) + DO UPDATE SET disabled_at = NULL + RETURNING + leaderboard_entry_id, leaderboard_id, battlesnake_id, + mu, sigma, display_score, games_played, first_place_finishes, non_first_finishes, + disabled_at, created_at, updated_at", + ) + .bind(leaderboard_id) + .bind(battlesnake_id) + .fetch_one(pool) + .await + .wrap_err("Failed to create or get leaderboard entry")?; + + Ok(entry) +} + +/// Get all active entries for a leaderboard (not disabled) +pub async fn get_active_entries( + pool: &PgPool, + leaderboard_id: Uuid, +) -> cja::Result> { + let entries = sqlx::query_as::<_, LeaderboardEntry>( + "SELECT + leaderboard_entry_id, leaderboard_id, battlesnake_id, + mu, sigma, display_score, games_played, first_place_finishes, non_first_finishes, + disabled_at, created_at, updated_at + FROM leaderboard_entries + WHERE leaderboard_id = $1 AND disabled_at IS NULL + ORDER BY display_score DESC", + ) + .bind(leaderboard_id) + .fetch_all(pool) + .await + .wrap_err("Failed to fetch active leaderboard entries")?; + + Ok(entries) +} + +/// Get ranked entries (only snakes with enough games) with snake/owner info +pub async fn get_ranked_entries( + pool: &PgPool, + leaderboard_id: Uuid, +) -> cja::Result> { + let entries = sqlx::query_as::<_, RankedEntry>( + "SELECT + le.leaderboard_entry_id, + le.battlesnake_id, + le.display_score, + le.games_played, + le.first_place_finishes, + le.non_first_finishes, + le.mu, + le.sigma, + b.name as snake_name, + u.github_login as owner_login + FROM leaderboard_entries le + JOIN battlesnakes b ON le.battlesnake_id = b.battlesnake_id + JOIN users u ON b.user_id = u.user_id + WHERE le.leaderboard_id = $1 + AND le.disabled_at IS NULL + AND le.games_played >= $2 + ORDER BY le.display_score DESC + LIMIT 100", + ) + .bind(leaderboard_id) + .bind(MIN_GAMES_FOR_RANKING) + .fetch_all(pool) + .await + .wrap_err("Failed to fetch ranked leaderboard entries")?; + + Ok(entries) +} + +/// Get placement entries (active snakes below minimum games threshold) +pub async fn get_placement_entries( + pool: &PgPool, + leaderboard_id: Uuid, +) -> cja::Result> { + let entries = sqlx::query_as::<_, RankedEntry>( + "SELECT + le.leaderboard_entry_id, + le.battlesnake_id, + le.display_score, + le.games_played, + le.first_place_finishes, + le.non_first_finishes, + le.mu, + le.sigma, + b.name as snake_name, + u.github_login as owner_login + FROM leaderboard_entries le + JOIN battlesnakes b ON le.battlesnake_id = b.battlesnake_id + JOIN users u ON b.user_id = u.user_id + WHERE le.leaderboard_id = $1 + AND le.disabled_at IS NULL + AND le.games_played < $2 + ORDER BY le.games_played DESC + LIMIT 100", + ) + .bind(leaderboard_id) + .bind(MIN_GAMES_FOR_RANKING) + .fetch_all(pool) + .await + .wrap_err("Failed to fetch placement leaderboard entries")?; + + Ok(entries) +} + +/// Get a specific entry by leaderboard and battlesnake +pub async fn get_entry( + pool: &PgPool, + leaderboard_id: Uuid, + battlesnake_id: Uuid, +) -> cja::Result> { + let entry = sqlx::query_as::<_, LeaderboardEntry>( + "SELECT + leaderboard_entry_id, leaderboard_id, battlesnake_id, + mu, sigma, display_score, games_played, first_place_finishes, non_first_finishes, + disabled_at, created_at, updated_at + FROM leaderboard_entries + WHERE leaderboard_id = $1 AND battlesnake_id = $2", + ) + .bind(leaderboard_id) + .bind(battlesnake_id) + .fetch_optional(pool) + .await + .wrap_err("Failed to fetch leaderboard entry")?; + + Ok(entry) +} + +/// Get a specific entry by leaderboard and battlesnake, locking the row for update. +/// Use this within a transaction to prevent concurrent rating updates. +pub async fn get_entry_for_update<'e, E>( + executor: E, + leaderboard_id: Uuid, + battlesnake_id: Uuid, +) -> cja::Result> +where + E: sqlx::Executor<'e, Database = Postgres>, +{ + let entry = sqlx::query_as::<_, LeaderboardEntry>( + "SELECT + leaderboard_entry_id, leaderboard_id, battlesnake_id, + mu, sigma, display_score, games_played, first_place_finishes, non_first_finishes, + disabled_at, created_at, updated_at + FROM leaderboard_entries + WHERE leaderboard_id = $1 AND battlesnake_id = $2 + FOR UPDATE", + ) + .bind(leaderboard_id) + .bind(battlesnake_id) + .fetch_optional(executor) + .await + .wrap_err("Failed to fetch leaderboard entry for update")?; + + Ok(entry) +} + +/// Get entry by ID +pub async fn get_entry_by_id( + pool: &PgPool, + leaderboard_entry_id: Uuid, +) -> cja::Result> { + let entry = sqlx::query_as::<_, LeaderboardEntry>( + "SELECT + leaderboard_entry_id, leaderboard_id, battlesnake_id, + mu, sigma, display_score, games_played, first_place_finishes, non_first_finishes, + disabled_at, created_at, updated_at + FROM leaderboard_entries + WHERE leaderboard_entry_id = $1", + ) + .bind(leaderboard_entry_id) + .fetch_optional(pool) + .await + .wrap_err("Failed to fetch leaderboard entry by ID")?; + + Ok(entry) +} + +/// Update rating for an entry after a game. +/// Accepts any sqlx executor (pool or transaction). +pub async fn update_rating<'e, E>( + executor: E, + entry_id: Uuid, + mu: f64, + sigma: f64, + display_score: f64, + is_first_place: bool, +) -> cja::Result<()> +where + E: sqlx::Executor<'e, Database = Postgres>, +{ + sqlx::query( + "UPDATE leaderboard_entries + SET mu = $2, sigma = $3, display_score = $4, + games_played = games_played + 1, + first_place_finishes = first_place_finishes + CASE WHEN $5 THEN 1 ELSE 0 END, + non_first_finishes = non_first_finishes + CASE WHEN $5 THEN 0 ELSE 1 END + WHERE leaderboard_entry_id = $1", + ) + .bind(entry_id) + .bind(mu) + .bind(sigma) + .bind(display_score) + .bind(is_first_place) + .execute(executor) + .await + .wrap_err("Failed to update rating")?; + + Ok(()) +} + +/// Pause or resume a leaderboard entry +pub async fn set_disabled( + pool: &PgPool, + entry_id: Uuid, + disabled_at: Option>, +) -> cja::Result<()> { + sqlx::query( + "UPDATE leaderboard_entries + SET disabled_at = $2 + WHERE leaderboard_entry_id = $1", + ) + .bind(entry_id) + .bind(disabled_at) + .execute(pool) + .await + .wrap_err("Failed to update leaderboard entry disabled status")?; + + Ok(()) +} + +/// Get entries for a specific user across a leaderboard +pub async fn get_user_entries( + pool: &PgPool, + leaderboard_id: Uuid, + user_id: Uuid, +) -> cja::Result> { + let entries = sqlx::query_as::<_, LeaderboardEntry>( + "SELECT + le.leaderboard_entry_id, le.leaderboard_id, le.battlesnake_id, + le.mu, le.sigma, le.display_score, le.games_played, le.first_place_finishes, le.non_first_finishes, + le.disabled_at, le.created_at, le.updated_at + FROM leaderboard_entries le + JOIN battlesnakes b ON le.battlesnake_id = b.battlesnake_id + WHERE le.leaderboard_id = $1 AND b.user_id = $2 + ORDER BY le.display_score DESC", + ) + .bind(leaderboard_id) + .bind(user_id) + .fetch_all(pool) + .await + .wrap_err("Failed to fetch user leaderboard entries")?; + + Ok(entries) +} + +// --- Leaderboard game queries --- + +/// Create a leaderboard game link. Accepts any sqlx executor (pool or transaction). +pub async fn create_leaderboard_game<'e, E>( + executor: E, + leaderboard_id: Uuid, + game_id: Uuid, +) -> cja::Result +where + E: sqlx::Executor<'e, Database = Postgres>, +{ + let game = sqlx::query_as::<_, LeaderboardGame>( + "INSERT INTO leaderboard_games (leaderboard_id, game_id) + VALUES ($1, $2) + RETURNING leaderboard_game_id, leaderboard_id, game_id, created_at", + ) + .bind(leaderboard_id) + .bind(game_id) + .fetch_one(executor) + .await + .wrap_err("Failed to create leaderboard game")?; + + Ok(game) +} + +pub async fn find_leaderboard_game_by_game_id( + pool: &PgPool, + game_id: Uuid, +) -> cja::Result> { + let game = sqlx::query_as::<_, LeaderboardGame>( + "SELECT leaderboard_game_id, leaderboard_id, game_id, created_at + FROM leaderboard_games + WHERE game_id = $1", + ) + .bind(game_id) + .fetch_optional(pool) + .await + .wrap_err("Failed to find leaderboard game by game_id")?; + + Ok(game) +} + +// --- Leaderboard game result queries --- + +pub struct CreateGameResult { + pub leaderboard_game_id: Uuid, + pub leaderboard_entry_id: Uuid, + pub placement: i32, + pub mu_before: f64, + pub mu_after: f64, + pub sigma_before: f64, + pub sigma_after: f64, + pub display_score_change: f64, +} + +/// Record a game result for a snake. Accepts any sqlx executor (pool or transaction). +/// Uses ON CONFLICT DO NOTHING as a DB-level idempotency guard. +pub async fn create_game_result<'e, E>( + executor: E, + data: CreateGameResult, +) -> cja::Result> +where + E: sqlx::Executor<'e, Database = Postgres>, +{ + let result = sqlx::query_as::<_, LeaderboardGameResult>( + "INSERT INTO leaderboard_game_results ( + leaderboard_game_id, leaderboard_entry_id, placement, + mu_before, mu_after, sigma_before, sigma_after, display_score_change + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (leaderboard_game_id, leaderboard_entry_id) DO NOTHING + RETURNING + leaderboard_game_result_id, leaderboard_game_id, leaderboard_entry_id, + placement, mu_before, mu_after, sigma_before, sigma_after, + display_score_change, created_at", + ) + .bind(data.leaderboard_game_id) + .bind(data.leaderboard_entry_id) + .bind(data.placement) + .bind(data.mu_before) + .bind(data.mu_after) + .bind(data.sigma_before) + .bind(data.sigma_after) + .bind(data.display_score_change) + .fetch_optional(executor) + .await + .wrap_err("Failed to create leaderboard game result")?; + + Ok(result) +} + +/// Count active participants in a leaderboard +pub async fn count_active_entries(pool: &PgPool, leaderboard_id: Uuid) -> cja::Result { + let row: (i64,) = sqlx::query_as( + "SELECT COUNT(*) + FROM leaderboard_entries + WHERE leaderboard_id = $1 AND disabled_at IS NULL", + ) + .bind(leaderboard_id) + .fetch_one(pool) + .await + .wrap_err("Failed to count active leaderboard entries")?; + + Ok(row.0) +} diff --git a/server/src/models/mod.rs b/server/src/models/mod.rs index 584c222..6d6b38a 100644 --- a/server/src/models/mod.rs +++ b/server/src/models/mod.rs @@ -3,6 +3,7 @@ pub mod battlesnake; pub mod flow; pub mod game; pub mod game_battlesnake; +pub mod leaderboard; pub mod session; pub mod turn; pub mod user; diff --git a/server/src/routes.rs b/server/src/routes.rs index ae7e8d4..4887f55 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -16,6 +16,7 @@ pub mod auth; pub mod battlesnake; pub mod game; pub mod github_auth; +pub mod leaderboard; pub fn routes(app_state: AppState) -> axum::Router { // CORS layer for API routes - allows board.battlesnake.com to access our API @@ -43,6 +44,20 @@ pub fn routes(app_state: AppState) -> axum::Router { .route("/games/{id}/details", get(api::games::show_game)) .route("/games/status", post(api::games::batch_game_status)) .route("/admin/stats", get(admin::stats_json)) + // Leaderboard API endpoints + .route("/leaderboards", get(api::leaderboards::list_leaderboards)) + .route( + "/leaderboards/{id}/rankings", + get(api::leaderboards::get_rankings), + ) + .route( + "/leaderboards/{id}/entries", + post(api::leaderboards::create_entry), + ) + .route( + "/leaderboards/{id}/entries/{battlesnake_id}", + delete(api::leaderboards::delete_entry), + ) .layer(cors); axum::Router::new() @@ -103,6 +118,17 @@ pub fn routes(app_state: AppState) -> axum::Router { axum::routing::post(game::remove_battlesnake), ) .route("/games/flow/{id}/search", get(game::search_battlesnakes)) + // Leaderboard routes + .route("/leaderboards", get(leaderboard::list_leaderboards)) + .route("/leaderboards/{id}", get(leaderboard::show_leaderboard)) + .route( + "/leaderboards/{id}/join", + axum::routing::post(leaderboard::join_leaderboard), + ) + .route( + "/leaderboards/{id}/leave", + axum::routing::post(leaderboard::leave_leaderboard), + ) // Admin routes .route("/admin", get(admin::dashboard)) // Game API routes for board viewer (with CORS) @@ -137,6 +163,7 @@ async fn root_page( div class="user-actions" style="margin-top: 10px;" { a href="/me" class="btn btn-primary" { "Profile" } a href="/battlesnakes" class="btn btn-primary" { "Battlesnakes" } + a href="/leaderboards" class="btn btn-primary" { "Leaderboards" } a href="/auth/logout" class="btn btn-secondary" { "Logout" } } } diff --git a/server/src/routes/api/leaderboards.rs b/server/src/routes/api/leaderboards.rs new file mode 100644 index 0000000..4f8accf --- /dev/null +++ b/server/src/routes/api/leaderboards.rs @@ -0,0 +1,298 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + models::{ + battlesnake::{self, Visibility}, + leaderboard::{self, MIN_GAMES_FOR_RANKING}, + }, + routes::auth::ApiUser, + state::AppState, +}; + +#[derive(Debug, Serialize)] +pub struct LeaderboardResponse { + pub id: Uuid, + pub name: String, + pub active: bool, + pub created_at: chrono::DateTime, +} + +#[derive(Debug, Serialize)] +pub struct RankingEntry { + pub rank: usize, + pub battlesnake_id: Uuid, + pub snake_name: String, + pub owner: String, + pub display_score: f64, + pub games_played: i32, + pub first_place_finishes: i32, + pub non_first_finishes: i32, + pub first_place_rate: f64, +} + +#[derive(Debug, Serialize)] +pub struct RankingsResponse { + pub leaderboard_id: Uuid, + pub leaderboard_name: String, + pub min_games: i32, + pub ranked: Vec, + pub placement: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct OptInRequest { + pub battlesnake_id: Uuid, +} + +#[derive(Debug, Serialize)] +pub struct EntryResponse { + pub leaderboard_entry_id: Uuid, + pub battlesnake_id: Uuid, + pub display_score: f64, + pub games_played: i32, + pub first_place_finishes: i32, + pub non_first_finishes: i32, + pub active: bool, +} + +/// GET /api/leaderboards +pub async fn list_leaderboards( + State(state): State, +) -> Result { + let leaderboards = leaderboard::get_all_leaderboards(&state.db) + .await + .map_err(|e| { + tracing::error!("Failed to list leaderboards: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) + })?; + + let response: Vec = leaderboards + .into_iter() + .map(|lb| LeaderboardResponse { + id: lb.leaderboard_id, + name: lb.name, + active: lb.disabled_at.is_none(), + created_at: lb.created_at, + }) + .collect(); + + Ok(Json(response)) +} + +/// GET /api/leaderboards/:id/rankings +pub async fn get_rankings( + State(state): State, + Path(leaderboard_id): Path, +) -> Result { + let lb = leaderboard::get_leaderboard_by_id(&state.db, leaderboard_id) + .await + .map_err(|e| { + tracing::error!("Failed to fetch leaderboard: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) + })? + .ok_or((StatusCode::NOT_FOUND, "Leaderboard not found".to_string()))?; + + let ranked = leaderboard::get_ranked_entries(&state.db, leaderboard_id) + .await + .map_err(|e| { + tracing::error!("Failed to fetch ranked entries: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) + })?; + + let placement = leaderboard::get_placement_entries(&state.db, leaderboard_id) + .await + .map_err(|e| { + tracing::error!("Failed to fetch placement entries: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) + })?; + + fn to_ranking_entries( + entries: Vec, + start_rank: usize, + ) -> Vec { + entries + .into_iter() + .enumerate() + .map(|(i, e)| { + let first_place_rate = if e.games_played > 0 { + e.first_place_finishes as f64 / e.games_played as f64 + } else { + 0.0 + }; + RankingEntry { + rank: start_rank + i, + battlesnake_id: e.battlesnake_id, + snake_name: e.snake_name, + owner: e.owner_login, + display_score: e.display_score, + games_played: e.games_played, + first_place_finishes: e.first_place_finishes, + non_first_finishes: e.non_first_finishes, + first_place_rate, + } + }) + .collect() + } + + let ranked_entries = to_ranking_entries(ranked, 1); + let placement_entries = to_ranking_entries(placement, 0); + + Ok(Json(RankingsResponse { + leaderboard_id: lb.leaderboard_id, + leaderboard_name: lb.name, + min_games: MIN_GAMES_FOR_RANKING, + ranked: ranked_entries, + placement: placement_entries, + })) +} + +/// POST /api/leaderboards/:id/entries — opt-in a snake +pub async fn create_entry( + State(state): State, + ApiUser(user): ApiUser, + Path(leaderboard_id): Path, + Json(request): Json, +) -> Result { + // Verify leaderboard exists and is active + let lb = leaderboard::get_leaderboard_by_id(&state.db, leaderboard_id) + .await + .map_err(|e| { + tracing::error!("Failed to fetch leaderboard: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) + })? + .ok_or((StatusCode::NOT_FOUND, "Leaderboard not found".to_string()))?; + + if lb.disabled_at.is_some() { + return Err(( + StatusCode::BAD_REQUEST, + "Leaderboard is not active".to_string(), + )); + } + + // Verify snake belongs to user and is public + let snake = battlesnake::get_battlesnake_by_id(&state.db, request.battlesnake_id) + .await + .map_err(|e| { + tracing::error!("Failed to fetch battlesnake: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) + })? + .ok_or((StatusCode::NOT_FOUND, "Battlesnake not found".to_string()))?; + + if snake.user_id != user.user_id { + return Err(( + StatusCode::FORBIDDEN, + "You don't own this battlesnake".to_string(), + )); + } + + if snake.visibility != Visibility::Public { + return Err(( + StatusCode::BAD_REQUEST, + "Only public snakes can join leaderboards".to_string(), + )); + } + + let entry = leaderboard::get_or_create_entry(&state.db, leaderboard_id, request.battlesnake_id) + .await + .map_err(|e| { + tracing::error!("Failed to create entry: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) + })?; + + Ok(( + StatusCode::CREATED, + Json(EntryResponse { + leaderboard_entry_id: entry.leaderboard_entry_id, + battlesnake_id: entry.battlesnake_id, + display_score: entry.display_score, + games_played: entry.games_played, + first_place_finishes: entry.first_place_finishes, + non_first_finishes: entry.non_first_finishes, + active: entry.disabled_at.is_none(), + }), + )) +} + +/// DELETE /api/leaderboards/:id/entries/:battlesnake_id — opt-out (pause) +pub async fn delete_entry( + State(state): State, + ApiUser(user): ApiUser, + Path((leaderboard_id, battlesnake_id)): Path<(Uuid, Uuid)>, +) -> Result { + // Verify snake belongs to user + let snake = battlesnake::get_battlesnake_by_id(&state.db, battlesnake_id) + .await + .map_err(|e| { + tracing::error!("Failed to fetch battlesnake: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) + })? + .ok_or((StatusCode::NOT_FOUND, "Battlesnake not found".to_string()))?; + + if snake.user_id != user.user_id { + return Err(( + StatusCode::FORBIDDEN, + "You don't own this battlesnake".to_string(), + )); + } + + let entry = leaderboard::get_entry(&state.db, leaderboard_id, battlesnake_id) + .await + .map_err(|e| { + tracing::error!("Failed to fetch entry: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) + })? + .ok_or(( + StatusCode::NOT_FOUND, + "Snake is not in this leaderboard".to_string(), + ))?; + + leaderboard::set_disabled( + &state.db, + entry.leaderboard_entry_id, + Some(chrono::Utc::now()), + ) + .await + .map_err(|e| { + tracing::error!("Failed to disable entry: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) + })?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/server/src/routes/api/mod.rs b/server/src/routes/api/mod.rs index 1be84b3..348e1cb 100644 --- a/server/src/routes/api/mod.rs +++ b/server/src/routes/api/mod.rs @@ -1,3 +1,4 @@ pub mod games; +pub mod leaderboards; pub mod snakes; pub mod tokens; diff --git a/server/src/routes/leaderboard.rs b/server/src/routes/leaderboard.rs new file mode 100644 index 0000000..5aaaa18 --- /dev/null +++ b/server/src/routes/leaderboard.rs @@ -0,0 +1,357 @@ +use axum::{ + Form, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, +}; +use color_eyre::eyre::Context as _; +use maud::html; +use uuid::Uuid; + +use crate::{ + components::page_factory::PageFactory, + errors::{ServerResult, WithRedirect}, + models::{ + battlesnake::{self, Visibility}, + leaderboard::{self, MIN_GAMES_FOR_RANKING}, + }, + routes::auth::{CurrentUser, OptionalUser}, + state::AppState, +}; + +/// GET /leaderboards — list all leaderboards +pub async fn list_leaderboards( + State(state): State, + OptionalUser(_user): OptionalUser, + page_factory: PageFactory, +) -> ServerResult { + let leaderboards = leaderboard::get_all_leaderboards(&state.db) + .await + .wrap_err("Failed to fetch leaderboards")?; + + Ok(page_factory.create_page( + "Leaderboards".to_string(), + Box::new(html! { + div class="container" { + h1 { "Leaderboards" } + + @if leaderboards.is_empty() { + p { "No leaderboards available yet." } + } @else { + div class="leaderboards-list" { + @for lb in &leaderboards { + div class="card" style="border: 1px solid #ddd; border-radius: 8px; padding: 20px; margin-bottom: 16px;" { + h2 { + a href={"/leaderboards/"(lb.leaderboard_id)} { (lb.name) } + } + @if lb.disabled_at.is_some() { + span class="badge bg-secondary text-white" { "Inactive" } + } @else { + span class="badge bg-success text-white" { "Active" } + } + } + } + } + } + + div class="nav" style="margin-top: 20px;" { + a href="/" { "Back to Home" } + } + } + }), + )) +} + +/// GET /leaderboards/:id — leaderboard detail with rankings +pub async fn show_leaderboard( + State(state): State, + OptionalUser(user): OptionalUser, + Path(leaderboard_id): Path, + page_factory: PageFactory, +) -> ServerResult { + let lb = leaderboard::get_leaderboard_by_id(&state.db, leaderboard_id) + .await + .wrap_err("Failed to fetch leaderboard")? + .ok_or_else(|| { + crate::errors::ServerError( + color_eyre::eyre::eyre!("Leaderboard not found"), + StatusCode::NOT_FOUND, + ) + })?; + + let ranked = leaderboard::get_ranked_entries(&state.db, leaderboard_id) + .await + .wrap_err("Failed to fetch ranked entries")?; + + let placement = leaderboard::get_placement_entries(&state.db, leaderboard_id) + .await + .wrap_err("Failed to fetch placement entries")?; + + // Get user's snakes for the join form + let user_snakes = if let Some(ref u) = user { + battlesnake::get_battlesnakes_by_user_id(&state.db, u.user_id) + .await + .wrap_err("Failed to fetch user's battlesnakes")? + } else { + vec![] + }; + + // Get user's entries in this leaderboard + let user_entries = if let Some(ref u) = user { + leaderboard::get_user_entries(&state.db, leaderboard_id, u.user_id) + .await + .wrap_err("Failed to fetch user's leaderboard entries")? + } else { + vec![] + }; + + let user_entry_snake_ids: Vec = user_entries.iter().map(|e| e.battlesnake_id).collect(); + + Ok(page_factory.create_page( + format!("Leaderboard: {}", lb.name), + Box::new(html! { + div class="container" { + h1 { "Leaderboard: " (lb.name) } + + // Join/leave section for logged-in users + @if user.is_some() { + div style="margin-bottom: 20px; padding: 16px; border: 1px solid #ddd; border-radius: 8px;" { + h3 { "Your Snakes" } + + // Show currently joined snakes with leave button + @for entry in &user_entries { + @if let Some(snake) = user_snakes.iter().find(|s| s.battlesnake_id == entry.battlesnake_id) { + div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;" { + span { (snake.name) } + @if entry.disabled_at.is_some() { + span class="badge bg-secondary text-white" { "Paused" } + form action={"/leaderboards/"(leaderboard_id)"/join"} method="post" style="display: inline;" { + input type="hidden" name="battlesnake_id" value=(snake.battlesnake_id); + button type="submit" class="btn btn-sm btn-success" { "Resume" } + } + } @else { + span class="badge bg-success text-white" { "Active" } + form action={"/leaderboards/"(leaderboard_id)"/leave"} method="post" style="display: inline;" { + input type="hidden" name="battlesnake_id" value=(snake.battlesnake_id); + button type="submit" class="btn btn-sm btn-warning" { "Pause" } + } + } + span style="color: #666;" { + "Score: " (format!("{:.1}", entry.display_score)) + " | Games: " (entry.games_played) + } + } + } + } + + // Show joinable snakes (public, not already joined) + @let joinable: Vec<_> = user_snakes.iter() + .filter(|s| s.visibility == Visibility::Public && !user_entry_snake_ids.contains(&s.battlesnake_id)) + .collect(); + @if !joinable.is_empty() { + form action={"/leaderboards/"(leaderboard_id)"/join"} method="post" style="margin-top: 10px;" { + label { "Join with: " } + select name="battlesnake_id" { + @for snake in joinable { + option value=(snake.battlesnake_id) { (snake.name) } + } + } + button type="submit" class="btn btn-sm btn-primary" style="margin-left: 8px;" { "Join" } + } + } + } + } + + // Rankings table + h2 { "Rankings" } + @if ranked.is_empty() { + p { "No snakes have completed enough games to be ranked yet. (Minimum: " (MIN_GAMES_FOR_RANKING) " games)" } + } @else { + table class="table" { + thead { + tr { + th { "Rank" } + th { "Snake" } + th { "Owner" } + th { "Score" } + th { "Games" } + th { "1st Place %" } + } + } + tbody { + @for (i, entry) in ranked.iter().enumerate() { + tr { + td { (i + 1) } + td { (entry.snake_name) } + td { (entry.owner_login) } + td { (format!("{:.1}", entry.display_score)) } + td { (entry.games_played) } + td { + @if entry.games_played > 0 { + (format!("{:.0}%", (entry.first_place_finishes as f64 / entry.games_played as f64) * 100.0)) + } @else { + "N/A" + } + } + } + } + } + } + } + + // Placement section + @if !placement.is_empty() { + h2 { "In Placement" } + p style="color: #666;" { "These snakes need more games before appearing in rankings." } + table class="table" { + thead { + tr { + th { "Snake" } + th { "Owner" } + th { "Games Played" } + th { "Games Remaining" } + } + } + tbody { + @for entry in &placement { + tr { + td { (entry.snake_name) } + td { (entry.owner_login) } + td { (entry.games_played) } + td { (MIN_GAMES_FOR_RANKING - entry.games_played) } + } + } + } + } + } + + div class="nav" style="margin-top: 20px;" { + a href="/leaderboards" { "Back to Leaderboards" } + span { " | " } + a href="/" { "Home" } + } + } + }), + )) +} + +#[derive(serde::Deserialize)] +pub struct JoinLeaveForm { + pub battlesnake_id: Uuid, +} + +/// POST /leaderboards/:id/join — opt-in a snake +pub async fn join_leaderboard( + State(state): State, + CurrentUser(user): CurrentUser, + Path(leaderboard_id): Path, + Form(form): Form, +) -> ServerResult { + let redirect = Redirect::to(&format!("/leaderboards/{leaderboard_id}")); + + // Verify leaderboard exists and is active + let lb = leaderboard::get_leaderboard_by_id(&state.db, leaderboard_id) + .await + .wrap_err("Failed to fetch leaderboard") + .with_redirect(redirect.clone())?; + + let lb = lb.ok_or_else(|| { + crate::errors::ServerError( + color_eyre::eyre::eyre!("Leaderboard not found"), + redirect.clone(), + ) + })?; + + if lb.disabled_at.is_some() { + return Err(crate::errors::ServerError( + color_eyre::eyre::eyre!("Leaderboard is not active"), + redirect, + )); + } + + // Verify snake belongs to user and is public + let snake = battlesnake::get_battlesnake_by_id(&state.db, form.battlesnake_id) + .await + .wrap_err("Failed to fetch battlesnake") + .with_redirect(redirect.clone())? + .ok_or_else(|| { + crate::errors::ServerError( + color_eyre::eyre::eyre!("Battlesnake not found"), + redirect.clone(), + ) + })?; + + if snake.user_id != user.user_id { + return Err(crate::errors::ServerError( + color_eyre::eyre::eyre!("You don't own this battlesnake"), + redirect, + )); + } + + if snake.visibility != Visibility::Public { + return Err(crate::errors::ServerError( + color_eyre::eyre::eyre!("Only public snakes can join leaderboards"), + redirect, + )); + } + + // Opt-in (or resume if paused) + leaderboard::get_or_create_entry(&state.db, leaderboard_id, form.battlesnake_id) + .await + .wrap_err("Failed to join leaderboard") + .with_redirect(redirect.clone())?; + + Ok(redirect) +} + +/// POST /leaderboards/:id/leave — pause a snake +pub async fn leave_leaderboard( + State(state): State, + CurrentUser(user): CurrentUser, + Path(leaderboard_id): Path, + Form(form): Form, +) -> ServerResult { + let redirect = Redirect::to(&format!("/leaderboards/{leaderboard_id}")); + + // Verify snake belongs to user + let snake = battlesnake::get_battlesnake_by_id(&state.db, form.battlesnake_id) + .await + .wrap_err("Failed to fetch battlesnake") + .with_redirect(redirect.clone())? + .ok_or_else(|| { + crate::errors::ServerError( + color_eyre::eyre::eyre!("Battlesnake not found"), + redirect.clone(), + ) + })?; + + if snake.user_id != user.user_id { + return Err(crate::errors::ServerError( + color_eyre::eyre::eyre!("You don't own this battlesnake"), + redirect, + )); + } + + // Find the entry and pause it + let entry = leaderboard::get_entry(&state.db, leaderboard_id, form.battlesnake_id) + .await + .wrap_err("Failed to fetch entry") + .with_redirect(redirect.clone())? + .ok_or_else(|| { + crate::errors::ServerError( + color_eyre::eyre::eyre!("Snake is not in this leaderboard"), + redirect.clone(), + ) + })?; + + leaderboard::set_disabled( + &state.db, + entry.leaderboard_entry_id, + Some(chrono::Utc::now()), + ) + .await + .wrap_err("Failed to pause entry") + .with_redirect(redirect.clone())?; + + Ok(redirect) +}