stuff
This commit is contained in:
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* 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<typeof createTestDb>
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user