From 42d79487a1b1cb75b57c34abcbe4d8b94ed10b85 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 13 Mar 2026 10:46:03 +0000 Subject: [PATCH] =?UTF-8?q?test:=20add=20odds=20calculation=20and=20retro-?= =?UTF-8?q?moves=20coverage=20=E2=80=94=2050=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Odds: eloProbability, calculateOdds, fractional/American display, payout calculation, bet validation (30 tests). Retro moves: RETRO_MOVES data, lookupMove, scoreRetroResponse, generateRetroChallenge, mock response generation (20 tests). Co-Authored-By: Claude Opus 4.6 --- server/src/engine/odds.test.ts | 162 +++++++++++++++---------- server/src/engine/retro-moves.test.ts | 166 ++++++++++++++++++++++++++ 2 files changed, 262 insertions(+), 66 deletions(-) create mode 100644 server/src/engine/retro-moves.test.ts diff --git a/server/src/engine/odds.test.ts b/server/src/engine/odds.test.ts index a13a818..15bae3a 100644 --- a/server/src/engine/odds.test.ts +++ b/server/src/engine/odds.test.ts @@ -1,3 +1,6 @@ +/** + * Odds calculation coverage tests — Phase 8.3 + */ import { describe, it, expect } from 'vitest' import { eloProbability, @@ -9,66 +12,80 @@ import { } from './odds.js' describe('eloProbability', () => { - it('equal ELO gives 50/50', () => { + it('equal ratings → 50%', () => { expect(eloProbability(1200, 1200)).toBeCloseTo(0.5, 5) }) - it('higher ELO favored', () => { - expect(eloProbability(1400, 1200)).toBeGreaterThan(0.7) + it('400 point advantage → ~91%', () => { + const prob = eloProbability(1600, 1200) + expect(prob).toBeGreaterThan(0.9) + expect(prob).toBeLessThan(0.92) }) - it('lower ELO underdog', () => { - expect(eloProbability(1000, 1400)).toBeLessThan(0.15) + it('400 point disadvantage → ~9%', () => { + const prob = eloProbability(1200, 1600) + expect(prob).toBeGreaterThan(0.08) + expect(prob).toBeLessThan(0.1) }) - it('probabilities are complementary', () => { - const pA = eloProbability(1300, 1100) - const pB = eloProbability(1100, 1300) - expect(pA + pB).toBeCloseTo(1, 5) - }) - - it('extreme ELO difference stays finite', () => { - const p = eloProbability(100, 2500) - expect(p).toBeGreaterThan(0) - expect(p).toBeLessThan(0.001) - expect(isFinite(p)).toBe(true) + it('always between 0 and 1', () => { + for (const [a, b] of [[0, 0], [0, 5000], [5000, 0], [1200, 1200]]) { + const p = eloProbability(a, b) + expect(p).toBeGreaterThan(0) + expect(p).toBeLessThan(1) + } }) }) describe('calculateOdds', () => { - it('equal ELO gives near-even odds', () => { + it('equal ELO → ~50/50 odds', () => { const odds = calculateOdds(1200, 1200) expect(odds.botAWinProb).toBeCloseTo(0.5, 1) expect(odds.botBWinProb).toBeCloseTo(0.5, 1) - expect(odds.botADecimalOdds).toBeCloseTo(2.0, 0) + expect(odds.spread).toBe(0) }) - it('house edge reduces payout below fair odds', () => { - const odds = calculateOdds(1200, 1200, { houseEdge: 0.05 }) + it('probabilities sum to 1', () => { + const odds = calculateOdds(1500, 1200) + expect(odds.botAWinProb + odds.botBWinProb).toBeCloseTo(1, 2) + }) + + it('higher ELO gets higher probability', () => { + const odds = calculateOdds(1500, 1200) + expect(odds.botAWinProb).toBeGreaterThan(odds.botBWinProb) + }) + + it('payout multiplier < fair odds (house edge)', () => { + const odds = calculateOdds(1200, 1200) expect(odds.botAPayoutMultiplier).toBeLessThan(odds.botADecimalOdds) expect(odds.botBPayoutMultiplier).toBeLessThan(odds.botBDecimalOdds) }) - it('favorite has lower payout multiplier', () => { - const odds = calculateOdds(1500, 1200) - expect(odds.botAPayoutMultiplier).toBeLessThan(odds.botBPayoutMultiplier) - }) - - it('streak adjusts probability', () => { + it('streak adjustment shifts probability', () => { const noStreak = calculateOdds(1200, 1200) - const withStreak = calculateOdds(1200, 1200, { streakA: 5 }) + const withStreak = calculateOdds(1200, 1200, { streakA: 5, streakB: 0 }) expect(withStreak.botAWinProb).toBeGreaterThan(noStreak.botAWinProb) }) - it('probabilities always sum to 1', () => { - const odds = calculateOdds(1600, 1000, { streakA: 3, streakB: 1 }) - expect(odds.botAWinProb + odds.botBWinProb).toBeCloseTo(1, 2) + it('recent form adjustment shifts probability', () => { + const noForm = calculateOdds(1200, 1200) + const withForm = calculateOdds(1200, 1200, { + recentWinRateA: 0.9, + recentWinRateB: 0.1, + }) + expect(withForm.botAWinProb).toBeGreaterThan(noForm.botAWinProb) }) - it('probabilities capped between 2% and 98%', () => { - const odds = calculateOdds(3000, 500, { streakA: 5 }) - expect(odds.botAWinProb).toBeLessThanOrEqual(0.98) - expect(odds.botBWinProb).toBeGreaterThanOrEqual(0.02) + it('custom house edge changes payouts', () => { + const low = calculateOdds(1200, 1200, { houseEdge: 0.01 }) + const high = calculateOdds(1200, 1200, { houseEdge: 0.10 }) + expect(low.botAPayoutMultiplier).toBeGreaterThan(high.botAPayoutMultiplier) + }) + + it('probability clamped to 0.02-0.98', () => { + const extreme = calculateOdds(5000, 0, { streakA: 5, streakB: 0 }) + expect(extreme.botAWinProb).toBeLessThanOrEqual(0.98) + expect(extreme.botBWinProb).toBeGreaterThanOrEqual(0.02) }) it('spread reflects ELO difference', () => { @@ -78,74 +95,87 @@ describe('calculateOdds', () => { }) describe('oddsToFractional', () => { - it('even money', () => { + it('2.0 → "1/1" (even money)', () => { expect(oddsToFractional(2.0)).toBe('1/1') }) - it('3/1', () => { - expect(oddsToFractional(4.0)).toBe('3/1') + it('3.0 → "2/1"', () => { + expect(oddsToFractional(3.0)).toBe('2/1') }) - it('1/2', () => { + it('1.5 → "1/2"', () => { expect(oddsToFractional(1.5)).toBe('1/2') }) + + it('decimal ≤ 1 → "0/1"', () => { + expect(oddsToFractional(1.0)).toBe('0/1') + expect(oddsToFractional(0.5)).toBe('0/1') + }) + + it('odd decimal uses /100 fallback', () => { + const result = oddsToFractional(1.123) + expect(result).toMatch(/\//) + }) }) describe('oddsToAmerican', () => { - it('underdog shows positive', () => { + it('2.0 → "+100"', () => { + expect(oddsToAmerican(2.0)).toBe('+100') + }) + + it('3.0 → "+200"', () => { expect(oddsToAmerican(3.0)).toBe('+200') }) - it('favorite shows negative', () => { + it('1.5 → "-200"', () => { expect(oddsToAmerican(1.5)).toBe('-200') }) - - it('even money', () => { - expect(oddsToAmerican(2.0)).toBe('+100') - }) }) describe('calculatePayout', () => { - it('100 sats at 2x = 200', () => { - expect(calculatePayout(100, 2.0)).toBe(200) + it('1000 sats × 2.0 = 2000 sats', () => { + expect(calculatePayout(1000, 2.0)).toBe(2000) }) - it('floors to integer sats', () => { - expect(calculatePayout(100, 1.94)).toBe(194) + it('floors fractional sats', () => { + expect(calculatePayout(100, 1.5)).toBe(150) + expect(calculatePayout(333, 1.5)).toBe(499) }) }) describe('validateBet', () => { - it('valid bet', () => { + it('valid bet within range', () => { expect(validateBet(1000).valid).toBe(true) }) - it('below minimum', () => { + it('rejects zero', () => { + expect(validateBet(0).valid).toBe(false) + }) + + it('rejects negative', () => { + const r = validateBet(-1) + expect(r.valid).toBe(false) + expect(r.error).toContain('positive') + }) + + it('rejects non-integer', () => { + expect(validateBet(1.5).valid).toBe(false) + }) + + it('rejects below minimum', () => { const r = validateBet(50) expect(r.valid).toBe(false) expect(r.error).toContain('Minimum') }) - it('above maximum', () => { + it('rejects above maximum', () => { const r = validateBet(200_000) expect(r.valid).toBe(false) expect(r.error).toContain('Maximum') }) - it('zero is invalid', () => { - expect(validateBet(0).valid).toBe(false) - }) - - it('negative is invalid', () => { - expect(validateBet(-100).valid).toBe(false) - }) - - it('non-integer is invalid', () => { - expect(validateBet(100.5).valid).toBe(false) - }) - - it('custom limits', () => { - expect(validateBet(50, 10, 100).valid).toBe(true) - expect(validateBet(5, 10, 100).valid).toBe(false) + it('custom min/max', () => { + expect(validateBet(5, 1, 10).valid).toBe(true) + expect(validateBet(15, 1, 10).valid).toBe(false) }) }) diff --git a/server/src/engine/retro-moves.test.ts b/server/src/engine/retro-moves.test.ts new file mode 100644 index 0000000..14fa19a --- /dev/null +++ b/server/src/engine/retro-moves.test.ts @@ -0,0 +1,166 @@ +/** + * Retro moves coverage tests — Phase 8.3 + */ +import { describe, it, expect } from 'vitest' +import { + RETRO_MOVES, + lookupMove, + scoreRetroResponse, + generateRetroChallenge, + generateMockRetroResponse, + getRetroMoveInputs, +} from './retro-moves.js' + +describe('RETRO_MOVES data', () => { + it('has at least 20 moves', () => { + expect(RETRO_MOVES.length).toBeGreaterThanOrEqual(20) + }) + + it('all tiers represented', () => { + const tiers = new Set(RETRO_MOVES.map(m => m.tier)) + expect(tiers.has('basic')).toBe(true) + expect(tiers.has('standard')).toBe(true) + expect(tiers.has('super')).toBe(true) + expect(tiers.has('ultra')).toBe(true) + }) + + it('all moves have positive damage', () => { + for (const m of RETRO_MOVES) { + expect(m.damage).toBeGreaterThan(0) + expect(m.input.length).toBeGreaterThan(0) + expect(m.name.length).toBeGreaterThan(0) + } + }) +}) + +describe('lookupMove', () => { + it('finds basic move by input', () => { + const move = lookupMove('A') + expect(move).not.toBeNull() + expect(move!.name).toBe('Jab') + }) + + it('finds super move', () => { + const move = lookupMove('↓→↓→+A') + expect(move).not.toBeNull() + expect(move!.name).toBe('Hadouken') + }) + + it('returns null for invalid input', () => { + expect(lookupMove('XYZZY')).toBeNull() + }) + + it('case insensitive', () => { + const move = lookupMove('a') + expect(move).not.toBeNull() + }) +}) + +describe('scoreRetroResponse', () => { + it('null answer → zero score', () => { + const result = scoreRetroResponse(null, []) + expect(result.totalDamage).toBe(0) + expect(result.moves).toHaveLength(0) + expect(result.score).toBe(0) + }) + + it('single known move scores its base damage', () => { + const result = scoreRetroResponse('A', ['A']) + expect(result.moves).toHaveLength(1) + expect(result.moves[0].name).toBe('Jab') + expect(result.moves[0].discovered).toBe(false) + expect(result.totalDamage).toBe(5) // Jab = 5 + }) + + it('discovered move gets 1.5x damage', () => { + const result = scoreRetroResponse('↓→↓→+A', []) // Hadouken not in known + expect(result.moves[0].discovered).toBe(true) + expect(result.moves[0].damage).toBe(Math.round(22 * 1.5)) // 33 + }) + + it('pipe-separated combo parses correctly', () => { + const result = scoreRetroResponse('A | B | →+A', ['A', 'B', '→+A']) + expect(result.moves).toHaveLength(3) + expect(result.totalDamage).toBe(5 + 6 + 8) // 19 + }) + + it('invalid move in combo → 0 damage for that move', () => { + // canonicalize extracts buttons from text, so use pure symbols that won't match + const result = scoreRetroResponse('A | ??? | B', ['A', 'B']) + expect(result.moves[1].name).toBeNull() + expect(result.moves[1].damage).toBe(0) + expect(result.totalDamage).toBe(5 + 0 + 6) + }) + + it('max 3 moves parsed', () => { + const result = scoreRetroResponse('A | B | →+A | ←+B', ['A', 'B', '→+A', '←+B']) + expect(result.moves).toHaveLength(3) + }) + + it('score capped at 10', () => { + // 3 super moves with discovery = huge damage + const result = scoreRetroResponse('↓→↓→+A | ←↓→+B | ↑↑↓↓+A', []) + expect(result.score).toBeLessThanOrEqual(10) + }) +}) + +describe('generateRetroChallenge', () => { + it('returns valid challenge object', () => { + const ch = generateRetroChallenge() + expect(ch.type).toBe('retro_mode') + expect(ch.scoring).toBe('factual') + expect(ch.timeout_ms).toBe(12000) + expect(ch.prompt).toContain('ARCADE ROUND') + expect(ch.answers!.length).toBeGreaterThanOrEqual(7) // 4 basics + 3-5 standards + }) + + it('challenge prompt lists known moves', () => { + const ch = generateRetroChallenge() + expect(ch.prompt).toContain('dmg') + expect(ch.prompt).toContain('combo1 | combo2 | combo3') + }) +}) + +describe('generateMockRetroResponse', () => { + it('returns pipe-separated moves', () => { + const resp = generateMockRetroResponse(1200) + expect(resp).toContain('|') + const parts = resp.split('|') + expect(parts.length).toBe(3) + }) + + it('low ELO bot may produce gibberish', () => { + let hasGibberish = false + for (let i = 0; i < 50; i++) { + const resp = generateMockRetroResponse(800) + const parts = resp.split('|').map(s => s.trim()) + for (const p of parts) { + if (!lookupMove(p)) hasGibberish = true + } + } + // Low ELO should sometimes produce invalid moves + expect(hasGibberish).toBe(true) + }) + + it('high ELO bot uses mostly valid moves', () => { + let validCount = 0 + let totalCount = 0 + for (let i = 0; i < 50; i++) { + const resp = generateMockRetroResponse(2000) + const parts = resp.split('|').map(s => s.trim()) + for (const p of parts) { + totalCount++ + if (lookupMove(p)) validCount++ + } + } + expect(validCount / totalCount).toBeGreaterThan(0.7) + }) +}) + +describe('getRetroMoveInputs', () => { + it('returns all move inputs', () => { + const inputs = getRetroMoveInputs() + expect(inputs.length).toBe(RETRO_MOVES.length) + expect(inputs.includes('A')).toBe(true) + }) +})