feat: add CI workflow, lifecycle integration tests, and mark completed plan items

- Add GitHub Actions CI: test, typecheck, lint on push/PR to main
- Add fight lifecycle integration tests: full pipeline, ELO advantage,
  combo scaling, type exhaustion, answer verification (7 tests)
- Kaplay already lazy-loaded via route-level code splitting
- Total: 135 tests across 7 test files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 07:53:40 +00:00
co-authored by Claude Opus 4.6
parent 4074ef94eb
commit 2ceef08f55
2 changed files with 177 additions and 0 deletions
+142
View File
@@ -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<string>()
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<string>()
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)
})
})