Pokemon_server / sim /battle-stream.ts
Jofthomas's picture
Jofthomas HF staff
Upload 4781 files
5c2ed06 verified
/**
* 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<string> {
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<string>({
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<string>;
readonly log: string[];
readonly debug: boolean;
constructor(playerStream: Streams.ObjectReadWriteStream<string>, 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();
}
}