diff --git a/config/formats.js b/config/formats.js index 525900328e..52e31feff4 100644 --- a/config/formats.js +++ b/config/formats.js @@ -269,7 +269,7 @@ let Formats = [ canUseRandomTeam: true, debug: true, maxLevel: 9999, - onBegin() { this.trunc = n => Math.trunc(n); }, + trunc(n) { return Math.trunc(n); }, defaultLevel: 100, teamLength: { validate: [1, 24], @@ -552,7 +552,7 @@ let Formats = [ searchShow: false, canUseRandomTeam: true, maxLevel: 9999, - onBegin() { this.trunc = n => Math.trunc(n); }, + trunc(n) { return Math.trunc(n); }, defaultLevel: 100, debug: true, teamLength: { @@ -1435,7 +1435,7 @@ let Formats = [ canUseRandomTeam: true, debug: true, maxLevel: 9999, - onBegin() { this.trunc = n => Math.trunc(n); }, + trunc(n) { return Math.trunc(n); }, defaultLevel: 100, // no restrictions, for serious (other than team preview) ruleset: ['Team Preview', 'Cancel Mod'], @@ -1517,7 +1517,7 @@ let Formats = [ searchShow: false, canUseRandomTeam: true, maxLevel: 9999, - onBegin() { this.trunc = n => Math.trunc(n); }, + trunc(n) { return Math.trunc(n); }, defaultLevel: 100, debug: true, // no restrictions, for serious (other than team preview) @@ -1548,7 +1548,7 @@ let Formats = [ searchShow: false, canUseRandomTeam: true, maxLevel: 9999, - onBegin() { this.trunc = n => Math.trunc(n); }, + trunc(n) { return Math.trunc(n); }, defaultLevel: 100, debug: true, // no restrictions, for serious (other than team preview) @@ -1642,7 +1642,7 @@ let Formats = [ canUseRandomTeam: true, debug: true, maxLevel: 9999, - onBegin() { this.trunc = n => Math.trunc(n); }, + trunc(n) { return Math.trunc(n); }, defaultLevel: 100, // no restrictions, for serious (other than team preview) ruleset: ['Team Preview', 'Cancel Mod'], @@ -1691,7 +1691,7 @@ let Formats = [ canUseRandomTeam: true, debug: true, maxLevel: 9999, - onBegin() { this.trunc = n => Math.trunc(n); }, + trunc(n) { return Math.trunc(n); }, defaultLevel: 100, // no restrictions, for serious (other than team preview) ruleset: ['Team Preview', 'Cancel Mod'], @@ -1770,7 +1770,7 @@ let Formats = [ canUseRandomTeam: true, debug: true, maxLevel: 9999, - onBegin() { this.trunc = n => Math.trunc(n); }, + trunc(n) { return Math.trunc(n); }, defaultLevel: 100, // no restrictions ruleset: ['Cancel Mod'], @@ -1803,7 +1803,7 @@ let Formats = [ canUseRandomTeam: true, debug: true, maxLevel: 9999, - onBegin() { this.trunc = n => Math.trunc(n); }, + trunc(n) { return Math.trunc(n); }, defaultLevel: 100, // no restrictions ruleset: ['Cancel Mod'], diff --git a/data/abilities.js b/data/abilities.js index 230c73aa36..d6b22e8066 100644 --- a/data/abilities.js +++ b/data/abilities.js @@ -2132,7 +2132,7 @@ let BattleAbilities = { // only reset .showCure if it's false // (once you know a Pokemon has Natural Cure, its cures are always known) - if (!pokemon.showCure) delete pokemon.showCure; + if (!pokemon.showCure) pokemon.showCure = undefined; }, id: "naturalcure", name: "Natural Cure", diff --git a/data/mods/gen1/scripts.js b/data/mods/gen1/scripts.js index 3645c88647..809b3e6fb2 100644 --- a/data/mods/gen1/scripts.js +++ b/data/mods/gen1/scripts.js @@ -92,7 +92,7 @@ let BattleScripts = { runMove(moveOrMoveName, pokemon, targetLoc, sourceEffect) { let target = this.getTarget(pokemon, moveOrMoveName, targetLoc); let move = this.getActiveMove(moveOrMoveName); - if (target && target.subFainted) delete target.subFainted; + if (target && target.subFainted) target.subFainted = null; this.setActiveMove(move, pokemon, target); diff --git a/data/mods/stadium/scripts.js b/data/mods/stadium/scripts.js index 62d575e769..67fae8b631 100644 --- a/data/mods/stadium/scripts.js +++ b/data/mods/stadium/scripts.js @@ -81,7 +81,7 @@ let BattleScripts = { runMove(moveOrMoveName, pokemon, targetLoc, sourceEffect) { let move = this.getActiveMove(moveOrMoveName); let target = this.getTarget(pokemon, move, targetLoc); - if (target && target.subFainted) delete target.subFainted; + if (target && target.subFainted) target.subFainted = null; this.setActiveMove(move, pokemon, target); diff --git a/sim/battle.ts b/sim/battle.ts index a2c6be5432..68d2a80b75 100644 --- a/sim/battle.ts +++ b/sim/battle.ts @@ -11,6 +11,7 @@ import {Field} from './field'; import {Pokemon} from './pokemon'; import {PRNG, PRNGSeed} from './prng'; import {Side} from './side'; +import {State} from './state'; /** A Pokemon that has fainted. */ interface FaintedPokemon { @@ -30,6 +31,7 @@ interface BattleOptions { p3?: PlayerOptions; // Player 3 data p4?: PlayerOptions; // Player 4 data debug?: boolean; // show debug mode option + deserialized?: boolean; strictChoices?: boolean; // whether invalid choices should throw } @@ -48,6 +50,7 @@ export type RequestState = 'teampreview' | 'move' | 'switch' | ''; export class Battle extends Dex.ModdedDex { readonly id: ''; readonly debugMode: boolean; + readonly deserialized: boolean; readonly strictChoices: boolean; readonly format: string; readonly formatData: AnyObject; @@ -118,6 +121,7 @@ export class Battle extends Dex.ModdedDex { this.id = ''; this.debugMode = format.debug || !!options.debug; + this.deserialized = !!options.deserialized; this.strictChoices = !!options.strictChoices; this.format = format.id; this.formatData = {id: format.id}; @@ -207,6 +211,14 @@ export class Battle extends Dex.ModdedDex { } } + toJSON(): AnyObject { + return State.serializeBattle(this); + } + + static fromJSON(serialized: string | AnyObject): Battle { + return State.deserializeBattle(serialized); + } + get p1() { return this.sides[0]; } @@ -1020,12 +1032,34 @@ export class Battle extends Dex.ModdedDex { type = this.requestState; } - // default to no request - const requests: any[] = Array(this.sides.length).fill(null); for (const side of this.sides) { side.activeRequest = null; } + const maxTeamSize = this.getMaxTeamSize(); + if (type === 'teampreview') { + this.add('teampreview' + (maxTeamSize !== 6 ? '|' + maxTeamSize : '')); + } + + const requests = this.getRequests(type, maxTeamSize); + for (let i = 0; i < this.sides.length; i++) { + this.sides[i].emitRequest(requests[i]); + } + + if (this.sides.every(side => side.isChoiceDone())) { + throw new Error(`Choices are done immediately after a request`); + } + } + + getMaxTeamSize() { + const teamLengthData = this.getFormat().teamLength; + return (teamLengthData && teamLengthData.battle) || 6; + } + + getRequests(type: RequestState, maxTeamSize: number) { + // default to no request + const requests: any[] = Array(this.sides.length).fill(null); + switch (type) { case 'switch': { for (let i = 0; i < this.sides.length; i++) { @@ -1042,10 +1076,6 @@ export class Battle extends Dex.ModdedDex { } case 'teampreview': - const teamLengthData = this.getFormat().teamLength; - let maxTeamSize = teamLengthData && teamLengthData.battle; - this.add('teampreview' + (maxTeamSize ? '|' + maxTeamSize : '')); - if (!maxTeamSize) maxTeamSize = 6; for (let i = 0; i < this.sides.length; i++) { const side = this.sides[i]; side.maxTeamSize = maxTeamSize; @@ -1065,19 +1095,14 @@ export class Battle extends Dex.ModdedDex { const allRequestsMade = requests.every(request => request); for (let i = 0; i < this.sides.length; i++) { - const side = this.sides[i]; - const request = requests[i]; - if (request) { - if (!this.supportCancel || !allRequestsMade) request.noCancel = true; - side.emitRequest(request); + if (requests[i]) { + if (!this.supportCancel || !allRequestsMade) requests[i].noCancel = true; } else { - side.emitRequest({wait: true, side: side.getRequestData()}); + requests[i] = {wait: true, side: this.sides[i].getRequestData()}; } } - if (this.sides.every(side => side.isChoiceDone())) { - throw new Error(`Choices are done immediately after a request`); - } + return requests; } tiebreak() { @@ -1544,6 +1569,8 @@ export class Battle extends Dex.ModdedDex { } start() { + // deserialized should use restart instead + if (this.deserialized) return; // need all players to start if (!this.sides.every(side => !!side)) return; @@ -1568,9 +1595,8 @@ export class Battle extends Dex.ModdedDex { this.add('rated', typeof this.rated === 'string' ? this.rated : ''); } - if (format.onBegin) { - format.onBegin.call(this); - } + if (format.onBegin) format.onBegin.call(this); + if (format.trunc) this.trunc = format.trunc; for (const rule of this.getRuleTable(format).keys()) { if (rule.startsWith('+') || rule.startsWith('-') || rule.startsWith('!')) continue; const subFormat = this.getFormat(rule); @@ -1590,6 +1616,16 @@ export class Battle extends Dex.ModdedDex { if (!this.requestState) this.go(); } + restart(send?: (type: string, data: string | string[]) => void) { + if (!this.deserialized) throw new Error('Attempt to restart a battle which has not been deserialized'); + + const format = this.getFormat(); + if (format.trunc) this.trunc = format.trunc; + + // @ts-ignore - readonly + this.send = send; + } + boost( boost: SparseBoostsTable, target: Pokemon | null = null, source: Pokemon | null = null, effect: Effect | null = null, isSecondary: boolean = false, isSelf: boolean = false) { @@ -2667,7 +2703,7 @@ export class Battle extends Dex.ModdedDex { foeActive.removeVolatile('substitutebroken'); } } - delete action.pokemon.draggedIn; + action.pokemon.draggedIn = null; break; case 'runPrimal': if (!action.pokemon.transformed) { @@ -3004,7 +3040,7 @@ export class Battle extends Dex.ModdedDex { const format = this.getFormat(); let team = options.team; if (typeof team === 'string') team = Dex.fastUnpackTeam(team); - if (!format.team && team) return team; + if ((!format.team || this.deserialized) && team) return team; if (!options.seed) { options.seed = PRNG.generateSeed(); diff --git a/sim/field.ts b/sim/field.ts index 01f6823684..cf49f19f1c 100644 --- a/sim/field.ts +++ b/sim/field.ts @@ -4,8 +4,12 @@ * * @license MIT */ + +import {State} from './state'; + export class Field { readonly battle: Battle; + readonly id: ''; weather: string; weatherData: AnyObject; @@ -15,6 +19,7 @@ export class Field { constructor(battle: Battle) { this.battle = battle; + this.id = ''; this.weather = ''; this.weatherData = {id: ''}; @@ -23,6 +28,10 @@ export class Field { this.pseudoWeather = {}; } + toJSON(): AnyObject { + return State.serializeField(this); + } + setWeather(status: string | PureEffect, source: Pokemon | 'debug' | null = null, sourceEffect: Effect | null = null) { status = this.battle.getEffect(status); if (!sourceEffect && this.battle.effect) sourceEffect = this.battle.effect; diff --git a/sim/globals.ts b/sim/globals.ts index 10abfec668..85fefc1e34 100644 --- a/sim/globals.ts +++ b/sim/globals.ts @@ -1054,6 +1054,7 @@ interface FormatsData extends EventMethods { onValidateTeam?: (this: ModdedDex, team: PokemonSet[], format: Format, teamHas: AnyObject) => string[] | void validateSet?: (this: Validator, set: PokemonSet, teamHas: AnyObject) => string[] | void validateTeam?: (this: Validator, team: PokemonSet[], removeNicknames: boolean) => string[] | void, + trunc?: (n: number) => number; section?: string, column?: number } diff --git a/sim/pokemon.ts b/sim/pokemon.ts index 48556a05b5..c2bb7f1380 100644 --- a/sim/pokemon.ts +++ b/sim/pokemon.ts @@ -5,6 +5,8 @@ * @license MIT license */ +import {State} from './state'; + /** A Pokemon's move slot. */ interface MoveSlot { id: string; @@ -53,7 +55,7 @@ export class Pokemon { status: string; statusData: AnyObject; volatiles: AnyObject; - showCure: boolean; + showCure?: boolean; /** * These are the basic stats that appear on the in-game stats screen: @@ -384,6 +386,10 @@ export class Pokemon { this.m = {}; } + toJSON(): AnyObject { + return State.serializePokemon(this); + } + get moves(): readonly string[] { return this.moveSlots.map(moveSlot => moveSlot.id); } diff --git a/sim/side.ts b/sim/side.ts index ee629b9cb3..8f45734f9b 100644 --- a/sim/side.ts +++ b/sim/side.ts @@ -6,6 +6,7 @@ */ import {RequestState} from './battle'; import {Pokemon} from './pokemon'; +import {State} from './state'; /** A single action that can be chosen. */ interface ChosenAction { @@ -13,6 +14,7 @@ interface ChosenAction { pokemon?: Pokemon; // the pokemon doing the action targetLoc?: number; // relative location of the target to pokemon (move action only) moveid: string; // a move to use (move action only) + move?: ActiveMove; // the active move corresponding to moveid (move action only) target?: Pokemon; // the target of the action index?: number; // the chosen index in Team Preview side?: Side; // the action's side @@ -22,7 +24,7 @@ interface ChosenAction { } /** What the player has chosen to happen. */ -interface Choice { +export interface Choice { cantUndo: boolean; // true if the choice can't be cancelled because of the maybeTrapped issue error: string; // contains error text in the case of a choice error actions: ChosenAction[]; // array of chosen actions @@ -121,6 +123,10 @@ export class Side { 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'; diff --git a/sim/state.ts b/sim/state.ts new file mode 100644 index 0000000000..ea24baeec2 --- /dev/null +++ b/sim/state.ts @@ -0,0 +1,415 @@ +/** + * Simulator State + * Pokemon Showdown - http://pokemonshowdown.com/ + * + * @license MIT + */ + +import {Battle} from './battle'; +import Dex = require('./dex'); +import * as Data from './dex-data'; +import {Field} from './field'; +import {Pokemon} from './pokemon'; +import {PRNG} from './prng'; +import {Choice, Side} from './side'; + +// The simulator supports up to 24 different Pokemon on a team. Serialization +// uses letters instead of numbers to indicate indices/positions, but where +// the simulator only gives a position to active Pokemon, serialization +// uses letters for every Pokemon on a team. Active pokemon will still +// have the same letter as their position would indicate, but non-active +// team members are filled in with subsequent letters. +const POSITIONS = 'abcdefghijklmnopqrstuvwx'; + +// Several types we serialize as 'references' in the form '[Type]' because +// they are either circular or they are (or at least, should be) immutable +// and thus can simply be reconsituted as needed. +// NOTE: Template is not strictly immutable as some OM formats rely on an +// onModifyTemplate event - deserialization is not possible for such formats. +type Referable = Battle | Field | Side | Pokemon | PureEffect | Ability | Item | Move | Template; + +// Certain fields are either redundant (transient caches, constants, duplicate +// information) or require special treatment. These sets contain the specific +// keys which we skip during default (de)serialization and (the keys which) +// need special treatment from these sets are then handled manually. + +// Battle inherits from Dex, but all of Dex's fields are redundant - we can +// just recreate the Dex from the format. +const BATTLE = new Set([ + ...Object.keys(Dex), 'id', 'log', 'inherit', 'cachedFormat', + 'zMoveTable', 'teamGenerator', 'NOT_FAIL', 'FAIL', 'SILENT_FAIL', + 'field', 'sides', 'prng', 'hints', 'deserialized', +]); +const FIELD = new Set(['id', 'battle']); +const SIDE = new Set(['battle', 'team', 'pokemon', 'choice', 'activeRequest']); +const POKEMON = new Set([ + 'side', 'battle', 'set', 'name', 'fullname', 'id', 'species', + 'speciesid', 'happiness', 'level', 'pokeball', 'baseMoveSlots', +]); +const CHOICE = new Set(['switchIns']); +const ACTIVE_MOVE = new Set(['move']); + +export const State = new class { + // REFERABLE is used to determine which objects are of the Referable type by + // comparing their constructors. Unfortunately, we need to set this dynamically + // due to circular module dependencies on Battle and Field instead + // of simply initializing it as a const. See isReferable for where this + // gets lazily created on demand. + // tslint:disable-next-line: ban-types + private REFERABLE?: Set; + + serializeBattle(battle: Battle): /* Battle */ AnyObject { + const state: /* Battle */ AnyObject = this.serialize(battle, BATTLE, battle); + state.field = this.serializeField(battle.field); + state.sides = new Array(battle.sides.length); + for (const [i, side] of battle.sides.entries()) { + state.sides[i] = this.serializeSide(side); + } + state.prng = battle.prng.seed; + state.hints = Array.from(battle.hints); + // We treat log specially because we only set it back on Battle after everything + // else has been deserialized to avoid anything accidentally `add`-ing to it. + state.log = battle.log; + return state; + } + + // Deserialization can only really be done on the root Battle object as + // the leaf nodes like Side or Pokemon contain backreferences to Battle + // but don't contain the information to fill it in because the cycles in + // the graph have been serialized as references. Once deserialzized, the + // Battle can then be restarted (and provided with a `send` function for + // receiving updates). + deserializeBattle(serialized: string | /* Battle */ AnyObject): Battle { + const state: /* Battle */ AnyObject = + typeof serialized === 'string' ? JSON.parse(serialized) : serialized; + const options = { + formatid: state.format, + seed: state.prngSeed, + rated: state.rated, + debug: state.debugMode, + // We need to tell the Battle that we're creating that it's been + // deserialized so that it allows us to populate it correctly and + // doesn't attempt to start playing out until we're ready. + deserialized: true, + strictChoices: state.strictChoices, + }; + for (const side of state.sides) { + // When we instantiate the Battle again we need the pokemon to be in + // the correct order they were in at the start of the Battle which was + // serialized. See serializeSide below for an explanation about the + // encoding format used deserializeSide for where we reorder the Side's + // pokemon to match their ordering at the point of serialization. + const team = side.team.split(side.team.length > 9 ? ',' : ''); + // @ts-ignore - index signature + options[side.id] = { + name: side.name, + avatar: side.avatar, + team: team.map((p: string) => side.pokemon[Number(p) - 1].set), + }; + } + // We create the Battle, allowing it to instantiate the Field/Side/Pokemon + // objects for us. The objects it creates will be incorrect, but we descend + // down through the fields and repopulate all of the objects with the + // correct state afterwards. + const battle = new Battle(options); + // Calling `new Battle(...)` means side.pokemon is ordered to match what it + // was at the start of the battle (state.team), but we need to order the Pokemon + // back in their correct order based on how the battle has progressed. We need + // do to this before making any deserialization calls so that `fromRef` will + // be correct. + for (const [i, s] of state.sides.entries()) { + const side = battle.sides[i]; + const ordered = new Array(side.pokemon.length); + const team = s.team.split(s.team.length > 9 ? ',' : ''); + for (const [j, pos] of team.entries()) { + ordered[Number(pos) - 1] = side.pokemon[j]; + } + side.pokemon = ordered; + } + this.deserialize(state, battle, BATTLE, battle); + this.deserializeField(state.field, battle.field); + let activeRequests = false; + for (const [i, side] of state.sides.entries()) { + this.deserializeSide(side, battle.sides[i]); + activeRequests = activeRequests || side.activeRequest === undefined; + } + // Since battle.getRequests depends on the state of each side we can't combine + // this loop with the one above which deserializes the sides. We also only do this + // if there are any active requests, not only to avoid have to recompute request + // states we wouldnt be using, but also because battle.getRequests will mutate + // state on occasion (eg. `pokemon.getMoves` sets `pokemon.trapped = true` if locked). + if (activeRequests) { + const requests = battle.getRequests(battle.requestState, battle.getMaxTeamSize()); + for (const [i, side] of state.sides.entries()) { + battle.sides[i].activeRequest = side.activeRequest === null ? null : requests[i]; + } + } + battle.prng = new PRNG(state.prng); + // @ts-ignore - readonly + battle.hints = new Set(state.hints); + // @ts-ignore - readonly + battle.log = state.log; + return battle; + } + + serializeField(field: Field): /* Field */ AnyObject { + return this.serialize(field, FIELD, field.battle); + } + + private deserializeField(state: /* Field */ AnyObject, field: Field) { + this.deserialize(state, field, FIELD, field.battle); + } + + serializeSide(side: Side): /* Side */ AnyObject { + const state: /* Side */ AnyObject = this.serialize(side, SIDE, side.battle); + state.pokemon = new Array(side.pokemon.length); + const team = new Array(side.pokemon.length); + for (const [i, pokemon] of side.pokemon.entries()) { + state.pokemon[i] = this.serializePokemon(pokemon); + team[side.team.indexOf(pokemon.set)] = i + 1; + } + // We encode the team such that it could be used as a valid `/team` command + // during decoding to transform the current ordering of the serialized Side's + // pokemon array into the original team ordering at the start of the battle. + // This is *not* the same as the original `/team` command used to order the + // pokemon in team preview, but this encoding results in the most intuitive + // and readable debugging of the raw JSON, so we're willing to add a small + // amount of complexity to the encoding/decoding process to accommodate this. + state.team = team.join(team.length > 9 ? ',' : ''); + state.choice = this.serializeChoice(side.choice, side.battle); + // If activeRequest is null we encode it as a tombstone indicator to ensure + // that during serialization when we recompute the activeRequest we don't turn + // `activeRequest = null` into `activeRequest = { wait: true, ... }`. + if (side.activeRequest === null) state.activeRequest = null; + return state; + } + + private deserializeSide(state: /* Side */ AnyObject, side: Side) { + this.deserialize(state, side, SIDE, side.battle); + for (const [i, pokemon] of state.pokemon.entries()) { + this.deserializePokemon(pokemon, side.pokemon[i]); + } + this.deserializeChoice(state.choice, side.choice, side.battle); + } + + serializePokemon(pokemon: Pokemon): /* Pokemon */ AnyObject { + const state: /* Pokemon */ AnyObject = this.serialize(pokemon, POKEMON, pokemon.battle); + state.set = pokemon.set; + // Only serialize the baseMoveSlots if they differ from moveSlots. We could get fancy and + // only serialize the diff and its index but thats overkill for a pretty niche case anyway. + if (pokemon.baseMoveSlots.length !== pokemon.moveSlots.length || + !pokemon.baseMoveSlots.every((ms, i) => ms === pokemon.moveSlots[i])) { + state.baseMoveSlots = this.serializeWithRefs(pokemon.baseMoveSlots, pokemon.battle); + } + return state; + } + + private deserializePokemon(state: /* Pokemon */ AnyObject, pokemon: Pokemon) { + this.deserialize(state, pokemon, POKEMON, pokemon.battle); + // @ts-ignore - readonly + pokemon.set = state.set; + // baseMoveSlots and moveSlots need to point to the same objects (ie. identity, not equality). + // If we serialized the baseMoveSlots, replace any that match moveSlots to preserve the + // identity relationship requirement. + let baseMoveSlots; + if (state.baseMoveSlots) { + baseMoveSlots = this.deserializeWithRefs(state.baseMoveSlots, pokemon.battle); + for (const [i, baseMoveSlot] of baseMoveSlots.entries()) { + const moveSlot = pokemon.moveSlots[i]; + if (moveSlot.id === baseMoveSlot.id && !moveSlot.virtual) { + baseMoveSlots[i] = moveSlot; + } + } + } else { + baseMoveSlots = pokemon.moveSlots.slice(); + } + // @ts-ignore - readonly + pokemon.baseMoveSlots = baseMoveSlots; + if (state.showCure === undefined) pokemon.showCure = undefined; + } + + private serializeChoice(choice: Choice, battle: Battle): /* Choice */ AnyObject { + const state: /* Choice */ AnyObject = this.serialize(choice, CHOICE, battle); + state.switchIns = Array.from(choice.switchIns); + return state; + } + + private deserializeChoice(state: /* Choice */ AnyObject, choice: Choice, battle: Battle) { + this.deserialize(state, choice, CHOICE, battle); + choice.switchIns = new Set(state.switchIns); + } + + // Simply looking for a 'hit' field to determine if an object is an ActiveMove or not seems + // pretty fragile, but its no different than what the simulator is doing. We go further and + // also check if the object has an 'id', as that's what we will intrepret as the Move. + private isActiveMove(obj: AnyObject): obj is ActiveMove { + return obj.hasOwnProperty('hit') && (obj.hasOwnProperty('id') || obj.hasOwnProperty('move')); + } + + // ActiveMove is somewhat problematic (#5415) as it sometimes extends a Move and adds on + // some mutable fields. We'd like to avoid displaying all the readonly fields of Move + // (which in theory should not be changed by the ActiveMove...), so we collapse them + // into a 'move: [Move:...]' reference. If isActiveMove returns a false positive *and* + // and object contains an 'id' field matching a Move *and* it contains fields with the + // same name as said Move then we'll miss them during serialization and won't + // deserialize properly. This is unlikely to be the case, and would probably indicate + // a bug in the simulator if it ever happened, but if not, the isActiveMove check can + // be extended. + private serializeActiveMove(move: ActiveMove, battle: Battle): /* ActiveMove */ AnyObject { + const base = battle.getMove(move.id); + const skip = new Set([...ACTIVE_MOVE]); + for (const [key, value] of Object.entries(base)) { + // This should really be a deepEquals check to see if anything on ActiveMove was + // modified from the base Move, but that ends up being expensive and mostly unnecessary + // as ActiveMove currently only mutates its simple fields (eg. `type`, `target`) anyway. + // @ts-ignore - index signature + if (typeof value === 'object' || move[key] === value) skip.add(key); + } + const state: /* ActiveMove */ AnyObject = this.serialize(move, skip, battle); + state.move = `[Move:${move.id}]`; + return state; + } + + private deserializeActiveMove(state: /* ActiveMove */ AnyObject, battle: Battle): ActiveMove { + const move = battle.getActiveMove(this.fromRef(state.move, battle)! as Move); + this.deserialize(state, move, ACTIVE_MOVE, battle); + return move; + } + + private serializeWithRefs(obj: unknown, battle: Battle): unknown { + switch (typeof obj) { + case 'function': + return undefined; // elide functions + case 'undefined': + case 'boolean': + case 'number': + case 'string': + return obj; + case 'object': + if (obj === null) return null; + if (Array.isArray(obj)) { + const arr = new Array(obj.length); + for (const [i, o] of obj.entries()) { + arr[i] = this.serializeWithRefs(o, battle); + } + return arr; + } + + if (this.isActiveMove(obj)) return this.serializeActiveMove(obj, battle); + if (this.isReferable(obj)) return this.toRef(obj); + if (obj.constructor !== Object) { + // If we're getting this error, some 'special' field has been added to + // an object and we need to update the logic in this file to handle it. + // The most common case it that someone added a Set/Map which probably + // needs to be serialized as an Array/Object respectively - see how + // Battle 'hints' or Choice 'switchIns' are handled (and you will likely + // need to add the new field to the respective skip constant). + throw new TypeError(`Unsupported type ${obj.constructor.name}: ${obj}`); + } + + const o: any = {}; + for (const [key, value] of Object.entries(obj)) { + o[key] = this.serializeWithRefs(value, battle); + } + return o; + default: + throw new TypeError(`Unexpected typeof === '${typeof obj}': ${obj}`); + } + } + + private deserializeWithRefs(obj: unknown, battle: Battle) { + switch (typeof obj) { + case 'undefined': + case 'boolean': + case 'number': + return obj; + case 'string': + return this.fromRef(obj, battle) || obj; + case 'object': + if (obj === null) return null; + if (Array.isArray(obj)) { + const arr = new Array(obj.length); + for (const [i, o] of obj.entries()) { + arr[i] = this.deserializeWithRefs(o, battle); + } + return arr; + } + + if (this.isActiveMove(obj)) return this.deserializeActiveMove(obj, battle); + + const o: any = {}; + for (const [key, value] of Object.entries(obj)) { + o[key] = this.deserializeWithRefs(value, battle); + } + return o; + case 'function': // lol wtf + default: + throw new TypeError(`Unexpected typeof === '${typeof obj}': ${obj}`); + } + } + + private isReferable(obj: object): obj is Referable { + // NOTE: see explanation on the declaration above for why this must be defined lazily. + if (!this.REFERABLE) { + this.REFERABLE = new Set([ + Battle, Field, Side, Pokemon, Data.PureEffect, + Data.Ability, Data.Item, Data.Move, Data.Template, + ]); + } + return this.REFERABLE!.has(obj.constructor); + } + + private toRef(obj: Referable): string { + // Pokemon's 'id' is not only more verbose than a position, it also isn't guaranteed + // to be uniquely identifying in custom games without Nickname/Species Clause. + const id = obj instanceof Pokemon ? `${obj.side.id}${POSITIONS[obj.position]}` : `${obj.id}`; + return `[${obj.constructor.name}${id ? ':' : ''}${id}]`; + } + + private fromRef(ref: string, battle: Battle): Referable | undefined { + // References are sort of fragile - we're mostly just counting on there + // being a low chance that some string field in a simulator object will not + // 'look' like one. However, it also needs to match one of the Referable + // class types to be decode, so we're probably OK. We could make the reference + // markers more esoteric with additional sigils etc to avoid collisions, but + // we're making a conscious decision to favor readability over robustness. + if (ref.charAt(0) !== '[' && ref.slice(-1) !== ']') return undefined; + + ref = ref.substring(1, ref.length - 1); + // There's only one instance of these thus they don't need an id to differentiate. + if (ref === 'Battle') return battle; + if (ref === 'Field') return battle.field; + + const [type, id] = ref.split(':'); + switch (type) { + case 'Side': return battle.sides[Number(id[1]) - 1]; + case 'Pokemon': return battle.sides[Number(id[1]) - 1].pokemon[POSITIONS.indexOf(id[2])]; + case 'Ability': return battle.getAbility(id); + case 'Item': return battle.getItem(id); + case 'Move': return battle.getMove(id); + case 'PureEffect': return battle.getEffect(id); + case 'Template': return battle.getTemplate(id); + default: return undefined; // maybe we actually got unlucky and its a string + } + } + + private serialize(obj: object, skip: Set, battle: Battle): AnyObject { + const state: AnyObject = {}; + for (const [key, value] of Object.entries(obj)) { + if (skip.has(key)) continue; + const val = this.serializeWithRefs(value, battle); + // JSON.stringify will get rid of keys with undefined values anyway, but + // we also do it here so that assert.deepStrictEqual works on battle.toJSON(). + if (typeof val !== 'undefined') state[key] = val; + } + return state; + } + + private deserialize(state: AnyObject, obj: object, skip: Set, battle: Battle) { + for (const [key, value] of Object.entries(state)) { + if (skip.has(key)) continue; + // @ts-ignore - index signature + obj[key] = this.deserializeWithRefs(value, battle); + } + } +}; diff --git a/sim/tools/exhaustive-runner.ts b/sim/tools/exhaustive-runner.ts index 4b59664119..4f1f50e95e 100644 --- a/sim/tools/exhaustive-runner.ts +++ b/sim/tools/exhaustive-runner.ts @@ -26,6 +26,7 @@ export interface ExhaustiveRunnerOptions { log?: boolean; maxGames?: number; maxFailures?: number; + dual?: boolean | 'debug'; } export class ExhaustiveRunner { @@ -49,6 +50,7 @@ export class ExhaustiveRunner { private readonly log: boolean; private readonly maxGames?: number; private readonly maxFailures?: number; + private readonly dual: boolean | 'debug'; private failures: number; private games: number; @@ -61,6 +63,7 @@ export class ExhaustiveRunner { this.log = !!options.log; this.maxGames = options.maxGames; this.maxFailures = options.maxFailures || ExhaustiveRunner.MAX_FAILURES; + this.dual = options.dual || false; this.failures = 0; this.games = 0; @@ -84,6 +87,7 @@ export class ExhaustiveRunner { p1options: {team: generator.generate(), createAI}, p2options: {team: generator.generate(), createAI}, format: this.format, + dual: this.dual, error: true, }).run(); @@ -92,7 +96,7 @@ export class ExhaustiveRunner { this.failures++; console.error( `\n\nRun \`node tools/simulate exhaustive --cycles=${this.cycles} ` + - `--format=${this.format} --seed${seed.join()}\`:\n`, err); + `--format=${this.format} --seed=${seed.join()}\`:\n`, err); } } while ((!this.maxGames || this.games < this.maxGames) && (!this.maxFailures || this.failures < this.maxFailures) && diff --git a/sim/tools/runner.ts b/sim/tools/runner.ts index 5ec6ab057e..d254cc6158 100644 --- a/sim/tools/runner.ts +++ b/sim/tools/runner.ts @@ -5,7 +5,11 @@ * @license MIT */ +import assert = require('assert'); +import fs = require('fs'); + import {ObjectReadWriteStream} from '../../lib/streams'; +import {Battle} from '../battle'; import * as BattleStreams from '../battle-stream'; import {PRNG, PRNGSeed} from '../prng'; import {RandomPlayerAI} from './random-player-ai'; @@ -26,6 +30,7 @@ export interface RunnerOptions { input?: boolean; output?: boolean; error?: boolean; + dual?: boolean | 'debug'; } export class Runner { @@ -42,6 +47,7 @@ export class Runner { private readonly input: boolean; private readonly output: boolean; private readonly error: boolean; + private readonly dual: boolean | 'debug'; constructor(options: RunnerOptions) { this.format = options.format; @@ -54,10 +60,13 @@ export class Runner { this.input = !!options.input; this.output = !!options.output; this.error = !!options.error; + this.dual = options.dual || false; } async run() { - const battleStream = new RawBattleStream(this.input); + 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 => { @@ -66,7 +75,8 @@ export class Runner { }); } - private async runGame(format: string, battleStream: RawBattleStream) { + private async runGame(format: string, battleStream: RawBattleStream | DualStream) { + // @ts-ignore - DualStream implements everything relevant from BattleStream. const streams = BattleStreams.getPlayerStreams(battleStream); const spec = {formatid: format, seed: this.prng.seed}; const p1spec = this.getPlayerSpec("Bot 1", this.p1options); @@ -129,3 +139,68 @@ class RawBattleStream extends BattleStreams.BattleStream { 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.deepStrictEqual(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.strictEqual(test, control); + return control; + } + + write(message: string) { + this.control._write(message); + this.test._write(message); + this.compare(); + } + + async end() { + // We need to compare first because _end() destroys the battle object. + this.compare(true); + await this.control._end(); + await this.test._end(); + } + + 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.deepStrictEqual(test, control); + } catch (err) { + 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); + } +} diff --git a/test/sim/misc/state.js b/test/sim/misc/state.js new file mode 100644 index 0000000000..82cd5df700 --- /dev/null +++ b/test/sim/misc/state.js @@ -0,0 +1,50 @@ +'use strict'; + +const assert = require('./../../assert'); +const common = require('./../../common'); +const Battle = require('./../../../.sim-dist/battle').Battle; + +const TEAMS = [[ + {species: 'Mew', ability: 'synchronize', item: 'assaultvest', moves: ['psychic']}, + {species: 'Ditto', ability: 'imposter', item: 'choicescarf', moves: ['transform']}, + {species: 'Amoonguss', ability: 'effectspore', item: 'blacksludge', moves: ['toxic']}, + {species: 'Gliscor', ability: 'poisonheal', item: 'toxicorb', moves: ['curse']}, + {species: 'Zoroark', ability: 'illusion', item: 'leftovers', moves: ['knockoff']}, + {species: 'Gengar', ability: 'cursedbody', item: 'brightpowder', moves: ['disable']}, +], [ + {species: 'Ninjask', ability: 'speedboost', item: 'rockyhelmet', moves: ['batonpass']}, + {species: 'Hippowdon', ability: 'sandstream', item: 'choiceband', moves: ['earthquake']}, + {species: 'Jirachi', ability: 'serenegrace', item: 'choicescarf', moves: ['ironhead']}, + {species: 'Chansey', ability: 'naturalcure', item: 'eviolite', moves: ['seismictoss']}, + {species: 'Vaporeon', ability: 'waterabsorb', item: 'wacanberry', moves: ['surf']}, + {species: 'Snorlax', ability: 'thickfat', item: 'leftovers', moves: ['rest']}, +]]; + +describe('State', function () { + describe('Battles', function () { + it('should be able to be serialized and deserialized without affecting functionality', function () { + const control = common.createBattle(TEAMS); + let test = common.createBattle(TEAMS); + + while (!(control.ended || test.ended)) { + control.makeChoices(); + test.makeChoices(); + + assert.deepStrictEqual(test.toJSON(), control.toJSON()); + + // Roundtrip the test battle to confirm it still works. + const send = test.send; + test = Battle.fromJSON(JSON.stringify(test)); + test.restart(send); + } + + control.destroy(); + test.destroy(); + }); + it('should require special treatment for complex objects', function () { + const battle = common.createBattle(TEAMS); + battle.foo = new Map(); + assert.throws(() => battle.toJSON(), /Unsupported type Map/); + }); + }); +}); diff --git a/tools/simulate.js b/tools/simulate.js index 440b35b5f5..bd25ad7da1 100644 --- a/tools/simulate.js +++ b/tools/simulate.js @@ -68,7 +68,7 @@ const RejectionTracker = new class { for (const u of this.unhandled) { const error = (u.reason instanceof Error) ? u.reason : new Error(`Promise rejected with value: ${u.reason}`); - console.error(error.stack); + console.error(`UNHANDLED PROMISE REJECTION:\n${error.stack}`); i++; } process.exit(code + i); @@ -136,7 +136,7 @@ case 'exhaustive': do { for (let format of formats) { failures += await new ExhaustiveRunner({ - format, cycles, prng, maxFailures, log: true, + format, cycles, prng, maxFailures, log: true, dual: argv.dual, }).run(); process.stdout.write('\n'); if (failures >= maxFailures) break;