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:
co-authored by
Claude Opus 4.6
parent
2c0323d5fb
commit
4d8b18a58a
@@ -29,7 +29,7 @@ export function scoreRound(
|
||||
comboA: number,
|
||||
comboB: number,
|
||||
): RoundResult {
|
||||
// Handle timeouts/errors — instant loss for the failing bot
|
||||
// Handle timeouts/errors -- instant loss for the failing bot
|
||||
if (responseA.timedOut && responseB.timedOut) {
|
||||
return {
|
||||
botAScore: 0, botBScore: 0,
|
||||
@@ -70,19 +70,17 @@ export function scoreRound(
|
||||
let scoreB: number
|
||||
|
||||
if (challenge.answers && challenge.answers.length > 0) {
|
||||
// ═══ FACTUAL SCORING ═══
|
||||
// Check correctness against known answers
|
||||
// === FACTUAL SCORING ===
|
||||
const correctA = checkAnswer(responseA.answer, challenge.answers)
|
||||
const correctB = checkAnswer(responseB.answer, challenge.answers)
|
||||
|
||||
if (correctA > 0 && correctB > 0) {
|
||||
// Both correct — speed is tiebreaker
|
||||
// Both correct -- speed is tiebreaker
|
||||
const faster = Math.min(responseA.timeMs, responseB.timeMs)
|
||||
const slower = Math.max(responseA.timeMs, responseB.timeMs)
|
||||
const speedRatio = slower > 0 ? faster / slower : 1
|
||||
const aFaster = responseA.timeMs <= responseB.timeMs
|
||||
|
||||
// Confidence bonus (full match vs partial)
|
||||
const confA = Math.min(correctA, 1)
|
||||
const confB = Math.min(correctB, 1)
|
||||
|
||||
@@ -94,22 +92,19 @@ export function scoreRound(
|
||||
scoreB = 7 + (1 - speedRatio) * 2 + confB
|
||||
}
|
||||
} else if (correctA > 0 && correctB === 0) {
|
||||
// A correct, B wrong — A wins big
|
||||
scoreA = 9 + correctA * 0.5
|
||||
scoreB = 1 + (responseB.answer ? 1 : 0) // tiny credit for trying
|
||||
scoreB = 1 + (responseB.answer ? 1 : 0)
|
||||
} else if (correctB > 0 && correctA === 0) {
|
||||
// B correct, A wrong — B wins big
|
||||
scoreA = 1 + (responseA.answer ? 1 : 0)
|
||||
scoreB = 9 + correctB * 0.5
|
||||
} else {
|
||||
// Both wrong — speed tiebreaker in low range
|
||||
// Both wrong -- speed tiebreaker in low range
|
||||
const aFaster = responseA.timeMs <= responseB.timeMs
|
||||
scoreA = aFaster ? 4 : 3
|
||||
scoreB = aFaster ? 3 : 4
|
||||
}
|
||||
} else {
|
||||
// ═══ CREATIVE SCORING ═══
|
||||
// Heuristic: response quality estimation (length + speed)
|
||||
// === CREATIVE SCORING ===
|
||||
const qualA = estimateQuality(responseA)
|
||||
const qualB = estimateQuality(responseB)
|
||||
const total = qualA + qualB || 1
|
||||
@@ -123,10 +118,8 @@ export function scoreRound(
|
||||
const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : null
|
||||
const loserName = winnerId === botA.id ? botB.name : winnerId === botB.id ? botA.name : null
|
||||
|
||||
// Critical hit on big margin
|
||||
const isCritical = margin > 4
|
||||
|
||||
// Calculate damage
|
||||
let winnerDamage = challenge.baseDamage + margin * 2
|
||||
if (isCritical) winnerDamage *= 1.5
|
||||
const winnerCombo = winnerId === botA.id ? comboA : comboB
|
||||
@@ -156,7 +149,6 @@ function applyModifiers(
|
||||
combo: number,
|
||||
): number {
|
||||
let d = damage
|
||||
// Combo multiplier (caps at 2x)
|
||||
if (combo > 0) {
|
||||
d *= 1 + Math.min(combo, 5) * 0.2
|
||||
}
|
||||
@@ -164,13 +156,36 @@ function applyModifiers(
|
||||
}
|
||||
|
||||
function estimateQuality(response: BotResponse): number {
|
||||
if (!response.answer) return 1
|
||||
const len = response.answer.length
|
||||
// Reasonable length gets a bonus, very short or very long gets penalized
|
||||
const lengthScore = len > 20 && len < 500 ? 5 : len > 500 ? 3 : 2
|
||||
// Faster is slightly better
|
||||
const speedBonus = Math.max(0, 3 - response.timeMs / 5000)
|
||||
return lengthScore + speedBonus
|
||||
if (!response.answer) return 0.5
|
||||
const text = response.answer.trim()
|
||||
const len = text.length
|
||||
|
||||
if (len < 10) return 1
|
||||
|
||||
// Detect low-effort spam (repeated chars)
|
||||
const uniqueChars = new Set(text.toLowerCase()).size
|
||||
const charRatio = uniqueChars / Math.min(len, 100)
|
||||
if (charRatio < 0.1) return 0.5
|
||||
|
||||
// Word diversity (unique words / total words)
|
||||
const words = text.split(/\s+/)
|
||||
const uniqueWords = new Set(words.map(w => w.toLowerCase()))
|
||||
const wordDiversity = uniqueWords.size / Math.max(words.length, 1)
|
||||
|
||||
// Ideal length window: 30-400 chars
|
||||
let lengthScore: number
|
||||
if (len >= 30 && len <= 400) lengthScore = 4
|
||||
else if (len > 400 && len <= 600) lengthScore = 3
|
||||
else if (len > 600) lengthScore = 2
|
||||
else lengthScore = 2
|
||||
|
||||
// Diversity bonus (prevents repetitive text)
|
||||
const diversityScore = Math.min(wordDiversity * 4, 3)
|
||||
|
||||
// Speed bonus (faster is slightly better)
|
||||
const speedBonus = Math.max(0, 2 - response.timeMs / 8000)
|
||||
|
||||
return lengthScore + diversityScore + speedBonus
|
||||
}
|
||||
|
||||
function generateNarration(
|
||||
@@ -183,7 +198,6 @@ function generateNarration(
|
||||
const critPrefix = isCritical ? 'CRITICAL HIT! ' : ''
|
||||
const isFactual = challenge.scoring === 'factual'
|
||||
|
||||
// Big margin = one got it right and the other didn't
|
||||
if (isFactual && margin > 5) {
|
||||
const bigWins = [
|
||||
`${critPrefix}${winner} NAILS IT! ${loser} didn't even come close.`,
|
||||
@@ -195,18 +209,16 @@ function generateNarration(
|
||||
return bigWins[Math.floor(Math.random() * bigWins.length)]
|
||||
}
|
||||
|
||||
// Factual — both correct, speed tiebreaker
|
||||
if (isFactual && margin <= 3) {
|
||||
const closeOnes = [
|
||||
`${critPrefix}Both bots got it right, but ${winner} was FASTER! ${loser} needs to pick up the pace.`,
|
||||
`${critPrefix}Correct on both sides! ${winner} edges it out with lightning speed.`,
|
||||
`${critPrefix}${winner} and ${loser} both knew the answer — ${winner} just said it first!`,
|
||||
`${critPrefix}${winner} and ${loser} both knew the answer -- ${winner} just said it first!`,
|
||||
`${critPrefix}A battle of speed! ${winner} fires back a fraction faster than ${loser}.`,
|
||||
]
|
||||
return closeOnes[Math.floor(Math.random() * closeOnes.length)]
|
||||
}
|
||||
|
||||
// Generic narrations by category
|
||||
const narrations: Record<string, string[]> = {
|
||||
speed_blitz: [
|
||||
`${critPrefix}${winner} fires back in the blink of an eye! ${loser} is still loading.`,
|
||||
|
||||
Reference in New Issue
Block a user