From 6a00cfe3243bca5aa654feb0583356cf78a1f12e Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 13 Mar 2026 10:20:50 +0000 Subject: [PATCH] =?UTF-8?q?test:=20add=20soak=20and=20stress=20tests=20?= =?UTF-8?q?=E2=80=94=201000=20fights,=2050=20concurrent=20queue=20joins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soak: 1000 random fights verify zero crashes, ELO bell curve around 1200, and bounded heap growth. Stress: 50 concurrent queue joins verify no races, no duplicates, correct rejoin behavior. Co-Authored-By: Claude Opus 4.6 --- server/src/engine/soak.test.ts | 211 +++++++++++++++++++++++++++++++ server/src/engine/stress.test.ts | 135 ++++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 server/src/engine/soak.test.ts create mode 100644 server/src/engine/stress.test.ts diff --git a/server/src/engine/soak.test.ts b/server/src/engine/soak.test.ts new file mode 100644 index 0000000..9bfd2e6 --- /dev/null +++ b/server/src/engine/soak.test.ts @@ -0,0 +1,211 @@ +/** + * Soak test: 1000 simulated fights with random matchups. + * Verifies: zero crashes, all complete, ELO bell curve, no memory growth. + */ +import { describe, it, expect } from 'vitest' +import { scoreRound, calculateElo } from './scoring.js' +import { pickChallenge } from './challenges.js' + +const FIGHT_COUNT = 1000 +const BOT_COUNT = 50 +const MAX_ROUNDS = 10 +const STARTING_HP = 200 +const STARTING_ELO = 1200 + +interface Bot { + id: string + name: string + elo: number + wins: number + losses: number + draws: number +} + +function createBots(): Bot[] { + return Array.from({ length: BOT_COUNT }, (_, i) => ({ + id: `bot-${i}`, + name: `SoakBot${i}`, + elo: STARTING_ELO, + wins: 0, + losses: 0, + draws: 0, + })) +} + +function randomAnswer(correct: string[], correctChance: number): string | null { + if (Math.random() < correctChance) return correct[0] + if (Math.random() < 0.1) return null // 10% timeout + return 'wrong-answer' +} + +function simulateFight( + botA: Bot, + botB: Bot, +): 'a' | 'b' | 'draw' { + let hpA = STARTING_HP + let hpB = STARTING_HP + const usedTypes = new Set() + let comboA = 0 + let comboB = 0 + + for (let round = 1; round <= MAX_ROUNDS; round++) { + const challenge = pickChallenge(usedTypes, null, undefined, round) + usedTypes.add(challenge.type) + + const answers = challenge.answers || ['42'] + // Vary correctness: higher ELO bots are slightly more accurate + const chanceA = 0.5 + (botA.elo - 1000) / 2000 + const chanceB = 0.5 + (botB.elo - 1000) / 2000 + const ansA = randomAnswer(answers, Math.max(0.3, Math.min(0.95, chanceA))) + const ansB = randomAnswer(answers, Math.max(0.3, Math.min(0.95, chanceB))) + const timeA = 200 + Math.random() * 4800 + const timeB = 200 + Math.random() * 4800 + + const respA = { answer: ansA, timeMs: timeA, timedOut: ansA === null, error: false } + const respB = { answer: ansB, timeMs: timeB, timedOut: ansB === null, error: false } + + const result = scoreRound( + challenge, + { id: botA.id, name: botA.name }, + { id: botB.id, name: botB.name }, + respA, + respB, + null, + comboA, + comboB, + ) + + // Apply damage + hpB = Math.max(0, hpB - result.botADamage) + hpA = Math.max(0, hpA - result.botBDamage) + + // Update combos + if (result.winnerId === botA.id) { comboA++; comboB = 0 } + else if (result.winnerId === botB.id) { comboB++; comboA = 0 } + + // KO check + if (hpA <= 0 || hpB <= 0) { + return hpA <= 0 ? 'b' : 'a' + } + } + + // No KO — winner by HP + if (hpA > hpB) return 'a' + if (hpB > hpA) return 'b' + return 'draw' +} + +describe('soak test — 1000 fights', () => { + it('all 1000 fights complete without crashes', () => { + const bots = createBots() + let completed = 0 + let draws = 0 + let kos = 0 + + for (let f = 0; f < FIGHT_COUNT; f++) { + // Random matchup (different bots) + const iA = Math.floor(Math.random() * BOT_COUNT) + let iB = Math.floor(Math.random() * BOT_COUNT) + while (iB === iA) iB = Math.floor(Math.random() * BOT_COUNT) + + const botA = bots[iA] + const botB = bots[iB] + + const result = simulateFight(botA, botB) + + if (result === 'draw') { + botA.draws++ + botB.draws++ + draws++ + } else if (result === 'a') { + botA.wins++ + botB.losses++ + const elo = calculateElo(botA.elo, botB.elo) + botA.elo = elo.newWinnerElo + botB.elo = elo.newLoserElo + } else { + botB.wins++ + botA.losses++ + const elo = calculateElo(botB.elo, botA.elo) + botB.elo = elo.newWinnerElo + botA.elo = elo.newLoserElo + } + + completed++ + } + + expect(completed).toBe(FIGHT_COUNT) + }, 120_000) // 120s timeout + + it('ELO distribution forms bell curve around starting ELO', () => { + const bots = createBots() + + for (let f = 0; f < FIGHT_COUNT; f++) { + const iA = Math.floor(Math.random() * BOT_COUNT) + let iB = Math.floor(Math.random() * BOT_COUNT) + while (iB === iA) iB = Math.floor(Math.random() * BOT_COUNT) + + const botA = bots[iA] + const botB = bots[iB] + const result = simulateFight(botA, botB) + + if (result === 'a') { + const elo = calculateElo(botA.elo, botB.elo) + botA.elo = elo.newWinnerElo + botB.elo = elo.newLoserElo + } else if (result === 'b') { + const elo = calculateElo(botB.elo, botA.elo) + botB.elo = elo.newWinnerElo + botA.elo = elo.newLoserElo + } + } + + const elos = bots.map(b => b.elo) + const mean = elos.reduce((a, b) => a + b, 0) / elos.length + + // Mean should be close to 1200 (zero-sum system) + expect(mean).toBeGreaterThan(1100) + expect(mean).toBeLessThan(1300) + + // All ELOs should be finite + for (const e of elos) { + expect(isFinite(e)).toBe(true) + } + + // Should have spread (not all same ELO) + const min = Math.min(...elos) + const max = Math.max(...elos) + expect(max - min).toBeGreaterThan(50) // some differentiation + }, 120_000) + + it('no memory growth: heap stays bounded', () => { + const bots = createBots() + const heapBefore = process.memoryUsage().heapUsed + + for (let f = 0; f < FIGHT_COUNT; f++) { + const iA = Math.floor(Math.random() * BOT_COUNT) + let iB = Math.floor(Math.random() * BOT_COUNT) + while (iB === iA) iB = Math.floor(Math.random() * BOT_COUNT) + + const result = simulateFight(bots[iA], bots[iB]) + if (result === 'a') { + const elo = calculateElo(bots[iA].elo, bots[iB].elo) + bots[iA].elo = elo.newWinnerElo + bots[iB].elo = elo.newLoserElo + } else if (result === 'b') { + const elo = calculateElo(bots[iB].elo, bots[iA].elo) + bots[iB].elo = elo.newWinnerElo + bots[iA].elo = elo.newLoserElo + } + } + + // Force GC if available + if (global.gc) global.gc() + + const heapAfter = process.memoryUsage().heapUsed + const growth = heapAfter - heapBefore + + // Allow up to 50MB growth (generous, should be <10MB for pure computation) + expect(growth).toBeLessThan(50 * 1024 * 1024) + }, 120_000) +}) diff --git a/server/src/engine/stress.test.ts b/server/src/engine/stress.test.ts new file mode 100644 index 0000000..77cd906 --- /dev/null +++ b/server/src/engine/stress.test.ts @@ -0,0 +1,135 @@ +/** + * Stress test: 50 concurrent queue joins. + * Verifies no race conditions, no duplicate matches, consistent state. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock orchestrator: track fight creation +const fightIds: string[] = [] +let fightCounter = 0 +vi.mock('./orchestrator.js', () => ({ + runFightAsync: vi.fn(async () => { + const id = `fight-${++fightCounter}` + fightIds.push(id) + return id + }), + isInFight: vi.fn().mockReturnValue(false), + getActiveFightId: vi.fn().mockReturnValue(null), +})) + +vi.mock('./mock.js', () => ({ + seedMockBots: vi.fn().mockResolvedValue(undefined), +})) + +// Mock DB: each bot has unique ID and data +vi.mock('../db/index.js', () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => ({ + limit: vi.fn().mockImplementation(async () => { + // Return a valid bot for any query — the bot ID is injected via joinQueue param + return [{ + id: 'dynamic', + name: 'StressBot', + webhookUrl: 'https://example.com/webhook', + eloRating: 1200, + isActive: true, + secretHash: 'abc', + }] + }), + }), + }), + }), + }, + schema: { + bots: { + id: 'id', name: 'name', secretHash: 'secretHash', + webhookUrl: 'webhookUrl', eloRating: 'eloRating', isActive: 'isActive', + }, + }, +})) + +const { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } = await import('./queue.js') + +describe('stress test — concurrent queue operations', () => { + beforeEach(() => { + vi.clearAllMocks() + fightIds.length = 0 + fightCounter = 0 + // Drain queue + while (getQueueSize() > 0) { + const snap = getQueueSnapshot() + if (snap[0]) leaveQueue(snap[0].botId) + } + }) + + it('50 concurrent joins: bots pair up, no orphans, no duplicates', async () => { + const BOT_COUNT = 50 + const promises: Promise[] = [] + + // Launch 50 bots concurrently + for (let i = 0; i < BOT_COUNT; i++) { + promises.push(joinQueue(`stress-bot-${i}`)) + } + + // Wait for all to resolve (matched or timeout-mocked) + const results = await Promise.allSettled(promises) + + // Count successes and failures + const fulfilled = results.filter(r => r.status === 'fulfilled') + const rejected = results.filter(r => r.status === 'rejected') + + // Most should succeed (25 pairs = 50 bots) + // Some may get "Rejoined queue" if same bot ID collides + expect(fulfilled.length).toBeGreaterThanOrEqual(BOT_COUNT - 2) + + // Queue should be empty after all matches + expect(getQueueSize()).toBe(0) + + // Each fight ID should be unique + const fightIdSet = new Set(fulfilled.map(r => (r as PromiseFulfilledResult).value)) + expect(fightIdSet.size).toBe(fulfilled.length / 2 || fightIdSet.size) + }, 30_000) + + it('rapid join/leave cycles: no crashes or state corruption', async () => { + const CYCLES = 20 + + for (let i = 0; i < CYCLES; i++) { + const botId = `cycle-bot-${i}` + const promise = joinQueue(botId) + + // Immediately leave + await new Promise(r => setTimeout(r, 0)) // flush microtasks + leaveQueue(botId) + + // Swallow rejection from leaveQueue + await promise.catch(() => {}) + } + + expect(getQueueSize()).toBe(0) + }, 15_000) + + it('rejoining queue replaces previous entry', async () => { + // Attach catch handler immediately to prevent unhandled rejection + const promise1 = joinQueue('rejoin-bot') + promise1.catch(() => {}) // prevent unhandled rejection warning + await new Promise(r => setTimeout(r, 0)) // flush + + expect(getQueueSize()).toBe(1) + + // Rejoin — should replace the old entry + const promise2 = joinQueue('rejoin-bot') + await new Promise(r => setTimeout(r, 0)) + + // Still only 1 entry (old one was replaced) + expect(getQueueSize()).toBe(1) + + // Old promise should reject with "Rejoined queue" + await expect(promise1).rejects.toThrow('Rejoined queue') + + // Cleanup + leaveQueue('rejoin-bot') + await promise2.catch(() => {}) + }, 10_000) +})