|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { Utils } from '../lib/utils'; |
|
import type { RequestState } from './battle'; |
|
import { Pokemon, type EffectState } from './pokemon'; |
|
import { State } from './state'; |
|
import { toID } from './dex'; |
|
|
|
|
|
export interface ChosenAction { |
|
choice: 'move' | 'switch' | 'instaswitch' | 'revivalblessing' | 'team' | 'shift' | 'pass'; |
|
pokemon?: Pokemon; |
|
targetLoc?: number; |
|
moveid: string; |
|
move?: ActiveMove; |
|
target?: Pokemon; |
|
index?: number; |
|
side?: Side; |
|
mega?: boolean | null; |
|
megax?: boolean | null; |
|
megay?: boolean | null; |
|
zmove?: string; |
|
maxMove?: string; |
|
terastallize?: string; |
|
priority?: number; |
|
} |
|
|
|
|
|
export interface Choice { |
|
cantUndo: boolean; |
|
error: string; |
|
actions: ChosenAction[]; |
|
forcedSwitchesLeft: number; |
|
forcedPassesLeft: number; |
|
switchIns: Set<number>; |
|
zMove: boolean; |
|
mega: boolean; |
|
ultra: boolean; |
|
dynamax: boolean; |
|
terastallize: boolean; |
|
} |
|
|
|
export interface PokemonSwitchRequestData { |
|
|
|
|
|
|
|
|
|
ident: string; |
|
|
|
|
|
|
|
|
|
details: string; |
|
condition: string; |
|
active: boolean; |
|
stats: StatsExceptHPTable; |
|
|
|
|
|
|
|
moves: ID[]; |
|
|
|
baseAbility: ID; |
|
item: ID; |
|
pokeball: ID; |
|
|
|
ability?: ID; |
|
|
|
commanding?: boolean; |
|
|
|
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; |
|
|
|
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; |
|
|
|
readonly n: number; |
|
|
|
name: string; |
|
avatar: string; |
|
foe: Side = null!; |
|
|
|
allySide: Side | null = null; |
|
team: PokemonSet[]; |
|
pokemon: Pokemon[]; |
|
active: Pokemon[]; |
|
|
|
pokemonLeft: number; |
|
zMoveUsed: boolean; |
|
|
|
|
|
|
|
|
|
|
|
|
|
dynamaxUsed: boolean; |
|
|
|
faintedLastTurn: Pokemon | null; |
|
faintedThisTurn: Pokemon | null; |
|
totalFainted: number; |
|
|
|
lastSelectedMove: ID = ''; |
|
|
|
|
|
sideConditions: { [id: string]: EffectState }; |
|
slotConditions: { [id: string]: EffectState }[]; |
|
|
|
activeRequest: ChoiceRequest | null; |
|
choice: Choice; |
|
|
|
|
|
|
|
|
|
|
|
|
|
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) { |
|
|
|
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 = []; |
|
|
|
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, |
|
}; |
|
|
|
|
|
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; |
|
|
|
|
|
if (this.battle.gameType === 'multi' && this.battle.turn % 2 !== [1, 1, 0, 0][this.n]) return false; |
|
|
|
|
|
|
|
return !this.dynamaxUsed; |
|
} |
|
|
|
|
|
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); |
|
} |
|
|
|
|
|
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) { |
|
|
|
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(); |
|
} |
|
|
|
|
|
this.getChoiceIndex(); |
|
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]; |
|
|
|
|
|
|
|
|
|
const request = pokemon.getMoveRequestData(); |
|
let moveid = ''; |
|
let targetType = ''; |
|
if (autoChoose) moveText = 1; |
|
if (typeof moveText === 'number' || (moveText && /^[0-9]+$/.test(moveText))) { |
|
|
|
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 { |
|
|
|
|
|
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); |
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
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) { |
|
|
|
|
|
if (this.battle.gen <= 4) this.send('-activate', pokemon, 'move: Struggle'); |
|
moveid = 'struggle'; |
|
} else if (maxMove) { |
|
|
|
if (pokemon.maxMoveDisabled(move)) { |
|
return this.emitChoiceError(`Can't move: ${pokemon.name}'s ${maxMove.name} is disabled`); |
|
} |
|
} else if (!zMove) { |
|
|
|
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) { |
|
|
|
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; |
|
} |
|
|
|
} |
|
|
|
|
|
|
|
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) { |
|
|
|
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) { |
|
|
|
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) { |
|
|
|
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`); |
|
} |
|
|
|
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; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
positions.splice(pickedTeamSize); |
|
|
|
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); |
|
|
|
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) { |
|
|
|
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) { |
|
|
|
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 (/\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': |
|
|
|
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) { |
|
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; |
|
} |
|
|
|
|
|
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() { |
|
|
|
|
|
|
|
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 = []; |
|
|
|
|
|
this.pokemon = []; |
|
this.active = []; |
|
this.foe = null!; |
|
(this as any).battle = null!; |
|
} |
|
} |
|
|