feat: v3 — fight loop, expanded challenges, FightPage ownership guard

- Add fight-loop CLI for automated mock fights with elo-based matchmaking
- Expand challenge types, scoring narrations, arenas, and mock bot pool
- FightPage "Fight again" buttons now only show for your own bot
- FightViewer async scene init, live fight polling with round counter
- Extract mock answers into separate answers module

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-06 23:44:40 +00:00
co-authored by Claude Opus 4.6
parent 47d20fbe66
commit 2c0323d5fb
14 changed files with 2669 additions and 996 deletions
+111
View File
@@ -0,0 +1,111 @@
/**
* Answer checking for factual challenges.
* Handles: case insensitivity, numeric equivalence, containment matching,
* number words (forty = 40), stripped punctuation/articles.
*/
const NUMBER_WORDS: Record<string, number> = {
zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5,
six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15,
sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19, twenty: 20,
thirty: 30, forty: 40, fifty: 50, sixty: 60, seventy: 70,
eighty: 80, ninety: 90, hundred: 100, thousand: 1000, million: 1000000,
}
function normalize(s: string): string {
return s
.toLowerCase()
.trim()
.replace(/[.,!?;:'"()\[\]{}<>]/g, '') // strip punctuation
.replace(/\s+/g, ' ') // collapse whitespace
.replace(/^(the|a|an|its|it is|it's) /i, '') // strip leading articles
.trim()
}
function tryParseNumber(s: string): number | null {
const cleaned = normalize(s)
// Direct numeric parse
const direct = Number(cleaned)
if (!isNaN(direct) && cleaned !== '') return direct
// Number word lookup
if (NUMBER_WORDS[cleaned] !== undefined) return NUMBER_WORDS[cleaned]
// Compound number words: "twenty one" → 21, "three hundred" → 300
const words = cleaned.split(/[\s-]+/)
if (words.length <= 4 && words.every(w => NUMBER_WORDS[w] !== undefined)) {
let total = 0
let current = 0
for (const w of words) {
const val = NUMBER_WORDS[w]
if (val >= 100) {
current = (current || 1) * val
} else {
current += val
}
}
total += current
return total
}
return null
}
/**
* Check if a bot's response matches any of the accepted answers.
* Returns a confidence score: 1.0 = definite match, 0.5 = partial, 0 = no match.
*/
export function checkAnswer(response: string | null, acceptedAnswers: string[]): number {
if (!response || response.trim() === '') return 0
const normResponse = normalize(response)
if (normResponse === '') return 0
for (const accepted of acceptedAnswers) {
const normAccepted = normalize(accepted)
// 1. Exact match after normalization
if (normResponse === normAccepted) return 1.0
// 2. Numeric equivalence
const respNum = tryParseNumber(normResponse)
const accNum = tryParseNumber(normAccepted)
if (respNum !== null && accNum !== null && respNum === accNum) return 1.0
// 3. Response contains the accepted answer
if (normResponse.includes(normAccepted) && normAccepted.length >= 2) return 1.0
// 4. Accepted answer contains the response (for short definitive answers)
if (normAccepted.includes(normResponse) && normResponse.length >= 3) return 0.8
// 5. Check if number appears anywhere in a longer response
if (accNum !== null) {
// Look for the number in the response text
const numStr = String(accNum)
if (normResponse.includes(numStr)) return 1.0
// Check number words in response
const responseNum = tryParseNumber(normResponse.split(/\s+/).find(w => tryParseNumber(w) !== null) || '')
if (responseNum !== null && responseNum === accNum) return 0.9
}
// 6. Word-level containment — all words of the answer appear in the response
const acceptedWords = normAccepted.split(/\s+/)
if (acceptedWords.length >= 2) {
const allFound = acceptedWords.every(w => normResponse.includes(w))
if (allFound) return 0.9
}
}
// 7. For true/false questions, check if the response starts with the right keyword
const tfAnswer = acceptedAnswers.find(a => a.toLowerCase() === 'true' || a.toLowerCase() === 'false')
if (tfAnswer) {
const firstWord = normResponse.split(/\s+/)[0]
if (firstWord === tfAnswer.toLowerCase()) return 1.0
// "that's true" / "this is false" etc.
if (normResponse.includes(tfAnswer.toLowerCase())) return 0.9
}
return 0
}