From 4248188af46a37e92d26c2d23546d80f14b7ea99 Mon Sep 17 00:00:00 2001 From: PartMan Date: Wed, 7 Jan 2026 23:15:45 +0530 Subject: [PATCH 1/3] chore: Initial battle setup --- .env.example | 3 + src/enabled.ts | 1 + src/ps/battle/battle.ts | 224 +++++++ src/ps/battle/data/effectiveness.ts | 80 +++ src/ps/battle/data/index.ts | 316 ++++++++++ src/ps/battle/decision/api.ts | 120 ++++ src/ps/battle/decision/heuristic.ts | 593 ++++++++++++++++++ src/ps/battle/decision/index.ts | 189 ++++++ src/ps/battle/decision/random.ts | 22 + src/ps/battle/index.ts | 387 ++++++++++++ src/ps/battle/parser.ts | 921 ++++++++++++++++++++++++++++ src/ps/battle/types.ts | 291 +++++++++ src/ps/handlers/autores.ts | 2 + src/ps/handlers/battle.ts | 105 ++++ src/ps/index.ts | 17 + src/sentinel/live.ts | 3 + 16 files changed, 3274 insertions(+) create mode 100644 src/ps/battle/battle.ts create mode 100644 src/ps/battle/data/effectiveness.ts create mode 100644 src/ps/battle/data/index.ts create mode 100644 src/ps/battle/decision/api.ts create mode 100644 src/ps/battle/decision/heuristic.ts create mode 100644 src/ps/battle/decision/index.ts create mode 100644 src/ps/battle/decision/random.ts create mode 100644 src/ps/battle/index.ts create mode 100644 src/ps/battle/parser.ts create mode 100644 src/ps/battle/types.ts create mode 100644 src/ps/handlers/battle.ts diff --git a/.env.example b/.env.example index c3df8dc5..9e86f127 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,9 @@ PS_ROOMS=botdevelopment PS_AVATAR=supernerd PS_GLOBAL_BOT=false +USE_BATTLE=true + + USE_DISCORD=true DISCORD_CLIENT_ID=ID_HERE DISCORD_TOKEN=TOKEN_HERE diff --git a/src/enabled.ts b/src/enabled.ts index f8c58fa7..01155d71 100644 --- a/src/enabled.ts +++ b/src/enabled.ts @@ -4,4 +4,5 @@ export const IS_ENABLED = { DB: process.env.USE_DB === 'true', WEB: process.env.USE_WEB === 'true', SHEETS: process.env.USE_SHEETS === 'true', + BATTLE: process.env.USE_BATTLE === 'true', }; diff --git a/src/ps/battle/battle.ts b/src/ps/battle/battle.ts new file mode 100644 index 00000000..fba78fee --- /dev/null +++ b/src/ps/battle/battle.ts @@ -0,0 +1,224 @@ +/** + * Battle instance class. + * Manages state for a single battle. + */ + +import { actionToCommand, getLegalActions } from '@/ps/battle/decision'; +import { parseRequest, updateOurTeamFromRequest } from '@/ps/battle/parser'; +import { Logger } from '@/utils/logger'; +import { sample, useRNG } from '@/utils/random'; + +import type { DecisionEngine } from '@/ps/battle/decision'; +import type { BattleRequest, BattleState, FormatConfig, Hazards, Screens, Side } from '@/ps/battle/types'; +import type { Client } from 'ps-client'; + +export class Battle { + state: BattleState; + roomId: string; + private client: Client; + private engine: DecisionEngine; + seed: number; + RNG: () => number; + + constructor(client: Client, roomId: string, format: FormatConfig, ourSide: 'p1' | 'p2', engine: DecisionEngine, seed?: number) { + this.client = client; + this.roomId = roomId; + this.engine = engine; + this.state = this.createInitialState(roomId, format, ourSide); + + const rngSeed = seed ?? sample(1e12); + this.seed = rngSeed; + const RNG = useRNG(rngSeed); + this.RNG = RNG; + } + + send(text: string): void { + this.client.getRoom(this.roomId).send(text); + } + + private createInitialState(roomId: string, format: FormatConfig, ourSide: 'p1' | 'p2'): BattleState { + const createHazards = (): Hazards => ({ + stealthRock: false, + spikes: 0, + toxicSpikes: 0, + stickyWeb: false, + }); + + const createScreens = (): Screens => ({ + reflect: 0, + lightScreen: 0, + auroraVeil: 0, + }); + + const createSide = (id: 'p1' | 'p2'): Side => ({ + name: '', + odentifier: id, + active: null, + team: [], + teamSize: format.teamSize, + faintedCount: 0, + totalPokemon: format.teamSize, + hazards: createHazards(), + screens: createScreens(), + tailwind: 0, + wish: null, + }); + + return { + format, + turn: 0, + phase: format.hasTeamPreview ? 'teamPreview' : 'active', + p1: createSide('p1'), + p2: createSide('p2'), + field: { + weather: null, + weatherTurns: 0, + terrain: null, + terrainTurns: 0, + trickRoom: 0, + gravity: 0, + magicRoom: 0, + wonderRoom: 0, + }, + roomId, + startedAt: new Date(), + ourSide, + rqid: 0, + }; + } + + /** + * Process a protocol line and potentially return a command. + */ + async processLine(line: string): Promise { + if (!line) return null; + + // Handle request separately (contains JSON) + if (line.startsWith('|request|')) { + const json = line.slice(9); + return this.handleRequest(json); + } + + // Parse protocol line and update state + if (line.startsWith('|')) { + // donotpush TODO + // parseProtocolLine(this.state, line); + } + + // Handle battle end + if (line.startsWith('|win|') || line.startsWith('|tie|')) { + this.state.phase = 'ended'; + const won = this.didWeWin(line); + await this.engine.onBattleEnd?.(this.state, won); + Logger.log(`[Battle] ${this.state.roomId} ended - ${won ? 'Won' : 'Lost'}`); + } + + return null; + } + + private didWeWin(line: string): boolean { + // |win|Username or |tie| + if (line.startsWith('|tie|')) return false; + + const winner = line.slice(5); + const ourName = this.state[this.state.ourSide].name; + + return winner.toLowerCase() === ourName.toLowerCase(); + } + + async handleRequest(json: string): Promise { + if (!json) return null; + + let request: BattleRequest; + try { + request = parseRequest(json); + } catch (err) { + Logger.errorLog(err instanceof Error ? err : new Error(String(err))); + return null; + } + + this.state.rqid = request.rqid; + + // Wait request - opponent still deciding + if (request.requestType === 'wait') { + this.state.phase = 'waiting'; + return null; + } + + // Update phase + this.updatePhase(request); + + // Update our team info from request + if (request.side) { + updateOurTeamFromRequest(this.state, request.side.pokemon); + + // Detect our side from request if not set + if (request.side.id) { + this.state.ourSide = request.side.id; + } + } + + // Get legal actions + const legalActions = getLegalActions(request, this.state); + + if (legalActions.length === 0) { + return null; + } + + // Get decision from engine + try { + const result = await this.engine.decide({ + state: this.state, + request, + legalActions, + RNG: this.RNG, + }); + + Logger.log(`[Battle] ${this.state.roomId} Turn ${this.state.turn}: ${result.reasoning}`); + + // Convert action to command + return actionToCommand(result.action); + } catch (err) { + Logger.errorLog(err instanceof Error ? err : new Error(String(err))); + + // Fallback: pick first legal action + if (legalActions.length > 0) { + return actionToCommand(legalActions[0]); + } + return null; + } + } + + private updatePhase(request: BattleRequest): void { + if (request.requestType === 'teamPreview') { + this.state.phase = 'teamPreview'; + } else if (request.forceSwitch?.some(Boolean)) { + this.state.phase = 'forceSwitch'; + } else { + this.state.phase = 'active'; + } + } + + /** + * Check if the battle has ended. + */ + isEnded(): boolean { + return this.state.phase === 'ended'; + } + + /** + * Get summary of current state (for debugging). + */ + getSummary(): string { + const { state } = this; + const us = state[state.ourSide]; + const them = state[state.ourSide === 'p1' ? 'p2' : 'p1']; + + const ourActive = us.active?.species ?? 'None'; + const theirActive = them.active?.species ?? 'None'; + const ourRemaining = us.team.filter(p => !p.fainted).length; + const theirRemaining = them.team.filter(p => !p.fainted).length; + + return `Turn ${state.turn}: ${ourActive} vs ${theirActive} (${ourRemaining}-${theirRemaining})`; + } +} diff --git a/src/ps/battle/data/effectiveness.ts b/src/ps/battle/data/effectiveness.ts new file mode 100644 index 00000000..d8949b4e --- /dev/null +++ b/src/ps/battle/data/effectiveness.ts @@ -0,0 +1,80 @@ +/** + * Type effectiveness calculation. + * Uses typechart data from ps-client. + */ + +import { moves, typechart } from 'ps-client/data'; + +import { toId } from '@/utils/toId'; + +import type { TypeName } from '@/ps/battle/types'; + +/** + * Convert damageTaken value to multiplier. + * ps-client format: 0 = neutral (1x), 1 = super effective (2x), 2 = resistant (0.5x), 3 = immune (0x) + */ +function damageTakenToMultiplier(value: number): number { + switch (value) { + case 1: + return 2; + case 2: + return 0.5; + case 3: + return 0; + default: + return 1; + } +} + +/** + * Get type effectiveness multiplier for an attack type against defense types. + */ +export function getEffectiveness(attackType: TypeName, defenseTypes: TypeName[]): number { + let multiplier = 1; + + for (const defType of defenseTypes) { + const defTypeId = toId(defType) as Lowercase; + const typeData = typechart[defTypeId]; + + if (typeData) { + const damageTaken = typeData.damageTaken[attackType]; + if (typeof damageTaken === 'number') { + multiplier *= damageTakenToMultiplier(damageTaken); + } + } + } + + return multiplier; +} + +/** + * Get type effectiveness multiplier against a Pokemon by species name. + * Uses the Pokemon data module to look up types. + */ +export function getEffectivenessVsPokemon(attackType: TypeName | keyof typeof moves, pokemonTypes: TypeName[]): number { + const typeId = toId(attackType); + if (typeId in moves) return getEffectiveness(moves[typeId].type, pokemonTypes); + return getEffectiveness(attackType as TypeName, pokemonTypes); +} + +/** + * Check if a type is immune to another type. + */ +export function isImmune(attackType: TypeName | keyof typeof moves, defenseTypes: TypeName[]): boolean { + return getEffectivenessVsPokemon(attackType, defenseTypes) === 0; +} + +/** + * Check if a type is super effective. + */ +export function isSuperEffective(attackType: TypeName | keyof typeof moves, defenseTypes: TypeName[]): boolean { + return getEffectivenessVsPokemon(attackType, defenseTypes) > 1; +} + +/** + * Check if a type is not very effective. + */ +export function isResisted(attackType: TypeName | keyof typeof moves, defenseTypes: TypeName[]): boolean { + const eff = getEffectivenessVsPokemon(attackType, defenseTypes); + return eff > 0 && eff < 1; +} diff --git a/src/ps/battle/data/index.ts b/src/ps/battle/data/index.ts new file mode 100644 index 00000000..510818c6 --- /dev/null +++ b/src/ps/battle/data/index.ts @@ -0,0 +1,316 @@ +/** + * Pokemon data access layer. + * Uses ps-client for Pokemon data (moves, abilities, species). + */ + +import { abilities, items, moves, pokedex } from 'ps-client/data'; + +import { toId } from '@/utils/toId'; + +import type { Types } from 'ps-client/data'; + +// Re-export effectiveness utilities +export { getEffectiveness, getEffectivenessVsPokemon, isImmune, isResisted, isSuperEffective } from '@/ps/battle/data/effectiveness'; + +// ============ Species Data ============ + +export interface SpeciesData { + name: string; + id: string; + types: Types[]; + baseStats: { + hp: number; + atk: number; + def: number; + spa: number; + spd: number; + spe: number; + }; + abilities: string[]; + weightkg: number; +} + +/** + * Get species data by name or ID. + * Note: ps-client data is not generation-specific, so gen parameter is ignored. + */ +export function getSpecies(name: string, _gen?: number): SpeciesData | null { + const id = toId(name); + const species = pokedex[id]; + + if (!species) { + return null; + } + + const abilityList: string[] = []; + if (species.abilities[0]) abilityList.push(species.abilities[0]); + if (species.abilities[1]) abilityList.push(species.abilities[1]); + if (species.abilities.H) abilityList.push(species.abilities.H); + if (species.abilities.S) abilityList.push(species.abilities.S); + + return { + name: species.name, + id: species.id, + types: species.types, + baseStats: { ...species.baseStats }, + abilities: abilityList, + weightkg: species.weightkg, + }; +} + +/** + * Get types for a species. + */ +export function getSpeciesTypes(name: string, _gen?: number): Types[] { + const species = getSpecies(name); + return species?.types ?? []; +} + +// ============ Move Data ============ + +export interface MoveData { + name: string; + id: string; + type: Types; + category: 'Physical' | 'Special' | 'Status'; + basePower: number; + accuracy: number | true; + pp: number; + priority: number; + target: string; + flags: Record; + // Boost effects + boosts?: Partial> | undefined; + selfBoost?: { boosts: Partial> } | undefined; + secondary?: + | { + chance?: number | undefined; + boosts?: Partial> | undefined; + self?: { boosts?: Partial> | undefined } | undefined; + status?: string | undefined; + volatileStatus?: string | undefined; + } + | undefined; + // Special properties + drain?: [number, number] | undefined; + recoil?: [number, number] | undefined; + heal?: number[] | null | undefined; + volatileStatus?: string | undefined; + sideCondition?: string | undefined; + weather?: string | undefined; + terrain?: string | undefined; + pseudoWeather?: string | undefined; + forceSwitch?: boolean | undefined; + selfSwitch?: boolean | string | undefined; + hasCrashDamage?: boolean | undefined; + mindBlownRecoil?: boolean | undefined; + stealsBoosts?: boolean | undefined; + breaksProtect?: boolean | undefined; + willCrit?: boolean | undefined; + ohko?: boolean | string | undefined; +} + +/** + * Get move data by name or ID. + * Note: ps-client data is not generation-specific, so gen parameter is ignored. + */ +export function getMove(name: string, _gen?: number): MoveData | null { + const id = toId(name); + const move = moves[id]; + + if (!move) { + return null; + } + + return { + name: move.name, + id, + type: move.type, + category: move.category, + basePower: move.basePower, + accuracy: move.accuracy, + pp: move.pp, + priority: move.priority, + target: move.target, + flags: { ...move.flags }, + boosts: move.boosts ? { ...move.boosts } : undefined, + selfBoost: move.selfBoost ? { boosts: { ...move.selfBoost.boosts } } : undefined, + secondary: move.secondary + ? { + chance: move.secondary.chance, + boosts: move.secondary.boosts ? { ...move.secondary.boosts } : undefined, + self: move.secondary.self + ? { boosts: move.secondary.self.boosts ? { ...move.secondary.self.boosts } : undefined } + : undefined, + status: move.secondary.status, + volatileStatus: move.secondary.volatileStatus, + } + : undefined, + drain: move.drain, + recoil: move.recoil, + heal: move.heal, + volatileStatus: move.volatileStatus, + sideCondition: move.sideCondition, + weather: move.weather, + terrain: move.terrain, + pseudoWeather: move.pseudoWeather, + forceSwitch: move.forceSwitch, + selfSwitch: move.selfSwitch, + hasCrashDamage: move.hasCrashDamage, + mindBlownRecoil: move.mindBlownRecoil, + stealsBoosts: move.stealsBoosts, + breaksProtect: move.breaksProtect, + willCrit: move.willCrit, + ohko: move.ohko, + }; +} + +/** + * Check if a move is a status move. + */ +export function isStatusMove(name: string, _gen?: number): boolean { + const move = getMove(name); + return move?.category === 'Status'; +} + +/** + * Check if a move is a hazard-setting move. + */ +export function isHazardMove(name: string): boolean { + const id = toId(name); + return ['stealthrock', 'spikes', 'toxicspikes', 'stickyweb'].includes(id); +} + +/** + * Check if a move is a recovery move. + */ +export function isRecoveryMove(name: string, _gen?: number): boolean { + const move = getMove(name); + if (!move) return false; + return !!move.heal || !!move.drain; +} + +// ============ Ability Data ============ + +export interface AbilityData { + name: string; + id: string; + desc?: string | undefined; + shortDesc?: string | undefined; +} + +/** + * Get ability data by name or ID. + */ +export function getAbility(name: string, _gen?: number): AbilityData | null { + const id = toId(name); + const ability = abilities[id]; + + if (!ability) { + return null; + } + + return { + name: ability.name, + id, + desc: ability.desc, + shortDesc: ability.shortDesc, + }; +} + +/** + * Check if an ability grants Ground immunity. + */ +export function grantsGroundImmunity(abilityName: string | null): boolean { + if (!abilityName) return false; + const id = toId(abilityName); + return id === 'levitate'; +} + +/** + * Check if an ability is Adaptability (boosts STAB). + */ +export function isAdaptability(abilityName: string | null): boolean { + if (!abilityName) return false; + return toId(abilityName) === 'adaptability'; +} + +// ============ Item Data ============ + +export interface ItemData { + name: string; + id: string; + desc?: string | undefined; + shortDesc?: string | undefined; + isBerry?: boolean | undefined; + isChoice?: boolean | undefined; +} + +/** + * Get item data by name or ID. + */ +export function getItem(name: string): ItemData | null { + const id = toId(name); + const item = items[id]; + + if (!item) { + return null; + } + + return { + name: item.name, + id, + desc: item.desc, + shortDesc: item.shortDesc, + isBerry: item.isBerry, + isChoice: item.isChoice, + }; +} + +/** + * Check if an item grants Ground immunity. + */ +export function itemGrantsGroundImmunity(itemName: string | null): boolean { + if (!itemName) return false; + return toId(itemName) === 'airballoon'; +} + +/** + * Check if an item is a choice item. + */ +export function isChoiceItem(itemName: string | null): boolean { + if (!itemName) return false; + const item = getItem(itemName); + return item?.isChoice ?? false; +} + +/** + * Check if an item boosts a specific type's moves. + */ +export function getItemTypeBoost(itemName: string | null): Types | null { + if (!itemName) return null; + const id = toId(itemName); + + const typeBoostItems: Record = { + silkscarf: 'Normal', + charcoal: 'Fire', + mysticwater: 'Water', + magnet: 'Electric', + miracleseed: 'Grass', + nevermeltice: 'Ice', + blackbelt: 'Fighting', + poisonbarb: 'Poison', + softsand: 'Ground', + sharpbeak: 'Flying', + twistedspoon: 'Psychic', + silverpowder: 'Bug', + hardstone: 'Rock', + spelltag: 'Ghost', + dragonfang: 'Dragon', + blackglasses: 'Dark', + metalcoat: 'Steel', + fairyfeather: 'Fairy', + }; + + return typeBoostItems[id] ?? null; +} diff --git a/src/ps/battle/decision/api.ts b/src/ps/battle/decision/api.ts new file mode 100644 index 00000000..7165a79d --- /dev/null +++ b/src/ps/battle/decision/api.ts @@ -0,0 +1,120 @@ +/** + * API-based decision engine. + * Delegates decision making to an external service. + * Useful for heavy ML inference or complex battle bots. + */ + +import type { DecisionEngine } from '@/ps/battle/decision'; +import type { Action, BattleState, DecisionContext, DecisionResult } from '@/ps/battle/types'; + +export interface APIDecisionEngineConfig { + url: string; + timeout?: number | undefined; + headers?: Record | undefined; + /** API key (will be sent as Authorization header) */ + apiKey?: string | undefined; +} + +export class APIDecisionEngine implements DecisionEngine { + name = 'api'; + private config: APIDecisionEngineConfig; + + constructor(config: APIDecisionEngineConfig) { + this.config = { + timeout: 10000, + ...config, + }; + } + + async decide(ctx: DecisionContext): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.config.timeout); + + try { + const headers: Record = { + 'Content-Type': 'application/json', + ...this.config.headers, + }; + + if (this.config.apiKey) { + headers['Authorization'] = `Bearer ${this.config.apiKey}`; + } + + const response = await fetch(`${this.config.url}/decide`, { + method: 'POST', + headers, + body: JSON.stringify({ + state: this.serializeState(ctx.state), + request: ctx.request, + legalActions: ctx.legalActions, + }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`API returned ${response.status}: ${await response.text()}`); + } + + const data = (await response.json()) as { action: Action; confidence?: number; reasoning?: string }; + + return { + action: data.action, + confidence: data.confidence ?? undefined, + reasoning: data.reasoning ?? 'API decision', + }; + } finally { + clearTimeout(timeout); + } + } + + async onBattleStart(state: BattleState): Promise { + // Optionally notify API of new battle + try { + await fetch(`${this.config.url}/battle/start`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(this.config.apiKey ? { Authorization: `Bearer ${this.config.apiKey}` } : {}), + }, + body: JSON.stringify({ + roomId: state.roomId, + format: state.format.id, + ourSide: state.ourSide, + }), + }); + } catch { + // Non-critical, ignore errors + } + } + + async onBattleEnd(state: BattleState, won: boolean): Promise { + // Optionally notify API of battle result (for learning) + try { + await fetch(`${this.config.url}/battle/end`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(this.config.apiKey ? { Authorization: `Bearer ${this.config.apiKey}` } : {}), + }, + body: JSON.stringify({ + roomId: state.roomId, + won, + turn: state.turn, + }), + }); + } catch { + // Non-critical, ignore errors + } + } + + private serializeState(state: BattleState): object { + // Convert state to JSON-serializable format + // Handle Sets and other non-serializable types + return JSON.parse( + JSON.stringify(state, (_, value) => { + if (value instanceof Set) return [...value]; + return value; + }) + ); + } +} diff --git a/src/ps/battle/decision/heuristic.ts b/src/ps/battle/decision/heuristic.ts new file mode 100644 index 00000000..d6d5d7ea --- /dev/null +++ b/src/ps/battle/decision/heuristic.ts @@ -0,0 +1,593 @@ +/** + * Heuristic decision engine based on PartProfessor AI. + * Uses weighted scoring to evaluate moves and switches. + * + * @see https://github.com/PartMan7/PartProfessor/blob/master/data/BATTLE/ai.js + */ + +import { + getEffectiveness, + getMove, + getSpecies, + grantsGroundImmunity, + isAdaptability, + isHazardMove, + itemGrantsGroundImmunity, +} from '@/ps/battle/data'; +import { sample } from '@/utils/random'; +import { toId } from '@/utils/toId'; + +import type { DecisionEngine } from '@/ps/battle/decision'; +import type { + AILevel, + Action, + BattleState, + DecisionContext, + DecisionResult, + MoveAction, + Pokemon, + SwitchAction, + TypeName, +} from '@/ps/battle/types'; + +/** + * Heuristic-based decision engine. + * AI levels: + * - 0: Random + * - 1: Basic type effectiveness + * - 2: Full heuristic with move weights, hazards, setup, etc. + */ +export class HeuristicDecisionEngine implements DecisionEngine { + name = 'heuristic'; + aiLevel: AILevel; + + constructor(aiLevel: AILevel = 2) { + this.aiLevel = aiLevel; + } + + async decide(ctx: DecisionContext): Promise { + const { request, legalActions } = ctx; + + if (legalActions.length === 0) { + return { action: { type: 'pass' }, confidence: 0, reasoning: 'No legal actions' }; + } + + if (legalActions.length === 1) { + return { action: legalActions[0], confidence: 1, reasoning: 'Only one legal action' }; + } + + // Handle team preview + if (request.requestType === 'teamPreview') { + return this.handleTeamPreview(ctx); + } + + // Handle force switch + if (request.requestType === 'switch') { + return this.handleForceSwitch(ctx); + } + + // Normal turn - evaluate all actions + return this.handleNormalTurn(ctx); + } + + // ============ Team Preview ============ + + private handleTeamPreview(ctx: DecisionContext): DecisionResult { + const { state, legalActions } = ctx; + const teamAction = legalActions.find(a => a.type === 'team'); + if (!teamAction || teamAction.type !== 'team') { + return { action: { type: 'pass' }, confidence: 0, reasoning: 'No team action' }; + } + + if (this.aiLevel === 0) { + // Random order + const order = teamAction.order.slice().shuffle(ctx.RNG); + return { action: { type: 'team', order }, confidence: 0.5, reasoning: 'Random team order' }; + } + + // Evaluate each Pokemon's effectiveness against opponent's team + const ourSide = state[state.ourSide]; + const theirSide = state[state.ourSide === 'p1' ? 'p2' : 'p1']; + const gen = state.format.generation; + + const scores: Record = {}; + + for (let i = 0; i < ourSide.team.length; i++) { + const pokemon = ourSide.team[i]; + if (!pokemon) continue; + + let score = 0; + + // Score based on offensive coverage against opponent's visible team + const moves = pokemon.moves ?? pokemon.knownMoves; + for (const moveId of moves) { + const move = getMove(moveId, gen); + if (!move || move.category === 'Status') continue; + + for (const opponent of theirSide.team) { + if (!opponent) continue; + const eff = getEffectiveness(move.type, opponent.types); + score += eff > 1 ? eff * 10 : 0; + } + } + + // Bonus for hazard setters as leads + if (moves.some(m => isHazardMove(m))) { + score += 30; + } + + scores[i + 1] = score; + } + + // Sort by score descending + const order = Object.entries(scores) + .sort(([, a], [, b]) => b - a) + .map(([slot]) => parseInt(slot)); + + // Fill remaining slots if needed + while (order.length < teamAction.order.length) { + for (let i = 1; i <= ourSide.team.length; i++) { + if (!order.includes(i)) { + order.push(i); + break; + } + } + } + + return { + action: { type: 'team', order }, + confidence: 0.7, + reasoning: `Lead: ${ourSide.team[order[0] - 1]?.species}`, + }; + } + + // ============ Force Switch ============ + + private handleForceSwitch(ctx: DecisionContext): DecisionResult { + const { state, legalActions } = ctx; + const switches = legalActions.filter((a): a is SwitchAction => a.type === 'switch'); + + if (switches.length === 0) { + return { action: { type: 'pass' }, confidence: 0, reasoning: 'No switches available' }; + } + + if (switches.length === 1) { + return { action: switches[0], confidence: 1, reasoning: 'Only one switch available' }; + } + + if (this.aiLevel === 0) { + const action = switches.random(ctx.RNG)!; + return { action, confidence: 0.5, reasoning: 'Random switch' }; + } + + const ourSide = state[state.ourSide]; + const theirSide = state[state.ourSide === 'p1' ? 'p2' : 'p1']; + const opponent = theirSide.active; + + // Score each switch option + const scored = switches.map(sw => { + const pokemon = ourSide.team[sw.slot - 1]; + const score = this.scoreSwitchIn(pokemon, opponent, state); + return { action: sw, score }; + }); + + // Pick based on weighted probability + const weights: Record = {}; + for (const { action, score } of scored) { + // Use score^4 for more decisive selection + weights[action.slot.toString()] = Math.max(0.01, score) ** 4; + } + + const selectedSlot = parseInt(sample(weights)); + const selected = scored.find(s => s.action.slot === selectedSlot) ?? scored[0]; + + return { + action: selected.action, + confidence: this.normalizeScore(selected.score), + reasoning: `Switch to ${ourSide.team[selected.action.slot - 1]?.species}`, + }; + } + + // ============ Normal Turn ============ + + private handleNormalTurn(ctx: DecisionContext): DecisionResult { + const { state, legalActions } = ctx; + + if (this.aiLevel === 0) { + const action = legalActions.random(ctx.RNG)!; + return { action, confidence: 0.5, reasoning: 'Random action' }; + } + + const ourSide = state[state.ourSide]; + const theirSide = state[state.ourSide === 'p1' ? 'p2' : 'p1']; + const active = ourSide.active; + const opponent = theirSide.active; + + if (!active) { + return { action: { type: 'pass' }, confidence: 0, reasoning: 'No active Pokemon' }; + } + + // Score all actions + const scored = legalActions.map(action => { + const score = this.scoreAction(action, state, active, opponent); + return { action, score }; + }); + + // Pick based on weighted probability + const weights: Record = {}; + for (let i = 0; i < scored.length; i++) { + weights[i.toString()] = Math.max(0.01, scored[i].score); + } + + const selectedIndex = parseInt(sample(weights)); + const selected = scored[selectedIndex] ?? scored[0]; + + let reasoning = ''; + if (selected.action.type === 'move') { + reasoning = `Use ${selected.action.moveId}`; + } else if (selected.action.type === 'switch') { + reasoning = `Switch to ${ourSide.team[selected.action.slot - 1]?.species}`; + } + + return { + action: selected.action, + confidence: this.normalizeScore(selected.score), + reasoning, + }; + } + + // ============ Scoring Functions ============ + + private scoreAction(action: Action, state: BattleState, active: Pokemon, opponent: Pokemon | null): number { + switch (action.type) { + case 'move': + return this.scoreMove(action, state, active, opponent); + case 'switch': + return this.scoreSwitch(action, state, active, opponent); + case 'pass': + return -100; + default: + return 0; + } + } + + private scoreMove(action: MoveAction, state: BattleState, active: Pokemon, opponent: Pokemon | null): number { + const gen = state.format.generation; + const move = getMove(action.moveId, gen); + if (!move) return 0.01; + + let score = 0; + const ourSide = state[state.ourSide]; + const theirSide = state[state.ourSide === 'p1' ? 'p2' : 'p1']; + const selfBoosts = active.boosts; + const allMoves = active.moves ?? active.knownMoves; + + // Base power contribution + if (move.basePower) { + score += move.basePower; + } + + // ============ Setup Move Evaluation ============ + if (move.boosts && move.target === 'self') { + // Offensive setup (Swords Dance, Nasty Plot, etc.) + const atkBoost = move.boosts.atk || 0; + const spaBoost = move.boosts.spa || 0; + + if (atkBoost > 0) { + // Only value if we have physical moves + const hasPhysical = allMoves.some(m => { + const md = getMove(m, gen); + return md && md.category === 'Physical'; + }); + if (hasPhysical) { + const currentAtk = selfBoosts.atk || 0; + score += Math.sqrt((6 - currentAtk) * atkBoost * 200); + } + } + + if (spaBoost > 0) { + // Only value if we have special moves + const hasSpecial = allMoves.some(m => { + const md = getMove(m, gen); + return md && md.category === 'Special'; + }); + if (hasSpecial) { + const currentSpa = selfBoosts.spa || 0; + score += Math.sqrt((6 - currentSpa) * spaBoost * 200); + } + } + + // Speed setup + if (move.boosts.spe && move.boosts.spe > 0) { + const currentSpe = selfBoosts.spe || 0; + score += Math.sqrt((6 - currentSpe) * move.boosts.spe * 200); + } + + // Defense setup (less valuable but still useful) + if (move.boosts.def && move.boosts.def > 0) { + const currentDef = selfBoosts.def || 0; + score += Math.sqrt((6 - currentDef) * move.boosts.def * 50); + } + if (move.boosts.spd && move.boosts.spd > 0) { + const currentSpd = selfBoosts.spd || 0; + score += Math.sqrt((6 - currentSpd) * move.boosts.spd * 50); + } + } + + // Self-boost on attacking moves (like Power-Up Punch) + if (move.selfBoost?.boosts) { + const boosts = move.selfBoost.boosts; + for (const stat of ['atk', 'spa', 'spe'] as const) { + if (boosts[stat] && boosts[stat]! > 0) { + const current = selfBoosts[stat] || 0; + score += Math.sqrt((6 - current) * boosts[stat]! * 100); + } + } + } + + // Secondary effect boosts (like Charge Beam) + if (move.secondary?.self?.boosts) { + const chance = move.secondary.chance || 100; + const boosts = move.secondary.self.boosts; + for (const stat of ['atk', 'spa', 'spe'] as const) { + if (boosts[stat] && boosts[stat]! > 0) { + const current = selfBoosts[stat] || 0; + score += (chance / 100) * Math.sqrt((6 - current) * boosts[stat]! * 50); + } + } + } + + // ============ Type Effectiveness ============ + if (opponent && move.category !== 'Status') { + // Check for Ground immunity (Levitate, Air Balloon) + if (move.type === 'Ground') { + if (grantsGroundImmunity(opponent.knownAbility) || itemGrantsGroundImmunity(opponent.knownItem)) { + return 0.5; + } + // Check possible Levitate + const species = getSpecies(opponent.species, gen); + if (species?.abilities.some(a => toId(a) === 'levitate')) { + score /= 4; + } + } + + // Shedinja check - only super effective moves work + if (toId(opponent.species) === 'shedinja') { + const eff = getEffectiveness(move.type, opponent.types); + if (eff <= 1) return 0; + } + + // Type effectiveness multiplier + const effectiveness = getEffectiveness(move.type, opponent.types); + score *= effectiveness; + } + + // ============ STAB Bonus ============ + if (move.category !== 'Status') { + const species = getSpecies(active.species, gen); + const hasStab = species?.types.includes(move.type as TypeName) || active.types.includes(move.type as TypeName); + if (hasStab) { + const ability = active.ability || active.knownAbility; + score *= isAdaptability(ability) ? 2 : 1.5; + } + } + + // ============ Hazard Moves ============ + const moveId = toId(move.name); + const setHazards = theirSide.hazards; + + if (moveId === 'stealthrock' && !setHazards.stealthRock) { + // Value based on remaining opponent Pokemon + const remainingOpponents = theirSide.team.filter(p => !p.fainted && p.hp > 0).length; + score += remainingOpponents * 20; + } + + if (moveId === 'spikes' && setHazards.spikes < 3) { + const remainingOpponents = theirSide.team.filter(p => !p.fainted && p.hp > 0).length; + score += remainingOpponents * (3 - setHazards.spikes) * 10; + } + + if (moveId === 'toxicspikes' && setHazards.toxicSpikes < 2) { + const remainingOpponents = theirSide.team.filter(p => !p.fainted && p.hp > 0).length; + score += remainingOpponents * (2 - setHazards.toxicSpikes) * 12; + } + + if (moveId === 'stickyweb' && !setHazards.stickyWeb) { + const remainingOpponents = theirSide.team.filter(p => !p.fainted && p.hp > 0).length; + score += remainingOpponents * 15; + } + + // ============ Hazard Removal ============ + const ourHazards = ourSide.hazards; + const hasOurHazards = ourHazards.stealthRock || ourHazards.spikes > 0 || ourHazards.toxicSpikes > 0; + if ((moveId === 'rapidspin' || moveId === 'defog') && hasOurHazards) { + const remainingTeam = ourSide.team.filter(p => !p.fainted && p.hp > 0).length; + score += remainingTeam * 15; + } + + // ============ Recovery Moves ============ + if (move.heal || move.drain) { + const hpPercent = active.maxHp ? active.hp / active.maxHp : active.hp; + if (hpPercent < 0.5) { + score += (1 - hpPercent) * 100; + } + } + + // ============ Status Moves ============ + if (move.category === 'Status' && opponent && !opponent.status) { + if (['toxic', 'willowisp', 'thunderwave', 'spore', 'sleeppowder'].includes(moveId)) { + score += 40; + } + } + + // ============ Priority Moves ============ + if (move.priority > 0 && opponent && opponent.hp < 0.3) { + score += move.priority * 30; + } + + // ============ Gimmick Bonuses ============ + if (action.mega) score += 15; + if (action.dynamax) score += 20; + if (action.terastallize) { + // Tera is valuable for STAB boost or defensive typing + // TODO: You only get one in the match, so this needs to be weighted differently based on how desperately you need to do it NOW + const teraType = active.teraType; + if (teraType === move.type) { + score += 25; // STAB boost + } + } + + // ============ Accuracy Penalty ============ + if (typeof move.accuracy === 'number' && move.accuracy < 100) { + score *= move.accuracy / 100; + } + + return Math.max(0.01, score); + } + + private scoreSwitch(action: SwitchAction, state: BattleState, active: Pokemon, opponent: Pokemon | null): number { + const ourSide = state[state.ourSide]; + const pokemon = ourSide.team[action.slot - 1]; + + if (!pokemon || pokemon.fainted || pokemon.hp <= 0) { + return 0; + } + + let score = this.scoreSwitchIn(pokemon, opponent, state); + + // Penalty for switching in general (loses momentum) + score -= 20; + + // Compare to current active + if (active) { + const currentScore = this.scoreCurrentActive(active, opponent, state); + // Only switch if significantly better + if (score < currentScore * 1.2) { + score *= 0.5; + } + } + + return Math.max(0.01, score); + } + + private scoreSwitchIn(pokemon: Pokemon, opponent: Pokemon | null, state: BattleState): number { + if (!pokemon || pokemon.fainted) return 0; + + const gen = state.format.generation; + const ourSide = state[state.ourSide]; + let score = 50; + + // Prefer healthier Pokemon + const hpPercent = pokemon.maxHp ? pokemon.hp / pokemon.maxHp : pokemon.hp; + score += hpPercent * 30; + + // Prefer Pokemon without status + if (!pokemon.status) score += 10; + if (pokemon.status === 'brn') score -= 15; + if (pokemon.status === 'par') score -= 10; + if (pokemon.status === 'tox') score -= 20; + + // Evaluate offensive matchup + const moves = pokemon.moves ?? pokemon.knownMoves; + let bestMoveScore = 0; + + for (const moveId of moves) { + const move = getMove(moveId, gen); + if (!move || move.category === 'Status') continue; + + let moveScore = move.basePower || 0; + + // Type effectiveness vs opponent + if (opponent) { + const eff = getEffectiveness(move.type, opponent.types); + moveScore *= eff; + } + + // STAB + if (pokemon.types.includes(move.type as TypeName)) { + moveScore *= 1.5; + } + + bestMoveScore = Math.max(bestMoveScore, moveScore); + } + + score += bestMoveScore; + + // Defensive matchup - how much damage will we take? + if (opponent) { + const theirMoves = opponent.knownMoves; + let worstThreat = 1; + + for (const moveId of theirMoves) { + const move = getMove(moveId, gen); + if (!move || move.category === 'Status') continue; + + const eff = getEffectiveness(move.type, pokemon.types); + worstThreat = Math.max(worstThreat, eff); + } + + score /= 1.1 + worstThreat ** 2; + } + + // Hazard damage consideration + if (ourSide.hazards.stealthRock) { + const rockEff = getEffectiveness('Rock', pokemon.types); + score -= rockEff * 15; + } + score -= ourSide.hazards.spikes * 8; + if (ourSide.hazards.toxicSpikes > 0 && !pokemon.types.includes('Poison') && !pokemon.types.includes('Steel')) { + score -= ourSide.hazards.toxicSpikes * 10; + } + if (ourSide.hazards.stickyWeb) { + score -= 10; + } + + return score; + } + + private scoreCurrentActive(active: Pokemon, opponent: Pokemon | null, state: BattleState): number { + const gen = state.format.generation; + let score = 50; + + // HP value + const hpPercent = active.maxHp ? active.hp / active.maxHp : active.hp; + score += hpPercent * 30; + + // Boost value + const boostValue = Object.values(active.boosts).sum(); + score += boostValue * 15; + + // Offensive potential + const moves = active.moves ?? active.knownMoves; + let bestMoveScore = 0; + + for (const moveId of moves) { + const move = getMove(moveId, gen); + if (!move || move.category === 'Status') continue; + + let moveScore = move.basePower || 0; + + if (opponent) { + const eff = getEffectiveness(move.type, opponent.types); + moveScore *= eff; + } + + if (active.types.includes(move.type as TypeName)) { + moveScore *= 1.5; + } + + bestMoveScore = Math.max(bestMoveScore, moveScore); + } + + score += bestMoveScore; + + return score; + } + + // ============ Utility ============ + + private normalizeScore(score: number): number { + // Convert arbitrary score to 0-1 confidence + return Math.max(0, Math.min(1, (score + 100) / 300)); + } +} diff --git a/src/ps/battle/decision/index.ts b/src/ps/battle/decision/index.ts new file mode 100644 index 00000000..8ebd5001 --- /dev/null +++ b/src/ps/battle/decision/index.ts @@ -0,0 +1,189 @@ +/** + * Decision engine interface and utilities. + * Provides a common interface for different AI implementations. + */ + +import { Logger } from '@/utils/logger'; + +import type { Action, BattleRequest, BattleState, DecisionContext, DecisionResult } from '@/ps/battle/types'; + +/** + * Interface for battle decision engines. + * Implementations can range from random to ML-based. + */ +export interface DecisionEngine { + /** Name of the engine (for logging) */ + name: string; + + /** Select an action given the current battle context */ + decide(ctx: DecisionContext): Promise; + + /** Called when battle starts (for initialization) */ + onBattleStart?(state: BattleState): Promise; + + /** Called when battle ends (for learning/logging) */ + onBattleEnd?(state: BattleState, won: boolean): Promise; +} + +/** + * Chain of decision engines - tries each in order until one succeeds. + * Useful for fallback behavior (e.g., API -> Heuristic -> Random). + */ +export class DecisionEngineChain implements DecisionEngine { + name = 'chain'; + private engines: DecisionEngine[]; + + constructor(engines: DecisionEngine[]) { + this.engines = engines; + } + + async decide(ctx: DecisionContext): Promise { + for (const engine of this.engines) { + try { + const result = await engine.decide(ctx); + return { + ...result, + reasoning: `[${engine.name}] ${result.reasoning || ''}`.trim(), + }; + } catch (err) { + // Log and try next engine + Logger.errorLog(err instanceof Error ? err : new Error(`Engine ${engine.name} failed: ${err}`)); + } + } + + // Ultimate fallback: first legal action + const action = ctx.legalActions[0] ?? { type: 'pass' as const }; + return { action, confidence: 0, reasoning: '[fallback] No engine succeeded' }; + } + + async onBattleStart(state: BattleState): Promise { + await Promise.all(this.engines.map(e => e.onBattleStart?.(state))); + } + + async onBattleEnd(state: BattleState, won: boolean): Promise { + await Promise.all(this.engines.map(e => e.onBattleEnd?.(state, won))); + } +} + +/** + * Get legal actions from a battle request. + */ +export function getLegalActions(request: BattleRequest, state: BattleState): Action[] { + const actions: Action[] = []; + + if (request.requestType === 'wait') { + return []; + } + + if (request.requestType === 'teamPreview') { + // For team preview, generate team order action + const teamSize = request.maxTeamSize ?? state.format.teamSize; + const order = Array.from({ length: teamSize }, (_, i) => i + 1); + actions.push({ type: 'team', order }); + return actions; + } + + if (request.requestType === 'switch' || request.forceSwitch?.[0]) { + // Must switch - only switch actions allowed + const ourSide = state[state.ourSide]; + for (let i = 0; i < ourSide.team.length; i++) { + const pokemon = ourSide.team[i]; + if (!pokemon.active && !pokemon.fainted && pokemon.hp > 0) { + actions.push({ + type: 'switch', + slot: i + 1, + pokemonName: pokemon.species, + }); + } + } + return actions; + } + + // Normal move selection + if (request.active?.[0]) { + const active = request.active[0]; + const ourSide = state[state.ourSide]; + + // Add move actions + for (let i = 0; i < active.moves.length; i++) { + const move = active.moves[i]; + if (!move.disabled && move.pp > 0) { + const baseAction: Action = { + type: 'move', + slot: i + 1, + moveId: move.id, + }; + actions.push(baseAction); + + // Mega evolution + if (active.canMegaEvo) { + actions.push({ ...baseAction, mega: true }); + } + + // Z-Move + if (active.canZMove?.[i]) { + actions.push({ ...baseAction, zmove: true }); + } + + // Dynamax + if (active.canDynamax) { + actions.push({ ...baseAction, dynamax: true }); + } + + // Terastallize + if (active.canTerastallize) { + actions.push({ ...baseAction, terastallize: true }); + } + } + } + + // Add switch actions (if not trapped) + if (!active.trapped && !active.maybeTrapped) { + for (let i = 0; i < ourSide.team.length; i++) { + const pokemon = ourSide.team[i]; + if (!pokemon.active && !pokemon.fainted && pokemon.hp > 0) { + actions.push({ + type: 'switch', + slot: i + 1, + pokemonName: pokemon.species, + }); + } + } + } + } + + // If no actions available, add pass + if (actions.length === 0) { + actions.push({ type: 'pass' }); + } + + return actions; +} + +/** + * Convert an action to a PS command string. + */ +export function actionToCommand(action: Action): string { + switch (action.type) { + case 'move': { + let cmd = `move ${action.slot}`; + if (action.mega) cmd += ' mega'; + if (action.zmove) cmd += ' zmove'; + if (action.dynamax) cmd += ' dynamax'; + if (action.terastallize) cmd += ' terastallize'; + if (action.target !== undefined) cmd += ` ${action.target}`; + return cmd; + } + case 'switch': + return `switch ${action.slot}`; + case 'team': + return `team ${action.order.join('')}`; + case 'pass': + return 'pass'; + } +} + +// Re-export implementations +export { APIDecisionEngine } from '@/ps/battle/decision/api'; +export { HeuristicDecisionEngine } from '@/ps/battle/decision/heuristic'; +export { RandomDecisionEngine } from '@/ps/battle/decision/random'; diff --git a/src/ps/battle/decision/random.ts b/src/ps/battle/decision/random.ts new file mode 100644 index 00000000..5891514e --- /dev/null +++ b/src/ps/battle/decision/random.ts @@ -0,0 +1,22 @@ +/** + * Random decision engine - baseline/fallback. + * Picks a random legal action. + */ + +import type { DecisionEngine } from '@/ps/battle/decision'; +import type { DecisionContext, DecisionResult } from '@/ps/battle/types'; + +export class RandomDecisionEngine implements DecisionEngine { + name = 'random'; + + async decide(ctx: DecisionContext): Promise { + const { legalActions } = ctx; + + if (legalActions.length === 0) { + return { action: { type: 'pass' }, confidence: 0, reasoning: 'No legal actions' }; + } + + const action = legalActions.random(ctx.RNG)!; + return { action, confidence: 0, reasoning: 'Random selection' }; + } +} diff --git a/src/ps/battle/index.ts b/src/ps/battle/index.ts new file mode 100644 index 00000000..b72069b7 --- /dev/null +++ b/src/ps/battle/index.ts @@ -0,0 +1,387 @@ +/** + * Battle Manager - orchestrates all battle instances. + * Handles ladder queue, challenge acceptance, and routing. + */ + +import { Battle } from '@/ps/battle/battle'; +import { APIDecisionEngine, DecisionEngineChain, HeuristicDecisionEngine, RandomDecisionEngine } from '@/ps/battle/decision'; +import { FORMATS } from '@/ps/battle/types'; +import { Logger } from '@/utils/logger'; +import { toId } from '@/utils/toId'; + +import type { DecisionEngine } from '@/ps/battle/decision'; +import type { AILevel, FormatConfig } from '@/ps/battle/types'; +import type { Client, Room } from 'ps-client'; + +export interface BattleManagerConfig { + /** External decision API URL (optional) */ + apiUrl?: string | undefined; + /** API key for external service */ + apiKey?: string | undefined; + /** Use built-in heuristic engine */ + useHeuristic?: boolean | undefined; + /** + * AI difficulty level + * 0: Random + * 1: Basic type effectiveness + * 2: Heuristic-based with move weights, hazards, setup, etc. + */ + aiLevel?: AILevel | undefined; + /** Auto-queue for ladder battles */ + autoLadder?: boolean | undefined; + /** Formats to ladder */ + ladderFormats?: string[] | undefined; + /** Max simultaneous battles */ + maxConcurrent?: number | undefined; + /** Auto-accept challenges */ + acceptChallenges?: boolean | undefined; + /** Formats to accept challenges for */ + acceptFormats?: string[] | undefined; +} + +export class BattleManager { + client: Client; + decisionEngine: DecisionEngine; + battles: Map = new Map(); + private config: BattleManagerConfig; + + // Ladder state + private isLaddering = false; + private currentSearches = new Set(); + private ladderInterval: ReturnType | null = null; + + // Statistics + stats = { + battlesStarted: 0, + battlesWon: 0, + battlesLost: 0, + }; + + constructor(client: Client, config: BattleManagerConfig = {}) { + this.client = client; + this.config = { + useHeuristic: true, + aiLevel: 2, + maxConcurrent: 1, + acceptChallenges: false, + ...config, + }; + + // Build decision engine chain + this.decisionEngine = this.buildDecisionEngine(); + + Logger.log('[BattleManager] Initialized'); + } + + private buildDecisionEngine(): DecisionEngine { + const engines: DecisionEngine[] = []; + + // API engine (highest priority if configured) + if (this.config.apiUrl) { + engines.push( + new APIDecisionEngine({ + url: this.config.apiUrl, + apiKey: this.config.apiKey, + }) + ); + } + + // Heuristic engine + if (this.config.useHeuristic !== false) { + engines.push(new HeuristicDecisionEngine(this.config.aiLevel ?? 2)); + } + + // Random fallback + engines.push(new RandomDecisionEngine()); + + return new DecisionEngineChain(engines); + } + + // ============ Message Handling ============ + + /** + * Handle a message from any room. + * Call this for all messages; it will filter for battle rooms. + */ + async handleMessage(room: Room | null, line: string, isIntro?: boolean): Promise { + if (isIntro) return; + if (!room || !room.id.startsWith('battle-')) return; + + const roomId = room.id; + + // Get or create battle instance + let battle = this.battles.get(roomId); + + if (!battle) { + // New battle + const format = this.parseFormatFromRoom(roomId); + const ourSide = this.detectOurSide(line); + + if (!ourSide) { + // Can't determine side yet, store line for later? + // For now, default to p1 and correct later from request + battle = new Battle(this.client, roomId, format, 'p1', this.decisionEngine); + } else { + battle = new Battle(this.client, roomId, format, ourSide, this.decisionEngine); + } + + this.battles.set(roomId, battle); + this.stats.battlesStarted++; + + await this.decisionEngine.onBattleStart?.(battle.state); + Logger.log(`[Battle] Started ${roomId}`); + } + + // Process the line + const command = await battle.processLine(line); + + if (command) { + room.send(`/choose ${command}`); + } + + // Clean up ended battles + if (battle.isEnded()) { + // Track win/loss + const ourFainted = battle.state[battle.state.ourSide].faintedCount; + const theirFainted = battle.state[battle.state.ourSide === 'p1' ? 'p2' : 'p1'].faintedCount; + const didWin = ourFainted < theirFainted; + if (didWin) { + this.stats.battlesWon++; + } else { + this.stats.battlesLost++; + } + + this.battles.delete(roomId); + this.currentSearches.clear(); + + // Continue laddering if enabled + if (this.isLaddering) { + this.maybeStartSearch(); + } + } + } + + parseFormatFromRoom(roomId: string): FormatConfig { + // Format: battle-{format}-{id} + // e.g., battle-gen9randombattle-12345 + const parts = roomId.split('-'); + const formatId = parts.slice(1, -1).join('-'); + + if (FORMATS[formatId]) { + return FORMATS[formatId]; + } + + // Infer format + return this.inferFormat(formatId); + } + + inferFormat(formatId: string): FormatConfig { + formatId = toId(formatId); + const isRandom = formatId.includes('random'); + const genMatch = formatId.match(/gen(\d)/); + const generation = genMatch ? parseInt(genMatch[1]) : 9; + + return { + id: formatId, + generation, + isRandom, + hasTeamPreview: !isRandom, + teamSize: 6, + }; + } + + private detectOurSide(line: string): 'p1' | 'p2' | null { + // Try to detect from |player| line + if (line.startsWith('|player|')) { + const parts = line.split('|'); + if (parts.length >= 4) { + const playerId = parts[2] as 'p1' | 'p2'; + const playerName = parts[3]; + const ourUsername = this.client.status.username; + if (ourUsername && toId(playerName) === toId(ourUsername)) { + return playerId; + } + } + } + return null; + } + + // ============ Ladder Management ============ + + /** + * Start laddering in specified formats. + */ + startLadder(formats?: string[]): void { + const targetFormats = formats ?? this.config.ladderFormats ?? ['gen9randombattle']; + this.config.ladderFormats = targetFormats; + this.isLaddering = true; + + Logger.log(`[Ladder] Starting for: ${targetFormats.join(', ')}`); + + this.maybeStartSearch(); + + // Periodic check for search continuation + if (!this.ladderInterval) { + this.ladderInterval = setInterval(() => { + if (this.isLaddering) { + this.maybeStartSearch(); + } + }, 30000); + } + } + + /** + * Stop laddering. + */ + stopLadder(): void { + this.isLaddering = false; + this.currentSearches.clear(); + + if (this.ladderInterval) { + clearInterval(this.ladderInterval); + this.ladderInterval = null; + } + + Logger.log('[Ladder] Stopped'); + } + + private maybeStartSearch(): void { + if (!this.isLaddering) return; + + const currentBattles = this.battles.size; + const currentSearches = this.currentSearches.size; + const maxConcurrent = this.config.maxConcurrent ?? 1; + + if (currentBattles + currentSearches >= maxConcurrent) { + return; + } + + const formats = this.config.ladderFormats ?? []; + if (formats.length === 0) return; + + // Pick a random format + const format = formats[Math.floor(Math.random() * formats.length)]; + this.searchBattle(format); + } + + /** + * Search for a ladder battle. + */ + searchBattle(formatId: string): void { + if (this.currentSearches.has(formatId)) return; + + this.currentSearches.add(formatId); + this.client.send(`|/search ${formatId}`); + + Logger.log(`[Ladder] Searching ${formatId}`); + + // Remove from searches after timeout + setTimeout(() => { + this.currentSearches.delete(formatId); + }, 60000); + } + + /** + * Cancel current ladder search. + */ + cancelSearch(): void { + this.client.send('|/cancelsearch'); + this.currentSearches.clear(); + } + + // ============ Challenge Handling ============ + + /** + * Accept a battle challenge. + */ + acceptChallenge(user: string, formatId: string): void { + if (!this.config.acceptChallenges) { + this.client.send(`|/reject ${user}`); + Logger.log(`[Challenge] Rejected from ${user} - auto-accept challenges is disabled`); + const message = "Hi, I'm a bot, and I don't accept challenges. Please challenge a real user instead!"; + this.client.addUser(user).send(message); + return; + } + // Check if we should accept this format + const acceptFormats = this.config.acceptFormats ?? this.config.ladderFormats ?? ['gen9randombattle']; + const format = FORMATS[formatId] ?? this.inferFormat(formatId); + + if (!format.isRandom && !acceptFormats.includes(formatId)) { + Logger.log(`[Challenge] Rejected from ${user} - format ${formatId} not in accept list`); + this.client.send(`|/reject ${user}`); + const message = + acceptFormats.length > 0 + ? `Hi, I'm a bot, and I don't accept challenges for this tier. I can fite in ${acceptFormats.list(', ')}.` + : "Hi, I'm a bot, and I don't accept challenges. Please challenge a real user instead!"; + this.client.addUser(user).send(message); + return; + } + + this.client.send(`|/accept ${user}`); + Logger.log(`[Challenge] Accepted from ${user} for ${formatId}`); + } + + /** + * Challenge a user to a battle. + */ + challengeUser(user: string, formatId: string): void { + this.client.send(`|/challenge ${user}, ${formatId}`); + Logger.log(`[Challenge] Sent to ${user} for ${formatId}`); + } + + // ============ Statistics ============ + + /** + * Get current statistics. + */ + getStats(): { + activeBattles: number; + searching: string[]; + isLaddering: boolean; + battlesStarted: number; + battlesWon: number; + battlesLost: number; + winRate: string; + } { + const total = this.stats.battlesWon + this.stats.battlesLost; + const winRate = total > 0 ? ((this.stats.battlesWon / total) * 100).toFixed(1) + '%' : 'N/A'; + + return { + activeBattles: this.battles.size, + searching: [...this.currentSearches], + isLaddering: this.isLaddering, + ...this.stats, + winRate, + }; + } + + /** + * Reset statistics. + */ + resetStats(): void { + this.stats = { + battlesStarted: 0, + battlesWon: 0, + battlesLost: 0, + }; + } + + /** + * Get a specific battle instance. + */ + getBattle(roomId: string): Battle | undefined { + return this.battles.get(roomId); + } + + /** + * Get all active battles. + */ + getActiveBattles(): Battle[] { + return [...this.battles.values()]; + } +} + +// Re-export types +export type { DecisionEngine } from '@/ps/battle/decision'; +export { Battle } from '@/ps/battle/battle'; +export * from '@/ps/battle/types'; diff --git a/src/ps/battle/parser.ts b/src/ps/battle/parser.ts new file mode 100644 index 00000000..e49bcee7 --- /dev/null +++ b/src/ps/battle/parser.ts @@ -0,0 +1,921 @@ +/** + * Pokemon Showdown battle protocol parser. + * Parses battle messages and updates battle state. + */ + +import { Battle } from '@/ps/battle/battle'; +import { getSpeciesTypes } from '@/ps/battle/data'; +import { + type ActiveRequest, + type BattleRequest, + type BattleState, + type BoostId, + FORMATS, + type Pokemon, + type Side, + type SidePokemon, + type StatusId, + type Terrain, + type TypeName, + type Weather, +} from '@/ps/battle/types'; +import { getBattleManager } from '@/ps/handlers/battle'; +import { mapValues } from '@/utils/map'; +import { toId } from '@/utils/toId'; + +import type { Client } from 'ps-client'; + +const BattleProtocolEvents: Record void> = { + request: (state, args, battle) => { + battle.handleRequest(args.join('|')).then(command => { + if (command) { + battle.send(`/choose ${command}`); + } + }); + }, + + player: parsePlayer, + teamsize: parseTeamSize, + // gametype: parseGameType, + gen: (state, args) => { + state.format.generation = parseInt(args[0]) || state.format.generation; + }, + tier: (_state, _args) => { + // state.format.name = args[0]; + }, + rule: (_state, _args) => { + // state.format.rules = args[0]; + }, + start: (state, _args) => { + state.phase = 'active'; + }, + turn: (state, args) => { + state.turn = parseInt(args[0]) || state.turn; + }, + + // Pokemon actions + switch: parseSwitch, + drag: parseSwitch, + move: parseMove, + detailschange: parseDetailsChange, + '-formechange': parseDetailsChange, + '-damage': parseDamage, + '-heal': parseHeal, + '-status': parseStatus, + '-curestatus': parseCureStatus, + '-cureteam': parseCureTeam, + + '-boost': (state, args) => parseBoost(state, args, 1), + '-unboost': (state, args) => parseBoost(state, args, -1), + '-setboost': (state, args) => parseSetBoost(state, args), + '-clearboost': parseClearBoost, + '-copyboost': parseCopyBoost, + '-invertboost': parseInvertBoost, + '-swapboost': parseSwapBoost, + '-ability': parseAbility, + '-endability': parseEndAbility, + '-item': parseItem, + '-enditem': parseEndItem, + '-weather': parseWeather, + '-fieldstart': parseFieldStart, + '-fieldend': parseFieldEnd, + '-sidestart': parseSideStart, + '-sideend': parseSideEnd, + '-start': parseVolatileStart, + '-end': parseVolatileEnd, + '-mega': parseMega, + '-burst': parseMega, + '-terastallize': parseTerastallize, + faint: parseFaint, + win: (state, _args) => { + state.phase = 'ended'; + }, + tie: (state, _args) => { + state.phase = 'ended'; + }, + '-transform': parseTransform, +}; + +/** + * Register battle events for the PS client. + * @param PS - The PS client instance. + * @returns A function to unregister the events. + */ +export function registerBattleEvents(PS: Client): () => void { + const eventsList = mapValues(BattleProtocolEvents, (handler, event) => { + return { + event, + callback: (room: string, line: string, isIntro: boolean) => { + if (isIntro) return; + if (!room.startsWith('battle-')) return; + const battle = getBattleManager()!.getBattle(room); + if (!battle) return; + handler(battle.state, line.split('|'), battle); + }, + }; + }); + + PS.on('updatesearch', onUpdateSearch); + for (const event in eventsList) { + PS.on(event, eventsList[event].callback); + } + + return () => { + PS.off('updatesearch', onUpdateSearch); + for (const event in eventsList) { + PS.off(event, eventsList[event].callback); + } + }; +} + +// |updatesearch|{"searching":[],"games":{"battle-gen9randombattle-2513365049":"[Gen 9] Random Battle"}} +function onUpdateSearch(this: Client, room: string, line: string): void { + const { games } = JSON.parse(line) as { searching: string[]; games?: Record }; + const manager = getBattleManager()!; + for (const room in games) { + const existingBattle = manager.getBattle(room); + if (existingBattle) continue; + const formatId = toId(games[room] ?? ''); + if (!formatId || !FORMATS[formatId]) continue; + const battle = new Battle(manager.client, room, FORMATS[formatId], 'p1', manager.decisionEngine); + manager.battles.set(room, battle); + } +} + +/** + * Parse a battle protocol line and update state. + */ +export function ____deprecatedParseProtocolLine(state: BattleState, line: string): void { + if (!line.startsWith('|')) return; + + const parts = line.slice(1).split('|'); + const cmd = parts[0]; + const args = parts.slice(1); + + switch (cmd) { + case 'player': + parsePlayer(state, args); + break; + case 'teamsize': + parseTeamSize(state, args); + break; + case 'gametype': + // Singles, doubles, etc - could track if needed + break; + case 'gen': + state.format.generation = parseInt(args[0]) || state.format.generation; + break; + case 'tier': + // Format name + break; + case 'rule': + // Rules - could track if needed + break; + case 'start': + state.phase = 'active'; + break; + case 'turn': + state.turn = parseInt(args[0]) || state.turn; + break; + + // Pokemon actions + case 'switch': + case 'drag': + parseSwitch(state, args); + break; + case 'move': + parseMove(state, args); + break; + case 'detailschange': + case '-formechange': + parseDetailsChange(state, args); + break; + + // Damage/healing + case '-damage': + parseDamage(state, args); + break; + case '-heal': + parseHeal(state, args); + break; + + // Status + case '-status': + parseStatus(state, args); + break; + case '-curestatus': + parseCureStatus(state, args); + break; + case '-cureteam': + parseCureTeam(state, args); + break; + + // Boosts + case '-boost': + parseBoost(state, args, 1); + break; + case '-unboost': + parseBoost(state, args, -1); + break; + case '-setboost': + parseSetBoost(state, args); + break; + case '-clearboost': + case '-clearpositiveboost': + case '-clearnegativeboost': + parseClearBoost(state, args); + break; + case '-copyboost': + parseCopyBoost(state, args); + break; + case '-invertboost': + parseInvertBoost(state, args); + break; + case '-swapboost': + parseSwapBoost(state, args); + break; + + // Abilities/items + case '-ability': + parseAbility(state, args); + break; + case '-endability': + parseEndAbility(state, args); + break; + case '-item': + parseItem(state, args); + break; + case '-enditem': + parseEndItem(state, args); + break; + + // Field effects + case '-weather': + parseWeather(state, args); + break; + case '-fieldstart': + parseFieldStart(state, args); + break; + case '-fieldend': + parseFieldEnd(state, args); + break; + + // Side effects + case '-sidestart': + parseSideStart(state, args); + break; + case '-sideend': + parseSideEnd(state, args); + break; + + // Volatile status + case '-start': + parseVolatileStart(state, args); + break; + case '-end': + parseVolatileEnd(state, args); + break; + + // Mega/Dynamax/Tera + case '-mega': + case '-burst': // Ultra Burst + parseMega(state, args); + break; + case '-terastallize': + parseTerastallize(state, args); + break; + + // Fainting + case 'faint': + parseFaint(state, args); + break; + + // Battle end + case 'win': + case 'tie': + state.phase = 'ended'; + break; + + // Transform/Ditto + case '-transform': + parseTransform(state, args); + break; + + default: + // Many other messages we don't need to track + break; + } +} + +// ============ Parse Helpers ============ + +function getSide(state: BattleState, ident: string): Side { + return ident.startsWith('p1') ? state.p1 : state.p2; +} + +function parseIdent(ident: string): { side: 'p1' | 'p2'; position: string; name: string } { + // Format: "p1a: Pikachu" or "p2: Pikachu" + const match = ident.match(/^(p[12])([a-c])?:\s*(.+)$/); + if (!match) return { side: 'p1', position: 'a', name: ident }; + return { + side: match[1] as 'p1' | 'p2', + position: match[2] || 'a', + name: match[3], + }; +} + +function parseDetails(details: string): { species: string; level: number; gender: 'M' | 'F' | null; shiny: boolean } { + // Format: "Pikachu, L50, M" or "Pikachu, L50, F, shiny" + const parts = details.split(', '); + const species = parts[0]; + let level = 100; + let gender: 'M' | 'F' | null = null; + let shiny = false; + + for (const part of parts.slice(1)) { + if (part.startsWith('L')) { + level = parseInt(part.slice(1)) || 100; + } else if (part === 'M') { + gender = 'M'; + } else if (part === 'F') { + gender = 'F'; + } else if (part === 'shiny') { + shiny = true; + } + } + + return { species, level, gender, shiny }; +} + +function parseCondition(condition: string): { hp: number; maxHp: number | null; status: StatusId | null } { + // Format: "100/100" or "75/100 par" or "0 fnt" + if (condition === '0 fnt') { + return { hp: 0, maxHp: null, status: 'fnt' }; + } + + const [hpPart, statusPart] = condition.split(' '); + const [current, max] = hpPart.split('/').map(Number); + + return { + hp: current, + maxHp: max || null, + status: (statusPart as StatusId) || null, + }; +} + +function findOrCreatePokemon(side: Side, name: string, gen: number): Pokemon { + const speciesId = toId(name); + let pokemon = side.team.find(p => toId(p.species) === speciesId || p.speciesId === speciesId); + + if (!pokemon) { + const types = getSpeciesTypes(name, gen); + pokemon = { + species: name, + speciesId, + level: 100, + gender: null, + types: types as TypeName[], + hp: 1, + maxHp: null, + status: null, + boosts: {}, + volatiles: new Set(), + knownMoves: [], + knownAbility: null, + knownItem: null, + itemConsumed: false, + terastallized: null, + dynamaxed: false, + active: false, + fainted: false, + slot: side.team.length, + }; + side.team.push(pokemon); + } + + return pokemon; +} + +// ============ Individual Parsers ============ + +function parsePlayer(state: BattleState, args: string[]): void { + const [playerId, name] = args; + if (playerId === 'p1') { + state.p1.name = name; + } else if (playerId === 'p2') { + state.p2.name = name; + } +} + +function parseTeamSize(state: BattleState, args: string[]): void { + const [playerId, size] = args; + const teamSize = parseInt(size) || 6; + if (playerId === 'p1') { + state.p1.teamSize = teamSize; + state.p1.totalPokemon = teamSize; + } else if (playerId === 'p2') { + state.p2.teamSize = teamSize; + state.p2.totalPokemon = teamSize; + } +} + +function parseSwitch(state: BattleState, args: string[]): void { + const [ident, details, condition] = args; + const { side: sideId } = parseIdent(ident); + const side = getSide(state, ident); + const { species, level, gender, shiny } = parseDetails(details); + const { hp, maxHp, status } = parseCondition(condition); + + // Deactivate previous active + if (side.active) { + side.active.active = false; + // Clear volatile statuses on switch + side.active.boosts = {}; + side.active.volatiles.clear(); + } + + // Find or create pokemon + const pokemon = findOrCreatePokemon(side, species, state.format.generation); + pokemon.species = species; + pokemon.speciesId = toId(species); + pokemon.level = level; + pokemon.gender = gender; + pokemon.shiny = shiny; + + // Update types if species changed + const types = getSpeciesTypes(species, state.format.generation); + if (types.length) { + pokemon.types = types as TypeName[]; + } + + // Determine if we have absolute HP or percentage + if (sideId === state.ourSide) { + pokemon.hp = hp; + pokemon.maxHp = maxHp; + } else { + // Opponent - HP is shown as percentage (0-100) + pokemon.hp = hp / 100; + pokemon.maxHp = null; + } + + pokemon.status = status; + pokemon.active = true; + pokemon.fainted = status === 'fnt'; + pokemon.boosts = {}; + pokemon.volatiles.clear(); + pokemon.dynamaxed = false; + + side.active = pokemon; +} + +function parseMove(state: BattleState, args: string[]): void { + const [ident, moveName] = args; + const side = getSide(state, ident); + const pokemon = side.active; + + if (pokemon) { + const moveId = toId(moveName); + if (!pokemon.knownMoves.includes(moveId)) { + pokemon.knownMoves.push(moveId); + } + } +} + +function parseDetailsChange(state: BattleState, args: string[]): void { + const [ident, details] = args; + const side = getSide(state, ident); + const { species } = parseDetails(details); + + if (side.active) { + side.active.species = species; + side.active.speciesId = toId(species); + const types = getSpeciesTypes(species, state.format.generation); + if (types.length) { + side.active.types = types as TypeName[]; + } + } +} + +function parseDamage(state: BattleState, args: string[]): void { + const [ident, condition] = args; + const side = getSide(state, ident); + const { hp, status } = parseCondition(condition); + + if (side.active) { + if (ident.startsWith(state.ourSide)) { + side.active.hp = hp; + } else { + side.active.hp = hp / 100; + } + if (status) side.active.status = status; + if (status === 'fnt') side.active.fainted = true; + } +} + +function parseHeal(state: BattleState, args: string[]): void { + const [ident, condition] = args; + const side = getSide(state, ident); + const { hp, status } = parseCondition(condition); + + if (side.active) { + if (ident.startsWith(state.ourSide)) { + side.active.hp = hp; + } else { + side.active.hp = hp / 100; + } + if (status) side.active.status = status; + } +} + +function parseStatus(state: BattleState, args: string[]): void { + const [ident, status] = args; + const side = getSide(state, ident); + + if (side.active) { + side.active.status = status as StatusId; + } +} + +function parseCureStatus(state: BattleState, args: string[]): void { + const [ident] = args; + const side = getSide(state, ident); + + if (side.active) { + side.active.status = null; + } +} + +function parseCureTeam(state: BattleState, args: string[]): void { + const [ident] = args; + const side = getSide(state, ident); + + for (const pokemon of side.team) { + if (pokemon.status !== 'fnt') { + pokemon.status = null; + } + } +} + +function parseBoost(state: BattleState, args: string[], direction: 1 | -1): void { + const [ident, stat, amount] = args; + const side = getSide(state, ident); + + if (side.active) { + const boostStat = stat as BoostId; + const currentBoost = side.active.boosts[boostStat] || 0; + const delta = direction * parseInt(amount); + side.active.boosts[boostStat] = Math.max(-6, Math.min(6, currentBoost + delta)); + } +} + +function parseSetBoost(state: BattleState, args: string[]): void { + const [ident, stat, amount] = args; + const side = getSide(state, ident); + + if (side.active) { + side.active.boosts[stat as BoostId] = parseInt(amount); + } +} + +function parseClearBoost(state: BattleState, args: string[]): void { + const [ident] = args; + const side = getSide(state, ident); + + if (side.active) { + side.active.boosts = {}; + } +} + +function parseCopyBoost(state: BattleState, args: string[]): void { + const [sourceIdent, targetIdent] = args; + const sourceSide = getSide(state, sourceIdent); + const targetSide = getSide(state, targetIdent); + + if (sourceSide.active && targetSide.active) { + targetSide.active.boosts = { ...sourceSide.active.boosts }; + } +} + +function parseInvertBoost(state: BattleState, args: string[]): void { + const [ident] = args; + const side = getSide(state, ident); + + if (side.active) { + const boosts = side.active.boosts; + for (const stat in boosts) { + boosts[stat as BoostId] = -(boosts[stat as BoostId] || 0); + } + } +} + +function parseSwapBoost(state: BattleState, args: string[]): void { + const [ident1, ident2, stats] = args; + const side1 = getSide(state, ident1); + const side2 = getSide(state, ident2); + + if (side1.active && side2.active) { + const statsToSwap = stats ? stats.split(', ') : Object.keys(side1.active.boosts); + for (const stat of statsToSwap) { + const temp = side1.active.boosts[stat as BoostId] || 0; + side1.active.boosts[stat as BoostId] = side2.active.boosts[stat as BoostId] || 0; + side2.active.boosts[stat as BoostId] = temp; + } + } +} + +function parseAbility(state: BattleState, args: string[]): void { + const [ident, ability] = args; + const side = getSide(state, ident); + + if (side.active) { + side.active.knownAbility = ability; + } +} + +function parseEndAbility(state: BattleState, args: string[]): void { + const [ident] = args; + const side = getSide(state, ident); + + if (side.active) { + side.active.knownAbility = null; + } +} + +function parseItem(state: BattleState, args: string[]): void { + const [ident, item] = args; + const side = getSide(state, ident); + + if (side.active) { + side.active.knownItem = item; + } +} + +function parseEndItem(state: BattleState, args: string[]): void { + const [ident] = args; + const side = getSide(state, ident); + + if (side.active) { + side.active.itemConsumed = true; + side.active.knownItem = null; + } +} + +function parseWeather(state: BattleState, args: string[]): void { + const [weather] = args; + + if (weather === 'none') { + state.field.weather = null; + state.field.weatherTurns = 0; + } else { + const weatherMap: Record = { + SunnyDay: 'sun', + RainDance: 'rain', + Sandstorm: 'sand', + Snow: 'snow', + Hail: 'hail', + DesolateLand: 'harshsun', + PrimordialSea: 'heavyrain', + DeltaStream: 'strongwinds', + }; + state.field.weather = weatherMap[weather] ?? (weather.toLowerCase() as Weather); + state.field.weatherTurns = 5; // Default, will decrement + } +} + +function parseFieldStart(state: BattleState, args: string[]): void { + const [condition] = args; + + if (condition.includes('Terrain')) { + const terrainMap: Record = { + 'Electric Terrain': 'electric', + 'Grassy Terrain': 'grassy', + 'Misty Terrain': 'misty', + 'Psychic Terrain': 'psychic', + }; + state.field.terrain = terrainMap[condition] ?? null; + state.field.terrainTurns = 5; + } else if (condition === 'Trick Room') { + state.field.trickRoom = 5; + } else if (condition === 'Gravity') { + state.field.gravity = 5; + } else if (condition === 'Magic Room') { + state.field.magicRoom = 5; + } else if (condition === 'Wonder Room') { + state.field.wonderRoom = 5; + } +} + +function parseFieldEnd(state: BattleState, args: string[]): void { + const [condition] = args; + + if (condition.includes('Terrain')) { + state.field.terrain = null; + state.field.terrainTurns = 0; + } else if (condition === 'Trick Room') { + state.field.trickRoom = 0; + } else if (condition === 'Gravity') { + state.field.gravity = 0; + } else if (condition === 'Magic Room') { + state.field.magicRoom = 0; + } else if (condition === 'Wonder Room') { + state.field.wonderRoom = 0; + } +} + +function parseSideStart(state: BattleState, args: string[]): void { + const [sideIdent, condition] = args; + const side = getSide(state, sideIdent); + + const condId = toId(condition); + + if (condId === 'stealthrock') { + side.hazards.stealthRock = true; + } else if (condId === 'spikes') { + side.hazards.spikes = Math.min(3, side.hazards.spikes + 1); + } else if (condId === 'toxicspikes') { + side.hazards.toxicSpikes = Math.min(2, side.hazards.toxicSpikes + 1); + } else if (condId === 'stickyweb') { + side.hazards.stickyWeb = true; + } else if (condId === 'reflect') { + side.screens.reflect = 5; + } else if (condId === 'lightscreen') { + side.screens.lightScreen = 5; + } else if (condId === 'auroraveil') { + side.screens.auroraVeil = 5; + } else if (condId === 'tailwind') { + side.tailwind = 4; + } +} + +function parseSideEnd(state: BattleState, args: string[]): void { + const [sideIdent, condition] = args; + const side = getSide(state, sideIdent); + + const condId = toId(condition); + + if (condId === 'stealthrock') { + side.hazards.stealthRock = false; + } else if (condId === 'spikes') { + side.hazards.spikes = 0; + } else if (condId === 'toxicspikes') { + side.hazards.toxicSpikes = 0; + } else if (condId === 'stickyweb') { + side.hazards.stickyWeb = false; + } else if (condId === 'reflect') { + side.screens.reflect = 0; + } else if (condId === 'lightscreen') { + side.screens.lightScreen = 0; + } else if (condId === 'auroraveil') { + side.screens.auroraVeil = 0; + } else if (condId === 'tailwind') { + side.tailwind = 0; + } +} + +function parseVolatileStart(state: BattleState, args: string[]): void { + const [ident, volatile] = args; + const side = getSide(state, ident); + + if (side.active) { + side.active.volatiles.add(toId(volatile)); + } +} + +function parseVolatileEnd(state: BattleState, args: string[]): void { + const [ident, volatile] = args; + const side = getSide(state, ident); + + if (side.active) { + side.active.volatiles.delete(toId(volatile)); + } +} + +function parseMega(state: BattleState, args: string[]): void { + const [ident, _pokemon, _stone] = args; + const side = getSide(state, ident); + + if (side.active) { + // Mega evolution - types may change + // The -formechange message will handle species/type updates + } +} + +function parseTerastallize(state: BattleState, args: string[]): void { + const [ident, teraType] = args; + const side = getSide(state, ident); + + if (side.active) { + side.active.terastallized = teraType as TypeName; + // Terastallizing changes the Pokemon's type + side.active.types = [teraType as TypeName]; + } +} + +function parseFaint(state: BattleState, args: string[]): void { + const [ident] = args; + const side = getSide(state, ident); + + if (side.active) { + side.active.fainted = true; + side.active.hp = 0; + side.active.status = 'fnt'; + side.faintedCount++; + } +} + +function parseTransform(state: BattleState, args: string[]): void { + const [sourceIdent, targetIdent] = args; + const sourceSide = getSide(state, sourceIdent); + const targetSide = getSide(state, targetIdent); + + if (sourceSide.active && targetSide.active) { + // Transform copies appearance, types, stats (except HP), moves, etc. + sourceSide.active.types = [...targetSide.active.types]; + sourceSide.active.boosts = { ...targetSide.active.boosts }; + // Note: Species display changes but we keep tracking the original Pokemon + } +} + +// ============ Request Parsing ============ + +/** + * Parse a battle request JSON. + */ +export function parseRequest(json: string): BattleRequest { + if (!json) { + return { requestType: 'wait', rqid: 0 }; + } + + const data = JSON.parse(json); + + let requestType: BattleRequest['requestType'] = 'wait'; + + if (data.wait) { + requestType = 'wait'; + } else if (data.teamPreview) { + requestType = 'teamPreview'; + } else if (data.forceSwitch?.some(Boolean)) { + requestType = 'switch'; + } else if (data.active) { + requestType = 'move'; + } + + return { + requestType, + rqid: data.rqid || 0, + active: data.active as ActiveRequest[] | undefined, + side: data.side, + forceSwitch: data.forceSwitch, + teamPreview: data.teamPreview, + maxTeamSize: data.maxTeamSize, + }; +} + +/** + * Update our team from request side data. + */ +export function updateOurTeamFromRequest(state: BattleState, sidePokemon: SidePokemon[]): void { + const ourSide = state[state.ourSide]; + + ourSide.team = sidePokemon.map((p, i) => { + const { species, level, gender, shiny } = parseDetails(p.details); + const { hp, maxHp, status } = parseCondition(p.condition); + const types = getSpeciesTypes(species, state.format.generation); + + const existing = ourSide.team[i]; + + return { + species, + speciesId: toId(species), + level, + gender, + shiny, + types: types as TypeName[], + hp, + maxHp, + status, + boosts: existing?.boosts ?? {}, + volatiles: existing?.volatiles ?? new Set(), + knownMoves: p.moves, + moves: p.moves, + knownAbility: p.ability, + ability: p.ability, + knownItem: p.item, + item: p.item, + itemConsumed: false, + terastallized: null, + dynamaxed: false, + active: p.active, + fainted: status === 'fnt', + slot: i, + stats: p.stats, + teraType: p.teraType, + }; + }); + + // Update active reference + const activePokemon = ourSide.team.find(p => p.active); + if (activePokemon) { + ourSide.active = activePokemon; + } +} diff --git a/src/ps/battle/types.ts b/src/ps/battle/types.ts new file mode 100644 index 00000000..1504bfc9 --- /dev/null +++ b/src/ps/battle/types.ts @@ -0,0 +1,291 @@ +/** + * Battle system type definitions. + * Uses types from ps-client where applicable. + */ + +import { formats } from 'ps-client/data'; + +import { toId } from '@/utils/toId'; + +// Re-export ps-client types (alphabetically ordered) +export type { Ability as PSAbility, Item as PSItem, Species as PSSpecies, Types as TypeName } from 'ps-client/data'; + +// Import Types for local use +import type { Types } from 'ps-client/data'; + +// ============ Format Configuration ============ + +export interface FormatConfig { + id: string; + generation: number; + isRandom: boolean; + hasTeamPreview: boolean; + teamSize: number; + pickedTeamSize?: number | undefined; +} + +/** + * Parse format configuration from ps-client formats data. + */ +function parseFormats(): Record { + const result: Record = {}; + + for (const entry of formats) { + if ('section' in entry) continue; // Skip section headers + + const format = entry; + if (!format.name) continue; + + const id = toId(format.name); + const genMatch = id.match(/gen(\d)/); + const generation = genMatch ? parseInt(genMatch[1]) : 9; + const isRandom = id.includes('random'); + const isDoubles = format.gameType === 'doubles'; + + result[id] = { + id, + generation, + isRandom, + hasTeamPreview: !isRandom && format.ruleset?.includes('Team Preview') !== false, + teamSize: 6, + pickedTeamSize: isDoubles ? 4 : 6, + }; + } + + return result; +} + +export const FORMATS: Record = parseFormats(); + +// ============ Pokemon Types ============ + +export type StatId = 'hp' | 'atk' | 'def' | 'spa' | 'spd' | 'spe'; +export type BoostId = 'atk' | 'def' | 'spa' | 'spd' | 'spe' | 'accuracy' | 'evasion'; +export type Stats = Record; +export type Boosts = Partial>; + +export type StatusId = 'brn' | 'par' | 'slp' | 'frz' | 'psn' | 'tox' | 'fnt'; + +// ============ Pokemon State ============ + +export interface Pokemon { + // Identity + species: string; + speciesId: string; + level: number; + gender: 'M' | 'F' | 'N' | null; + shiny?: boolean; + + // Types (known once species is revealed) + types: Types[]; + + // Current state + hp: number; // 0-1 normalized for opponent, absolute for our team + maxHp: number | null; // Only known for our team + status: StatusId | null; + statusTurns?: number; + boosts: Boosts; + volatiles: Set; + + // Known information (revealed during battle) + knownMoves: string[]; + knownAbility: string | null; + knownItem: string | null; + itemConsumed: boolean; + terastallized: Types | null; + dynamaxed: boolean; + + // Full information (only for our side) + moves?: string[]; + ability?: string; + item?: string | undefined; + stats?: Stats | undefined; + teraType?: Types | undefined; + + // Battle position + active: boolean; + fainted: boolean; + slot: number; +} + +// ============ Side State ============ + +export interface Hazards { + stealthRock: boolean; + spikes: number; // 0-3 + toxicSpikes: number; // 0-2 + stickyWeb: boolean; +} + +export interface Screens { + reflect: number; // Turns remaining + lightScreen: number; + auroraVeil: number; +} + +export interface Side { + name: string; + odentifier: 'p1' | 'p2'; + active: Pokemon | null; + team: Pokemon[]; + teamSize: number; + faintedCount: number; + totalPokemon: number; + + hazards: Hazards; + screens: Screens; + tailwind: number; + wish: { hp: number; turns: number } | null; +} + +// ============ Field State ============ + +export type Weather = 'sun' | 'rain' | 'sand' | 'snow' | 'hail' | 'harshsun' | 'heavyrain' | 'strongwinds' | null; +export type Terrain = 'electric' | 'grassy' | 'misty' | 'psychic' | null; + +export interface Field { + weather: Weather; + weatherTurns: number; + terrain: Terrain; + terrainTurns: number; + trickRoom: number; + gravity: number; + magicRoom: number; + wonderRoom: number; +} + +// ============ Battle State ============ + +export type BattlePhase = 'teamPreview' | 'active' | 'forceSwitch' | 'waiting' | 'ended'; + +export interface BattleState { + format: FormatConfig; + turn: number; + phase: BattlePhase; + + p1: Side; + p2: Side; + field: Field; + + // Metadata + roomId: string; + startedAt: Date; + ourSide: 'p1' | 'p2'; + rqid: number; +} + +// ============ Actions ============ + +export type Action = MoveAction | SwitchAction | TeamAction | PassAction; + +export interface MoveAction { + type: 'move'; + slot: number; // 1-4 + moveId: string; + target?: number; + mega?: boolean; + zmove?: boolean; + dynamax?: boolean; + terastallize?: boolean; +} + +export interface SwitchAction { + type: 'switch'; + slot: number; // 1-6 + pokemonName: string; +} + +export interface TeamAction { + type: 'team'; + order: number[]; // Lead order +} + +export interface PassAction { + type: 'pass'; +} + +// ============ Request Types ============ + +export interface BattleRequest { + requestType: 'move' | 'switch' | 'teamPreview' | 'wait'; + rqid: number; + + active?: ActiveRequest[] | undefined; + side?: SideRequest | undefined; + forceSwitch?: boolean[] | undefined; + + teamPreview?: boolean | undefined; + maxTeamSize?: number | undefined; +} + +export interface ActiveRequest { + moves: MoveRequest[]; + trapped?: boolean; + maybeTrapped?: boolean; + canMegaEvo?: boolean; + canUltraBurst?: boolean; + canZMove?: (ZMoveRequest | null)[]; + canDynamax?: boolean; + maxMoves?: { maxMoves: MaxMoveRequest[] }; + canTerastallize?: Types; +} + +export interface MoveRequest { + move: string; + id: string; + pp: number; + maxpp: number; + disabled?: boolean; + disabledSource?: string; + target?: string; +} + +export interface ZMoveRequest { + move: string; + target: string; +} + +export interface MaxMoveRequest { + move: string; + target: string; +} + +export interface SideRequest { + name: string; + id: 'p1' | 'p2'; + pokemon: SidePokemon[]; +} + +export interface SidePokemon { + ident: string; + details: string; + condition: string; + active: boolean; + moves: string[]; + baseAbility: string; + ability: string; + item: string; + pokeball: string; + stats: Stats; + teraType?: Types | undefined; +} + +// ============ Decision Engine Types ============ + +export interface DecisionContext { + state: BattleState; + request: BattleRequest; + legalActions: Action[]; + /** Seeded RNG function for deterministic decisions */ + RNG: () => number; +} + +export interface DecisionResult { + action: Action; + confidence?: number | undefined; + reasoning?: string | undefined; +} + +// ============ AI Level ============ + +export type AILevel = 0 | 1 | 2; diff --git a/src/ps/handlers/autores.ts b/src/ps/handlers/autores.ts index 8e099a1f..acd6bf57 100644 --- a/src/ps/handlers/autores.ts +++ b/src/ps/handlers/autores.ts @@ -13,6 +13,8 @@ export function autoResHandler(message: PSMessage) { if (message.awaited) return; // Don't do this if the user submitted a valid prompt response + if (message.type === 'pm' && (message.command === '/challenge' || message.command === '/log')) return; + const helpMessage = `Hi, I'm ${username}, a chatbot by ${owner}! My prefix is \`\`${prefix}\`\` - try \`\`${prefix}help\`\` or \`\`${prefix}commands!\`\``; if (toId(message.content) === message.parent.status.userid && message.content.endsWith('?')) { return message.author.send(helpMessage); diff --git a/src/ps/handlers/battle.ts b/src/ps/handlers/battle.ts new file mode 100644 index 00000000..b4098eec --- /dev/null +++ b/src/ps/handlers/battle.ts @@ -0,0 +1,105 @@ +/** + * Battle handler for Pokemon Showdown. + * Handles all battle-related messages and routes them to the BattleManager. + */ + +import { BattleManager } from '@/ps/battle'; +import { registerBattleEvents } from '@/ps/battle/parser'; +import { Logger } from '@/utils/logger'; + +import type { BattleManagerConfig } from '@/ps/battle'; +import type { PSMessage } from '@/types/ps'; +import type { Client } from 'ps-client'; + +// Singleton battle manager instance +let battleManager: BattleManager | null = null; + +/** + * Initialize the battle manager. + * Call this before handling any battle messages. + */ +export function initBattleManager(client: Client, config?: BattleManagerConfig): BattleManager { + if (battleManager) { + Logger.log('[Battle] Manager already initialized'); + return battleManager; + } + + battleManager = new BattleManager(client, config); + Logger.log('[Battle] Manager initialized'); + + registerBattleEvents(client); + Logger.log('[Battle] Events registered'); + + return battleManager; +} + +/** + * Get the battle manager instance. + */ +export function getBattleManager(): BattleManager | null { + return battleManager; +} + +/** + * Main battle message handler. + * Registers as a PS message handler. + */ +export function battleHandler(message: PSMessage): void { + // Skip if battle manager not initialized + if (!battleManager) return; + + // Skip intro messages + if (message.isIntro) return; + + if (message.type === 'pm' && message.author.id !== message.parent.status.userid && message.command === '/challenge') { + const [format, alsoFormatWhatTheHeckIsThis] = message.content.replace('/challenge ', '').lazySplit('|', 2); + handleChallenge(message.target.id, format); + } + + // Only handle battle room messages + const room = message.target; + if (!room || !room.id.startsWith('battle-')) return; + + // Get the raw line content + const line = message.raw || message.content; + if (!line) return; + + // Handle asynchronously but don't block + battleManager.handleMessage(room, line, message.isIntro).catch(err => { + if (err instanceof Error) { + Logger.errorLog(err); + } + }); +} + +/** + * Raw message handler for battle rooms. + * Use this for messages that don't go through the normal message handler. + */ +export function battleRawHandler(this: Client, roomId: string, data: string, isIntro: boolean): void { + if (!battleManager) return; + if (isIntro) return; + if (!roomId.startsWith('battle-')) return; + + const room = this.getRoom(roomId); + if (!room) return; + + // Split data into lines and process each + const lines = data.split('\n'); + for (const line of lines) { + if (line) { + battleManager.handleMessage(room, line, isIntro).catch(err => { + if (err instanceof Error) Logger.errorLog(err); + }); + } + } +} + +/** + * Handle challenges. + * Call this when receiving a challenge notification. + */ +export function handleChallenge(user: string, format: string): void { + if (!battleManager) return; + battleManager.acceptChallenge(user, format); +} diff --git a/src/ps/index.ts b/src/ps/index.ts index 52013489..d2f37ddb 100644 --- a/src/ps/index.ts +++ b/src/ps/index.ts @@ -3,6 +3,7 @@ import { Client } from 'ps-client'; import { avatar, password, rooms, username } from '@/config/ps'; import { IS_ENABLED } from '@/enabled'; import { registerEvent } from '@/ps/handlers'; +import { initBattleManager } from '@/ps/handlers/battle'; import { startPSCron } from '@/ps/handlers/cron'; import { transformHTML } from '@/ps/handlers/html'; import loadPS from '@/ps/loaders'; @@ -13,10 +14,26 @@ PS.on('login', () => Logger.log(`Connected to PS! [${username}]`)); if (IS_ENABLED.PS) loadPS().then(() => PS.connect()); +PS.on('login', () => { + if (IS_ENABLED.BATTLE) { + initBattleManager(PS, { + useHeuristic: true, + aiLevel: 2, + maxConcurrent: 1, + acceptChallenges: true, + acceptFormats: ['gen9randombattle'], + // Enable these to auto-ladder: + // autoLadder: true, + // ladderFormats: ['gen9randombattle'], + }); + } +}); + PS.on('message', msg => registerEvent(PS, 'commandHandler')(msg)); PS.on('message', msg => registerEvent(PS, 'interfaceHandler')(msg)); PS.on('message', msg => registerEvent(PS, 'autoResHandler')(msg)); PS.on('message', msg => registerEvent(PS, 'otherHandler')(msg)); +PS.on('message', msg => registerEvent(PS, 'battleHandler')(msg)); PS.on('join', registerEvent(PS, 'joinHandler')); PS.on('joinRoom', registerEvent(PS, 'joinRoomHandler')); diff --git a/src/sentinel/live.ts b/src/sentinel/live.ts index f0e0bd3e..d5f8704b 100644 --- a/src/sentinel/live.ts +++ b/src/sentinel/live.ts @@ -1,6 +1,7 @@ // The first versions can be imported directly; we'll update them via dynamic import calls. import { autoResHandler } from '@/ps/handlers/autores'; +import { battleHandler, battleRawHandler } from '@/ps/handlers/battle'; import { commandHandler } from '@/ps/handlers/commands'; import { GROUPED_PERMS } from '@/ps/handlers/commands/customPerms'; import { parse } from '@/ps/handlers/commands/parse'; @@ -29,6 +30,8 @@ export const LiveData = {}; /** @see {@link LiveData} */ export const LivePSHandlers = { autoResHandler, + battleHandler, + battleRawHandler, commandHandler, interfaceHandler, joinRoomHandler, From 07d6b0d015509c86851cc2c048a7c8abd29994dc Mon Sep 17 00:00:00 2001 From: PartMan Date: Thu, 8 Jan 2026 16:03:33 +0530 Subject: [PATCH 2/3] chore: Persist battle stat --- src/cache/persisted.ts | 2 + src/ps/battle/battle.ts | 39 --------- src/ps/battle/index.ts | 108 ++++++++--------------- src/ps/battle/parser.ts | 179 +++----------------------------------- src/ps/handlers/battle.ts | 51 +---------- src/sentinel/live.ts | 3 +- 6 files changed, 51 insertions(+), 331 deletions(-) diff --git a/src/cache/persisted.ts b/src/cache/persisted.ts index 8660c7e2..a610ad56 100644 --- a/src/cache/persisted.ts +++ b/src/cache/persisted.ts @@ -12,6 +12,7 @@ type CacheTypes = { openGames: { gameType: GamesList; id: string; roomid: string }[]; ugoCap: Record>>; ugoPoints: Record }>; + battleStats: { battlesStarted: number; battlesWon: number; battlesLost: number; battlesTied: number; }; }; const defaults: CacheTypes = { @@ -20,6 +21,7 @@ const defaults: CacheTypes = { openGames: [], ugoCap: {}, ugoPoints: {}, + battleStats: { battlesStarted: 0, battlesWon: 0, battlesLost: 0, battlesTied: 0 }, }; export type Cache = { diff --git a/src/ps/battle/battle.ts b/src/ps/battle/battle.ts index fba78fee..25e22dc1 100644 --- a/src/ps/battle/battle.ts +++ b/src/ps/battle/battle.ts @@ -87,45 +87,6 @@ export class Battle { }; } - /** - * Process a protocol line and potentially return a command. - */ - async processLine(line: string): Promise { - if (!line) return null; - - // Handle request separately (contains JSON) - if (line.startsWith('|request|')) { - const json = line.slice(9); - return this.handleRequest(json); - } - - // Parse protocol line and update state - if (line.startsWith('|')) { - // donotpush TODO - // parseProtocolLine(this.state, line); - } - - // Handle battle end - if (line.startsWith('|win|') || line.startsWith('|tie|')) { - this.state.phase = 'ended'; - const won = this.didWeWin(line); - await this.engine.onBattleEnd?.(this.state, won); - Logger.log(`[Battle] ${this.state.roomId} ended - ${won ? 'Won' : 'Lost'}`); - } - - return null; - } - - private didWeWin(line: string): boolean { - // |win|Username or |tie| - if (line.startsWith('|tie|')) return false; - - const winner = line.slice(5); - const ourName = this.state[this.state.ourSide].name; - - return winner.toLowerCase() === ourName.toLowerCase(); - } - async handleRequest(json: string): Promise { if (!json) return null; diff --git a/src/ps/battle/index.ts b/src/ps/battle/index.ts index b72069b7..17d53c3c 100644 --- a/src/ps/battle/index.ts +++ b/src/ps/battle/index.ts @@ -3,15 +3,16 @@ * Handles ladder queue, challenge acceptance, and routing. */ -import { Battle } from '@/ps/battle/battle'; +import { usePersistedCache } from '@/cache/persisted'; import { APIDecisionEngine, DecisionEngineChain, HeuristicDecisionEngine, RandomDecisionEngine } from '@/ps/battle/decision'; import { FORMATS } from '@/ps/battle/types'; import { Logger } from '@/utils/logger'; import { toId } from '@/utils/toId'; +import type { Battle } from '@/ps/battle/battle'; import type { DecisionEngine } from '@/ps/battle/decision'; import type { AILevel, FormatConfig } from '@/ps/battle/types'; -import type { Client, Room } from 'ps-client'; +import type { Client } from 'ps-client'; export interface BattleManagerConfig { /** External decision API URL (optional) */ @@ -51,11 +52,8 @@ export class BattleManager { private ladderInterval: ReturnType | null = null; // Statistics - stats = { - battlesStarted: 0, - battlesWon: 0, - battlesLost: 0, - }; + battleStatsCache = usePersistedCache('battleStats'); + stats = this.battleStatsCache.get(); constructor(client: Client, config: BattleManagerConfig = {}) { this.client = client; @@ -97,70 +95,6 @@ export class BattleManager { return new DecisionEngineChain(engines); } - // ============ Message Handling ============ - - /** - * Handle a message from any room. - * Call this for all messages; it will filter for battle rooms. - */ - async handleMessage(room: Room | null, line: string, isIntro?: boolean): Promise { - if (isIntro) return; - if (!room || !room.id.startsWith('battle-')) return; - - const roomId = room.id; - - // Get or create battle instance - let battle = this.battles.get(roomId); - - if (!battle) { - // New battle - const format = this.parseFormatFromRoom(roomId); - const ourSide = this.detectOurSide(line); - - if (!ourSide) { - // Can't determine side yet, store line for later? - // For now, default to p1 and correct later from request - battle = new Battle(this.client, roomId, format, 'p1', this.decisionEngine); - } else { - battle = new Battle(this.client, roomId, format, ourSide, this.decisionEngine); - } - - this.battles.set(roomId, battle); - this.stats.battlesStarted++; - - await this.decisionEngine.onBattleStart?.(battle.state); - Logger.log(`[Battle] Started ${roomId}`); - } - - // Process the line - const command = await battle.processLine(line); - - if (command) { - room.send(`/choose ${command}`); - } - - // Clean up ended battles - if (battle.isEnded()) { - // Track win/loss - const ourFainted = battle.state[battle.state.ourSide].faintedCount; - const theirFainted = battle.state[battle.state.ourSide === 'p1' ? 'p2' : 'p1'].faintedCount; - const didWin = ourFainted < theirFainted; - if (didWin) { - this.stats.battlesWon++; - } else { - this.stats.battlesLost++; - } - - this.battles.delete(roomId); - this.currentSearches.clear(); - - // Continue laddering if enabled - if (this.isLaddering) { - this.maybeStartSearch(); - } - } - } - parseFormatFromRoom(roomId: string): FormatConfig { // Format: battle-{format}-{id} // e.g., battle-gen9randombattle-12345 @@ -329,6 +263,33 @@ export class BattleManager { Logger.log(`[Challenge] Sent to ${user} for ${formatId}`); } + /** + * Handles battle conclusion, updates stats, and cleans up. + * @param roomId The ID of the battle room. + * @param result The outcome of the battle ('win', 'loss', or 'tie'). + */ + onBattleEnd(roomId: string, result: 'win' | 'loss' | 'tie'): void { + Logger.log(`[BattleManager] Battle ${roomId} ended with result: ${result}`); + + // Update stats + if (result === 'win') { + this.stats.battlesWon++; + } else if (result === 'loss') { + this.stats.battlesLost++; + } else { + this.stats.battlesTied++; + } + this.battleStatsCache.set(this.stats); + + // Clean up battle state + this.battles.delete(roomId); + + // If we were laddering, try to start a new search + if (this.isLaddering) { + this.maybeStartSearch(); + } + } + // ============ Statistics ============ /** @@ -341,9 +302,10 @@ export class BattleManager { battlesStarted: number; battlesWon: number; battlesLost: number; + battlesTied: number; winRate: string; } { - const total = this.stats.battlesWon + this.stats.battlesLost; + const total = this.stats.battlesWon + this.stats.battlesLost + this.stats.battlesTied; const winRate = total > 0 ? ((this.stats.battlesWon / total) * 100).toFixed(1) + '%' : 'N/A'; return { @@ -363,7 +325,9 @@ export class BattleManager { battlesStarted: 0, battlesWon: 0, battlesLost: 0, + battlesTied: 0, }; + this.battleStatsCache.set(this.stats); } /** diff --git a/src/ps/battle/parser.ts b/src/ps/battle/parser.ts index e49bcee7..be47e053 100644 --- a/src/ps/battle/parser.ts +++ b/src/ps/battle/parser.ts @@ -87,11 +87,19 @@ const BattleProtocolEvents: Record { + win: (state, args, battle) => { state.phase = 'ended'; + const battleManager = getBattleManager(); + if (!battleManager) return; + const winner = args[0]; + const isOurWin = toId(winner) === toId(battleManager.client.status.username || ''); + battleManager.onBattleEnd(battle.roomId, isOurWin ? 'win' : 'loss'); }, - tie: (state, _args) => { + tie: (state, _args, battle) => { state.phase = 'ended'; + const battleManager = getBattleManager(); + if (!battleManager) return; + battleManager.onBattleEnd(battle.roomId, 'tie'); }, '-transform': parseTransform, }; @@ -142,173 +150,6 @@ function onUpdateSearch(this: Client, room: string, line: string): void { } } -/** - * Parse a battle protocol line and update state. - */ -export function ____deprecatedParseProtocolLine(state: BattleState, line: string): void { - if (!line.startsWith('|')) return; - - const parts = line.slice(1).split('|'); - const cmd = parts[0]; - const args = parts.slice(1); - - switch (cmd) { - case 'player': - parsePlayer(state, args); - break; - case 'teamsize': - parseTeamSize(state, args); - break; - case 'gametype': - // Singles, doubles, etc - could track if needed - break; - case 'gen': - state.format.generation = parseInt(args[0]) || state.format.generation; - break; - case 'tier': - // Format name - break; - case 'rule': - // Rules - could track if needed - break; - case 'start': - state.phase = 'active'; - break; - case 'turn': - state.turn = parseInt(args[0]) || state.turn; - break; - - // Pokemon actions - case 'switch': - case 'drag': - parseSwitch(state, args); - break; - case 'move': - parseMove(state, args); - break; - case 'detailschange': - case '-formechange': - parseDetailsChange(state, args); - break; - - // Damage/healing - case '-damage': - parseDamage(state, args); - break; - case '-heal': - parseHeal(state, args); - break; - - // Status - case '-status': - parseStatus(state, args); - break; - case '-curestatus': - parseCureStatus(state, args); - break; - case '-cureteam': - parseCureTeam(state, args); - break; - - // Boosts - case '-boost': - parseBoost(state, args, 1); - break; - case '-unboost': - parseBoost(state, args, -1); - break; - case '-setboost': - parseSetBoost(state, args); - break; - case '-clearboost': - case '-clearpositiveboost': - case '-clearnegativeboost': - parseClearBoost(state, args); - break; - case '-copyboost': - parseCopyBoost(state, args); - break; - case '-invertboost': - parseInvertBoost(state, args); - break; - case '-swapboost': - parseSwapBoost(state, args); - break; - - // Abilities/items - case '-ability': - parseAbility(state, args); - break; - case '-endability': - parseEndAbility(state, args); - break; - case '-item': - parseItem(state, args); - break; - case '-enditem': - parseEndItem(state, args); - break; - - // Field effects - case '-weather': - parseWeather(state, args); - break; - case '-fieldstart': - parseFieldStart(state, args); - break; - case '-fieldend': - parseFieldEnd(state, args); - break; - - // Side effects - case '-sidestart': - parseSideStart(state, args); - break; - case '-sideend': - parseSideEnd(state, args); - break; - - // Volatile status - case '-start': - parseVolatileStart(state, args); - break; - case '-end': - parseVolatileEnd(state, args); - break; - - // Mega/Dynamax/Tera - case '-mega': - case '-burst': // Ultra Burst - parseMega(state, args); - break; - case '-terastallize': - parseTerastallize(state, args); - break; - - // Fainting - case 'faint': - parseFaint(state, args); - break; - - // Battle end - case 'win': - case 'tie': - state.phase = 'ended'; - break; - - // Transform/Ditto - case '-transform': - parseTransform(state, args); - break; - - default: - // Many other messages we don't need to track - break; - } -} - -// ============ Parse Helpers ============ - function getSide(state: BattleState, ident: string): Side { return ident.startsWith('p1') ? state.p1 : state.p2; } diff --git a/src/ps/handlers/battle.ts b/src/ps/handlers/battle.ts index b4098eec..85e5ad1c 100644 --- a/src/ps/handlers/battle.ts +++ b/src/ps/handlers/battle.ts @@ -52,54 +52,7 @@ export function battleHandler(message: PSMessage): void { if (message.isIntro) return; if (message.type === 'pm' && message.author.id !== message.parent.status.userid && message.command === '/challenge') { - const [format, alsoFormatWhatTheHeckIsThis] = message.content.replace('/challenge ', '').lazySplit('|', 2); - handleChallenge(message.target.id, format); + const [format, _alsoFormatWhatTheHeckIsThis] = message.content.replace('/challenge ', '').lazySplit('|', 2); + battleManager.acceptChallenge(message.target.id, format); } - - // Only handle battle room messages - const room = message.target; - if (!room || !room.id.startsWith('battle-')) return; - - // Get the raw line content - const line = message.raw || message.content; - if (!line) return; - - // Handle asynchronously but don't block - battleManager.handleMessage(room, line, message.isIntro).catch(err => { - if (err instanceof Error) { - Logger.errorLog(err); - } - }); -} - -/** - * Raw message handler for battle rooms. - * Use this for messages that don't go through the normal message handler. - */ -export function battleRawHandler(this: Client, roomId: string, data: string, isIntro: boolean): void { - if (!battleManager) return; - if (isIntro) return; - if (!roomId.startsWith('battle-')) return; - - const room = this.getRoom(roomId); - if (!room) return; - - // Split data into lines and process each - const lines = data.split('\n'); - for (const line of lines) { - if (line) { - battleManager.handleMessage(room, line, isIntro).catch(err => { - if (err instanceof Error) Logger.errorLog(err); - }); - } - } -} - -/** - * Handle challenges. - * Call this when receiving a challenge notification. - */ -export function handleChallenge(user: string, format: string): void { - if (!battleManager) return; - battleManager.acceptChallenge(user, format); } diff --git a/src/sentinel/live.ts b/src/sentinel/live.ts index d5f8704b..0ead9c0b 100644 --- a/src/sentinel/live.ts +++ b/src/sentinel/live.ts @@ -1,7 +1,7 @@ // The first versions can be imported directly; we'll update them via dynamic import calls. import { autoResHandler } from '@/ps/handlers/autores'; -import { battleHandler, battleRawHandler } from '@/ps/handlers/battle'; +import { battleHandler } from '@/ps/handlers/battle'; import { commandHandler } from '@/ps/handlers/commands'; import { GROUPED_PERMS } from '@/ps/handlers/commands/customPerms'; import { parse } from '@/ps/handlers/commands/parse'; @@ -31,7 +31,6 @@ export const LiveData = {}; export const LivePSHandlers = { autoResHandler, battleHandler, - battleRawHandler, commandHandler, interfaceHandler, joinRoomHandler, From 506ea844d24b1b644d19c7a62e0a8c428eb8a9e2 Mon Sep 17 00:00:00 2001 From: PartMan Date: Fri, 9 Jan 2026 09:48:46 +0530 Subject: [PATCH 3/3] chore: Leave battle when done --- src/ps/battle/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ps/battle/index.ts b/src/ps/battle/index.ts index 17d53c3c..262402d8 100644 --- a/src/ps/battle/index.ts +++ b/src/ps/battle/index.ts @@ -282,6 +282,8 @@ export class BattleManager { this.battleStatsCache.set(this.stats); // Clean up battle state + this.battles.get(roomId)?.send('GG!'); + this.battles.get(roomId)?.send('/part'); this.battles.delete(roomId); // If we were laddering, try to start a new search