/** * Battle Stream * Pokemon Showdown - http://pokemonshowdown.com/ * * Supports interacting with a PS battle in Stream format. * * This format is VERY NOT FINALIZED, please do not use it directly yet. * * @license MIT */ import { Streams, Utils } from '../lib'; import { Teams } from './teams'; import { Battle, extractChannelMessages } from './battle'; import type { ChoiceRequest } from './side'; /** * Like string.split(delimiter), but only recognizes the first `limit` * delimiters (default 1). * * `"1 2 3 4".split(" ", 2) => ["1", "2"]` * * `Utils.splitFirst("1 2 3 4", " ", 1) => ["1", "2 3 4"]` * * Returns an array of length exactly limit + 1. */ function splitFirst(str: string, delimiter: string, limit = 1) { const splitStr: string[] = []; while (splitStr.length < limit) { const delimiterIndex = str.indexOf(delimiter); if (delimiterIndex >= 0) { splitStr.push(str.slice(0, delimiterIndex)); str = str.slice(delimiterIndex + delimiter.length); } else { splitStr.push(str); str = ''; } } splitStr.push(str); return splitStr; } export class BattleStream extends Streams.ObjectReadWriteStream { debug: boolean; noCatch: boolean; replay: boolean | 'spectator'; keepAlive: boolean; battle: Battle | null; constructor(options: { debug?: boolean, noCatch?: boolean, keepAlive?: boolean, replay?: boolean | 'spectator', } = {}) { super(); this.debug = !!options.debug; this.noCatch = !!options.noCatch; this.replay = options.replay || false; this.keepAlive = !!options.keepAlive; this.battle = null; } _write(chunk: string) { if (this.noCatch) { this._writeLines(chunk); } else { try { this._writeLines(chunk); } catch (err: any) { this.pushError(err, true); return; } } if (this.battle) this.battle.sendUpdates(); } _writeLines(chunk: string) { for (const line of chunk.split('\n')) { if (line.startsWith('>')) { const [type, message] = splitFirst(line.slice(1), ' '); this._writeLine(type, message); } } } pushMessage(type: string, data: string) { if (this.replay) { if (type === 'update') { if (this.replay === 'spectator') { const channelMessages = extractChannelMessages(data, [0]); this.push(channelMessages[0].join('\n')); } else { const channelMessages = extractChannelMessages(data, [-1]); this.push(channelMessages[-1].join('\n')); } } return; } this.push(`${type}\n${data}`); } _writeLine(type: string, message: string) { switch (type) { case 'start': const options = JSON.parse(message); options.send = (t: string, data: any) => { if (Array.isArray(data)) data = data.join("\n"); this.pushMessage(t, data); if (t === 'end' && !this.keepAlive) this.pushEnd(); }; if (this.debug) options.debug = true; this.battle = new Battle(options); break; case 'player': const [slot, optionsText] = splitFirst(message, ' '); this.battle!.setPlayer(slot as SideID, JSON.parse(optionsText)); break; case 'p1': case 'p2': case 'p3': case 'p4': if (message === 'undo') { this.battle!.undoChoice(type); } else { this.battle!.choose(type, message); } break; case 'forcewin': case 'forcetie': this.battle!.win(type === 'forcewin' ? message as SideID : null); if (message) { this.battle!.inputLog.push(`>forcewin ${message}`); } else { this.battle!.inputLog.push(`>forcetie`); } break; case 'forcelose': this.battle!.lose(message as SideID); this.battle!.inputLog.push(`>forcelose ${message}`); break; case 'reseed': this.battle!.resetRNG(message as PRNGSeed); // could go inside resetRNG, but this makes using it in `eval` slightly less buggy this.battle!.inputLog.push(`>reseed ${this.battle!.prng.getSeed()}`); break; case 'tiebreak': this.battle!.tiebreak(); break; case 'chat-inputlogonly': this.battle!.inputLog.push(`>chat ${message}`); break; case 'chat': this.battle!.inputLog.push(`>chat ${message}`); this.battle!.add('chat', `${message}`); break; case 'eval': const battle = this.battle!; // n.b. this will usually but not always work - if you eval code that also affects the inputLog, // replaying the inputlog would double-play the change. battle.inputLog.push(`>${type} ${message}`); message = message.replace(/\f/g, '\n'); battle.add('', '>>> ' + message.replace(/\n/g, '\n||')); try { /* eslint-disable no-eval, @typescript-eslint/no-unused-vars */ const p1 = battle.sides[0]; const p2 = battle.sides[1]; const p3 = battle.sides[2]; const p4 = battle.sides[3]; const p1active = p1?.active[0]; const p2active = p2?.active[0]; const p3active = p3?.active[0]; const p4active = p4?.active[0]; const toID = battle.toID; const player = (input: string) => { input = toID(input); if (/^p[1-9]$/.test(input)) return battle.sides[parseInt(input.slice(1)) - 1]; if (/^[1-9]$/.test(input)) return battle.sides[parseInt(input) - 1]; for (const side of battle.sides) { if (toID(side.name) === input) return side; } return null; }; const pokemon = (side: string | Side, input: string) => { if (typeof side === 'string') side = player(side)!; input = toID(input); if (/^[1-9]$/.test(input)) return side.pokemon[parseInt(input) - 1]; return side.pokemon.find(p => p.baseSpecies.id === input || p.species.id === input); }; let result = eval(message); /* eslint-enable no-eval, @typescript-eslint/no-unused-vars */ if (result?.then) { result.then((unwrappedResult: any) => { unwrappedResult = Utils.visualize(unwrappedResult); battle.add('', 'Promise -> ' + unwrappedResult); battle.sendUpdates(); }, (error: Error) => { battle.add('', '<<< error: ' + error.message); battle.sendUpdates(); }); } else { result = Utils.visualize(result); result = result.replace(/\n/g, '\n||'); battle.add('', '<<< ' + result); } } catch (e: any) { battle.add('', '<<< error: ' + e.message); } break; case 'requestlog': this.push(`requesteddata\n${this.battle!.inputLog.join('\n')}`); break; case 'requestexport': this.push(`requesteddata\n${this.battle!.prngSeed}\n${this.battle!.inputLog.join('\n')}`); break; case 'requestteam': message = message.trim(); const slotNum = parseInt(message.slice(1)) - 1; if (isNaN(slotNum) || slotNum < 0) { throw new Error(`Team requested for slot ${message}, but that slot does not exist.`); } const side = this.battle!.sides[slotNum]; const team = Teams.pack(side.team); this.push(`requesteddata\n${team}`); break; case 'show-openteamsheets': this.battle!.showOpenTeamSheets(); break; case 'version': case 'version-origin': break; default: throw new Error(`Unrecognized command ">${type} ${message}"`); } } _writeEnd() { // if battle already ended, we don't need to pushEnd. if (!this.atEOF) this.pushEnd(); this._destroy(); } _destroy() { if (this.battle) this.battle.destroy(); } } /** * Splits a BattleStream into omniscient, spectator, p1, p2, p3 and p4 * streams, for ease of consumption. */ export function getPlayerStreams(stream: BattleStream) { const streams = { omniscient: new Streams.ObjectReadWriteStream({ write(data: string) { void stream.write(data); }, writeEnd() { return stream.writeEnd(); }, }), spectator: new Streams.ObjectReadStream({ read() {}, }), p1: new Streams.ObjectReadWriteStream({ write(data: string) { void stream.write(data.replace(/(^|\n)/g, `$1>p1 `)); }, }), p2: new Streams.ObjectReadWriteStream({ write(data: string) { void stream.write(data.replace(/(^|\n)/g, `$1>p2 `)); }, }), p3: new Streams.ObjectReadWriteStream({ write(data: string) { void stream.write(data.replace(/(^|\n)/g, `$1>p3 `)); }, }), p4: new Streams.ObjectReadWriteStream({ write(data: string) { void stream.write(data.replace(/(^|\n)/g, `$1>p4 `)); }, }), }; (async () => { for await (const chunk of stream) { const [type, data] = splitFirst(chunk, `\n`); switch (type) { case 'update': const channelMessages = extractChannelMessages(data, [-1, 0, 1, 2, 3, 4]); streams.omniscient.push(channelMessages[-1].join('\n')); streams.spectator.push(channelMessages[0].join('\n')); streams.p1.push(channelMessages[1].join('\n')); streams.p2.push(channelMessages[2].join('\n')); streams.p3.push(channelMessages[3].join('\n')); streams.p4.push(channelMessages[4].join('\n')); break; case 'sideupdate': const [side, sideData] = splitFirst(data, `\n`); streams[side as SideID].push(sideData); break; case 'end': // ignore break; } } for (const s of Object.values(streams)) { s.pushEnd(); } })().catch(err => { for (const s of Object.values(streams)) { s.pushError(err, true); } }); return streams; } export abstract class BattlePlayer { readonly stream: Streams.ObjectReadWriteStream; readonly log: string[]; readonly debug: boolean; constructor(playerStream: Streams.ObjectReadWriteStream, debug = false) { this.stream = playerStream; this.log = []; this.debug = debug; } async start() { for await (const chunk of this.stream) { this.receive(chunk); } } receive(chunk: string) { for (const line of chunk.split('\n')) { this.receiveLine(line); } } receiveLine(line: string) { if (this.debug) console.log(line); if (!line.startsWith('|')) return; const [cmd, rest] = splitFirst(line.slice(1), '|'); 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: ChoiceRequest): void; receiveError(error: Error) { throw error; } choose(choice: string) { void this.stream.write(choice); } } export class BattleTextStream extends Streams.ReadWriteStream { readonly battleStream: BattleStream; currentMessage: string; constructor(options: { debug?: boolean }) { super(); this.battleStream = new BattleStream(options); this.currentMessage = ''; void this._listen(); } async _listen() { for await (let message of this.battleStream) { if (!message.endsWith('\n')) message += '\n'; this.push(message + '\n'); } this.pushEnd(); } _write(message: string | Buffer) { this.currentMessage += `${message}`; const index = this.currentMessage.lastIndexOf('\n'); if (index >= 0) { void this.battleStream.write(this.currentMessage.slice(0, index)); this.currentMessage = this.currentMessage.slice(index + 1); } } _writeEnd() { return this.battleStream.writeEnd(); } }