mirror of
https://github.com/smogon/pokemon-showdown.git
synced 2026-03-21 17:25:10 -05:00
237 lines
6.8 KiB
TypeScript
237 lines
6.8 KiB
TypeScript
/**
|
|
* Battle Simulator runner.
|
|
* Pokemon Showdown - http://pokemonshowdown.com/
|
|
*
|
|
* @license MIT
|
|
*/
|
|
|
|
import { strict as assert } from 'assert';
|
|
import * as fs from 'fs';
|
|
|
|
import { Dex } from '..';
|
|
import { type ObjectReadWriteStream } from '../../lib/streams';
|
|
import { Battle } from '../battle';
|
|
import * as BattleStreams from '../battle-stream';
|
|
import { State } from '../state';
|
|
import { PRNG, type PRNGSeed } from '../prng';
|
|
import { RandomPlayerAI } from './random-player-ai';
|
|
|
|
export interface AIOptions {
|
|
createAI: (stream: ObjectReadWriteStream<string>, options: AIOptions) => RandomPlayerAI;
|
|
move?: number;
|
|
mega?: number;
|
|
seed?: PRNG | PRNGSeed | null;
|
|
team?: PokemonSet[];
|
|
}
|
|
|
|
export interface RunnerOptions {
|
|
format: string;
|
|
prng?: PRNG | PRNGSeed | null;
|
|
p1options?: AIOptions;
|
|
p2options?: AIOptions;
|
|
p3options?: AIOptions;
|
|
p4options?: AIOptions;
|
|
input?: boolean;
|
|
output?: boolean;
|
|
error?: boolean;
|
|
dual?: boolean | 'debug';
|
|
}
|
|
|
|
export class Runner {
|
|
static readonly AI_OPTIONS: AIOptions = {
|
|
createAI: (s: ObjectReadWriteStream<string>, o: AIOptions) => new RandomPlayerAI(s, o),
|
|
move: 0.7,
|
|
mega: 0.6,
|
|
};
|
|
|
|
private readonly prng: PRNG;
|
|
private readonly p1options: AIOptions;
|
|
private readonly p2options: AIOptions;
|
|
private readonly p3options: AIOptions;
|
|
private readonly p4options: AIOptions;
|
|
private readonly format: string;
|
|
private readonly input: boolean;
|
|
private readonly output: boolean;
|
|
private readonly error: boolean;
|
|
private readonly dual: boolean | 'debug';
|
|
|
|
constructor(options: RunnerOptions) {
|
|
this.format = options.format;
|
|
|
|
this.prng = PRNG.get(options.prng);
|
|
this.p1options = { ...Runner.AI_OPTIONS, ...options.p1options };
|
|
this.p2options = { ...Runner.AI_OPTIONS, ...options.p2options };
|
|
this.p3options = { ...Runner.AI_OPTIONS, ...options.p3options };
|
|
this.p4options = { ...Runner.AI_OPTIONS, ...options.p4options };
|
|
|
|
this.input = !!options.input;
|
|
this.output = !!options.output;
|
|
this.error = !!options.error;
|
|
this.dual = options.dual || false;
|
|
}
|
|
|
|
async run() {
|
|
const battleStream = this.dual ?
|
|
new DualStream(this.input, this.dual === 'debug') :
|
|
new RawBattleStream(this.input);
|
|
const game = this.runGame(this.format, battleStream);
|
|
if (!this.error) return game;
|
|
return game.catch(err => {
|
|
console.log(`\n${battleStream.rawInputLog.join('\n')}\n`);
|
|
throw err;
|
|
});
|
|
}
|
|
|
|
private async runGame(format: string, battleStream: RawBattleStream | DualStream) {
|
|
// @ts-expect-error - DualStream implements everything relevant from BattleStream.
|
|
const streams = BattleStreams.getPlayerStreams(battleStream);
|
|
const spec = { formatid: format, seed: this.prng.getSeed() };
|
|
const is4P = Dex.formats.get(format).playerCount > 2;
|
|
const p1spec = this.getPlayerSpec("Bot 1", this.p1options);
|
|
const p2spec = this.getPlayerSpec("Bot 2", this.p2options);
|
|
let p3spec: typeof p1spec, p4spec: typeof p1spec;
|
|
if (is4P) {
|
|
p3spec = this.getPlayerSpec("Bot 3", this.p3options);
|
|
p4spec = this.getPlayerSpec("Bot 4", this.p4options);
|
|
}
|
|
|
|
const p1 = this.p1options.createAI(
|
|
streams.p1, { seed: this.newSeed(), ...this.p1options }
|
|
);
|
|
const p2 = this.p2options.createAI(
|
|
streams.p2, { seed: this.newSeed(), ...this.p2options }
|
|
);
|
|
let p3: RandomPlayerAI, p4: RandomPlayerAI;
|
|
if (is4P) {
|
|
p3 = this.p4options.createAI(
|
|
streams.p3, { seed: this.newSeed(), ...this.p3options }
|
|
);
|
|
p4 = this.p4options.createAI(
|
|
streams.p4, { seed: this.newSeed(), ...this.p4options }
|
|
);
|
|
}
|
|
// TODO: Use `await Promise.race([streams.omniscient.read(), p1, p2])` to avoid
|
|
// leaving these promises dangling once it no longer causes memory leaks (v8#9069).
|
|
void p1.start();
|
|
void p2.start();
|
|
if (is4P) {
|
|
void p3!.start();
|
|
void p4!.start();
|
|
}
|
|
|
|
let initMessage = `>start ${JSON.stringify(spec)}\n` +
|
|
`>player p1 ${JSON.stringify(p1spec)}\n` +
|
|
`>player p2 ${JSON.stringify(p2spec)}`;
|
|
if (is4P) {
|
|
initMessage += `\n` +
|
|
`>player p3 ${JSON.stringify(p3spec!)}\n` +
|
|
`>player p4 ${JSON.stringify(p4spec!)}`;
|
|
}
|
|
void streams.omniscient.write(initMessage);
|
|
|
|
for await (const chunk of streams.omniscient) {
|
|
if (this.output) console.log(chunk);
|
|
}
|
|
return streams.omniscient.writeEnd();
|
|
}
|
|
|
|
// Same as PRNG#generatedSeed, only deterministic.
|
|
// NOTE: advances this.prng's seed by 4.
|
|
private newSeed(): PRNGSeed {
|
|
return [
|
|
this.prng.random(2 ** 16),
|
|
this.prng.random(2 ** 16),
|
|
this.prng.random(2 ** 16),
|
|
this.prng.random(2 ** 16),
|
|
].join(',') as PRNGSeed;
|
|
}
|
|
|
|
private getPlayerSpec(name: string, options: AIOptions) {
|
|
if (options.team) return { name, team: options.team };
|
|
return { name, seed: this.newSeed() };
|
|
}
|
|
}
|
|
|
|
class RawBattleStream extends BattleStreams.BattleStream {
|
|
readonly rawInputLog: string[];
|
|
|
|
private readonly input: boolean;
|
|
|
|
constructor(input: boolean) {
|
|
super();
|
|
this.input = !!input;
|
|
this.rawInputLog = [];
|
|
}
|
|
|
|
override _write(message: string) {
|
|
if (this.input) console.log(message);
|
|
this.rawInputLog.push(message);
|
|
super._write(message);
|
|
}
|
|
}
|
|
|
|
class DualStream {
|
|
private debug: boolean;
|
|
private readonly control: RawBattleStream;
|
|
private test: RawBattleStream;
|
|
|
|
constructor(input: boolean, debug: boolean) {
|
|
this.debug = debug;
|
|
// The input to both streams should be the same, so to satisfy the
|
|
// input flag we only need to track the raw input of one stream.
|
|
this.control = new RawBattleStream(input);
|
|
this.test = new RawBattleStream(false);
|
|
}
|
|
|
|
get rawInputLog() {
|
|
const control = this.control.rawInputLog;
|
|
const test = this.test.rawInputLog;
|
|
assert.deepEqual(test, control);
|
|
return control;
|
|
}
|
|
|
|
async read() {
|
|
const control = await this.control.read();
|
|
const test = await this.test.read();
|
|
// In debug mode, wait to catch this as a difference in the inputLog
|
|
// and error there so we get the full battle state dumped instead.
|
|
if (!this.debug) assert.equal(State.normalizeLog(test), State.normalizeLog(control));
|
|
return control;
|
|
}
|
|
|
|
write(message: string) {
|
|
this.control._write(message);
|
|
this.test._write(message);
|
|
this.compare();
|
|
}
|
|
|
|
writeEnd() {
|
|
// We need to compare first because _writeEnd() destroys the battle object.
|
|
this.compare(true);
|
|
this.control._writeEnd();
|
|
this.test._writeEnd();
|
|
}
|
|
|
|
compare(end?: boolean) {
|
|
if (!this.control.battle || !this.test.battle) return;
|
|
|
|
const control = this.control.battle.toJSON();
|
|
const test = this.test.battle.toJSON();
|
|
try {
|
|
assert.deepEqual(State.normalize(test), State.normalize(control));
|
|
} catch (err: any) {
|
|
if (this.debug) {
|
|
// NOTE: diffing these directly won't work because the key ordering isn't stable.
|
|
fs.writeFileSync('logs/control.json', JSON.stringify(control, null, 2));
|
|
fs.writeFileSync('logs/test.json', JSON.stringify(test, null, 2));
|
|
}
|
|
throw new Error(err.message);
|
|
}
|
|
|
|
if (end) return;
|
|
const send = this.test.battle.send;
|
|
this.test.battle = Battle.fromJSON(test);
|
|
this.test.battle.restart(send);
|
|
}
|
|
}
|