test: add soak and stress tests — 1000 fights, 50 concurrent queue joins

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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-13 10:20:50 +00:00
co-authored by Claude Opus 4.6
parent 27b3b89424
commit 6a00cfe324
2 changed files with 346 additions and 0 deletions
+211
View File
@@ -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<string>()
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)
})
+135
View File
@@ -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<string>[] = []
// 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<string>).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)
})