/** * Tournament engine tests — bracket generation, match scheduling, * elimination logic, and round progression. * * Uses vi.mock to swap the global db/schema/sqlite singleton with an * in-memory test database so every test gets a clean slate. */ import { describe, it, expect, beforeEach, vi } from 'vitest' import { createTestDb, insertTestBot } from '../test-helpers/db.js' import { nanoid } from 'nanoid' // Swap the db module before tournament code imports it let testDb: ReturnType vi.mock('../db/index.js', () => { // Lazy — the actual testDb is assigned in beforeEach, // but the module proxy always dereferences the live binding. return { get db() { return testDb.db }, get schema() { return testDb.schema }, get sqlite() { return testDb.sqlite }, } }) // Import AFTER mock is registered so the module picks up the proxy import { createTournament, joinTournament, startTournament, getTournamentBracket, listTournaments, getPendingMatches, linkFightToMatch, onFightFinished, } from './tournaments.js' import { eq, and } from 'drizzle-orm' // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- /** Insert N bots with ascending ELO (1200, 1300, 1400 ...) */ function seedBots(count: number) { const bots = [] for (let i = 0; i < count; i++) { const bot = insertTestBot(testDb.db, { id: `bot-${i}`, name: `Fighter-${i}`, eloRating: 1200 + i * 100, }) bots.push(bot) } return bots } /** Create a tournament and fill it with bots, returning the tournament id and bot ids */ function createAndFill(size: 8 | 16 | 32, botCount: number) { const bots = seedBots(botCount) const tid = createTournament(`Test-${size}`, 'single_elim', size, 0) for (const bot of bots) { joinTournament(tid, bot.id) } return { tid, bots } } /** * Insert a fight record into the DB so FK constraints are satisfied * when linking fights to tournament matches. */ function insertFight(fightId: string, botAId: string, botBId: string) { testDb.db.insert(testDb.schema.fights).values({ id: fightId, botAId, botBId, arena: 'test-arena', status: 'live', createdAt: new Date().toISOString(), }).run() } /** * Link a fight to a match with FK-safe fight insertion. * Creates the fight record, then links it to the match. */ function safeLink(matchId: string, fightId: string, botAId: string, botBId: string) { insertFight(fightId, botAId, botBId) linkFightToMatch(matchId, fightId) } // --------------------------------------------------------------------------- // setup // --------------------------------------------------------------------------- beforeEach(() => { testDb = createTestDb() }) // --------------------------------------------------------------------------- // createTournament // --------------------------------------------------------------------------- describe('createTournament', () => { it('creates tournament with correct defaults', () => { const id = createTournament('Halvening Cup', 'single_elim', 8, 500) const all = listTournaments() expect(all).toHaveLength(1) const t = all[0] expect(t.id).toBe(id) expect(t.name).toBe('Halvening Cup') expect(t.format).toBe('single_elim') expect(t.size).toBe(8) expect(t.entrySats).toBe(500) expect(t.prizeSats).toBe(4000) // 500 * 8 expect(t.status).toBe('open') expect(t.currentRound).toBe(0) }) it('creates free tournament (zero entry fee)', () => { createTournament('Free Arena', 'single_elim', 16) const all = listTournaments() expect(all[0].entrySats).toBe(0) expect(all[0].prizeSats).toBe(0) }) it('listTournaments filters by status', () => { createTournament('Open1', 'single_elim', 8) createTournament('Open2', 'single_elim', 8) expect(listTournaments('open')).toHaveLength(2) expect(listTournaments('active')).toHaveLength(0) expect(listTournaments('finished')).toHaveLength(0) }) }) // --------------------------------------------------------------------------- // joinTournament // --------------------------------------------------------------------------- describe('joinTournament', () => { it('adds bot entry', () => { const bots = seedBots(1) const tid = createTournament('Join Test', 'single_elim', 8) const entryId = joinTournament(tid, bots[0].id) expect(entryId).toBeTruthy() const bracket = getTournamentBracket(tid)! expect(bracket.entries).toHaveLength(1) expect(bracket.entries[0].botId).toBe(bots[0].id) }) it('rejects duplicate entry', () => { const bots = seedBots(1) const tid = createTournament('Dup Test', 'single_elim', 8) joinTournament(tid, bots[0].id) expect(() => joinTournament(tid, bots[0].id)) .toThrow('Bot already entered in this tournament') }) it('rejects entry to nonexistent tournament', () => { const bots = seedBots(1) expect(() => joinTournament('fake-id', bots[0].id)) .toThrow('Tournament not found') }) it('rejects entry when tournament is full', () => { const { tid } = createAndFill(8, 8) const extra = insertTestBot(testDb.db, { id: 'bot-extra', name: 'ExtraBot' }) expect(() => joinTournament(tid, extra.id)) .toThrow('Tournament is full') }) it('rejects entry to non-open tournament', () => { const { tid } = createAndFill(8, 4) startTournament(tid) const extra = insertTestBot(testDb.db, { id: 'bot-late', name: 'LateBot' }) expect(() => joinTournament(tid, extra.id)) .toThrow('Tournament is not accepting entries') }) }) // --------------------------------------------------------------------------- // startTournament & bracket generation // --------------------------------------------------------------------------- describe('startTournament', () => { it('requires at least 2 entries', () => { const bots = seedBots(1) const tid = createTournament('Tiny', 'single_elim', 8) joinTournament(tid, bots[0].id) expect(() => startTournament(tid)).toThrow('Need at least 2 entries') }) it('rejects double-start', () => { const { tid } = createAndFill(8, 4) startTournament(tid) expect(() => startTournament(tid)).toThrow('Tournament already started') }) it('sets status to active and currentRound >= 1', () => { const { tid } = createAndFill(8, 8) startTournament(tid) const bracket = getTournamentBracket(tid)! expect(bracket.tournament.status).toBe('active') expect(bracket.tournament.currentRound).toBeGreaterThanOrEqual(1) expect(bracket.tournament.startedAt).toBeTruthy() }) }) describe('bracket generation — 8 bots (full bracket)', () => { it('generates 4 round-1 matches for 8 bots', () => { const { tid } = createAndFill(8, 8) startTournament(tid) const bracket = getTournamentBracket(tid)! const r1 = bracket.matches.filter(m => m.round === 1) expect(r1).toHaveLength(4) }) it('seeds by ELO: highest vs lowest', () => { const { tid } = createAndFill(8, 8) startTournament(tid) const bracket = getTournamentBracket(tid)! const r1 = bracket.matches.filter(m => m.round === 1).sort((a, b) => a.matchIndex - b.matchIndex) // Seed 1 (highest ELO = bot-7) vs Seed 8 (lowest = bot-0) in match 0 expect(r1[0].botAId).toBe('bot-7') expect(r1[0].botBId).toBe('bot-0') // Seed 2 (bot-6) vs Seed 7 (bot-1) in match 1 expect(r1[1].botAId).toBe('bot-6') expect(r1[1].botBId).toBe('bot-1') }) it('all round-1 matches are pending (no byes)', () => { const { tid } = createAndFill(8, 8) startTournament(tid) const bracket = getTournamentBracket(tid)! const r1 = bracket.matches.filter(m => m.round === 1) expect(r1.every(m => m.status === 'pending')).toBe(true) }) }) describe('bracket generation — 4 bots in size-8 bracket (with byes)', () => { it('generates 4 round-1 matches, all byes auto-advance', () => { const { tid } = createAndFill(8, 4) startTournament(tid) const bracket = getTournamentBracket(tid)! const r1 = bracket.matches.filter(m => m.round === 1) expect(r1).toHaveLength(4) // 4 bots fill seeded slots [0..3], slots [4..7] are null. // Matches pair seeded[i] vs seeded[7-i], so every match is bot vs null = bye. // All 4 round-1 matches should be finished (auto-advanced). const byeMatches = r1.filter(m => m.status === 'finished') expect(byeMatches).toHaveLength(4) // Round 2 should already be generated with the 4 winners const r2 = bracket.matches.filter(m => m.round === 2) expect(r2.length).toBeGreaterThanOrEqual(2) }) it('bye matches have a winner set', () => { const { tid } = createAndFill(8, 4) startTournament(tid) const bracket = getTournamentBracket(tid)! const byeMatches = bracket.matches.filter(m => m.round === 1 && m.status === 'finished') for (const m of byeMatches) { expect(m.winnerId).toBeTruthy() // Winner should be the non-null bot if (m.botAId && !m.botBId) expect(m.winnerId).toBe(m.botAId) if (m.botBId && !m.botAId) expect(m.winnerId).toBe(m.botBId) } }) }) describe('bracket generation — 16 bots', () => { it('generates 8 round-1 matches for full 16-bot bracket', () => { const { tid } = createAndFill(16, 16) startTournament(tid) const bracket = getTournamentBracket(tid)! const r1 = bracket.matches.filter(m => m.round === 1) expect(r1).toHaveLength(8) expect(r1.every(m => m.status === 'pending')).toBe(true) }) }) // --------------------------------------------------------------------------- // getPendingMatches & linkFightToMatch // --------------------------------------------------------------------------- describe('getPendingMatches', () => { it('returns matches where both bots are present', () => { const { tid } = createAndFill(8, 8) startTournament(tid) const pending = getPendingMatches(tid) expect(pending.length).toBe(4) for (const m of pending) { expect(m.botAId).toBeTruthy() expect(m.botBId).toBeTruthy() expect(m.status).toBe('pending') } }) }) describe('linkFightToMatch', () => { it('sets fight ID and status to live', () => { const { tid } = createAndFill(8, 8) startTournament(tid) const pending = getPendingMatches(tid) const match = pending[0] safeLink(match.id, 'fight-xyz', match.botAId!, match.botBId!) const updated = testDb.db.select().from(testDb.schema.tournamentMatches) .where(eq(testDb.schema.tournamentMatches.id, match.id)) .get()! expect(updated.fightId).toBe('fight-xyz') expect(updated.status).toBe('live') }) }) // --------------------------------------------------------------------------- // onFightFinished — elimination & round progression // --------------------------------------------------------------------------- describe('onFightFinished — elimination logic', () => { it('marks loser as eliminated', () => { const { tid } = createAndFill(8, 8) startTournament(tid) const pending = getPendingMatches(tid) const match = pending[0] safeLink(match.id, 'fight-elim-1', match.botAId!, match.botBId!) onFightFinished('fight-elim-1', match.botAId!) // Loser (botB) should be eliminated const entry = testDb.db.select().from(testDb.schema.tournamentEntries) .where(and( eq(testDb.schema.tournamentEntries.tournamentId, tid), eq(testDb.schema.tournamentEntries.botId, match.botBId!), )) .get()! // SQLite stores boolean as 0/1 via raw query; drizzle may return number expect(entry.eliminated).toBeTruthy() }) it('updates match with winner and finished status', () => { const { tid } = createAndFill(8, 8) startTournament(tid) const pending = getPendingMatches(tid) const match = pending[0] safeLink(match.id, 'fight-elim-2', match.botAId!, match.botBId!) onFightFinished('fight-elim-2', match.botAId!) const updated = testDb.db.select().from(testDb.schema.tournamentMatches) .where(eq(testDb.schema.tournamentMatches.id, match.id)) .get()! expect(updated.winnerId).toBe(match.botAId) expect(updated.status).toBe('finished') }) it('ignores draw (null winnerId)', () => { const { tid } = createAndFill(8, 8) startTournament(tid) const pending = getPendingMatches(tid) safeLink(pending[0].id, 'fight-draw', pending[0].botAId!, pending[0].botBId!) // onFightFinished early-returns on falsy winnerId, so no-op onFightFinished('fight-draw', null as unknown as string) }) it('ignores non-tournament fights', () => { // No tournament context — should not throw onFightFinished('random-fight-id', 'some-bot') }) }) // --------------------------------------------------------------------------- // round progression — full tournament lifecycle // --------------------------------------------------------------------------- describe('round progression', () => { it('advances to round 2 when all round-1 matches finish', () => { const { tid } = createAndFill(8, 8) startTournament(tid) const pending = getPendingMatches(tid) expect(pending).toHaveLength(4) // Finish all round 1 matches — botA always wins for (const match of pending) { safeLink(match.id, `fight-r1-${match.matchIndex}`, match.botAId!, match.botBId!) onFightFinished(`fight-r1-${match.matchIndex}`, match.botAId!) } const bracket = getTournamentBracket(tid)! expect(bracket.tournament.currentRound).toBe(2) // Round 2 should have 2 matches const r2 = bracket.matches.filter(m => m.round === 2) expect(r2).toHaveLength(2) }) it('full 8-bot tournament completes in 3 rounds (8 -> 4 -> 2 -> 1)', () => { const { tid } = createAndFill(8, 8) startTournament(tid) // Round 1: 4 matches let pending = getPendingMatches(tid) expect(pending).toHaveLength(4) for (const m of pending) { safeLink(m.id, `fight-${m.round}-${m.matchIndex}`, m.botAId!, m.botBId!) onFightFinished(`fight-${m.round}-${m.matchIndex}`, m.botAId!) } // Round 2: 2 matches pending = getPendingMatches(tid) expect(pending).toHaveLength(2) for (const m of pending) { safeLink(m.id, `fight-${m.round}-${m.matchIndex}`, m.botAId!, m.botBId!) onFightFinished(`fight-${m.round}-${m.matchIndex}`, m.botAId!) } // Round 3 (final): 1 match pending = getPendingMatches(tid) expect(pending).toHaveLength(1) const finalMatch = pending[0] safeLink(finalMatch.id, 'fight-final', finalMatch.botAId!, finalMatch.botBId!) onFightFinished('fight-final', finalMatch.botAId!) // Tournament should be finished const bracket = getTournamentBracket(tid)! expect(bracket.tournament.status).toBe('finished') expect(bracket.tournament.finishedAt).toBeTruthy() }) it('partial round completion does not advance', () => { const { tid } = createAndFill(8, 8) startTournament(tid) const pending = getPendingMatches(tid) // Only finish 2 out of 4 matches for (let i = 0; i < 2; i++) { safeLink(pending[i].id, `fight-partial-${i}`, pending[i].botAId!, pending[i].botBId!) onFightFinished(`fight-partial-${i}`, pending[i].botAId!) } const bracket = getTournamentBracket(tid)! // Still in round 1 — not all matches finished expect(bracket.tournament.currentRound).toBe(1) // No round 2 matches generated yet const r2 = bracket.matches.filter(m => m.round === 2) expect(r2).toHaveLength(0) }) }) // --------------------------------------------------------------------------- // getTournamentBracket // --------------------------------------------------------------------------- describe('getTournamentBracket', () => { it('returns null for unknown tournament', () => { expect(getTournamentBracket('fake-id')).toBeNull() }) it('includes bot names in match data', () => { const { tid } = createAndFill(8, 8) startTournament(tid) const bracket = getTournamentBracket(tid)! const r1 = bracket.matches.filter(m => m.round === 1) for (const m of r1) { if (m.botAId) expect(m.botAName).toBeTruthy() if (m.botBId) expect(m.botBName).toBeTruthy() } }) it('entries show seed numbers after start', () => { const { tid } = createAndFill(8, 8) startTournament(tid) const bracket = getTournamentBracket(tid)! const seeds = bracket.entries.map(e => e.seed).sort((a, b) => a - b) expect(seeds).toEqual([1, 2, 3, 4, 5, 6, 7, 8]) }) it('highest ELO gets seed 1', () => { const { tid } = createAndFill(8, 8) startTournament(tid) const bracket = getTournamentBracket(tid)! // bot-7 has highest ELO (1200 + 7*100 = 1900) const topSeed = bracket.entries.find(e => e.seed === 1)! expect(topSeed.botId).toBe('bot-7') }) it('tracks eliminated status', () => { const { tid } = createAndFill(8, 8) startTournament(tid) // Before any fights, nobody eliminated let bracket = getTournamentBracket(tid)! expect(bracket.entries.every(e => !e.eliminated)).toBe(true) // Finish one match const pending = getPendingMatches(tid) safeLink(pending[0].id, 'fight-track', pending[0].botAId!, pending[0].botBId!) onFightFinished('fight-track', pending[0].botAId!) bracket = getTournamentBracket(tid)! const eliminated = bracket.entries.filter(e => e.eliminated) expect(eliminated).toHaveLength(1) expect(eliminated[0].botId).toBe(pending[0].botBId) }) })