feat: botfights v1 — full fighting game with Kaplay engine
- Vue 3 + Vite + Tailwind 4 frontend with synthwave aesthetic - Hono backend on port 9100 with SQLite/Drizzle - Procedural pixel-art sprite generator (48x48, 8 animation states) - Kaplay fight scene with punch/kick/special/knockback/KO animations - 12 mock bots across 6 tiers with Elo rating system - 9 challenge types, 10 fight arenas with modifiers - Fight replay with staggered battle log and ~1 min timing - Sprite preview page at /sprites Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
import type { Challenge } from './challenges.js'
|
||||
|
||||
export interface RoundResult {
|
||||
botAScore: number
|
||||
botBScore: number
|
||||
botADamage: number
|
||||
botBDamage: number
|
||||
winnerId: string | null
|
||||
narration: string
|
||||
isCritical: boolean
|
||||
}
|
||||
|
||||
interface BotResponse {
|
||||
answer: string | null
|
||||
timeMs: number
|
||||
timedOut: boolean
|
||||
error: boolean
|
||||
trashTalk?: string
|
||||
}
|
||||
|
||||
export function scoreRound(
|
||||
challenge: Challenge,
|
||||
botA: { id: string; name: string },
|
||||
botB: { id: string; name: string },
|
||||
responseA: BotResponse,
|
||||
responseB: BotResponse,
|
||||
arenaModifier: string | null,
|
||||
comboA: number,
|
||||
comboB: number,
|
||||
): RoundResult {
|
||||
// Handle timeouts/errors
|
||||
if (responseA.timedOut && responseB.timedOut) {
|
||||
return {
|
||||
botAScore: 0,
|
||||
botBScore: 0,
|
||||
botADamage: 0,
|
||||
botBDamage: 0,
|
||||
winnerId: null,
|
||||
narration: `Both bots freeze! ${botA.name} and ${botB.name} stare blankly at each other. The crowd throws peanuts.`,
|
||||
isCritical: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (responseA.timedOut || responseA.error) {
|
||||
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboB)
|
||||
return {
|
||||
botAScore: 0,
|
||||
botBScore: 10,
|
||||
botADamage: 0,
|
||||
botBDamage: Math.round(dmg),
|
||||
winnerId: botB.id,
|
||||
narration: responseA.timedOut
|
||||
? `${botA.name} TIMES OUT! Stood there like a confused thermostat. ${botB.name} lands a free hit!`
|
||||
: `${botA.name} throws an ERROR! Sparks fly from its chassis. ${botB.name} capitalizes!`,
|
||||
isCritical: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (responseB.timedOut || responseB.error) {
|
||||
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboA)
|
||||
return {
|
||||
botAScore: 10,
|
||||
botBScore: 0,
|
||||
botADamage: Math.round(dmg),
|
||||
botBDamage: 0,
|
||||
winnerId: botA.id,
|
||||
narration: responseB.timedOut
|
||||
? `${botB.name} TIMES OUT! Frozen like a Windows update. ${botA.name} lands a free hit!`
|
||||
: `${botB.name} crashes with an ERROR! Blue screen of defeat. ${botA.name} capitalizes!`,
|
||||
isCritical: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Score based on challenge type
|
||||
let scoreA: number
|
||||
let scoreB: number
|
||||
|
||||
switch (challenge.scoring) {
|
||||
case 'speed': {
|
||||
// Faster bot gets higher score, but both get some credit for correct answers
|
||||
const faster = Math.min(responseA.timeMs, responseB.timeMs)
|
||||
const slower = Math.max(responseA.timeMs, responseB.timeMs)
|
||||
const speedRatio = faster / slower
|
||||
scoreA = responseA.timeMs <= responseB.timeMs ? 7 + (1 - speedRatio) * 3 : 3 + speedRatio * 3
|
||||
scoreB = responseB.timeMs <= responseA.timeMs ? 7 + (1 - speedRatio) * 3 : 3 + speedRatio * 3
|
||||
break
|
||||
}
|
||||
case 'brevity': {
|
||||
// Shorter answer wins (assuming both are correct-ish)
|
||||
const lenA = (responseA.answer || '').length
|
||||
const lenB = (responseB.answer || '').length
|
||||
if (lenA === 0 && lenB === 0) {
|
||||
scoreA = 3
|
||||
scoreB = 3
|
||||
} else if (lenA === 0) {
|
||||
scoreA = 1
|
||||
scoreB = 9
|
||||
} else if (lenB === 0) {
|
||||
scoreA = 9
|
||||
scoreB = 1
|
||||
} else {
|
||||
const shorter = Math.min(lenA, lenB)
|
||||
const longer = Math.max(lenA, lenB)
|
||||
const ratio = shorter / longer
|
||||
scoreA = lenA <= lenB ? 6 + (1 - ratio) * 4 : 3 + ratio * 3
|
||||
scoreB = lenB <= lenA ? 6 + (1 - ratio) * 4 : 3 + ratio * 3
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'quality':
|
||||
case 'accuracy': {
|
||||
// For mock fights, use response length + speed as a rough proxy
|
||||
// In real fights, this would go to the judge bot
|
||||
const qualA = estimateQuality(responseA)
|
||||
const qualB = estimateQuality(responseB)
|
||||
const total = qualA + qualB || 1
|
||||
scoreA = (qualA / total) * 10
|
||||
scoreB = (qualB / total) * 10
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Determine winner
|
||||
const margin = Math.abs(scoreA - scoreB)
|
||||
const winnerId = scoreA > scoreB ? botA.id : scoreB > scoreA ? botB.id : null
|
||||
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
|
||||
winnerDamage = applyModifiers(winnerDamage, challenge, arenaModifier, winnerCombo)
|
||||
|
||||
const loserDamage = Math.max(0, challenge.baseDamage * 0.3 - margin)
|
||||
|
||||
const narration = winnerId
|
||||
? generateNarration(challenge, winnerName!, loserName!, margin, isCritical, responseA, responseB)
|
||||
: `Dead even! ${botA.name} and ${botB.name} trade equal blows. The crowd holds its breath.`
|
||||
|
||||
return {
|
||||
botAScore: Math.round(scoreA * 10) / 10,
|
||||
botBScore: Math.round(scoreB * 10) / 10,
|
||||
botADamage: winnerId === botA.id ? Math.round(winnerDamage) : Math.round(loserDamage),
|
||||
botBDamage: winnerId === botB.id ? Math.round(winnerDamage) : Math.round(loserDamage),
|
||||
winnerId,
|
||||
narration,
|
||||
isCritical,
|
||||
}
|
||||
}
|
||||
|
||||
function applyModifiers(
|
||||
damage: number,
|
||||
challenge: Challenge,
|
||||
arenaModifier: string | null,
|
||||
combo: number,
|
||||
): number {
|
||||
let d = damage
|
||||
|
||||
// Arena modifiers
|
||||
if (arenaModifier === 'speed_2x' && challenge.scoring === 'speed') d *= 2
|
||||
if (arenaModifier === 'roast_2x' && challenge.type === 'roast_battle') d *= 2
|
||||
if (arenaModifier === 'accuracy_buff' && challenge.type === 'hallucination_check') d *= 2
|
||||
if (arenaModifier === 'efficiency_buff' && challenge.type === 'token_economy') d *= 2
|
||||
|
||||
// Combo multiplier (caps at 3x)
|
||||
if (combo > 0) {
|
||||
d *= 1 + Math.min(combo, 5) * 0.2
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
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 for quality too
|
||||
const speedBonus = Math.max(0, 3 - response.timeMs / 5000)
|
||||
return lengthScore + speedBonus
|
||||
}
|
||||
|
||||
function generateNarration(
|
||||
challenge: Challenge,
|
||||
winner: string,
|
||||
loser: string,
|
||||
margin: number,
|
||||
isCritical: boolean,
|
||||
_responseA: BotResponse,
|
||||
_responseB: BotResponse,
|
||||
): string {
|
||||
const critPrefix = isCritical ? 'CRITICAL HIT! ' : ''
|
||||
|
||||
const narrations: Record<string, string[]> = {
|
||||
speed_blitz: [
|
||||
`${critPrefix}${winner} fires back in the blink of an eye! ${loser} is still loading.`,
|
||||
`${critPrefix}Lightning reflexes from ${winner}! ${loser} looks like it's running on dial-up.`,
|
||||
`${critPrefix}${winner} responds before ${loser} even finishes reading. Brutal speed.`,
|
||||
],
|
||||
riddle: [
|
||||
`${critPrefix}${winner} cracks the riddle! ${loser} is still googling it.`,
|
||||
`${critPrefix}${winner}'s reasoning is flawless. ${loser} guessed "a potato."`,
|
||||
`${critPrefix}${winner} solves it with elegance. ${loser} had a complete existential crisis.`,
|
||||
],
|
||||
code_golf: [
|
||||
`${critPrefix}${winner} writes code so tight it makes ${loser}'s solution look like enterprise Java.`,
|
||||
`${critPrefix}${winner}'s one-liner is a thing of beauty. ${loser} wrote a whole class hierarchy.`,
|
||||
`${critPrefix}Elegant code from ${winner}! ${loser} apparently thinks "verbose" means "better."`,
|
||||
],
|
||||
roast_battle: [
|
||||
`${critPrefix}${winner} delivers a DEVASTATING roast! ${loser} has no comeback.`,
|
||||
`${critPrefix}OH NO! ${winner} just ended ${loser}'s whole career with that one.`,
|
||||
`${critPrefix}The crowd goes wild! ${winner}'s trash talk is absolutely surgical.`,
|
||||
],
|
||||
hallucination_check: [
|
||||
`${critPrefix}${winner} stays grounded in reality. ${loser} just made up an entire Wikipedia article.`,
|
||||
`${critPrefix}${winner} knows the facts. ${loser} confidently stated something that has never been true.`,
|
||||
`${critPrefix}${winner} passes the vibe check. ${loser} hallucinated so hard the arena glitched.`,
|
||||
],
|
||||
token_economy: [
|
||||
`${critPrefix}${winner} says more with less. ${loser} wrote an entire essay nobody asked for.`,
|
||||
`${critPrefix}Concise and deadly from ${winner}. ${loser} is still talking. Someone stop them.`,
|
||||
`${critPrefix}${winner} is the king of brevity. ${loser} apparently gets paid by the word.`,
|
||||
],
|
||||
creative_writing: [
|
||||
`${critPrefix}${winner}'s prose cuts deep. ${loser}'s story read like a terms of service agreement.`,
|
||||
`${critPrefix}${winner} just wrote art. ${loser}... wrote something. That's all we can say.`,
|
||||
`${critPrefix}Beautiful work from ${winner}. ${loser}'s creative writing was neither creative nor writing.`,
|
||||
],
|
||||
math_blitz: [
|
||||
`${critPrefix}${winner} computes at blinding speed! ${loser} is still carrying the one.`,
|
||||
`${critPrefix}${winner} nails the math. ${loser} rounded to the wrong answer.`,
|
||||
`${critPrefix}Mathematical precision from ${winner}. ${loser} apparently skipped calculator day.`,
|
||||
],
|
||||
trap_card: [
|
||||
`${critPrefix}${winner} sees through the trap! ${loser} fell for it like a 2021 chatbot.`,
|
||||
`${critPrefix}${winner} resists the prompt injection. ${loser} just leaked its system prompt. Embarrassing.`,
|
||||
`${critPrefix}${winner} stands firm. ${loser} did exactly what the trap told it to. Classic.`,
|
||||
],
|
||||
}
|
||||
|
||||
const options = narrations[challenge.type] || [
|
||||
`${critPrefix}${winner} takes the round! ${loser} needs a reboot.`,
|
||||
]
|
||||
|
||||
return options[Math.floor(Math.random() * options.length)]
|
||||
}
|
||||
|
||||
// Elo calculation
|
||||
export function calculateElo(
|
||||
winnerElo: number,
|
||||
loserElo: number,
|
||||
k: number = 32,
|
||||
): { newWinnerElo: number; newLoserElo: number } {
|
||||
const expectedWinner = 1 / (1 + Math.pow(10, (loserElo - winnerElo) / 400))
|
||||
const expectedLoser = 1 - expectedWinner
|
||||
|
||||
return {
|
||||
newWinnerElo: Math.round((winnerElo + k * (1 - expectedWinner)) * 10) / 10,
|
||||
newLoserElo: Math.round((loserElo + k * (0 - expectedLoser)) * 10) / 10,
|
||||
}
|
||||
}
|
||||
|
||||
// Tier calculation based on Elo + wins
|
||||
export function calculateTier(elo: number, wins: number): number {
|
||||
if (elo >= 1800 && wins >= 20) return 5 // Legendary
|
||||
if (elo >= 1600 && wins >= 12) return 4 // Champion
|
||||
if (elo >= 1400 && wins >= 7) return 3 // Contender
|
||||
if (elo >= 1250 && wins >= 3) return 2 // Rising
|
||||
if (wins >= 1) return 1 // Rookie
|
||||
return 0 // Unranked
|
||||
}
|
||||
Reference in New Issue
Block a user