Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/cache/persisted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type CacheTypes = {
openGames: { gameType: GamesList; id: string; roomid: string }[];
ugoCap: Record<string, Partial<Record<UGOBoardGames, number>>>;
ugoPoints: Record<string, { name: string; points: Partial<UGOPoints> }>;
battleStats: { battlesStarted: number; battlesWon: number; battlesLost: number; battlesTied: number; };
};

const defaults: CacheTypes = {
Expand All @@ -20,6 +21,7 @@ const defaults: CacheTypes = {
openGames: [],
ugoCap: {},
ugoPoints: {},
battleStats: { battlesStarted: 0, battlesWon: 0, battlesLost: 0, battlesTied: 0 },
};

export type Cache<T> = {
Expand Down
1 change: 1 addition & 0 deletions src/enabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
185 changes: 185 additions & 0 deletions src/ps/battle/battle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* 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,
};
}

async handleRequest(json: string): Promise<string | null> {
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})`;
}
}
80 changes: 80 additions & 0 deletions src/ps/battle/data/effectiveness.ts
Original file line number Diff line number Diff line change
@@ -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<TypeName>;
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;
}
Loading