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:
co-authored by
Claude Opus 4.6
parent
4074ef94eb
commit
2ceef08f55
@@ -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
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user