Files
botfights/server/src/engine/arcade-bot.ts
T
2026-04-11 19:46:37 +01:00

185 lines
6.1 KiB
TypeScript

// Arcade Bot — formats game state into challenge prompts and generates
// mock/classic bot action responses for arcade mode.
import { logger } from '../lib/logger.js'
export interface ArcadeGameState {
self: { hp: number; x: number; state: string; grounded: boolean }
opponent: { hp: number; x: number; state: string; grounded: boolean }
distance: number
timer: number
round: number
maxRounds: number
facingRight: boolean
}
const VALID_ACTIONS = [
'idle', 'move_forward', 'move_back', 'jump', 'crouch',
'punch', 'kick', 'block', 'jump_punch', 'jump_kick',
'fireball', 'uppercut', 'dash_punch', 'spinning_kick', 'super_jump_kick',
] as const
type BotAction = (typeof VALID_ACTIONS)[number]
/** Format game state into a challenge prompt for webhook/polling bots */
export function formatArcadeChallenge(state: ArcadeGameState): string {
const distLabel = state.distance > 250 ? 'far' : state.distance > 120 ? 'medium' : 'close'
const selfHpPct = Math.round((state.self.hp / 1000) * 100)
const oppHpPct = Math.round((state.opponent.hp / 1000) * 100)
return `ARCADE FIGHT — Real-time 2D fighter. You are P2.
ACTIONS (respond with comma-separated list, 3-8 actions):
move_forward, move_back, jump, crouch, punch (50dmg), kick (70dmg), block,
jump_punch, jump_kick, fireball (60dmg, ranged), uppercut (100dmg, launcher),
dash_punch (80dmg, rush), spinning_kick (90dmg, multi-hit), super_jump_kick (110dmg)
STATE:
You: HP ${state.self.hp}/1000 (${selfHpPct}%), x=${state.self.x}, ${state.self.state}${state.self.grounded ? '' : ' (airborne)'}
Opponent: HP ${state.opponent.hp}/1000 (${oppHpPct}%), x=${state.opponent.x}, ${state.opponent.state}${state.opponent.grounded ? '' : ' (airborne)'}
Distance: ${state.distance}px (${distLabel}) | Timer: ${state.timer}s | Round ${state.round}/${state.maxRounds}
Respond: {"answer":"action1, action2, action3, ..."}`
}
/** Parse a bot response into validated action list */
export function parseArcadeResponse(answer: string | null): BotAction[] {
if (!answer) return generateFallbackActions()
const parts = answer.split(',').map(s => s.trim().toLowerCase())
const actions: BotAction[] = []
for (const p of parts) {
if ((VALID_ACTIONS as readonly string[]).includes(p)) {
actions.push(p as BotAction)
}
}
return actions.length > 0 ? actions.slice(0, 10) : generateFallbackActions()
}
/** Generate mock/classic bot arcade actions based on game state */
export function generateArcadeBotActions(state: ArcadeGameState, personality: string): BotAction[] {
const actions: BotAction[] = []
const dist = state.distance
const selfHp = state.self.hp
const oppHp = state.opponent.hp
const oppState = state.opponent.state
const rng = () => Math.random()
// Personality-based aggression (0 = defensive, 1 = aggressive)
const aggression = getPersonalityAggression(personality)
// React to opponent's state
if (oppState === 'attacking' || oppState === 'kicking' || oppState === 'special') {
// Opponent attacking — defensive response
if (rng() < 0.4 + (1 - aggression) * 0.3) {
actions.push('block')
if (rng() < 0.3) actions.push('punch') // counter after block
return actions
}
if (rng() < 0.3) {
actions.push('move_back')
return actions
}
}
// Opponent in hitstun — press advantage
if (oppState === 'hit' || oppState === 'knockback') {
if (dist < 100) {
if (rng() < 0.4 * aggression) actions.push('uppercut')
else if (rng() < 0.5) actions.push('kick')
else actions.push('punch')
return actions
}
actions.push('move_forward')
actions.push('punch')
return actions
}
// Distance-based decisions
if (dist > 250) {
// Far range
if (rng() < 0.35 * aggression) {
actions.push('fireball')
} else if (rng() < 0.5) {
actions.push('move_forward')
actions.push('move_forward')
} else {
actions.push('move_forward')
if (rng() < 0.3) actions.push('jump')
}
} else if (dist > 120) {
// Medium range
if (rng() < 0.25 * aggression) {
actions.push('dash_punch')
} else if (rng() < 0.2 * aggression) {
actions.push('fireball')
} else if (rng() < 0.4) {
actions.push('move_forward')
actions.push(rng() < 0.5 ? 'punch' : 'kick')
} else if (rng() < 0.3) {
actions.push('jump_kick')
} else {
actions.push('move_forward')
}
} else {
// Close range
if (rng() < 0.15 * aggression) {
actions.push('uppercut')
} else if (rng() < 0.12 * aggression) {
actions.push('spinning_kick')
} else if (rng() < 0.35) {
actions.push(rng() < 0.5 ? 'punch' : 'kick')
if (rng() < 0.3 * aggression) actions.push('punch') // double tap
} else if (rng() < 0.25) {
actions.push('block')
} else if (rng() < 0.2) {
actions.push('crouch')
actions.push('kick') // sweep
} else {
actions.push('move_back')
if (rng() < 0.3) actions.push('fireball')
}
}
// Low HP = more defensive
if (selfHp < 300 && rng() < 0.3) {
actions.push('block')
actions.push('move_back')
}
// Opponent low HP = go for the kill
if (oppHp < 200 && rng() < 0.4 * aggression) {
actions.push('move_forward')
actions.push(rng() < 0.3 ? 'super_jump_kick' : 'dash_punch')
}
// Ensure at least one action
if (actions.length === 0) {
actions.push(rng() < 0.6 ? 'move_forward' : 'idle')
}
return actions
}
function getPersonalityAggression(personality: string): number {
const map: Record<string, number> = {
aggressive: 0.9, confident: 0.8, relentless: 0.95,
intimidating: 0.85, unstoppable: 0.9, lethal: 0.85,
destructive: 0.9, reckless: 0.95, chaotic: 0.8,
calculated: 0.6, systematic: 0.55, precise: 0.5,
tactical: 0.6, analytical: 0.5, logical: 0.45,
disciplined: 0.55, steady: 0.5, resilient: 0.4,
chill: 0.3, philosophical: 0.35, zen: 0.4,
panicky: 0.7, buggy: 0.6, dramatic: 0.65,
witty: 0.55, sarcastic: 0.5, based: 0.65,
omniscient: 0.7, transcendent: 0.6, cosmic: 0.55,
}
return map[personality] ?? 0.6
}
function generateFallbackActions(): BotAction[] {
return ['move_forward', 'punch', 'block']
}