diff --git a/sim/SIM-PROTOCOL.md b/sim/SIM-PROTOCOL.md index d98f3d5d2e..ed8c55cfd0 100644 --- a/sim/SIM-PROTOCOL.md +++ b/sim/SIM-PROTOCOL.md @@ -673,9 +673,15 @@ battle will continue. If an invalid decision is sent (trying to switch when you're trapped by Mean Look or something), you will receive a message starting with: -`|error|[Invalid choice]` +`|error|[Invalid choice] MESSAGE` -This will tell you to send a different decision. +This will tell you to send a different decision. If your previous choice +revealed additional information (For example: a move disabled by Imprison +or a trapping effect), the error will be followed with a `|request|` command +to base your decision off of: + +`|error|[Unavailable choice] MESSAGE` +`|request|REQUEST` ### Choice requests diff --git a/sim/battle-stream.ts b/sim/battle-stream.ts index d73251dceb..9b2792bb8a 100644 --- a/sim/battle-stream.ts +++ b/sim/battle-stream.ts @@ -271,20 +271,12 @@ export abstract class BattlePlayer { if (this.debug) console.log(line); if (line.charAt(0) !== '|') return; const [cmd, rest] = splitFirst(line.slice(1), '|'); - if (cmd === 'request') { - return this.receiveRequest(JSON.parse(rest)); - } - if (cmd === 'callback') { - return this.receiveCallback(rest.split('|')); - } - if (cmd === 'error') { - return this.receiveError(new Error(rest)); - } + if (cmd === 'request') return this.receiveRequest(JSON.parse(rest)); + if (cmd === 'error') return this.receiveError(new Error(rest)); this.log.push(line); } abstract receiveRequest(request: AnyObject): void; - abstract receiveCallback(callback: string[]): void; receiveError(error: Error) { throw error; diff --git a/sim/examples/random-player-ai.ts b/sim/examples/random-player-ai.ts index 2c378317a1..455f19d925 100644 --- a/sim/examples/random-player-ai.ts +++ b/sim/examples/random-player-ai.ts @@ -15,11 +15,6 @@ export class RandomPlayerAI extends BattlePlayer { protected readonly mega: number; protected readonly prng: PRNG; - trapped: Set; - disabled: Map>; - lastRequest?: AnyObject; - retry: boolean; - constructor( playerStream: ObjectReadWriteStream, options: {move?: number, mega?: number, seed?: PRNG | PRNGSeed | null } = {}, @@ -29,53 +24,16 @@ export class RandomPlayerAI extends BattlePlayer { this.move = options.move || 1.0; this.mega = options.mega || 0; this.prng = options.seed && !Array.isArray(options.seed) ? options.seed : new PRNG(options.seed); - - this.disabled = new Map(); - this.trapped = new Set(); - this.retry = false; } receiveError(error: Error) { - if (error.message.startsWith(`[Invalid choice]`) && this.retry) { - this.retry = false; - // If we get a choice error, we retry the choice using the last request provided - // we've got a '|callback' updating our state regarding what Pokemon are trapped - // or disabled. - this.makeChoice(this.lastRequest!); - } else { - throw error; - } + // If we made an unavailable choice we will receive a followup request to + // allow us the opportunity to correct our decision. + if (error.message.startsWith('[Unavailable choice]')) return; + throw error; } receiveRequest(request: AnyObject) { - this.disabled = new Map(); - this.trapped = new Set(); - this.retry = false; - - this.lastRequest = request; - this.makeChoice(request); - } - - receiveCallback(callback: string[]) { - const [type, ...args] = callback; - if (type === 'cant') { - this.retry = true; - const [pokemon, _, move] = args; - const position = pokemon[2].indexOf(`abcdef`); - let moves = this.disabled.get(position); - if (!moves) { - moves = new Set(); - this.disabled.set(position, moves); - } - moves.add(move); - } else if (type === 'trapped') { - this.retry = true; - const position = Number(args); - this.trapped.add(position); - } - } - - makeChoice(request: AnyObject) { if (request.wait) { // wait request // do nothing @@ -116,11 +74,9 @@ export class RandomPlayerAI extends BattlePlayer { canUltraBurst = canUltraBurst && active.canUltraBurst; canZMove = canZMove && !!active.canZMove; - const disabled = this.disabled.get(i); let canMove = [1, 2, 3, 4].slice(0, active.moves.length).filter(j => ( // not disabled - !active.moves[j - 1].disabled && - (!disabled || !disabled.has(toId(active.moves[j - 1].move))) + !active.moves[j - 1].disabled // NOTE: we don't actually check for whether we have PP or not because the // simulator will mark the move as disabled if there is zero PP and there are // situations where we actually need to use a move with 0 PP (Gen 1 Wrap). @@ -178,8 +134,7 @@ export class RandomPlayerAI extends BattlePlayer { // not fainted !pokemon[j - 1].condition.endsWith(` fnt`) )); - const trapped = active.trapped || (this.trapped && this.trapped.has(i)); - const switches = trapped ? [] : canSwitch; + const switches = active.trapped ? [] : canSwitch; if (switches.length && (!moves.length || this.prng.next() > this.move)) { const target = this.chooseSwitch( diff --git a/sim/side.ts b/sim/side.ts index e4525aefbf..110d426c4e 100644 --- a/sim/side.ts +++ b/sim/side.ts @@ -314,19 +314,16 @@ export class Side { this.battle.send('sideupdate', `${this.id}\n${sideUpdate}`); } - emitCallback(...args: (string | number | AnyObject)[]) { - this.battle.send('sideupdate', `${this.id}\n|callback|${args.join('|')}`); - } - emitRequest(update: AnyObject) { this.battle.send('sideupdate', `${this.id}\n|request|${JSON.stringify(update)}`); this.activeRequest = update; } - emitChoiceError(message: string) { + emitChoiceError(message: string, unavailable?: boolean) { this.choice.error = message; - this.battle.send('sideupdate', `${this.id}\n|error|[Invalid choice] ${message}`); - if (this.battle.strictChoices) throw new Error(`[Invalid choice] ${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; } @@ -408,7 +405,6 @@ export class Side { return this.emitChoiceError(`Can't move: ${pokemon.name} can't use ${move.name} as a Z-move`); } if (zMove && this.choice.zMove) { - this.emitCallback('cantz', pokemon); return this.emitChoiceError(`Can't move: You can't Z-move more than once per battle`); } @@ -462,8 +458,26 @@ export class Side { if (!isEnabled) { // Request a different choice if (autoChoose) throw new Error(`autoChoose chose a disabled move`); - this.emitCallback('cant', pokemon, disabledSource, moveid); - return this.emitChoiceError(`Can't move: ${pokemon.name}'s ${move.name} is disabled`); + 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 } @@ -475,7 +489,6 @@ export class Side { return this.emitChoiceError(`Can't move: ${pokemon.name} can't mega evolve`); } if (mega && this.choice.mega) { - this.emitCallback('cantmega', pokemon); return this.emitChoiceError(`Can't move: You can only mega-evolve once per battle`); } const ultra = (megaOrZ === 'ultra'); @@ -483,7 +496,6 @@ export class Side { return this.emitChoiceError(`Can't move: ${pokemon.name} can't mega evolve`); } if (ultra && this.choice.ultra) { - this.emitCallback('cantmega', pokemon); return this.emitChoiceError(`Can't move: You can only ultra burst once per battle`); } @@ -507,6 +519,15 @@ export class Side { return true; } + updateRequestForPokemon(pokemon: Pokemon, update: (req: AnyObject) => boolean) { + if (!this.activeRequest || !this.activeRequest.active) { + throw new Error(`Can't update a request without active Pokemon`); + } + const req = this.activeRequest.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`); @@ -559,8 +580,21 @@ export class Side { if (this.requestState === 'move') { if (pokemon.trapped) { - this.emitCallback('trapped', pokemon.position); - return this.emitChoiceError(`Can't switch: The active Pokémon is 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(); } diff --git a/test/assert.js b/test/assert.js index c098674e83..fde274d40e 100644 --- a/test/assert.js +++ b/test/assert.js @@ -74,17 +74,17 @@ assert.holdsItem = function (pokemon, message) { }); }; -assert.trapped = function (fn, message) { +assert.trapped = function (fn, unavailable, message) { assert.throws( - fn, /\[Invalid choice\] Can't switch: The active Pokémon is trapped/, + fn, new RegExp(`\\[${unavailable ? 'Unavailable' : 'Invalid'} choice\\] Can't switch: The active Pokémon is trapped`), message || 'Expected active Pokemon to be trapped.'); }; -assert.cantMove = function (fn, pokemon, move, message) { +assert.cantMove = function (fn, pokemon, move, unavailable, message) { message = message || `Expected ${pokemon} to not be able to use ${move}.`; if (pokemon && move) { assert.throws( - fn, new RegExp(`\\[Invalid choice\\] Can't move:.*${pokemon}.*${move}`, 'i'), message); + fn, new RegExp(`\\[${unavailable ? 'Unavailable' : 'Invalid'} choice\\] Can't move:.*${pokemon}.*${move}`, 'i'), message); } else { assert.throws(fn, /\[Invalid choice\] Can't move:/, message); } @@ -96,7 +96,7 @@ assert.cantUndo = function (fn, message) { }; assert.cantTarget = function (fn, move, message) { - assert.cantMove(fn, 'target', move, message || `Expected not to be able to choose a target for ${move}.`); + assert.cantMove(fn, 'target', move, false, message || `Expected not to be able to choose a target for ${move}.`); }; assert.statStage = function (pokemon, statName, stage, message) { diff --git a/test/simulator/abilities/arenatrap.js b/test/simulator/abilities/arenatrap.js index 3c2b3b81bd..4c447ed5fd 100644 --- a/test/simulator/abilities/arenatrap.js +++ b/test/simulator/abilities/arenatrap.js @@ -31,7 +31,7 @@ describe('Arena Trap', function () { assert.species(p2active[0], 'Dusknoir'); battle.makeChoices('move snore', 'switch 5'); assert.species(p2active[0], 'Magnezone'); - assert.trapped(() => battle.makeChoices('', 'switch 6')); + assert.trapped(() => battle.makeChoices('', 'switch 6'), true); assert.species(p2active[0], 'Magnezone'); // Magnezone is trapped @@ -41,7 +41,7 @@ describe('Arena Trap', function () { battle.makeChoices('move snore', 'switch 6'); assert.species(p2active[0], 'Vaporeon'); - assert.trapped(() => battle.makeChoices('default', 'switch 2')); // Vaporeon is trapped + assert.trapped(() => battle.makeChoices('default', 'switch 2'), true); // Vaporeon is trapped assert.species(p2active[0], 'Vaporeon'); battle.makeChoices('move telekinesis', 'default'); // Telekinesis @@ -51,7 +51,7 @@ describe('Arena Trap', function () { battle.makeChoices('move gravity', 'default'); // Gravity - assert.trapped(() => battle.makeChoices('', 'switch 4')); // Tornadus is trapped + assert.trapped(() => battle.makeChoices('', 'switch 4'), true); // Tornadus is trapped assert.species(p2active[0], 'Tornadus'); }); }); diff --git a/test/simulator/abilities/magnetpull.js b/test/simulator/abilities/magnetpull.js index 636671e98a..a3be607f58 100644 --- a/test/simulator/abilities/magnetpull.js +++ b/test/simulator/abilities/magnetpull.js @@ -18,13 +18,13 @@ describe('Magnet Pull', function () { {species: "Starmie", ability: 'illuminate', moves: ['reflecttype']}, ]}); - assert.trapped(() => battle.makeChoices('', 'switch 2')); + assert.trapped(() => battle.makeChoices('', 'switch 2'), true); battle.makeChoices('auto', 'auto'); assert.species(battle.p2.active[0], 'Heatran'); battle.makeChoices('auto', 'switch 2'); assert.species(battle.p2.active[0], 'Starmie'); battle.makeChoices('move charge', 'move reflecttype'); // Reflect Type makes Starmie part Steel - assert.trapped(() => battle.makeChoices('', 'switch 2')); + assert.trapped(() => battle.makeChoices('', 'switch 2'), true); battle.makeChoices('auto', 'auto'); assert.species(battle.p2.active[0], 'Starmie'); }); diff --git a/test/simulator/abilities/shadowtag.js b/test/simulator/abilities/shadowtag.js index 7ebbbb1f43..66103615e4 100644 --- a/test/simulator/abilities/shadowtag.js +++ b/test/simulator/abilities/shadowtag.js @@ -17,7 +17,7 @@ describe('Shadow Tag', function () { {species: "Tornadus", ability: 'defiant', moves: ['tailwind']}, {species: "Heatran", ability: 'flashfire', moves: ['roar']}, ]}); - assert.trapped(() => battle.makeChoices('move counter', 'switch 2')); + assert.trapped(() => battle.makeChoices('move counter', 'switch 2'), true); }); it('should not prevent Pokemon from switching out using moves', function () { diff --git a/test/simulator/misc/decisions.js b/test/simulator/misc/decisions.js index d722fc9d8e..f4eee73d38 100644 --- a/test/simulator/misc/decisions.js +++ b/test/simulator/misc/decisions.js @@ -300,11 +300,11 @@ describe('Choices', function () { it('should not force Struggle usage on move attempt when choosing a disabled move', function () { battle = common.createBattle(); battle.setPlayer('p1', {team: [{species: "Mew", item: 'assaultvest', ability: 'synchronize', moves: ['recover', 'icebeam']}]}); - battle.setPlayer('p2', {team: [{species: "Rhydon", item: '', ability: 'prankster', moves: ['struggle', 'surf']}]}); + battle.setPlayer('p2', {team: [{species: "Rhydon", item: '', ability: 'prankster', moves: ['surf']}]}); const failingAttacker = battle.p1.active[0]; - battle.p2.chooseMove(2); + battle.p2.chooseMove(1); - assert.cantMove(() => battle.p1.chooseMove(1), 'Mew', 'Recover'); + assert.cantMove(() => battle.p1.chooseMove(1), 'Mew', 'Recover', true); assert.strictEqual(battle.turn, 1); assert.notStrictEqual(failingAttacker.lastMove && failingAttacker.lastMove.id, 'struggle'); @@ -314,7 +314,7 @@ describe('Choices', function () { }); it('should send meaningful feedback to players if they try to use a disabled move', function () { - battle = common.createBattle(); + battle = common.createBattle({strictChoices: false}); battle.setPlayer('p1', {team: [{species: "Skarmory", ability: 'sturdy', moves: ['spikes', 'roost']}]}); battle.setPlayer('p2', {team: [{species: "Smeargle", ability: 'owntempo', moves: ['imprison', 'spikes']}]}); @@ -324,15 +324,14 @@ describe('Choices', function () { battle.send = (type, data) => { if (type === 'sideupdate') buffer.push(Array.isArray(data) ? data.join('\n') : data); }; - assert.cantMove(() => battle.makeChoices('move 1', 'default'), 'Skarmory', 'Spikes'); - assert(buffer.length >= 1); - assert(buffer.some(message => { - return message.startsWith('p1\n') && /\bcant\b/.test(message) && (/\|0\b/.test(message) || /\|p1a\b/.test(message)); - })); + battle.p1.chooseMove(1); + assert(buffer.length >= 2); + assert(buffer.some(message => message.startsWith('p1\n|error|[Unavailable choice]'))); + assert(buffer.some(message => message.startsWith('p1\n|request|') && JSON.parse(message.slice(12)).active[0].moves[0].disabled)); }); it('should send meaningful feedback to players if they try to switch a trapped Pokémon out', function () { - battle = common.createBattle(); + battle = common.createBattle({strictChoices: false}); battle.setPlayer('p1', {team: [ {species: "Scizor", ability: 'swarm', moves: ['bulletpunch']}, {species: "Azumarill", ability: 'sapsipper', moves: ['aquajet']}, @@ -343,11 +342,10 @@ describe('Choices', function () { battle.send = (type, data) => { if (type === 'sideupdate') buffer.push(Array.isArray(data) ? data.join('\n') : data); }; - assert.trapped(() => battle.makeChoices('switch 2', 'default')); - assert(buffer.length >= 1); - assert(buffer.some(message => { - return message.startsWith('p1\n') && /\btrapped\b/.test(message) && (/\|0\b/.test(message) || /\|p1a\b/.test(message)); - })); + battle.p1.chooseSwitch(2); + assert(buffer.length >= 2); + assert(buffer.some(message => message.startsWith('p1\n|error|[Unavailable choice]'))); + assert(buffer.some(message => message.startsWith('p1\n|request|') && JSON.parse(message.slice(12)).active[0].trapped)); }); }); diff --git a/test/simulator/moves/imprison.js b/test/simulator/moves/imprison.js index c0ebab6d1a..1dfdce43da 100644 --- a/test/simulator/moves/imprison.js +++ b/test/simulator/moves/imprison.js @@ -24,7 +24,7 @@ describe('Imprison', function () { battle.makeChoices('move imprison', 'move calmmind'); assert.statStage(battle.p2.active[0], 'spa', 0); assert.statStage(battle.p2.active[0], 'spd', 0); - assert.cantMove(() => battle.choose('p2', 'move calmmind'), 'Abra', 'Calm Mind'); + assert.cantMove(() => battle.choose('p2', 'move calmmind'), 'Abra', 'Calm Mind', true); // Imprison doesn't end when the foe switches battle.makeChoices('default', 'switch 2');