diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0abe79f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Tests + run: pnpm test -- --run + + - name: Type check + run: | + pnpm --filter server exec tsc --noEmit + pnpm --filter frontend exec vue-tsc --noEmit + + - name: Lint + run: pnpm lint diff --git a/server/src/engine/lifecycle.test.ts b/server/src/engine/lifecycle.test.ts new file mode 100644 index 0000000..9bf1361 --- /dev/null +++ b/server/src/engine/lifecycle.test.ts @@ -0,0 +1,142 @@ +/** + * End-to-end lifecycle tests for the fight scoring pipeline. + * Tests the full challenge → response → scoring → elo → tier flow + * without requiring database access. + */ +import { describe, it, expect } from 'vitest' +import { pickChallenge, getAllChallengeTypes, type Challenge } from './challenges.js' +import { checkAnswer } from './answers.js' +import { scoreRound, calculateElo, calculateTier } from './scoring.js' +import { mockResponse } from './mock.js' + +function simulateFight(eloA: number, eloB: number, rounds = 5) { + const botA = { id: 'a1', name: 'FighterA' } + const botB = { id: 'b1', name: 'FighterB' } + const usedTypes = new Set() + let comboA = 0 + let comboB = 0 + const results = [] + + for (let i = 0; i < rounds; i++) { + const challenge = pickChallenge(usedTypes, null) + usedTypes.add(challenge.type) + + const respA = mockResponse(challenge, 'confident', eloA) + const respB = mockResponse(challenge, 'clueless', eloB) + + const result = scoreRound( + challenge, botA, botB, + { answer: respA.answer, timeMs: respA.timeMs, timedOut: respA.timedOut, error: respA.error }, + { answer: respB.answer, timeMs: respB.timeMs, timedOut: respB.timedOut, error: respB.error }, + null, comboA, comboB, + ) + + if (result.winnerId === botA.id) { comboA++; comboB = 0 } + else if (result.winnerId === botB.id) { comboB++; comboA = 0 } + + results.push(result) + } + + return results +} + +describe('fight lifecycle', () => { + it('full pipeline: challenge → mock response → scoring → results', () => { + const results = simulateFight(1800, 1000) + + expect(results.length).toBe(5) + for (const r of results) { + expect(r.botAScore).toBeGreaterThanOrEqual(0) + expect(r.botBScore).toBeGreaterThanOrEqual(0) + expect(r.botADamage).toBeGreaterThanOrEqual(0) + expect(r.botBDamage).toBeGreaterThanOrEqual(0) + expect(typeof r.narration).toBe('string') + expect(typeof r.isCritical).toBe('boolean') + } + }) + + it('higher ELO bot wins more rounds on average', () => { + let highWins = 0 + let lowWins = 0 + // Run many fights to average out randomness + for (let f = 0; f < 20; f++) { + const results = simulateFight(1800, 900) + for (const r of results) { + if (r.winnerId === 'a1') highWins++ + else if (r.winnerId === 'b1') lowWins++ + } + } + expect(highWins).toBeGreaterThan(lowWins) + }) + + it('Elo updates reflect fight outcome', () => { + const eloA = 1400 + const eloB = 1400 + const { newWinnerElo, newLoserElo } = calculateElo(eloA, eloB) + expect(newWinnerElo).toBeGreaterThan(eloA) + expect(newLoserElo).toBeLessThan(eloB) + // Sum should be roughly preserved (zero-sum) + expect(Math.abs((newWinnerElo + newLoserElo) - (eloA + eloB))).toBeLessThan(1) + }) + + it('tier progresses with wins and Elo', () => { + expect(calculateTier(1200, 0)).toBe(0) // no wins + expect(calculateTier(1200, 1)).toBe(1) // bronze + expect(calculateTier(1200, 3)).toBe(2) // silver + expect(calculateTier(1350, 7)).toBe(3) // gold + expect(calculateTier(1500, 15)).toBe(4) // platinum + expect(calculateTier(1700, 25)).toBe(5) // diamond + expect(calculateTier(1900, 40)).toBe(6) // legend + }) + + it('no type repeats in single fight until exhausted', () => { + const usedTypes = new Set() + const allTypes = getAllChallengeTypes() + + // Pick challenges for all 16 types — no repeats + for (let i = 0; i < allTypes.length; i++) { + const c = pickChallenge(usedTypes, null) + expect(usedTypes.has(c.type)).toBe(false) + usedTypes.add(c.type) + } + expect(usedTypes.size).toBe(allTypes.length) + + // After exhausting all types, reset works + const c = pickChallenge(usedTypes, null) + expect(c).toBeTruthy() + }) + + it('checkAnswer integrates with challenge answers', () => { + // Pick factual challenges and verify correct answers score 1.0 + for (let i = 0; i < 50; i++) { + const c = pickChallenge(new Set(), null) + if (c.scoring === 'factual' && c.answers && c.answers.length > 0) { + const score = checkAnswer(c.answers[0], c.answers) + expect(score).toBe(1.0) + } + } + }) + + it('combo buildup increases damage across rounds', () => { + const challenge: Challenge = { + type: 'riddle', + label: 'Test', + prompt: 'Q?', + answers: ['4'], + scoring: 'factual', + baseDamage: 20, + timeout_ms: 8000, + } + const botA = { id: 'a1', name: 'A' } + const botB = { id: 'b1', name: 'B' } + const resp = { answer: '4', timeMs: 200, timedOut: false, error: false } + const wrong = { answer: 'x', timeMs: 200, timedOut: false, error: false } + + const r0 = scoreRound(challenge, botA, botB, resp, wrong, null, 0, 0) + const r3 = scoreRound(challenge, botA, botB, resp, wrong, null, 3, 0) + const r5 = scoreRound(challenge, botA, botB, resp, wrong, null, 5, 0) + + expect(r3.botADamage).toBeGreaterThan(r0.botADamage) + expect(r5.botADamage).toBeGreaterThan(r3.botADamage) + }) +})