/** * Simulator Side * Pokemon Showdown - http://pokemonshowdown.com/ * * There's a lot of ambiguity between the terms "player", "side", "team", * and "half-field", which I'll try to explain here: * * These terms usually all mean the same thing. The exceptions are: * * - Multi-battle: there are 2 half-fields, 2 teams, 4 sides * * - Free-for-all: there are 2 half-fields, 4 teams, 4 sides * * "Half-field" is usually abbreviated to "half". * * Function naming will be very careful about which term to use. Pay attention * if it's relevant to your code. * * @license MIT */ import { Utils } from '../lib/utils'; import type { RequestState } from './battle'; import { Pokemon, type EffectState } from './pokemon'; import { State } from './state'; import { toID } from './dex'; /** A single action that can be chosen. Choices will have one Action for each pokemon. */ export interface ChosenAction { choice: 'move' | 'switch' | 'instaswitch' | 'revivalblessing' | 'team' | 'shift' | 'pass';// action type pokemon?: Pokemon; // the pokemon doing the action targetLoc?: number; // relative location of the target to pokemon (move action only) moveid: string; // a move to use (move action only) move?: ActiveMove; // the active move corresponding to moveid (move action only) target?: Pokemon; // the target of the action index?: number; // the chosen index in Team Preview side?: Side; // the action's side mega?: boolean | null; // true if megaing or ultra bursting megax?: boolean | null; // true if megaing x megay?: boolean | null; // true if megaing y zmove?: string; // if zmoving, the name of the zmove maxMove?: string; // if dynamaxed, the name of the max move terastallize?: string; // if terastallizing, tera type priority?: number; // priority of the action } /** One single turn's choice for one single player. */ export interface Choice { cantUndo: boolean; // true if the choice can't be cancelled because of the maybeTrapped issue error: string; // contains error text in the case of a choice error actions: ChosenAction[]; // array of chosen actions forcedSwitchesLeft: number; // number of switches left that need to be performed forcedPassesLeft: number; // number of passes left that need to be performed switchIns: Set; // indexes of pokemon chosen to switch in zMove: boolean; // true if a Z-move has already been selected mega: boolean; // true if a mega evolution has already been selected ultra: boolean; // true if an ultra burst has already been selected dynamax: boolean; // true if a dynamax has already been selected terastallize: boolean; // true if a terastallization has already been inputted } export interface PokemonSwitchRequestData { /** * `` `${sideid}: ${name}` `` * @see {Pokemon#fullname} */ ident: string; /** * Details string. * @see {Pokemon#details} */ details: string; condition: string; active: boolean; stats: StatsExceptHPTable; /** * Move IDs for choosable moves. Also includes Hidden Power Type, Frustration/Return power. */ moves: ID[]; /** Permanent ability (the one applied on switch-in). */ baseAbility: ID; item: ID; pokeball: ID; /** Current ability. Only sent in Gen 7+. */ ability?: ID; /** @see https://dex.pokemonshowdown.com/abilities/commander */ commanding?: boolean; /** @see https://dex.pokemonshowdown.com/moves/revivalblessing */ reviving?: boolean; teraType?: string; terastallized?: string; } export interface PokemonMoveRequestData { moves: { move: string, id: ID, target?: string, disabled?: string | boolean }[]; maybeDisabled?: boolean; trapped?: boolean; maybeTrapped?: boolean; canMegaEvo?: boolean; canMegaEvoX?: boolean; canMegaEvoY?: boolean; canUltraBurst?: boolean; canZMove?: AnyObject | null; canDynamax?: boolean; maxMoves?: DynamaxOptions; canTerastallize?: string; } export interface DynamaxOptions { maxMoves: ({ move: string, target: MoveTarget, disabled?: boolean })[]; gigantamax?: string; } export interface SideRequestData { name: string; /** Side ID (`p1`, `p2`, `p3`, or `p4`), not the ID of the side's name. */ id: SideID; pokemon: PokemonSwitchRequestData[]; noCancel?: boolean; } export interface SwitchRequest { wait?: undefined; teamPreview?: undefined; forceSwitch: boolean[]; side: SideRequestData; noCancel?: boolean; } export interface TeamPreviewRequest { wait?: undefined; teamPreview: true; forceSwitch?: undefined; maxChosenTeamSize?: number; side: SideRequestData; noCancel?: boolean; } export interface MoveRequest { wait?: undefined; teamPreview?: undefined; forceSwitch?: undefined; active: PokemonMoveRequestData[]; side: SideRequestData; ally?: SideRequestData; noCancel?: boolean; } export interface WaitRequest { wait: true; teamPreview?: undefined; forceSwitch?: undefined; side: SideRequestData; noCancel?: boolean; } export type ChoiceRequest = SwitchRequest | TeamPreviewRequest | MoveRequest | WaitRequest; export class Side { readonly battle: Battle; readonly id: SideID; /** Index in `battle.sides`: `battle.sides[side.n] === side` */ readonly n: number; name: string; avatar: string; foe: Side = null!; // set in battle.start() /** Only exists in multi battle, for the allied side */ allySide: Side | null = null; // set in battle.start() team: PokemonSet[]; pokemon: Pokemon[]; active: Pokemon[]; pokemonLeft: number; zMoveUsed: boolean; /** * This will be true in any gen before 8 or if the player (or their battle partner) has dynamaxed once already * * Use Side.canDynamaxNow() to check if a side can dynamax instead of this property because only one * player per team can dynamax on any given turn of a gen 8 Multi Battle. */ dynamaxUsed: boolean; faintedLastTurn: Pokemon | null; faintedThisTurn: Pokemon | null; totalFainted: number; /** only used by Gen 1 Counter */ lastSelectedMove: ID = ''; /** these point to the same object as the ally's, in multi battles */ sideConditions: { [id: string]: EffectState }; slotConditions: { [id: string]: EffectState }[]; activeRequest: ChoiceRequest | null; choice: Choice; /** * In gen 1, all lastMove stuff is tracked on Side rather than Pokemon * (this is for Counter and Mirror Move) * This is also used for checking Self-KO clause in Pokemon Stadium 2. */ lastMove: Move | null; constructor(name: string, battle: Battle, sideNum: number, team: PokemonSet[]) { const sideScripts = battle.dex.data.Scripts.side; if (sideScripts) Object.assign(this, sideScripts); this.battle = battle; if (this.battle.format.side) Object.assign(this, this.battle.format.side); this.id = ['p1', 'p2', 'p3', 'p4'][sideNum] as SideID; this.n = sideNum; this.name = name; this.avatar = ''; this.team = team; this.pokemon = []; for (const set of this.team) { // console.log("NEW POKEMON: " + (this.team[i] ? this.team[i].name : '[unidentified]')); this.addPokemon(set); } switch (this.battle.gameType) { case 'doubles': this.active = [null!, null!]; break; case 'triples': case 'rotation': this.active = [null!, null!, null!]; break; default: this.active = [null!]; } this.pokemonLeft = this.pokemon.length; this.faintedLastTurn = null; this.faintedThisTurn = null; this.totalFainted = 0; this.zMoveUsed = false; this.dynamaxUsed = this.battle.gen !== 8; this.sideConditions = {}; this.slotConditions = []; // Array#fill doesn't work for this for (let i = 0; i < this.active.length; i++) this.slotConditions[i] = {}; this.activeRequest = null; this.choice = { cantUndo: false, error: ``, actions: [], forcedSwitchesLeft: 0, forcedPassesLeft: 0, switchIns: new Set(), zMove: false, mega: false, ultra: false, dynamax: false, terastallize: false, }; // old-gens this.lastMove = null; } toJSON(): AnyObject { return State.serializeSide(this); } get requestState(): RequestState { if (!this.activeRequest || this.activeRequest.wait) return ''; if (this.activeRequest.teamPreview) return 'teampreview'; if (this.activeRequest.forceSwitch) return 'switch'; return 'move'; } addPokemon(set: PokemonSet) { if (this.pokemon.length >= 24) return null; const newPokemon = new Pokemon(set, this); newPokemon.position = this.pokemon.length; this.pokemon.push(newPokemon); this.pokemonLeft++; return newPokemon; } canDynamaxNow(): boolean { if (this.battle.gen !== 8) return false; // In multi battles, players on a team are alternatingly given the option to dynamax each turn // On turn 1, the players on their team's respective left have the first chance (p1 and p2) if (this.battle.gameType === 'multi' && this.battle.turn % 2 !== [1, 1, 0, 0][this.n]) return false; // if (this.battle.gameType === 'multitriples' && this.battle.turn % 3 !== [1, 1, 2, 2, 0, 0][this.side.n]) { // return false; // } return !this.dynamaxUsed; } /** convert a Choice into a choice string */ getChoice() { if (this.choice.actions.length > 1 && this.choice.actions.every(action => action.choice === 'team')) { return `team ` + this.choice.actions.map(action => action.pokemon!.position + 1).join(', '); } return this.choice.actions.map(action => { switch (action.choice) { case 'move': let details = ``; if (action.targetLoc && this.active.length > 1) details += ` ${action.targetLoc > 0 ? '+' : ''}${action.targetLoc}`; if (action.mega) details += (action.pokemon!.item === 'ultranecroziumz' ? ` ultra` : ` mega`); if (action.zmove) details += ` zmove`; if (action.maxMove) details += ` dynamax`; if (action.terastallize) details += ` terastallize`; return `move ${action.moveid}${details}`; case 'switch': case 'instaswitch': case 'revivalblessing': return `switch ${action.target!.position + 1}`; case 'team': return `team ${action.pokemon!.position + 1}`; default: return action.choice; } }).join(', '); } toString() { return `${this.id}: ${this.name}`; } getRequestData(forAlly?: boolean): SideRequestData { const data: SideRequestData = { name: this.name, id: this.id, pokemon: [] as PokemonSwitchRequestData[], }; for (const pokemon of this.pokemon) { data.pokemon.push(pokemon.getSwitchRequestData(forAlly)); } return data; } randomFoe() { const actives = this.foes(); if (!actives.length) return null; return this.battle.sample(actives); } /** Intended as a way to iterate through all foe side conditions - do not use for anything else. */ foeSidesWithConditions() { if (this.battle.gameType === 'freeforall') return this.battle.sides.filter(side => side !== this); return [this.foe]; } foePokemonLeft() { if (this.battle.gameType === 'freeforall') { return this.battle.sides.filter(side => side !== this).map(side => side.pokemonLeft).reduce((a, b) => a + b); } if (this.foe.allySide) return this.foe.pokemonLeft + this.foe.allySide.pokemonLeft; return this.foe.pokemonLeft; } allies(all?: boolean) { // called during the first switch-in, so `active` can still contain nulls at this point let allies = this.activeTeam().filter(ally => ally); if (!all) allies = allies.filter(ally => !!ally.hp); return allies; } foes(all?: boolean) { if (this.battle.gameType === 'freeforall') { return this.battle.sides.map(side => side.active[0]) .filter(pokemon => pokemon && pokemon.side !== this && (all || !!pokemon.hp)); } return this.foe.allies(all); } activeTeam() { if (this.battle.gameType !== 'multi') return this.active; return this.battle.sides[this.n % 2].active.concat(this.battle.sides[this.n % 2 + 2].active); } hasAlly(pokemon: Pokemon) { return pokemon.side === this || pokemon.side === this.allySide; } addSideCondition( status: string | Condition, source: Pokemon | 'debug' | null = null, sourceEffect: Effect | null = null ): boolean { if (!source && this.battle.event?.target) source = this.battle.event.target; if (source === 'debug') source = this.active[0]; if (!source) throw new Error(`setting sidecond without a source`); if (!source.getSlot) source = (source as any as Side).active[0]; status = this.battle.dex.conditions.get(status); if (this.sideConditions[status.id]) { if (!(status as any).onSideRestart) return false; return this.battle.singleEvent('SideRestart', status, this.sideConditions[status.id], this, source, sourceEffect); } this.sideConditions[status.id] = this.battle.initEffectState({ id: status.id, target: this, source, sourceSlot: source.getSlot(), duration: status.duration, }); if (status.durationCallback) { this.sideConditions[status.id].duration = status.durationCallback.call(this.battle, this.active[0], source, sourceEffect); } if (!this.battle.singleEvent('SideStart', status, this.sideConditions[status.id], this, source, sourceEffect)) { delete this.sideConditions[status.id]; return false; } this.battle.runEvent('SideConditionStart', source, source, status); return true; } getSideCondition(status: string | Effect): Effect | null { status = this.battle.dex.conditions.get(status) as Effect; if (!this.sideConditions[status.id]) return null; return status; } getSideConditionData(status: string | Effect): AnyObject { status = this.battle.dex.conditions.get(status) as Effect; return this.sideConditions[status.id] || null; } removeSideCondition(status: string | Effect): boolean { status = this.battle.dex.conditions.get(status) as Effect; if (!this.sideConditions[status.id]) return false; this.battle.singleEvent('SideEnd', status, this.sideConditions[status.id], this); delete this.sideConditions[status.id]; return true; } addSlotCondition( target: Pokemon | number, status: string | Condition, source: Pokemon | 'debug' | null = null, sourceEffect: Effect | null = null ) { source ??= this.battle.event?.target || null; if (source === 'debug') source = this.active[0]; if (target instanceof Pokemon) target = target.position; if (!source) throw new Error(`setting sidecond without a source`); status = this.battle.dex.conditions.get(status); if (this.slotConditions[target][status.id]) { if (!status.onRestart) return false; return this.battle.singleEvent('Restart', status, this.slotConditions[target][status.id], this, source, sourceEffect); } const conditionState = this.slotConditions[target][status.id] = this.battle.initEffectState({ id: status.id, target: this, source, sourceSlot: source.getSlot(), isSlotCondition: true, duration: status.duration, }); if (status.durationCallback) { conditionState.duration = status.durationCallback.call(this.battle, this.active[0], source, sourceEffect); } if (!this.battle.singleEvent('Start', status, conditionState, this.active[target], source, sourceEffect)) { delete this.slotConditions[target][status.id]; return false; } return true; } getSlotCondition(target: Pokemon | number, status: string | Effect) { if (target instanceof Pokemon) target = target.position; status = this.battle.dex.conditions.get(status) as Effect; if (!this.slotConditions[target][status.id]) return null; return status; } removeSlotCondition(target: Pokemon | number, status: string | Effect) { if (target instanceof Pokemon) target = target.position; status = this.battle.dex.conditions.get(status) as Effect; if (!this.slotConditions[target][status.id]) return false; this.battle.singleEvent('End', status, this.slotConditions[target][status.id], this.active[target]); delete this.slotConditions[target][status.id]; return true; } send(...parts: (string | number | Function | AnyObject)[]) { const sideUpdate = '|' + parts.map(part => { if (typeof part !== 'function') return part; return part(this); }).join('|'); this.battle.send('sideupdate', `${this.id}\n${sideUpdate}`); } emitRequest(update: ChoiceRequest) { this.battle.send('sideupdate', `${this.id}\n|request|${JSON.stringify(update)}`); this.activeRequest = update; } emitChoiceError(message: string, unavailable?: boolean) { this.choice.error = message; const type = `[${unavailable ? 'Unavailable' : 'Invalid'} choice]`; this.battle.send('sideupdate', `${this.id}\n|error|${type} ${message}`); if (this.battle.strictChoices) throw new Error(`${type} ${message}`); return false; } isChoiceDone() { if (!this.requestState) return true; if (this.choice.forcedSwitchesLeft) return false; if (this.requestState === 'teampreview') { return this.choice.actions.length >= this.pickedTeamSize(); } // current request is move/switch this.getChoiceIndex(); // auto-pass return this.choice.actions.length >= this.active.length; } chooseMove( moveText?: string | number, targetLoc = 0, event: 'mega' | 'megax' | 'megay' | 'zmove' | 'ultra' | 'dynamax' | 'terastallize' | '' = '' ) { if (this.requestState !== 'move') { return this.emitChoiceError(`Can't move: You need a ${this.requestState} response`); } const index = this.getChoiceIndex(); if (index >= this.active.length) { return this.emitChoiceError(`Can't move: You sent more choices than unfainted Pokémon.`); } const autoChoose = !moveText; const pokemon: Pokemon = this.active[index]; // Parse moveText (name or index) // If the move is not found, the action is invalid without requiring further inspection. const request = pokemon.getMoveRequestData(); let moveid = ''; let targetType = ''; if (autoChoose) moveText = 1; if (typeof moveText === 'number' || (moveText && /^[0-9]+$/.test(moveText))) { // Parse a one-based move index. const moveIndex = Number(moveText) - 1; if (moveIndex < 0 || moveIndex >= request.moves.length || !request.moves[moveIndex]) { return this.emitChoiceError(`Can't move: Your ${pokemon.name} doesn't have a move ${moveIndex + 1}`); } moveid = request.moves[moveIndex].id; targetType = request.moves[moveIndex].target!; } else { // Parse a move ID. // Move names are also allowed, but may cause ambiguity (see client issue #167). moveid = toID(moveText); if (moveid.startsWith('hiddenpower')) { moveid = 'hiddenpower'; } for (const move of request.moves) { if (move.id !== moveid) continue; targetType = move.target || 'normal'; break; } if (!targetType && ['', 'dynamax'].includes(event) && request.maxMoves) { for (const [i, moveRequest] of request.maxMoves.maxMoves.entries()) { if (moveid === moveRequest.move) { moveid = request.moves[i].id; targetType = moveRequest.target; event = 'dynamax'; break; } } } if (!targetType && ['', 'zmove'].includes(event) && request.canZMove) { for (const [i, moveRequest] of request.canZMove.entries()) { if (!moveRequest) continue; if (moveid === toID(moveRequest.move)) { moveid = request.moves[i].id; targetType = moveRequest.target; event = 'zmove'; break; } } } if (!targetType) { return this.emitChoiceError(`Can't move: Your ${pokemon.name} doesn't have a move matching ${moveid}`); } } const moves = pokemon.getMoves(); if (autoChoose) { for (const [i, move] of request.moves.entries()) { if (move.disabled) continue; if (i < moves.length && move.id === moves[i].id && moves[i].disabled) continue; moveid = move.id; targetType = move.target!; break; } } const move = this.battle.dex.moves.get(moveid); // Z-move const zMove = event === 'zmove' ? this.battle.actions.getZMove(move, pokemon) : undefined; if (event === 'zmove' && !zMove) { return this.emitChoiceError(`Can't move: ${pokemon.name} can't use ${move.name} as a Z-move`); } if (zMove && this.choice.zMove) { return this.emitChoiceError(`Can't move: You can't Z-move more than once per battle`); } if (zMove) targetType = this.battle.dex.moves.get(zMove).target; // Dynamax // Is dynamaxed or will dynamax this turn. const maxMove = (event === 'dynamax' || pokemon.volatiles['dynamax']) ? this.battle.actions.getMaxMove(move, pokemon) : undefined; if (event === 'dynamax' && !maxMove) { return this.emitChoiceError(`Can't move: ${pokemon.name} can't use ${move.name} as a Max Move`); } if (maxMove) targetType = this.battle.dex.moves.get(maxMove).target; // Validate targetting if (autoChoose) { targetLoc = 0; } else if (this.battle.actions.targetTypeChoices(targetType)) { if (!targetLoc && this.active.length >= 2) { return this.emitChoiceError(`Can't move: ${move.name} needs a target`); } if (!this.battle.validTargetLoc(targetLoc, pokemon, targetType)) { return this.emitChoiceError(`Can't move: Invalid target for ${move.name}`); } } else { if (targetLoc) { return this.emitChoiceError(`Can't move: You can't choose a target for ${move.name}`); } } const lockedMove = pokemon.getLockedMove(); if (lockedMove) { let lockedMoveTargetLoc = pokemon.lastMoveTargetLoc || 0; const lockedMoveID = toID(lockedMove); if (pokemon.volatiles[lockedMoveID]?.targetLoc) { lockedMoveTargetLoc = pokemon.volatiles[lockedMoveID].targetLoc; } this.choice.actions.push({ choice: 'move', pokemon, targetLoc: lockedMoveTargetLoc, moveid: lockedMoveID, }); return true; } else if (!moves.length && !zMove) { // Override action and use Struggle if there are no enabled moves with PP // Gen 4 and earlier announce a Pokemon has no moves left before the turn begins, and only to that player's side. if (this.battle.gen <= 4) this.send('-activate', pokemon, 'move: Struggle'); moveid = 'struggle'; } else if (maxMove) { // Dynamaxed; only Taunt and Assault Vest disable Max Guard, but the base move must have PP remaining if (pokemon.maxMoveDisabled(move)) { return this.emitChoiceError(`Can't move: ${pokemon.name}'s ${maxMove.name} is disabled`); } } else if (!zMove) { // Check for disabled moves let isEnabled = false; let disabledSource = ''; for (const m of moves) { if (m.id !== moveid) continue; if (!m.disabled) { isEnabled = true; break; } else if (m.disabledSource) { disabledSource = m.disabledSource; } } if (!isEnabled) { // Request a different choice if (autoChoose) throw new Error(`autoChoose chose a disabled move`); const includeRequest = this.updateRequestForPokemon(pokemon, req => { let updated = false; for (const m of req.moves) { if (m.id === moveid) { if (!m.disabled) { m.disabled = true; updated = true; } if (m.disabledSource !== disabledSource) { m.disabledSource = disabledSource; updated = true; } break; } } return updated; }); const status = this.emitChoiceError(`Can't move: ${pokemon.name}'s ${move.name} is disabled`, includeRequest); if (includeRequest) this.emitRequest(this.activeRequest!); return status; } // The chosen move is valid yay } // Mega evolution const mixandmega = this.battle.format.mod === 'mixandmega'; const mega = (event === 'mega'); const megax = (event === 'megax'); const megay = (event === 'megay'); if (mega && !pokemon.canMegaEvo) { return this.emitChoiceError(`Can't move: ${pokemon.name} can't mega evolve`); } if (megax && !pokemon.canMegaEvoX) { return this.emitChoiceError(`Can't move: ${pokemon.name} can't mega evolve X`); } if (megay && !pokemon.canMegaEvoY) { return this.emitChoiceError(`Can't move: ${pokemon.name} can't mega evolve Y`); } if ((mega || megax || megay) && this.choice.mega && !mixandmega) { return this.emitChoiceError(`Can't move: You can only mega-evolve once per battle`); } const ultra = (event === 'ultra'); if (ultra && !pokemon.canUltraBurst) { return this.emitChoiceError(`Can't move: ${pokemon.name} can't ultra burst`); } if (ultra && this.choice.ultra && !mixandmega) { return this.emitChoiceError(`Can't move: You can only ultra burst once per battle`); } let dynamax = (event === 'dynamax'); const canDynamax = (this.activeRequest as MoveRequest)?.active[this.active.indexOf(pokemon)].canDynamax; if (dynamax && (this.choice.dynamax || !canDynamax)) { if (pokemon.volatiles['dynamax']) { dynamax = false; } else { if (this.battle.gen !== 8) { return this.emitChoiceError(`Can't move: Dynamaxing doesn't outside of Gen 8.`); } else if (pokemon.side.canDynamaxNow()) { return this.emitChoiceError(`Can't move: ${pokemon.name} can't Dynamax now.`); } else if (pokemon.side.allySide?.canDynamaxNow()) { return this.emitChoiceError(`Can't move: It's your partner's turn to Dynamax.`); } return this.emitChoiceError(`Can't move: You can only Dynamax once per battle.`); } } const terastallize = (event === 'terastallize'); if (terastallize && !pokemon.canTerastallize) { // Make this work properly return this.emitChoiceError(`Can't move: ${pokemon.name} can't Terastallize.`); } if (terastallize && this.choice.terastallize) { return this.emitChoiceError(`Can't move: You can only Terastallize once per battle.`); } if (terastallize && this.battle.gen !== 9) { // Make this work properly return this.emitChoiceError(`Can't move: You can only Terastallize in Gen 9.`); } this.choice.actions.push({ choice: 'move', pokemon, targetLoc, moveid, mega: mega || ultra, megax, megay, zmove: zMove, maxMove: maxMove ? maxMove.id : undefined, terastallize: terastallize ? pokemon.teraType : undefined, }); if (pokemon.maybeDisabled) { this.choice.cantUndo = this.choice.cantUndo || pokemon.isLastActive(); } if (mega || megax || megay) this.choice.mega = true; if (ultra) this.choice.ultra = true; if (zMove) this.choice.zMove = true; if (dynamax) this.choice.dynamax = true; if (terastallize) this.choice.terastallize = true; return true; } updateRequestForPokemon(pokemon: Pokemon, update: (req: AnyObject) => boolean) { if (!(this.activeRequest as MoveRequest)?.active) { throw new Error(`Can't update a request without active Pokemon`); } const req = (this.activeRequest as MoveRequest).active[pokemon.position]; if (!req) throw new Error(`Pokemon not found in request's active field`); return update(req); } chooseSwitch(slotText?: string) { if (this.requestState !== 'move' && this.requestState !== 'switch') { return this.emitChoiceError(`Can't switch: You need a ${this.requestState} response`); } const index = this.getChoiceIndex(); if (index >= this.active.length) { if (this.requestState === 'switch') { return this.emitChoiceError(`Can't switch: You sent more switches than Pokémon that need to switch`); } return this.emitChoiceError(`Can't switch: You sent more choices than unfainted Pokémon`); } const pokemon = this.active[index]; let slot; if (!slotText) { if (this.requestState !== 'switch') { return this.emitChoiceError(`Can't switch: You need to select a Pokémon to switch in`); } if (this.slotConditions[pokemon.position]['revivalblessing']) { slot = 0; while (!this.pokemon[slot].fainted) slot++; } else { if (!this.choice.forcedSwitchesLeft) return this.choosePass(); slot = this.active.length; while (this.choice.switchIns.has(slot) || this.pokemon[slot].fainted) slot++; } } else { slot = parseInt(slotText) - 1; } if (isNaN(slot) || slot < 0) { // maybe it's a name/species id! slot = -1; for (const [i, mon] of this.pokemon.entries()) { if (slotText!.toLowerCase() === mon.name.toLowerCase() || toID(slotText) === mon.species.id) { slot = i; break; } } if (slot < 0) { return this.emitChoiceError(`Can't switch: You do not have a Pokémon named "${slotText}" to switch to`); } } if (slot >= this.pokemon.length) { return this.emitChoiceError(`Can't switch: You do not have a Pokémon in slot ${slot + 1} to switch to`); } else if (slot < this.active.length && !this.slotConditions[pokemon.position]['revivalblessing']) { return this.emitChoiceError(`Can't switch: You can't switch to an active Pokémon`); } else if (this.choice.switchIns.has(slot)) { return this.emitChoiceError(`Can't switch: The Pokémon in slot ${slot + 1} can only switch in once`); } const targetPokemon = this.pokemon[slot]; if (this.slotConditions[pokemon.position]['revivalblessing']) { if (!targetPokemon.fainted) { return this.emitChoiceError(`Can't switch: You have to pass to a fainted Pokémon`); } // Should always subtract, but stop at 0 to prevent errors. this.choice.forcedSwitchesLeft = this.battle.clampIntRange(this.choice.forcedSwitchesLeft - 1, 0); pokemon.switchFlag = false; this.choice.actions.push({ choice: 'revivalblessing', pokemon, target: targetPokemon, } as ChosenAction); return true; } if (targetPokemon.fainted) { return this.emitChoiceError(`Can't switch: You can't switch to a fainted Pokémon`); } if (this.requestState === 'move') { if (pokemon.trapped) { const includeRequest = this.updateRequestForPokemon(pokemon, req => { let updated = false; if (req.maybeTrapped) { delete req.maybeTrapped; updated = true; } if (!req.trapped) { req.trapped = true; updated = true; } return updated; }); const status = this.emitChoiceError(`Can't switch: The active Pokémon is trapped`, includeRequest); if (includeRequest) this.emitRequest(this.activeRequest!); return status; } else if (pokemon.maybeTrapped) { this.choice.cantUndo = this.choice.cantUndo || pokemon.isLastActive(); } } else if (this.requestState === 'switch') { if (!this.choice.forcedSwitchesLeft) { throw new Error(`Player somehow switched too many Pokemon`); } this.choice.forcedSwitchesLeft--; } this.choice.switchIns.add(slot); this.choice.actions.push({ choice: (this.requestState === 'switch' ? 'instaswitch' : 'switch'), pokemon, target: targetPokemon, } as ChosenAction); return true; } /** * The number of pokemon you must choose in Team Preview. * * Note that PS doesn't support choosing fewer than this number of pokemon. * In the games, it is sometimes possible to bring fewer than this, but * since that's nearly always a mistake, we haven't gotten around to * supporting it. */ pickedTeamSize() { return Math.min(this.pokemon.length, this.battle.ruleTable.pickedTeamSize || Infinity); } chooseTeam(data = '') { if (this.requestState !== 'teampreview') { return this.emitChoiceError(`Can't choose for Team Preview: You're not in a Team Preview phase`); } const ruleTable = this.battle.ruleTable; let positions = data.split(data.includes(',') ? ',' : '') .map(datum => parseInt(datum) - 1); const pickedTeamSize = this.pickedTeamSize(); // make sure positions is exactly of length pickedTeamSize // - If too big: the client automatically sends a full list, so we just trim it down to size positions.splice(pickedTeamSize); // - If too small: we intentionally support only sending leads and having the sim fill in the rest if (positions.length === 0) { for (let i = 0; i < pickedTeamSize; i++) positions.push(i); } else if (positions.length < pickedTeamSize) { for (let i = 0; i < pickedTeamSize; i++) { if (!positions.includes(i)) positions.push(i); // duplicate in input, let the rest of the code handle the error message if (positions.length >= pickedTeamSize) break; } } for (const [index, pos] of positions.entries()) { if (isNaN(pos) || pos < 0 || pos >= this.pokemon.length) { return this.emitChoiceError(`Can't choose for Team Preview: You do not have a Pokémon in slot ${pos + 1}`); } if (positions.indexOf(pos) !== index) { return this.emitChoiceError(`Can't choose for Team Preview: The Pokémon in slot ${pos + 1} can only switch in once`); } } if (ruleTable.maxTotalLevel) { let totalLevel = 0; for (const pos of positions) totalLevel += this.pokemon[pos].level; if (totalLevel > ruleTable.maxTotalLevel) { if (!data) { // autoChoose positions = [...this.pokemon.keys()].sort((a, b) => (this.pokemon[a].level - this.pokemon[b].level)) .slice(0, pickedTeamSize); } else { return this.emitChoiceError(`Your selected team has a total level of ${totalLevel}, but it can't be above ${ruleTable.maxTotalLevel}; please select a valid team of ${pickedTeamSize} Pokémon`); } } } if (ruleTable.valueRules.has('forceselect')) { const species = this.battle.dex.species.get(ruleTable.valueRules.get('forceselect')); if (!data) { // autoChoose positions = [...this.pokemon.keys()].filter(pos => this.pokemon[pos].species.name === species.name) .concat([...this.pokemon.keys()].filter(pos => this.pokemon[pos].species.name !== species.name)) .slice(0, pickedTeamSize); } else { let hasSelection = false; for (const pos of positions) { if (this.pokemon[pos].species.name === species.name) { hasSelection = true; break; } } if (!hasSelection) { return this.emitChoiceError(`You must bring ${species.name} to the battle.`); } } } for (const [index, pos] of positions.entries()) { this.choice.switchIns.add(pos); this.choice.actions.push({ choice: 'team', index, pokemon: this.pokemon[pos], priority: -index, } as ChosenAction); } return true; } chooseShift() { const index = this.getChoiceIndex(); if (index >= this.active.length) { return this.emitChoiceError(`Can't shift: You do not have a Pokémon in slot ${index + 1}`); } else if (this.requestState !== 'move') { return this.emitChoiceError(`Can't shift: You can only shift during a move phase`); } else if (this.battle.gameType !== 'triples') { return this.emitChoiceError(`Can't shift: You can only shift to the center in triples`); } else if (index === 1) { return this.emitChoiceError(`Can't shift: You can only shift from the edge to the center`); } const pokemon: Pokemon = this.active[index]; this.choice.actions.push({ choice: 'shift', pokemon, } as ChosenAction); return true; } clearChoice() { let forcedSwitches = 0; let forcedPasses = 0; if (this.battle.requestState === 'switch') { const canSwitchOut = this.active.filter(pokemon => pokemon?.switchFlag).length; const canSwitchIn = this.pokemon.slice(this.active.length).filter(pokemon => pokemon && !pokemon.fainted).length; forcedSwitches = Math.min(canSwitchOut, canSwitchIn); forcedPasses = canSwitchOut - forcedSwitches; } this.choice = { cantUndo: false, error: ``, actions: [], forcedSwitchesLeft: forcedSwitches, forcedPassesLeft: forcedPasses, switchIns: new Set(), zMove: false, mega: false, ultra: false, dynamax: false, terastallize: false, }; } choose(input: string) { if (!this.requestState) { return this.emitChoiceError( this.battle.ended ? `Can't do anything: The game is over` : `Can't do anything: It's not your turn` ); } if (this.choice.cantUndo) { return this.emitChoiceError(`Can't undo: A trapping/disabling effect would cause undo to leak information`); } this.clearChoice(); const choiceStrings = (input.startsWith('team ') ? [input] : input.split(',')); if (choiceStrings.length > this.active.length) { return this.emitChoiceError( `Can't make choices: You sent choices for ${choiceStrings.length} Pokémon, but this is a ${this.battle.gameType} game!` ); } for (const choiceString of choiceStrings) { let [choiceType, data] = Utils.splitFirst(choiceString.trim(), ' '); data = data.trim(); switch (choiceType) { case 'move': const original = data; const error = () => this.emitChoiceError(`Conflicting arguments for "move": ${original}`); let targetLoc: number | undefined; let event: 'mega' | 'megax' | 'megay' | 'zmove' | 'ultra' | 'dynamax' | 'terastallize' | '' = ''; while (true) { // If data ends with a number, treat it as a target location. // We need to special case 'Conversion 2' so it doesn't get // confused with 'Conversion' erroneously sent with the target // '2' (since Conversion targets 'self', targetLoc can't be 2). if (/\s(?:-|\+)?[1-3]$/.test(data) && toID(data) !== 'conversion2') { if (targetLoc !== undefined) return error(); targetLoc = parseInt(data.slice(-2)); data = data.slice(0, -2).trim(); } else if (data.endsWith(' mega')) { if (event) return error(); event = 'mega'; data = data.slice(0, -5); } else if (data.endsWith(' megax')) { if (event) return error(); event = 'megax'; data = data.slice(0, -6); } else if (data.endsWith(' megay')) { if (event) return error(); event = 'megay'; data = data.slice(0, -6); } else if (data.endsWith(' zmove')) { if (event) return error(); event = 'zmove'; data = data.slice(0, -6); } else if (data.endsWith(' ultra')) { if (event) return error(); event = 'ultra'; data = data.slice(0, -6); } else if (data.endsWith(' dynamax')) { if (event) return error(); event = 'dynamax'; data = data.slice(0, -8); } else if (data.endsWith(' gigantamax')) { if (event) return error(); event = 'dynamax'; data = data.slice(0, -11); } else if (data.endsWith(' max')) { if (event) return error(); event = 'dynamax'; data = data.slice(0, -4); } else if (data.endsWith(' terastal')) { if (event) return error(); event = 'terastallize'; data = data.slice(0, -9); } else if (data.endsWith(' terastallize')) { if (event) return error(); event = 'terastallize'; data = data.slice(0, -13); } else { break; } } if (!this.chooseMove(data, targetLoc, event)) return false; break; case 'switch': this.chooseSwitch(data); break; case 'shift': if (data) return this.emitChoiceError(`Unrecognized data after "shift": ${data}`); if (!this.chooseShift()) return false; break; case 'team': if (!this.chooseTeam(data)) return false; break; case 'pass': case 'skip': if (data) return this.emitChoiceError(`Unrecognized data after "pass": ${data}`); if (!this.choosePass()) return false; break; case 'auto': case 'default': this.autoChoose(); break; default: this.emitChoiceError(`Unrecognized choice: ${choiceString}`); break; } } return !this.choice.error; } getChoiceIndex(isPass?: boolean) { let index = this.choice.actions.length; if (!isPass) { switch (this.requestState) { case 'move': // auto-pass while ( index < this.active.length && (this.active[index].fainted || this.active[index].volatiles['commanding']) ) { this.choosePass(); index++; } break; case 'switch': while (index < this.active.length && !this.active[index].switchFlag) { this.choosePass(); index++; } break; } } return index; } choosePass(): boolean | Side { const index = this.getChoiceIndex(true); if (index >= this.active.length) return false; const pokemon: Pokemon = this.active[index]; switch (this.requestState) { case 'switch': if (pokemon.switchFlag) { // This condition will always happen if called by Battle#choose() if (!this.choice.forcedPassesLeft) { return this.emitChoiceError(`Can't pass: You need to switch in a Pokémon to replace ${pokemon.name}`); } this.choice.forcedPassesLeft--; } break; case 'move': if (!pokemon.fainted && !pokemon.volatiles['commanding']) { return this.emitChoiceError(`Can't pass: Your ${pokemon.name} must make a move (or switch)`); } break; default: return this.emitChoiceError(`Can't pass: Not a move or switch request`); } this.choice.actions.push({ choice: 'pass', } as ChosenAction); return true; } /** Automatically finish a choice if not currently complete. */ autoChoose() { if (this.requestState === 'teampreview') { if (!this.isChoiceDone()) this.chooseTeam(); } else if (this.requestState === 'switch') { let i = 0; while (!this.isChoiceDone()) { if (!this.chooseSwitch()) throw new Error(`autoChoose switch crashed: ${this.choice.error}`); i++; if (i > 10) throw new Error(`autoChoose failed: infinite looping`); } } else if (this.requestState === 'move') { let i = 0; while (!this.isChoiceDone()) { if (!this.chooseMove()) throw new Error(`autoChoose crashed: ${this.choice.error}`); i++; if (i > 10) throw new Error(`autoChoose failed: infinite looping`); } } return true; } destroy() { // deallocate ourself // deallocate children and get rid of references to them for (const pokemon of this.pokemon) { if (pokemon) pokemon.destroy(); } for (const action of this.choice.actions) { delete action.side; delete action.pokemon; delete action.target; } this.choice.actions = []; // get rid of some possibly-circular references this.pokemon = []; this.active = []; this.foe = null!; (this as any).battle = null!; } }