2026-03-08 14:13:47 +00:00
|
|
|
// Retro Mode — arcade fighting game combo engine
|
|
|
|
|
import type { Challenge } from './challenges.js'
|
|
|
|
|
|
|
|
|
|
export interface RetroMove {
|
|
|
|
|
input: string
|
|
|
|
|
name: string
|
|
|
|
|
damage: number
|
|
|
|
|
tier: 'basic' | 'standard' | 'super' | 'ultra'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const RETRO_MOVES: RetroMove[] = [
|
|
|
|
|
// Basic — always revealed
|
|
|
|
|
{ input: 'A', name: 'Jab', damage: 5, tier: 'basic' },
|
|
|
|
|
{ input: 'B', name: 'Kick', damage: 6, tier: 'basic' },
|
|
|
|
|
{ input: '→+A', name: 'Hook', damage: 8, tier: 'basic' },
|
|
|
|
|
{ input: '←+B', name: 'Low Kick', damage: 7, tier: 'basic' },
|
|
|
|
|
// Standard — some revealed each fight
|
|
|
|
|
{ input: '↓→+A', name: 'Fireball', damage: 12, tier: 'standard' },
|
|
|
|
|
{ input: '↓←+B', name: 'Spin Kick', damage: 14, tier: 'standard' },
|
|
|
|
|
{ input: '→→+A', name: 'Dash Punch', damage: 15, tier: 'standard' },
|
|
|
|
|
{ input: '↑↓+A', name: 'Uppercut', damage: 16, tier: 'standard' },
|
|
|
|
|
{ input: '←→+B', name: 'Slide Kick', damage: 13, tier: 'standard' },
|
|
|
|
|
{ input: '↑+A', name: 'Rising Fist', damage: 11, tier: 'standard' },
|
|
|
|
|
{ input: '↓+B+A', name: 'Leg Sweep', damage: 10, tier: 'standard' },
|
|
|
|
|
{ input: '→+B+A', name: 'Elbow Strike', damage: 12, tier: 'standard' },
|
|
|
|
|
// Super — never revealed, must discover
|
|
|
|
|
{ input: '↓→↓→+A', name: 'Hadouken', damage: 22, tier: 'super' },
|
|
|
|
|
{ input: '←↓→+B', name: 'Dragon Kick', damage: 25, tier: 'super' },
|
|
|
|
|
{ input: '↑↑↓↓+A', name: 'Power Surge', damage: 28, tier: 'super' },
|
|
|
|
|
{ input: '→←→+A+B', name: 'Tiger Knee', damage: 24, tier: 'super' },
|
|
|
|
|
{ input: '↓↓↑+B+A', name: 'Shoryuken', damage: 26, tier: 'super' },
|
|
|
|
|
{ input: '←←→→+A', name: 'Sonic Boom', damage: 23, tier: 'super' },
|
|
|
|
|
{ input: '↑→↓←+A+B', name: 'Cyclone', damage: 30, tier: 'super' },
|
|
|
|
|
// Ultra — the ultimate secret
|
|
|
|
|
{ input: '↑↑↓↓←→←→+B+A', name: 'KONAMI CODE', damage: 50, tier: 'ultra' },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
// Build canonical form: arrows joined, then +buttons
|
|
|
|
|
function canonicalize(raw: string): string {
|
|
|
|
|
let s = raw
|
|
|
|
|
s = s.replace(/\bup\b/gi, '↑')
|
|
|
|
|
s = s.replace(/\bdown\b/gi, '↓')
|
|
|
|
|
s = s.replace(/\bleft\b/gi, '←')
|
|
|
|
|
s = s.replace(/\bright\b/gi, '→')
|
|
|
|
|
|
|
|
|
|
const dirs: string[] = []
|
|
|
|
|
const buttons: string[] = []
|
|
|
|
|
|
|
|
|
|
for (const c of s) {
|
|
|
|
|
if ('↑↓←→'.includes(c)) dirs.push(c)
|
|
|
|
|
else if ('Aa'.includes(c)) buttons.push('A')
|
|
|
|
|
else if ('Bb'.includes(c)) buttons.push('B')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dirs.length === 0 && buttons.length === 0) return ''
|
|
|
|
|
|
|
|
|
|
let result = dirs.join('')
|
|
|
|
|
if (buttons.length > 0) {
|
|
|
|
|
if (result.length > 0) result += '+'
|
|
|
|
|
result += buttons.join('+')
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Precompute canonical lookup
|
|
|
|
|
const MOVE_LOOKUP = new Map<string, RetroMove>()
|
|
|
|
|
for (const move of RETRO_MOVES) {
|
|
|
|
|
MOVE_LOOKUP.set(canonicalize(move.input), move)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function lookupMove(raw: string): RetroMove | null {
|
|
|
|
|
return MOVE_LOOKUP.get(canonicalize(raw)) || null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface RetroMoveResult {
|
|
|
|
|
input: string
|
|
|
|
|
name: string | null
|
|
|
|
|
damage: number
|
|
|
|
|
discovered: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface RetroScoreResult {
|
|
|
|
|
totalDamage: number
|
|
|
|
|
moves: RetroMoveResult[]
|
|
|
|
|
score: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function scoreRetroResponse(answer: string | null, knownInputs: string[]): RetroScoreResult {
|
|
|
|
|
if (!answer) return { totalDamage: 0, moves: [], score: 0 }
|
|
|
|
|
|
|
|
|
|
const knownSet = new Set(knownInputs.map(canonicalize))
|
|
|
|
|
const parts = answer.split('|').map(s => s.trim()).filter(Boolean).slice(0, 3)
|
|
|
|
|
const moves: RetroMoveResult[] = []
|
|
|
|
|
let totalDamage = 0
|
|
|
|
|
|
|
|
|
|
for (const raw of parts) {
|
|
|
|
|
const move = lookupMove(raw)
|
|
|
|
|
if (move) {
|
|
|
|
|
const isDiscovered = !knownSet.has(canonicalize(move.input))
|
|
|
|
|
const dmg = isDiscovered ? Math.round(move.damage * 1.5) : move.damage
|
|
|
|
|
totalDamage += dmg
|
|
|
|
|
moves.push({ input: raw, name: move.name, damage: dmg, discovered: isDiscovered })
|
|
|
|
|
} else {
|
|
|
|
|
moves.push({ input: raw, name: null, damage: 0, discovered: false })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { totalDamage, moves, score: Math.min(10, totalDamage / 5) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function generateRetroChallenge(): Challenge {
|
|
|
|
|
const basics = RETRO_MOVES.filter(m => m.tier === 'basic')
|
|
|
|
|
const standards = RETRO_MOVES.filter(m => m.tier === 'standard')
|
|
|
|
|
|
|
|
|
|
// Reveal 3-5 random standard moves
|
|
|
|
|
const shuffled = [...standards].sort(() => Math.random() - 0.5)
|
|
|
|
|
const revealCount = 3 + Math.floor(Math.random() * 3)
|
|
|
|
|
const revealed = shuffled.slice(0, revealCount)
|
|
|
|
|
|
|
|
|
|
const knownMoves = [...basics, ...revealed]
|
|
|
|
|
const knownInputs = knownMoves.map(m => m.input)
|
|
|
|
|
|
|
|
|
|
const moveList = knownMoves.map(m => ` ${m.input} = ${m.name} (${m.damage} dmg)`).join('\n')
|
|
|
|
|
|
2026-03-08 15:32:29 +00:00
|
|
|
const prompt = `ARCADE ROUND! Pick 3 moves:\n${moveList}\n\nSecret combos exist — longer chains hit harder.\nAnswer: combo1 | combo2 | combo3`
|
2026-03-08 14:13:47 +00:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
type: 'retro_mode',
|
|
|
|
|
label: 'Retro Mode',
|
|
|
|
|
prompt,
|
|
|
|
|
answers: knownInputs,
|
|
|
|
|
timeout_ms: 12000,
|
|
|
|
|
scoring: 'factual',
|
|
|
|
|
baseDamage: 22,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate mock retro response based on bot ELO
|
|
|
|
|
export function generateMockRetroResponse(elo: number): string {
|
|
|
|
|
const basics = RETRO_MOVES.filter(m => m.tier === 'basic')
|
|
|
|
|
const standards = RETRO_MOVES.filter(m => m.tier === 'standard')
|
|
|
|
|
const supers = RETRO_MOVES.filter(m => m.tier === 'super')
|
|
|
|
|
|
|
|
|
|
const moves: string[] = []
|
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
|
|
|
const roll = Math.random()
|
|
|
|
|
const superChance = Math.max(0, (elo - 1400) / 1500)
|
|
|
|
|
const standardChance = Math.max(0.3, (elo - 900) / 1200)
|
|
|
|
|
const whiffChance = Math.max(0, (1300 - elo) / 2000)
|
|
|
|
|
|
|
|
|
|
if (roll < whiffChance) {
|
|
|
|
|
const gibberish = ['↓↓↓+A', '→←+A+A', '↑+B+B+A', '←↓↑→+A', '→↓←+B+B'][Math.floor(Math.random() * 5)]
|
|
|
|
|
moves.push(gibberish)
|
|
|
|
|
} else if (roll < whiffChance + superChance) {
|
|
|
|
|
moves.push(supers[Math.floor(Math.random() * supers.length)].input)
|
|
|
|
|
} else if (roll < whiffChance + superChance + standardChance) {
|
|
|
|
|
moves.push(standards[Math.floor(Math.random() * standards.length)].input)
|
|
|
|
|
} else {
|
|
|
|
|
moves.push(basics[Math.floor(Math.random() * basics.length)].input)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return moves.join(' | ')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get all known inputs as flat strings for reference
|
|
|
|
|
export function getRetroMoveInputs(): string[] {
|
|
|
|
|
return RETRO_MOVES.map(m => m.input)
|
|
|
|
|
}
|