feat: v4 — TUI fight loop, rate limiting, webhook tooling, expanded choreographies

- Fight loop CLI with TUI renderer (ink-style terminal UI)
- Rate limiting middleware for API routes
- Queue cooldowns wired into orchestrator after fights
- Webhook test utility for bot debugging
- API docs route
- Expanded FightScene choreographies and weapon props
- Fix Drizzle transaction execution in orchestrator
- Schema additions, scoring/challenge/mock expansions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-07 00:14:46 +00:00
co-authored by Claude Opus 4.6
parent 2c0323d5fb
commit 4d8b18a58a
24 changed files with 2973 additions and 721 deletions
+82 -12
View File
@@ -1,7 +1,7 @@
/**
* Answer checking for factual challenges.
* Handles: case insensitivity, numeric equivalence, containment matching,
* number words (forty = 40), stripped punctuation/articles.
* number words (forty = 40), basic stemming, contraction normalization.
*/
const NUMBER_WORDS: Record<string, number> = {
@@ -23,6 +23,47 @@ function normalize(s: string): string {
.trim()
}
/** Expand contractions so "can't" matches "cannot", "don't" matches "do not" etc. */
function expandContractions(s: string): string {
return s
.replace(/\bcan'?t\b/gi, 'cannot')
.replace(/\bdon'?t\b/gi, 'do not')
.replace(/\bwon'?t\b/gi, 'will not')
.replace(/\bdoesn'?t\b/gi, 'does not')
.replace(/\bisn'?t\b/gi, 'is not')
.replace(/\baren'?t\b/gi, 'are not')
.replace(/\bwasn'?t\b/gi, 'was not')
.replace(/\bweren'?t\b/gi, 'were not')
.replace(/\bhasn'?t\b/gi, 'has not')
.replace(/\bhaven'?t\b/gi, 'have not')
.replace(/\bhadn'?t\b/gi, 'had not')
.replace(/\bcouldn'?t\b/gi, 'could not')
.replace(/\bshouldn'?t\b/gi, 'should not')
.replace(/\bwouldn'?t\b/gi, 'would not')
.replace(/\bit'?s\b/gi, 'it is')
.replace(/\bthat'?s\b/gi, 'that is')
.replace(/\bthey'?re\b/gi, 'they are')
.replace(/\bwe'?re\b/gi, 'we are')
.replace(/\byou'?re\b/gi, 'you are')
}
/** Basic English stemming: strip common suffixes for loose comparison. */
function stem(word: string): string {
if (word.length <= 3) return word
// Plural: buses → bus, foxes → fox, tries → tri (imperfect but ok)
if (word.endsWith('ies') && word.length > 4) return word.slice(0, -3) + 'y'
if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes') || word.endsWith('ches') || word.endsWith('shes')) {
return word.slice(0, -2)
}
if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1)
return word
}
/** Stem all words in a string. */
function stemAll(s: string): string {
return s.split(/\s+/).map(stem).join(' ')
}
function tryParseNumber(s: string): number | null {
const cleaned = normalize(s)
@@ -55,7 +96,7 @@ function tryParseNumber(s: string): number | 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.
* Returns a confidence score: 1.0 = definite match, 0.5-0.9 = partial, 0 = no match.
*/
export function checkAnswer(response: string | null, acceptedAnswers: string[]): number {
if (!response || response.trim() === '') return 0
@@ -63,48 +104,77 @@ export function checkAnswer(response: string | null, acceptedAnswers: string[]):
const normResponse = normalize(response)
if (normResponse === '') return 0
// Pre-compute expanded/stemmed versions once
const expandedResponse = normalize(expandContractions(response))
const stemmedResponse = stemAll(normResponse)
for (const accepted of acceptedAnswers) {
const normAccepted = normalize(accepted)
const expandedAccepted = normalize(expandContractions(accepted))
const stemmedAccepted = stemAll(normAccepted)
// 1. Exact match after normalization
if (normResponse === normAccepted) return 1.0
// 2. Numeric equivalence
// 2. Exact match after contraction expansion
if (expandedResponse === expandedAccepted) return 1.0
// 3. Exact match after stemming (catches plural/singular)
if (stemmedResponse === stemmedAccepted) return 1.0
// 4. 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
// 5. Response contains the accepted answer (or stemmed version)
if (normResponse.includes(normAccepted) && normAccepted.length >= 2) return 1.0
if (stemmedResponse.includes(stemmedAccepted) && stemmedAccepted.length >= 2) return 0.95
// 4. Accepted answer contains the response (for short definitive answers)
// 6. Accepted answer contains the response (for short definitive answers)
if (normAccepted.includes(normResponse) && normResponse.length >= 3) return 0.8
if (stemmedAccepted.includes(stemmedResponse) && stemmedResponse.length >= 3) return 0.75
// 5. Check if number appears anywhere in a longer response
// 7. 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
const responseWords = normResponse.split(/\s+/)
for (const w of responseWords) {
const parsed = tryParseNumber(w)
if (parsed !== null && parsed === accNum) return 0.9
}
}
// 6. Word-level containment — all words of the answer appear in the response
// 8. 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
// Try with stemming
const stemmedAccWords = stemmedAccepted.split(/\s+/)
const allStemFound = stemmedAccWords.every(w => stemmedResponse.includes(w))
if (allStemFound) return 0.85
}
// 9. Contraction-expanded word containment
if (expandedAccepted.split(/\s+/).length >= 2) {
const allFound = expandedAccepted.split(/\s+/).every(w => expandedResponse.includes(w))
if (allFound) return 0.85
}
}
// 7. For true/false questions, check if the response starts with the right keyword
// 10. 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.
// "that's true" / "this is false" / "yes" for true / "no" for false
if (normResponse.includes(tfAnswer.toLowerCase())) return 0.9
// "yes" ≈ true, "no" ≈ false
if (tfAnswer.toLowerCase() === 'true' && (firstWord === 'yes' || firstWord === 'correct' || firstWord === 'right')) return 0.9
if (tfAnswer.toLowerCase() === 'false' && (firstWord === 'no' || firstWord === 'incorrect' || firstWord === 'wrong')) return 0.9
}
return 0