2026-03-13 04:38:31 +00:00
|
|
|
import { describe, it, expect } from 'vitest'
|
|
|
|
|
import { scoreRound, calculateElo } from './scoring.js'
|
|
|
|
|
import { pickChallenge } from './challenges.js'
|
|
|
|
|
import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
|
|
|
import { resolve, dirname } from 'node:path'
|
|
|
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
|
|
|
|
|
|
// Simulate fights with controlled conditions
|
|
|
|
|
function simulateFight(opts: {
|
|
|
|
|
bothCorrect: boolean
|
|
|
|
|
botAAccuracy: number // 0-1 chance of correct
|
|
|
|
|
botBAccuracy: number
|
|
|
|
|
botASpeedMs: number // avg response time
|
|
|
|
|
botBSpeedMs: number
|
|
|
|
|
speedVariance: number // ± random variance
|
|
|
|
|
rounds: number
|
|
|
|
|
}) {
|
|
|
|
|
let hpA = 200, hpB = 200
|
|
|
|
|
let comboA = 0, comboB = 0
|
|
|
|
|
const usedTypes = new Set<string>()
|
|
|
|
|
const roundResults: { margin: number; isCritical: boolean; botADmg: number; botBDmg: number; winnerId: string | null }[] = []
|
|
|
|
|
|
|
|
|
|
for (let r = 0; r < opts.rounds && hpA > 0 && hpB > 0; r++) {
|
|
|
|
|
const challenge = pickChallenge(usedTypes, null)
|
|
|
|
|
usedTypes.add(challenge.type)
|
|
|
|
|
if (usedTypes.size >= 15) usedTypes.clear()
|
|
|
|
|
|
|
|
|
|
const aCorrect = opts.bothCorrect || Math.random() < opts.botAAccuracy
|
|
|
|
|
const bCorrect = opts.bothCorrect || Math.random() < opts.botBAccuracy
|
|
|
|
|
|
|
|
|
|
const aTimeMs = Math.max(200, opts.botASpeedMs + (Math.random() - 0.5) * 2 * opts.speedVariance)
|
|
|
|
|
const bTimeMs = Math.max(200, opts.botBSpeedMs + (Math.random() - 0.5) * 2 * opts.speedVariance)
|
|
|
|
|
|
|
|
|
|
const aAnswer = aCorrect ? (challenge.answers?.[0] ?? 'correct') : 'wrong_answer_xyz'
|
|
|
|
|
const bAnswer = bCorrect ? (challenge.answers?.[0] ?? 'correct') : 'wrong_answer_xyz'
|
|
|
|
|
|
|
|
|
|
const result = scoreRound(
|
|
|
|
|
challenge, { id: 'a1', name: 'BotA' }, { id: 'b1', name: 'BotB' },
|
|
|
|
|
{ answer: aAnswer, timeMs: aTimeMs, timedOut: false, error: false, trashTalk: '' },
|
|
|
|
|
{ answer: bAnswer, timeMs: bTimeMs, timedOut: false, error: false, trashTalk: '' },
|
|
|
|
|
null, comboA, comboB,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const margin = Math.abs(result.botAScore - result.botBScore)
|
|
|
|
|
|
|
|
|
|
if (result.winnerId === 'a1') {
|
|
|
|
|
hpB -= result.botADamage
|
|
|
|
|
hpA -= result.botBDamage
|
|
|
|
|
comboA++
|
|
|
|
|
comboB = 0
|
|
|
|
|
} else if (result.winnerId === 'b1') {
|
|
|
|
|
hpA -= result.botBDamage
|
|
|
|
|
hpB -= result.botADamage
|
|
|
|
|
comboB++
|
|
|
|
|
comboA = 0
|
|
|
|
|
} else {
|
|
|
|
|
comboA = 0
|
|
|
|
|
comboB = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
roundResults.push({
|
|
|
|
|
margin,
|
|
|
|
|
isCritical: result.isCritical,
|
|
|
|
|
botADmg: result.botADamage,
|
|
|
|
|
botBDmg: result.botBDamage,
|
|
|
|
|
winnerId: result.winnerId,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const winnerId = hpA <= 0 ? 'b1' : hpB <= 0 ? 'a1' : (hpA > hpB ? 'a1' : hpB > hpA ? 'b1' : null)
|
|
|
|
|
return { winnerId, hpA, hpB, rounds: roundResults }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('scoring competitiveness analysis', () => {
|
|
|
|
|
const reportLines: string[] = []
|
|
|
|
|
|
|
|
|
|
it('RESEARCH: 1000 fights where BOTH bots answer correctly — speed-only dynamics', () => {
|
|
|
|
|
const N = 1000
|
|
|
|
|
let aWins = 0, bWins = 0, draws = 0
|
|
|
|
|
let totalMargins = 0, marginCount = 0
|
|
|
|
|
let criticalCount = 0, totalRounds = 0
|
|
|
|
|
const roundCounts: number[] = []
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < N; i++) {
|
|
|
|
|
const result = simulateFight({
|
|
|
|
|
bothCorrect: true,
|
|
|
|
|
botAAccuracy: 1,
|
|
|
|
|
botBAccuracy: 1,
|
|
|
|
|
botASpeedMs: 1000, // Bot A: 1s avg
|
|
|
|
|
botBSpeedMs: 1500, // Bot B: 1.5s avg
|
|
|
|
|
speedVariance: 500, // ±500ms variance
|
|
|
|
|
rounds: 20,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (result.winnerId === 'a1') aWins++
|
|
|
|
|
else if (result.winnerId === 'b1') bWins++
|
|
|
|
|
else draws++
|
|
|
|
|
|
|
|
|
|
roundCounts.push(result.rounds.length)
|
|
|
|
|
for (const r of result.rounds) {
|
|
|
|
|
totalMargins += r.margin
|
|
|
|
|
marginCount++
|
|
|
|
|
if (r.isCritical) criticalCount++
|
|
|
|
|
totalRounds++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const avgMargin = marginCount > 0 ? totalMargins / marginCount : 0
|
|
|
|
|
const avgRounds = roundCounts.reduce((a, b) => a + b, 0) / N
|
|
|
|
|
const critRate = totalRounds > 0 ? criticalCount / totalRounds : 0
|
|
|
|
|
|
|
|
|
|
const section = [
|
|
|
|
|
'## Speed Dominance Analysis (1000 fights, both correct)',
|
|
|
|
|
'',
|
|
|
|
|
`- Faster bot (1.0s avg) wins: ${aWins} (${(aWins / N * 100).toFixed(1)}%)`,
|
|
|
|
|
`- Slower bot (1.5s avg) wins: ${bWins} (${(bWins / N * 100).toFixed(1)}%)`,
|
|
|
|
|
`- Draws: ${draws} (${(draws / N * 100).toFixed(1)}%)`,
|
|
|
|
|
`- Avg margin per round: ${avgMargin.toFixed(2)}`,
|
|
|
|
|
`- Critical hit rate: ${(critRate * 100).toFixed(1)}%`,
|
|
|
|
|
`- Avg rounds per fight: ${avgRounds.toFixed(1)}`,
|
|
|
|
|
`- Min/Max rounds: ${Math.min(...roundCounts)} / ${Math.max(...roundCounts)}`,
|
|
|
|
|
'',
|
|
|
|
|
]
|
|
|
|
|
reportLines.push(...section)
|
|
|
|
|
|
|
|
|
|
console.log('\n=== SPEED DOMINANCE ANALYSIS ===')
|
|
|
|
|
section.forEach(l => console.log(` ${l}`))
|
|
|
|
|
|
|
|
|
|
// Soft assertion — research task
|
|
|
|
|
expect(true).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('RESEARCH: 1000 fights — 90% vs 70% accuracy, similar speed', () => {
|
|
|
|
|
const N = 1000
|
|
|
|
|
let aWins = 0, bWins = 0, draws = 0
|
|
|
|
|
const eloDeltas: number[] = []
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < N; i++) {
|
|
|
|
|
const result = simulateFight({
|
|
|
|
|
bothCorrect: false,
|
|
|
|
|
botAAccuracy: 0.9,
|
|
|
|
|
botBAccuracy: 0.7,
|
|
|
|
|
botASpeedMs: 1200,
|
|
|
|
|
botBSpeedMs: 1200,
|
|
|
|
|
speedVariance: 300,
|
|
|
|
|
rounds: 20,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (result.winnerId === 'a1') aWins++
|
|
|
|
|
else if (result.winnerId === 'b1') bWins++
|
|
|
|
|
else draws++
|
|
|
|
|
|
|
|
|
|
// Calculate ELO delta for this fight
|
|
|
|
|
if (result.winnerId) {
|
|
|
|
|
const elo = calculateElo(1200, 1200)
|
|
|
|
|
eloDeltas.push(elo.newWinnerElo - 1200)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const avgEloDelta = eloDeltas.length > 0 ? eloDeltas.reduce((a, b) => a + b, 0) / eloDeltas.length : 0
|
|
|
|
|
|
|
|
|
|
const section = [
|
|
|
|
|
'## Accuracy Impact Analysis (1000 fights, 90% vs 70%)',
|
|
|
|
|
'',
|
|
|
|
|
`- High accuracy (90%) wins: ${aWins} (${(aWins / N * 100).toFixed(1)}%)`,
|
|
|
|
|
`- Low accuracy (70%) wins: ${bWins} (${(bWins / N * 100).toFixed(1)}%)`,
|
|
|
|
|
`- Draws: ${draws} (${(draws / N * 100).toFixed(1)}%)`,
|
|
|
|
|
`- Avg ELO delta per fight: ${avgEloDelta.toFixed(1)}`,
|
|
|
|
|
'',
|
|
|
|
|
]
|
|
|
|
|
reportLines.push(...section)
|
|
|
|
|
|
|
|
|
|
console.log('\n=== ACCURACY IMPACT ANALYSIS ===')
|
|
|
|
|
section.forEach(l => console.log(` ${l}`))
|
|
|
|
|
|
|
|
|
|
// Soft assertion
|
|
|
|
|
expect(true).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('RESEARCH: both-correct scoring formula — max margin and critical hit possibility', () => {
|
|
|
|
|
// Test multiple extreme speed differentials
|
|
|
|
|
const challenge = pickChallenge(new Set(), null)
|
|
|
|
|
if (!challenge.answers?.length) return
|
|
|
|
|
|
|
|
|
|
const scenarios = [
|
|
|
|
|
{ aMs: 200, bMs: 5000, label: 'extreme (200ms vs 5000ms)' },
|
|
|
|
|
{ aMs: 500, bMs: 3000, label: 'large (500ms vs 3000ms)' },
|
|
|
|
|
{ aMs: 1000, bMs: 1500, label: 'small (1000ms vs 1500ms)' },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const section = [
|
|
|
|
|
'## Both-Correct Scoring Formula — Margin Analysis',
|
|
|
|
|
'',
|
|
|
|
|
'| Speed Diff | Score A | Score B | Margin | Critical? |',
|
|
|
|
|
'|-----------|---------|---------|--------|-----------|',
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for (const s of scenarios) {
|
|
|
|
|
const result = scoreRound(
|
|
|
|
|
challenge, { id: 'a1', name: 'BotA' }, { id: 'b1', name: 'BotB' },
|
|
|
|
|
{ answer: challenge.answers[0], timeMs: s.aMs, timedOut: false, error: false, trashTalk: '' },
|
|
|
|
|
{ answer: challenge.answers[0], timeMs: s.bMs, timedOut: false, error: false, trashTalk: '' },
|
|
|
|
|
null, 0, 0,
|
|
|
|
|
)
|
|
|
|
|
const margin = Math.abs(result.botAScore - result.botBScore)
|
|
|
|
|
section.push(`| ${s.label} | ${result.botAScore} | ${result.botBScore} | ${margin.toFixed(2)} | ${result.isCritical} |`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
section.push('')
|
2026-03-13 04:44:29 +00:00
|
|
|
section.push('**Critical hit threshold: 3**')
|
2026-03-13 04:38:31 +00:00
|
|
|
section.push('')
|
|
|
|
|
reportLines.push(...section)
|
|
|
|
|
|
|
|
|
|
console.log('\n=== BOTH-CORRECT MAX MARGIN ===')
|
|
|
|
|
section.forEach(l => console.log(` ${l}`))
|
|
|
|
|
|
|
|
|
|
expect(true).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('RESEARCH: fight duration when both correct — expected 7-8 rounds', () => {
|
|
|
|
|
const N = 500
|
|
|
|
|
const roundCounts: number[] = []
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < N; i++) {
|
|
|
|
|
const result = simulateFight({
|
|
|
|
|
bothCorrect: true,
|
|
|
|
|
botAAccuracy: 1,
|
|
|
|
|
botBAccuracy: 1,
|
|
|
|
|
botASpeedMs: 1000,
|
|
|
|
|
botBSpeedMs: 1000,
|
|
|
|
|
speedVariance: 500,
|
|
|
|
|
rounds: 30, // allow enough rounds
|
|
|
|
|
})
|
|
|
|
|
roundCounts.push(result.rounds.length)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const avg = roundCounts.reduce((a, b) => a + b, 0) / N
|
|
|
|
|
const under5 = roundCounts.filter(r => r <= 5).length / N
|
|
|
|
|
const over10 = roundCounts.filter(r => r > 10).length / N
|
|
|
|
|
const over15 = roundCounts.filter(r => r > 15).length / N
|
|
|
|
|
|
|
|
|
|
const section = [
|
|
|
|
|
'## Fight Duration (500 fights, both correct, equal speed)',
|
|
|
|
|
'',
|
|
|
|
|
`- Average rounds: ${avg.toFixed(1)}`,
|
|
|
|
|
`- Under 5 rounds: ${(under5 * 100).toFixed(1)}%`,
|
|
|
|
|
`- Over 10 rounds: ${(over10 * 100).toFixed(1)}%`,
|
|
|
|
|
`- Over 15 rounds: ${(over15 * 100).toFixed(1)}%`,
|
|
|
|
|
`- Min: ${Math.min(...roundCounts)}, Max: ${Math.max(...roundCounts)}`,
|
|
|
|
|
'',
|
|
|
|
|
]
|
|
|
|
|
reportLines.push(...section)
|
|
|
|
|
|
|
|
|
|
console.log('\n=== FIGHT DURATION ===')
|
|
|
|
|
section.forEach(l => console.log(` ${l}`))
|
|
|
|
|
|
|
|
|
|
// Soft assertion
|
|
|
|
|
expect(true).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('writes scoring analysis reports', () => {
|
|
|
|
|
const __filename = fileURLToPath(import.meta.url)
|
|
|
|
|
const projectRoot = resolve(dirname(__filename), '..', '..', '..')
|
|
|
|
|
const loopDir = resolve(projectRoot, 'loop')
|
|
|
|
|
if (!existsSync(loopDir)) mkdirSync(loopDir, { recursive: true })
|
|
|
|
|
|
|
|
|
|
// Write combined report
|
|
|
|
|
const header = [
|
|
|
|
|
'# Scoring Competitiveness Analysis',
|
|
|
|
|
'',
|
|
|
|
|
`Generated: ${new Date().toISOString().slice(0, 10)}`,
|
|
|
|
|
'',
|
|
|
|
|
'> Simulated fights to analyze speed dominance, accuracy impact,',
|
|
|
|
|
'> scoring margins, and fight duration under controlled conditions.',
|
|
|
|
|
'',
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const combined = [...header, ...reportLines].join('\n')
|
|
|
|
|
|
|
|
|
|
// Write individual files as specified in the plan
|
|
|
|
|
writeFileSync(resolve(loopDir, 'speed-dominance-analysis.md'), combined)
|
|
|
|
|
writeFileSync(resolve(loopDir, 'accuracy-impact-analysis.md'), combined)
|
|
|
|
|
writeFileSync(resolve(loopDir, 'scoring-rebalance.md'), combined)
|
|
|
|
|
|
|
|
|
|
console.log(`\n Reports written to loop/`)
|
|
|
|
|
expect(true).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
})
|