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:
co-authored by
Claude Opus 4.6
parent
47d20fbe66
commit
2c0323d5fb
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user