feat: retro mode — arcade combo round with gamepad overlays
Every fight now includes one Retro Mode round where bots submit gamepad combo inputs (↑↓←→ A B). 24 moves across 4 tiers: basic (always shown), standard (partially revealed), super (must discover), and ultra (KONAMI CODE for 50 dmg). Discovery bonus gives 1.5x damage. Includes pixel-art gamepad overlays (P1/P2) with animated button presses, retro-specific narrations, and mock bot combo responses scaled by ELO. Also adds loops/plan.md with 11-phase production hardening roadmap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
0d6ba7d5a7
commit
1f8e8569ef
@@ -1,5 +1,6 @@
|
||||
import type { Challenge } from './challenges.js'
|
||||
import { checkAnswer } from './answers.js'
|
||||
import { scoreRetroResponse, type RetroScoreResult } from './retro-moves.js'
|
||||
|
||||
export interface RoundResult {
|
||||
botAScore: number
|
||||
@@ -29,6 +30,11 @@ export function scoreRound(
|
||||
comboA: number,
|
||||
comboB: number,
|
||||
): RoundResult {
|
||||
// Retro mode has its own scoring
|
||||
if (challenge.type === 'retro_mode') {
|
||||
return scoreRetroRound(challenge, botA, botB, responseA, responseB, arenaModifier, comboA, comboB)
|
||||
}
|
||||
|
||||
// Handle timeouts/errors -- instant loss for the failing bot
|
||||
if (responseA.timedOut && responseB.timedOut) {
|
||||
return {
|
||||
@@ -189,6 +195,7 @@ const ARENA_MODIFIER_TYPES: Record<string, string[]> = {
|
||||
nature_2x: ['nature_clash', 'animal_kingdom'],
|
||||
hack_2x: ['hack_battle'],
|
||||
sports_2x: ['sports_showdown'],
|
||||
retro_2x: ['retro_mode'],
|
||||
}
|
||||
|
||||
function applyModifiers(
|
||||
@@ -367,6 +374,15 @@ function generateNarration(
|
||||
`${critPrefix}${winner} played the market perfectly. ${loser} belongs on WallStreetBets.`,
|
||||
`${critPrefix}${loser}'s token strategy was worse than buying NFTs in 2022. ${winner} PROFITS!`,
|
||||
],
|
||||
retro_mode: [
|
||||
`${critPrefix}${winner}'s combo game is UNREAL! ${loser} should stick to button mashing!`,
|
||||
`${critPrefix}PERFECT INPUT from ${winner}! ${loser}'s controller might be broken!`,
|
||||
`${critPrefix}${winner} reads the frame data perfectly! ${loser} gets downloaded and DESTROYED!`,
|
||||
`${critPrefix}QUARTER CIRCLE FORWARD INTO PAIN! ${winner}'s arcade skills are LEGENDARY! ${loser} needs more quarters!`,
|
||||
`${critPrefix}${winner} plays like they wrote the strategy guide! ${loser} plays like they're using a dance pad!`,
|
||||
`${critPrefix}INSERT COIN TO CONTINUE? ${loser} is OUT of quarters! ${winner} DOMINATES the cabinet!`,
|
||||
`${critPrefix}${winner} chains combos like a speedrunner! ${loser} can't even find the start button!`,
|
||||
],
|
||||
food_fight: [
|
||||
`${critPrefix}${winner} serves up a five-star beating! ${loser} got ROASTED and TOASTED!`,
|
||||
`${critPrefix}${loser} just got served. Literally. ${winner} is the head chef of PAIN!`,
|
||||
@@ -387,6 +403,119 @@ function generateNarration(
|
||||
return options[Math.floor(Math.random() * options.length)]
|
||||
}
|
||||
|
||||
|
||||
function scoreRetroRound(
|
||||
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
|
||||
if (responseA.timedOut && responseB.timedOut) {
|
||||
return {
|
||||
botAScore: 0, botBScore: 0, botADamage: 0, botBDamage: 0, winnerId: null,
|
||||
narration: 'Both bots mash buttons frantically but cannot even find the start button! DOUBLE TIMEOUT!',
|
||||
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: dmg, winnerId: botB.id,
|
||||
narration: `${botA.name}'s controller disconnected! ${botB.name} lands free hits!`,
|
||||
isCritical: false,
|
||||
}
|
||||
}
|
||||
if (responseB.timedOut || responseB.error) {
|
||||
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboA)
|
||||
return {
|
||||
botAScore: 10, botBScore: 0, botADamage: dmg, botBDamage: 0, winnerId: botA.id,
|
||||
narration: `${botB.name}'s controller disconnected! ${botA.name} lands free hits!`,
|
||||
isCritical: false,
|
||||
}
|
||||
}
|
||||
|
||||
const knownInputs = challenge.answers || []
|
||||
const resultA = scoreRetroResponse(responseA.answer, knownInputs)
|
||||
const resultB = scoreRetroResponse(responseB.answer, knownInputs)
|
||||
|
||||
// Speed bonus: up to 20% more for faster responses
|
||||
let scoreA = resultA.score
|
||||
let scoreB = resultB.score
|
||||
const maxTime = challenge.timeout_ms
|
||||
if (scoreA > 0) scoreA *= 1 + Math.max(0, (maxTime - responseA.timeMs) / maxTime) * 0.2
|
||||
if (scoreB > 0) scoreB *= 1 + Math.max(0, (maxTime - responseB.timeMs) / maxTime) * 0.2
|
||||
|
||||
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
|
||||
const isCritical = margin > 4
|
||||
|
||||
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 winnerResult = winnerId === botA.id ? resultA : resultB
|
||||
const loserResult = winnerId === botA.id ? resultB : resultA
|
||||
|
||||
const moveSummary = (r: RetroScoreResult) =>
|
||||
r.moves.map(m => m.name ? (m.discovered ? '★' + m.name + '★' : m.name) : 'WHIFF').join(', ')
|
||||
|
||||
let narration: string
|
||||
if (!winnerId) {
|
||||
const draws = [
|
||||
'MIRROR MATCH! Both bots combo for equal damage! Frame-perfect tie!',
|
||||
'EQUAL POWER! The arcade cabinet shakes from perfectly matched inputs!',
|
||||
`DOUBLE K.O.! ${botA.name} and ${botB.name} hit identical damage totals! Insert another quarter!`,
|
||||
]
|
||||
narration = draws[Math.floor(Math.random() * draws.length)]
|
||||
} else {
|
||||
const hasDiscovery = winnerResult.moves.some(m => m.discovered)
|
||||
const discoveryNames = winnerResult.moves.filter(m => m.discovered).map(m => m.name)
|
||||
const loserWhiffs = loserResult.moves.filter(m => !m.name).length
|
||||
const narrations: string[] = []
|
||||
|
||||
if (hasDiscovery) {
|
||||
narrations.push(
|
||||
`SECRET COMBO UNLOCKED! ${winnerName} discovers ${discoveryNames.join(' + ')}! ${loserName} never saw it coming!`,
|
||||
`HIDDEN MOVE FOUND! ${winnerName} unleashes ${discoveryNames.join(' + ')} for MASSIVE damage!`,
|
||||
)
|
||||
}
|
||||
if (loserWhiffs >= 2) {
|
||||
narrations.push(
|
||||
`${loserName} mashes random buttons and WHIFFS ${loserWhiffs} times! ${winnerName} capitalizes with [${moveSummary(winnerResult)}]!`,
|
||||
)
|
||||
}
|
||||
narrations.push(
|
||||
`${winnerName} executes [${moveSummary(winnerResult)}] for ${winnerResult.totalDamage} total damage! ${loserName} can't keep up!`,
|
||||
`COMBO BREAKER! ${winnerName}'s inputs are FLAWLESS! ${loserName} gets bodied!`,
|
||||
`${winnerName} reads the frame data perfectly! ${loserName} gets downloaded and DESTROYED!`,
|
||||
`PERFECT INPUT! ${winnerName} chains ${winnerResult.moves.filter(m => m.name).length} moves! ${loserName}'s controller might be broken!`,
|
||||
`${winnerName} plays like they have the strategy guide! ${loserName} plays like they're using a steering wheel!`,
|
||||
)
|
||||
|
||||
const prefix = isCritical ? 'CRITICAL COMBO! ' : ''
|
||||
narration = prefix + narrations[Math.floor(Math.random() * narrations.length)]
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// Elo calculation
|
||||
export function calculateElo(
|
||||
winnerElo: number,
|
||||
|
||||
Reference in New Issue
Block a user