Spaces:
Paused
Paused
| /** | |
| * 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<number>; // 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!; | |
| } | |
| } | |