From ec3939b7f72baf0864b773d420d2c610e05001f1 Mon Sep 17 00:00:00 2001 From: taylorhansen Date: Mon, 15 Jan 2024 19:24:26 -0800 Subject: [PATCH] Refactor battle stream API Fix #366. Some other things fixed while debugging: Fix recharge being treated as a move causing agent requests to time out. Make BattleEnv.step() loop condition more robust. Set ZeroMQ high water mark on the JS worker end to ensure no dropped messages. Fix tqdm leaving artifacts in the terminal. Add simulator scripts for debugging. Expose timeout configs. Make simulateBattle() exceptions louder in the JS worker. Fix error logging/swallowing behavior in simulateBattle(). Fix wrapTimeout() call stack. Remove unused BattleParser type params TArgs, TResult. Fix game truncation handling. --- config/train_example.yml | 4 + scripts/sim-randbat.ts | 52 + scripts/sim_randbat.py | 118 ++ src/py/environments/battle_env.py | 19 +- src/py/environments/utils/battle_pool.py | 28 +- src/py/environments/utils/protocol.py | 11 +- src/py/train.py | 4 +- src/ts/battle/BattleDriver.test.ts | 243 ++- src/ts/battle/BattleDriver.ts | 156 +- src/ts/battle/agent/maxDamage.ts | 7 +- src/ts/battle/agent/random.test.ts | 2 + src/ts/battle/agent/random.ts | 7 + src/ts/battle/parser/BattleParser.ts | 19 +- src/ts/battle/parser/Context.test.ts | 213 --- src/ts/battle/parser/ParserHelpers.test.ts | 141 -- src/ts/battle/parser/StateHelpers.test.ts | 50 - src/ts/battle/parser/contextHelpers.test.ts | 141 ++ src/ts/battle/parser/events.test.ts | 1381 +++++++---------- src/ts/battle/parser/events.ts | 587 +++---- .../parser/{main.test.ts => gen4.test.ts} | 159 +- src/ts/battle/parser/gen4.ts | 14 + src/ts/battle/parser/index.test.ts | 4 +- src/ts/battle/parser/iterators.ts | 174 --- src/ts/battle/parser/main.ts | 12 - src/ts/battle/parser/parsing.ts | 261 ---- ...elpers.test.ts => protocolHelpers.test.ts} | 56 - src/ts/battle/parser/stateHelpers.test.ts | 43 + src/ts/battle/parser/utils.ts | 97 ++ src/ts/battle/state/switchOptions.test.ts | 2 +- .../battle/worker/ExperienceBattleParser.ts | 118 ++ src/ts/battle/worker/battle.ts | 391 +++-- .../battle/worker/experienceBattleParser.ts | 127 -- src/ts/battle/worker/protocol.ts | 13 +- src/ts/battle/worker/worker.ts | 193 +-- src/ts/protocol/EventParser.test.ts | 71 - src/ts/protocol/EventParser.ts | 43 - src/ts/protocol/index.test.ts | 4 +- src/ts/protocol/parser.test.ts | 29 + src/ts/protocol/parser.ts | 20 + src/ts/psbot/FakeRoomHandler.test.ts | 3 + src/ts/psbot/PsBot.test.ts | 20 +- src/ts/psbot/PsBot.ts | 36 +- src/ts/psbot/handlers/BattleHandler.ts | 19 +- src/ts/psbot/handlers/GlobalHandler.ts | 3 + src/ts/psbot/handlers/RoomHandler.ts | 2 + src/ts/psbot/handlers/wrappers.ts | 15 + src/ts/psbot/runner.ts | 17 +- src/ts/utils/timeout.ts | 21 +- src/ts/utils/types.ts | 3 + 49 files changed, 2211 insertions(+), 2942 deletions(-) create mode 100644 scripts/sim-randbat.ts create mode 100644 scripts/sim_randbat.py delete mode 100644 src/ts/battle/parser/Context.test.ts delete mode 100644 src/ts/battle/parser/ParserHelpers.test.ts delete mode 100644 src/ts/battle/parser/StateHelpers.test.ts create mode 100644 src/ts/battle/parser/contextHelpers.test.ts rename src/ts/battle/parser/{main.test.ts => gen4.test.ts} (72%) create mode 100644 src/ts/battle/parser/gen4.ts delete mode 100644 src/ts/battle/parser/iterators.ts delete mode 100644 src/ts/battle/parser/main.ts delete mode 100644 src/ts/battle/parser/parsing.ts rename src/ts/battle/parser/{helpers.test.ts => protocolHelpers.test.ts} (70%) create mode 100644 src/ts/battle/parser/stateHelpers.test.ts create mode 100644 src/ts/battle/parser/utils.ts create mode 100644 src/ts/battle/worker/ExperienceBattleParser.ts delete mode 100644 src/ts/battle/worker/experienceBattleParser.ts delete mode 100644 src/ts/protocol/EventParser.test.ts delete mode 100644 src/ts/protocol/EventParser.ts create mode 100644 src/ts/protocol/parser.test.ts create mode 100644 src/ts/protocol/parser.ts create mode 100644 src/ts/psbot/handlers/wrappers.ts create mode 100644 src/ts/utils/types.ts diff --git a/config/train_example.yml b/config/train_example.yml index 2985afcc..b777bf55 100644 --- a/config/train_example.yml +++ b/config/train_example.yml @@ -72,6 +72,8 @@ rollout: workers: 1 per_worker: 1 battles_per_log: 1000 + worker_timeout_ms: 60_000 # 1m + sim_timeout_ms: 300_000 # 5m state_type: numpy opponents: - name: previous @@ -86,6 +88,8 @@ eval: workers: 4 per_worker: 2 battles_per_log: 100 + worker_timeout_ms: 60_000 # 1m + sim_timeout_ms: 300_000 # 5m state_type: tensor opponents: - name: previous diff --git a/scripts/sim-randbat.ts b/scripts/sim-randbat.ts new file mode 100644 index 00000000..acbf2ace --- /dev/null +++ b/scripts/sim-randbat.ts @@ -0,0 +1,52 @@ +/** @file Simulates a random battle used for training. */ +import {randomAgent} from "../src/ts/battle/agent/random"; +import {gen4Parser} from "../src/ts/battle/parser/gen4"; +import {ExperienceBattleParser} from "../src/ts/battle/worker/ExperienceBattleParser"; +import {PlayerOptions, simulateBattle} from "../src/ts/battle/worker/battle"; +import {wrapTimeout} from "../src/ts/utils/timeout"; +import {Mutable} from "../src/ts/utils/types"; + +Error.stackTraceLimit = Infinity; + +const timeoutMs = 5000; // 5s +const battleTimeoutMs = 1000; // 1s +const maxTurns = 50; +const p1Exp = true; +const p2Exp = true; + +void (async function () { + const p1: Mutable = { + name: "p1", + agent: randomAgent, + parser: gen4Parser, + }; + const p2: Mutable = { + name: "p2", + agent: randomAgent, + parser: gen4Parser, + }; + if (p1Exp) { + const expParser = new ExperienceBattleParser(p1.parser, "p1"); + p1.parser = async (ctx, event) => await expParser.parse(ctx, event); + p1.agent = async (state, choices) => await randomAgent(state, choices); + } + if (p2Exp) { + const expParser = new ExperienceBattleParser(p2.parser, "p2"); + p2.parser = async (ctx, event) => await expParser.parse(ctx, event); + p2.agent = async (state, choices) => await randomAgent(state, choices); + } + + const result = await wrapTimeout( + async () => + await simulateBattle({ + players: {p1, p2}, + maxTurns, + timeoutMs: battleTimeoutMs, + }), + timeoutMs, + ); + console.log(`winner: ${result.winner}`); + console.log(`truncated: ${!!result.truncated}`); + console.log(`log path: ${result.logPath}`); + console.log(`err: ${result.err?.stack ?? result.err}`); +})().catch(err => console.log("sim-randbat failed:", err)); diff --git a/scripts/sim_randbat.py b/scripts/sim_randbat.py new file mode 100644 index 00000000..fbbaf063 --- /dev/null +++ b/scripts/sim_randbat.py @@ -0,0 +1,118 @@ +"""Simulates a random battle used for training.""" + +import asyncio +import os +import sys +from contextlib import closing +from itertools import chain +from typing import Optional, Union + +import numpy as np +import tensorflow as tf + +# So that we can `python -m scripts.sim_randbat` from project root. +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +# pylint: disable=wrong-import-position, import-error +from src.py.agents.agent import Agent +from src.py.agents.utils.epsilon_greedy import EpsilonGreedy +from src.py.environments.battle_env import ( + AgentDict, + BattleEnv, + BattleEnvConfig, + EvalOpponentConfig, + InfoDict, +) +from src.py.environments.utils.battle_pool import BattlePoolConfig +from src.py.models.utils.greedy import decode_action_rankings + + +class RandomAgent(Agent): + """Agent that acts randomly.""" + + def __init__(self, rng: Optional[tf.random.Generator] = None): + self._epsilon_greedy = EpsilonGreedy(exploration=1.0, rng=rng) + + def select_action( + self, + state: AgentDict[Union[np.ndarray, tf.Tensor]], + info: AgentDict[InfoDict], + ) -> AgentDict[list[str]]: + """Selects a random action.""" + _ = info + return dict( + zip( + state.keys(), + decode_action_rankings( + self._epsilon_greedy.rand_actions(len(state)) + ), + ) + ) + + def update_model( + self, state, reward, next_state, terminated, truncated, info + ): + """Not implemented.""" + raise NotImplementedError + + +async def sim_randbat(): + """Starts the simulator.""" + + rng = tf.random.get_global_generator() + + agent = RandomAgent(rng) + + env = BattleEnv( + config=BattleEnvConfig( + max_turns=50, + batch_limit=4, + pool=BattlePoolConfig( + workers=2, + per_worker=1, + battles_per_log=1, + worker_timeout_ms=1000, # 1s + sim_timeout_ms=60_000, # 1m + ), + state_type="tensor", + ), + rng=rng, + ) + await env.ready() + + with closing(env): + state, info = env.reset( + rollout_battles=10, + eval_opponents=( + EvalOpponentConfig( + name="eval_self", battles=10, type="model", model="model/p2" + ), + ), + ) + done = False + while not done: + action = agent.select_action(state, info) + (next_state, _, terminated, truncated, info, done) = await env.step( + action + ) + state = next_state + for key, ended in chain(terminated.items(), truncated.items()): + if ended: + state.pop(key) + info.pop(key) + for key, env_info in info.items(): + if key.player != "__env__": + continue + battle_result = env_info.get("battle_result", None) + if battle_result is None: + continue + print(battle_result) + + +def main(): + """Main entry point.""" + asyncio.run(sim_randbat()) + + +if __name__ == "__main__": + main() diff --git a/src/py/environments/battle_env.py b/src/py/environments/battle_env.py index 38a6016f..666b646b 100644 --- a/src/py/environments/battle_env.py +++ b/src/py/environments/battle_env.py @@ -338,16 +338,25 @@ async def step( num_pending = 0 all_reqs: AgentDict[Union[AgentRequest, AgentFinalRequest]] = {} while ( - self.config.batch_limit <= 0 - or num_pending < self.config.batch_limit - ) and await self.battle_pool.agent_poll( - timeout=0 if len(all_reqs) > 0 else None + ( + self.config.batch_limit <= 0 + or num_pending < self.config.batch_limit + ) + and ( + len(self.active_battles) > 0 + or (self.queue_task is not None and not self.queue_task.done()) + ) + and await self.battle_pool.agent_poll( + timeout=0 + if len(all_reqs) > 0 + else self.config.pool.worker_timeout_ms + ) ): key, req, state = await self.battle_pool.agent_recv( flags=zmq.DONTWAIT ) assert all_reqs.get(key, None) is None, ( - f"Received duplicate agent request for {key}: " + f"Received too many agent requests for {key}: " f"{(req)}, previous {all_reqs[key]}" ) all_reqs[key] = req diff --git a/src/py/environments/utils/battle_pool.py b/src/py/environments/utils/battle_pool.py index e8299b70..e381e675 100644 --- a/src/py/environments/utils/battle_pool.py +++ b/src/py/environments/utils/battle_pool.py @@ -55,6 +55,18 @@ class BattlePoolConfig: regardless of this value. Omit to not store logs except on error. """ + worker_timeout_ms: Optional[int] = None + """ + Worker communication timeout in milliseconds for both starting battles and + managing battle agents. Used for catching rare async bugs. + """ + + sim_timeout_ms: Optional[int] = None + """ + Simulator timeout in milliseconds for processing battle-related actions and + events. Used for catching rare async bugs. + """ + class BattleKey(NamedTuple): """Key type used to identify individual battles when using many workers.""" @@ -119,21 +131,16 @@ def __init__( self.ctx.setsockopt(zmq.LINGER, 0) # Prevent messages from getting dropped. self.ctx.setsockopt(zmq.ROUTER_MANDATORY, 1) - self.ctx.setsockopt(zmq.SNDHWM, 0) self.ctx.setsockopt(zmq.RCVHWM, 0) + self.ctx.setsockopt(zmq.SNDHWM, 0) + if config.worker_timeout_ms is not None: + self.ctx.setsockopt(zmq.RCVTIMEO, config.worker_timeout_ms) + self.ctx.setsockopt(zmq.SNDTIMEO, config.worker_timeout_ms) self.battle_sock = self.ctx.socket(zmq.ROUTER) - # Prevent indefinite blocking. - self.battle_sock.setsockopt(zmq.SNDTIMEO, 10_000) # 10s - self.battle_sock.setsockopt(zmq.RCVTIMEO, 10_000) self.battle_sock.bind(f"ipc:///tmp/psai-battle-socket-{self.sock_id}") self.agent_sock = self.ctx.socket(zmq.ROUTER) - # The JS simulator is very fast compared to the ML code so it shouldn't - # take long at all to send predictions to or receive requests from any - # of the connected workers. - self.agent_sock.setsockopt(zmq.SNDTIMEO, 10_000) # 10s - self.agent_sock.setsockopt(zmq.RCVTIMEO, 10_000) self.agent_sock.bind(f"ipc:///tmp/psai-agent-socket-{self.sock_id}") self.agent_poller = zmq.asyncio.Poller() @@ -260,6 +267,9 @@ async def queue_battle( "onlyLogOnError": self.config.battles_per_log is None or self.battle_count % self.config.battles_per_log != 0, "seed": prng_seeds[0], + "timeoutMs": self.config.sim_timeout_ms + if self.config.sim_timeout_ms is not None + else None, } await self.battle_sock.send_multipart( [worker_id, json.dumps(req).encode()] diff --git a/src/py/environments/utils/protocol.py b/src/py/environments/utils/protocol.py index c8b35796..eaa5c2c6 100644 --- a/src/py/environments/utils/protocol.py +++ b/src/py/environments/utils/protocol.py @@ -1,7 +1,7 @@ """ Describes the JSON protocol for the BattlePool. -Corresponds to src/ts/battle/worker/protocol.ts. +MUST keep this in sync with src/ts/battle/worker/protocol.ts. """ from typing import Optional, TypedDict @@ -75,6 +75,12 @@ class BattleRequest(TypedDict): seed: Optional[PRNGSeed] """Seed for battle engine.""" + timeoutMs: Optional[int] + """ + Simulator timeout in milliseconds for processing battle-related actions and + events. Used for catching rare async bugs. + """ + class BattleReply(TypedDict): """Result of finished battle.""" @@ -94,6 +100,9 @@ class BattleReply(TypedDict): truncated: Optional[bool] """Whether the battle was truncated due to max turn limit or error.""" + logPath: Optional[str] + """Resolved path to the log file.""" + err: Optional[str] """Captured exception with stack trace if it was thrown during the game.""" diff --git a/src/py/train.py b/src/py/train.py index 1b4c6fbe..b7134a2f 100644 --- a/src/py/train.py +++ b/src/py/train.py @@ -95,7 +95,7 @@ async def run_eval( unit="battles", unit_scale=True, dynamic_ncols=True, - position=0, + position=1, ) as pbar: state, info = env.reset(eval_opponents=opponents) done = False @@ -250,7 +250,7 @@ async def train(config: TrainConfig): dynamic_ncols=True, smoothing=0.1, initial=min(int(episode), config.rollout.num_episodes), - position=1, + position=0, ) as pbar: if config.rollout.eps_per_eval > 0 and not restored and episode == 0: # Pre-evaluation for comparison against the later trained model. diff --git a/src/ts/battle/BattleDriver.test.ts b/src/ts/battle/BattleDriver.test.ts index 1110a103..ab229d44 100644 --- a/src/ts/battle/BattleDriver.test.ts +++ b/src/ts/battle/BattleDriver.test.ts @@ -5,51 +5,27 @@ import {Logger} from "../utils/logging/Logger"; import {Verbose} from "../utils/logging/Verbose"; import {BattleDriver} from "./BattleDriver"; import {Action} from "./agent"; -import {consume, verify} from "./parser/parsing"; +import {BattleParser} from "./parser/BattleParser"; +import {defaultParser, eventParser} from "./parser/utils"; export const test = () => describe("BattleDriver", function () { + const unexpectedParser: BattleParser = (ctx, event) => { + throw new Error(`Unexpected event '${Protocol.key(event.args)}'`); + }; + it("Should correctly manage underlying BattleParser", async function () { + let parser = unexpectedParser; let requested = false; - const bh = new BattleDriver({ + const driver = new BattleDriver({ username: "username", // Fake BattleParser for demonstration. - async parser(ctx) { - // Initializer event. - await verify(ctx, "|request|"); - await consume(ctx); - - await verify(ctx, "|start|"); - await consume(ctx); - await verify(ctx, "|turn|"); - await consume(ctx); - - await verify(ctx, "|request|"); - requested = true; - let choices: Action[] = ["move 1", "move 2"]; - await ctx.agent(ctx.state, choices); - await expect(ctx.executor(choices[0])).to.eventually.be - .false; - await consume(ctx); - - await verify(ctx, "|turn|"); - await consume(ctx); - - await verify(ctx, "|request|"); - requested = true; - choices = ["move 1", "move 2"]; - await ctx.agent(ctx.state, choices); - await expect(ctx.executor(choices[0])).to.eventually.be - .false; - await consume(ctx); - - await verify(ctx, "|tie|"); - await consume(ctx); - }, + parser: async (ctx, event) => await parser(ctx, event), // Fake BattleAgent for demonstration. agent: async (state, choices) => { if (requested) { [choices[0], choices[1]] = [choices[1], choices[0]]; + requested = false; } return await Promise.resolve(); }, @@ -62,7 +38,8 @@ export const test = () => }); // Turn 1. - await bh.handle({ + parser = defaultParser("|request|"); + await driver.handle({ args: [ "request", JSON.stringify({ @@ -73,16 +50,30 @@ export const test = () => ], kwArgs: {}, }); - bh.halt(); - await bh.handle({args: ["start"], kwArgs: {}}); - await bh.handle({args: ["turn", "1" as Protocol.Num], kwArgs: {}}); - bh.halt(); + parser = unexpectedParser; + driver.halt(); + parser = defaultParser("|start|"); + await driver.handle({args: ["start"], kwArgs: {}}); + parser = defaultParser("|turn|"); + await driver.handle({ + args: ["turn", "1" as Protocol.Num], + kwArgs: {}, + }); + parser = eventParser("|request|", async ctx => { + requested = true; + const choices: Action[] = ["move 1", "move 2"]; + await ctx.agent(ctx.state, choices); + await expect(ctx.executor(choices[0])).to.eventually.be.false; + }); + driver.halt(); + parser = unexpectedParser; // Wait an extra tick to allow for the choice to be sent and // verified. await new Promise(res => setImmediate(res)); // Turn 2. - await bh.handle({ + parser = unexpectedParser; + await driver.handle({ args: [ "request", JSON.stringify({ @@ -93,58 +84,36 @@ export const test = () => ], kwArgs: {}, }); - bh.halt(); - await bh.handle({args: ["turn", "2" as Protocol.Num], kwArgs: {}}); - bh.halt(); + driver.halt(); + parser = defaultParser("|turn|"); + await driver.handle({ + args: ["turn", "2" as Protocol.Num], + kwArgs: {}, + }); + parser = eventParser("|request|", async ctx => { + requested = true; + const choices: Action[] = ["move 1", "move 2"]; + await ctx.agent(ctx.state, choices); + await expect(ctx.executor(choices[0])).to.eventually.be.false; + }); + driver.halt(); + parser = unexpectedParser; await new Promise(res => setImmediate(res)); // Turn 3: Game-over. - await bh.handle({args: ["tie"], kwArgs: {}}); - bh.halt(); - await bh.finish(); + parser = defaultParser("|tie|"); + await driver.handle({args: ["tie"], kwArgs: {}}); + parser = unexpectedParser; + driver.halt(); + driver.finish(); }); it("Should handle choice rejection and retrying due to unknown info", async function () { + let parser = unexpectedParser; let executorState = 0; const bh = new BattleDriver({ username: "username", - async parser(ctx) { - // Initializer event. - await verify(ctx, "|request|"); - await consume(ctx); - - await verify(ctx, "|start|"); - await consume(ctx); - await verify(ctx, "|turn|"); - await consume(ctx); - - await verify(ctx, "|request|"); - let choices: Action[] = ["move 1", "move 2"]; - // Try move 1. - await ctx.agent(ctx.state, choices); - await expect(ctx.executor(choices[0])).to.eventually.be - .true; - // Try move 2 after move 1 was rejected. - choices.shift(); - await ctx.agent(ctx.state, choices); - await expect(ctx.executor(choices[0])).to.eventually.be - .false; - // Move 2 was accepted. - await consume(ctx); - - await verify(ctx, "|turn|"); - await consume(ctx); - - await verify(ctx, "|request|"); - choices = ["move 2", "move 1"]; - await ctx.agent(ctx.state, choices); - await expect(ctx.executor(choices[0])).to.eventually.be - .false; - await consume(ctx); - - await verify(ctx, "|tie|"); - await consume(ctx); - }, + parser: async (ctx, event) => await parser(ctx, event), agent: async (state, choices) => { void state, choices; return await Promise.resolve(); @@ -162,6 +131,7 @@ export const test = () => }); // Turn 1. + parser = defaultParser("|request|"); await bh.handle({ args: [ "request", @@ -173,13 +143,29 @@ export const test = () => ], kwArgs: {}, }); + parser = unexpectedParser; bh.halt(); + parser = defaultParser("|start|"); await bh.handle({args: ["start"], kwArgs: {}}); + parser = defaultParser("|turn|"); await bh.handle({args: ["turn", "1" as Protocol.Num], kwArgs: {}}); + parser = eventParser("|request|", async ctx => { + const choices: Action[] = ["move 1", "move 2"]; + // Try move 1. + await ctx.agent(ctx.state, choices); + await expect(ctx.executor(choices[0])).to.eventually.be.true; + // Try move 2 after move 1 was rejected. + choices.shift(); + await ctx.agent(ctx.state, choices); + // Move 2 was accepted. + await expect(ctx.executor(choices[0])).to.eventually.be.false; + }); bh.halt(); + parser = unexpectedParser; // Wait an extra tick to allow for the choice to be sent and // verified. await new Promise(res => setImmediate(res)); + expect(executorState).to.equal(1); // First choice is rejected. await bh.handle({ @@ -188,6 +174,7 @@ export const test = () => }); bh.halt(); await new Promise(res => setImmediate(res)); + expect(executorState).to.equal(2); // Turn 2 after retried choice is accepted. await bh.handle({ @@ -202,64 +189,33 @@ export const test = () => kwArgs: {}, }); bh.halt(); + parser = defaultParser("|turn|"); await bh.handle({args: ["turn", "2" as Protocol.Num], kwArgs: {}}); + parser = eventParser("|request|", async ctx => { + const choices: Action[] = ["move 2", "move 1"]; + await ctx.agent(ctx.state, choices); + await expect(ctx.executor(choices[0])).to.eventually.be.false; + }); bh.halt(); + parser = unexpectedParser; await new Promise(res => setImmediate(res)); + expect(executorState).to.equal(3); // Turn 3: Game-over. + parser = defaultParser("|tie|"); await bh.handle({args: ["tie"], kwArgs: {}}); + parser = unexpectedParser; bh.halt(); - await bh.finish(); + bh.finish(); expect(executorState).to.equal(3); }); it("Should handle choice rejection and retrying due to newly revealed info", async function () { + let parser = unexpectedParser; let executorState = 0; const bh = new BattleDriver({ username: "username", - async parser(ctx) { - // Initializer event. - await verify(ctx, "|request|"); - await consume(ctx); - - await verify(ctx, "|start|"); - await consume(ctx); - await verify(ctx, "|turn|"); - await consume(ctx); - - await verify(ctx, "|request|"); - let choices: Action[] = ["move 1", "move 2", "move 3"]; - // Try move 1. - await ctx.agent(ctx.state, choices); - await expect(ctx.executor(choices[0])).to.eventually.equal( - "disabled", - ); - // Move 1 is disabled, instead try move 2. - choices.shift(); - await ctx.agent(ctx.state, choices); - await expect(ctx.executor(choices[0])).to.eventually.equal( - "disabled", - ); - // Move 2 is also disabled, try final move 3. - choices.shift(); - await ctx.agent(ctx.state, choices); - await expect(ctx.executor(choices[0])).to.eventually.be - .false; - await consume(ctx); - - await verify(ctx, "|turn|"); - await consume(ctx); - - await verify(ctx, "|request|"); - choices = ["move 3", "move 1"]; - await ctx.agent(ctx.state, choices); - await expect(ctx.executor(choices[0])).to.eventually.be - .false; - await consume(ctx); - - await verify(ctx, "|tie|"); - await consume(ctx); - }, + parser: async (ctx, event) => await parser(ctx, event), agent: async (state, choices) => { void state, choices; return await Promise.resolve(); @@ -279,6 +235,7 @@ export const test = () => }); // Turn 1. + parser = defaultParser("|request|"); await bh.handle({ args: [ "request", @@ -300,9 +257,30 @@ export const test = () => kwArgs: {}, }); bh.halt(); + parser = defaultParser("|start|"); await bh.handle({args: ["start"], kwArgs: {}}); + parser = defaultParser("|turn|"); await bh.handle({args: ["turn", "1" as Protocol.Num], kwArgs: {}}); + parser = eventParser("|request|", async ctx => { + const choices: Action[] = ["move 1", "move 2", "move 3"]; + // Try move 1. + await ctx.agent(ctx.state, choices); + await expect(ctx.executor(choices[0])).to.eventually.equal( + "disabled", + ); + // Move 1 is disabled, instead try move 2. + choices.shift(); + await ctx.agent(ctx.state, choices); + await expect(ctx.executor(choices[0])).to.eventually.equal( + "disabled", + ); + // Move 2 is also disabled, try final move 3. + choices.shift(); + await ctx.agent(ctx.state, choices); + await expect(ctx.executor(choices[0])).to.eventually.be.false; + }); bh.halt(); + parser = unexpectedParser; // Wait an extra tick to allow for the choice to be sent and // verified. await new Promise(res => setImmediate(res)); @@ -384,14 +362,23 @@ export const test = () => kwArgs: {}, }); bh.halt(); + parser = defaultParser("|turn|"); await bh.handle({args: ["turn", "2" as Protocol.Num], kwArgs: {}}); + parser = eventParser("|request|", async ctx => { + const choices: Action[] = ["move 3", "move 1"]; + await ctx.agent(ctx.state, choices); + await expect(ctx.executor(choices[0])).to.eventually.be.false; + }); bh.halt(); + parser = unexpectedParser; await new Promise(res => setImmediate(res)); // Turn 3: Game-over. + parser = defaultParser("|tie|"); await bh.handle({args: ["tie"], kwArgs: {}}); + parser = unexpectedParser; bh.halt(); - await bh.finish(); + bh.finish(); expect(executorState).to.equal(4); }); }); diff --git a/src/ts/battle/BattleDriver.ts b/src/ts/battle/BattleDriver.ts index a39af37f..ed108537 100644 --- a/src/ts/battle/BattleDriver.ts +++ b/src/ts/battle/BattleDriver.ts @@ -5,11 +5,9 @@ import {Logger} from "../utils/logging/Logger"; import {BattleAgent} from "./agent"; import { BattleParser, - ActionExecutor, ExecutorResult, + BattleParserContext, } from "./parser/BattleParser"; -import {BattleIterator} from "./parser/iterators"; -import {startBattleParser, StartBattleParserArgs} from "./parser/parsing"; import {BattleState} from "./state"; /** @@ -18,10 +16,7 @@ import {BattleState} from "./state"; * @template TAgent Battle agent type. * @template TResult Parser result type. */ -export interface BattleDriverArgs< - TAgent extends BattleAgent = BattleAgent, - TResult = unknown, -> { +export interface BattleDriverArgs { /** Client's username. */ readonly username: string; /** @@ -29,7 +24,7 @@ export interface BattleDriverArgs< * {@link agent}'s decision process. Should call the agent on `|request|` * events and forward selected actions to the {@link sender}. */ - readonly parser: BattleParser; + readonly parser: BattleParser; /** Function for deciding what to do when asked for a decision. */ readonly agent: TAgent; /** Used for sending messages to the battle stream. */ @@ -45,12 +40,11 @@ export interface BattleDriverArgs< * @template TAgent Battle agent type. * @template TResult Parser result type. */ -export class BattleDriver< - TAgent extends BattleAgent = BattleAgent, - TResult = unknown, -> { - /** Used for sending messages to the assigned server room. */ - private readonly sender: Sender; +export class BattleDriver { + /** Parses events to update the battle state. */ + private readonly parser: BattleParser; + /** Parser state. */ + private readonly ctx: BattleParserContext; /** Logger object. */ private readonly logger: Logger; @@ -70,18 +64,14 @@ export class BattleDriver< */ private unavailableChoice: "move" | "switch" | null = null; - /** Iterator for sending PS Events to the {@link BattleParser}. */ - private readonly iter: BattleIterator; /** * Promise for the {@link BattleParser} to finish making a decision after * parsing a `|request|` event. */ - private decisionPromise: ReturnType | null = null; - /** Promise for the entire {@link BattleParser} to finish. */ - private readonly finishPromise: Promise; + private decisionPromise: Promise | null = null; - /** Whether the game has been fully initialized. */ - private battling = false; + /** Will be true (battling) or false (game over) once initialized. */ + private battling: boolean | null = null; /** * Whether {@link handle} has parsed any relevant game events since the last * {@link halt} call. When this and {@link battling} are true, then we @@ -96,39 +86,35 @@ export class BattleDriver< agent, sender, logger, - }: BattleDriverArgs) { - this.sender = sender; + }: BattleDriverArgs) { + this.parser = parser; this.logger = logger; - - const executor: ActionExecutor = async (action, debug) => - await new Promise(res => { - this.executorRes = res; - this.logger.info(`Sending choice: ${action}`); - if ( - !this.sender( - `|/choose ${action}`, - ...(debug !== undefined ? [`|DEBUG: ${debug}`] : []), - ) - ) { - this.logger.debug("Can't send action, force accept"); - res(false); - } - }).finally(() => (this.executorRes = null)); - - const cfg: StartBattleParserArgs = { + this.ctx = { agent, - logger: this.logger.addPrefix("BattleParser: "), - executor, - getState: () => new BattleState(username), + logger, + executor: async (action, debug) => + await new Promise(res => { + this.executorRes = res; + this.logger.info(`Sending choice: ${action}`); + if ( + !sender( + `|/choose ${action}`, + ...(debug !== undefined + ? [`|DEBUG: ${debug}`] + : []), + ) + ) { + this.logger.debug("Can't send action, force accept"); + res(false); + } + }).finally(() => (this.executorRes = null)), + state: new BattleState(username), }; - - const {iter, finish} = startBattleParser(cfg, parser); - - this.iter = iter; - this.finishPromise = finish; } - /** Handles a battle event. */ + /** + * Handles a battle event. Returns results from zero or more parser calls. + */ public async handle(event: Event): Promise { // Filter out irrelevant/non-battle events. // TODO: Should be gen-specific. @@ -136,12 +122,11 @@ export class BattleDriver< return; } - // Game start. if (event.args[0] === "start") { + // Game start. this.battling = true; - } - // Game over. - else if (["win", "tie"].includes(event.args[0])) { + } else if (["win", "tie"].includes(event.args[0])) { + // Game over. this.battling = false; } @@ -153,9 +138,9 @@ export class BattleDriver< return; } this.handleRequest(event as Event<"|request|">); - // Use the first valid |request| event to also initialize during the - // init phase of parsing. - if (this.battling) { + // If this is the initial |request| before a |start| event, we + // should also pass this to the parser normally. + if (this.battling !== null) { return; } } @@ -165,19 +150,16 @@ export class BattleDriver< } // After verifying that this is a relevant game event that progresses - // the battle, we can safely assume that this means that our last sent - // decision from the last |request| event was accepted. + // the battle, we can safely assume that our last sent decision from the + // last |request| event was accepted. this.executorRes?.(false); - if (this.decisionPromise && (await this.decisionPromise).done) { - await this.finish(); - return; + if (this.decisionPromise) { + await this.decisionPromise; } // Process the game event normally. this.progress = true; - if ((await this.iter.next(event)).done) { - await this.finish(); - } + await this.parser(this.ctx, event); } /** Handles a halt signal after parsing a block of battle events. */ @@ -195,16 +177,21 @@ export class BattleDriver< } // Send the last saved |request| event to the BattleParser here. - // This reordering allows us to treat |request| as an actual request for - // a decision _after_ handling all of the relevant game events, since - // normally the |request| is sent first. - // Our BattleParser expects this ordering and is expected to possibly - // call our ActionExecutor here. Once the server sends a response, we - // can then use it to acknowledge or refuse the executor promise in - // handle() via this.executorRes. - this.decisionPromise = this.iter - .next(this.pendingRequest) - .finally(() => (this.decisionPromise = null)); + // Normally a |request| is sent before the events leading up to the + // actual request for a decision. Our BattleParser expects it to come + // after so we reorder it here. + // Typically this will cause ctx.executor to be called so we also want + // to resolve this parser call once the server sends a response instead + // of blocking here. + // FIXME: If the outer battle expects a decision (sent via ctx.executor) but the parser call doesn't send one during this call, this can cause a deadlock while waiting for new battle events. + this.decisionPromise = this.parser(this.ctx, this.pendingRequest).then( + () => { + this.decisionPromise = null; + }, + ); + // Prevent crashing due to possible uncaught rejection as we will await + // this later. + this.decisionPromise.catch(() => {}); // Reset for the next |request| event and the next block of // game-progressing events. this.pendingRequest = null; @@ -212,26 +199,27 @@ export class BattleDriver< } /** - * Waits for the internal {@link BattleParser} to return after handling a - * game-over. + * Finishes the BattleDriver normally. * * @returns Parser result. */ - public async finish(): Promise { - const result = await this.finishPromise; + public finish(): void { if (this.decisionPromise) { throw new Error( - "BattleParser finished but still has a pending decision. Was " + - "the ActionExecutor not called or awaited on |request|?", + "Battle finished but still has a pending decision. " + + "Likely ActionExecutor not called or the call hasn't " + + "resolved.", ); } - return result; } /** Forces the internal {@link BattleParser} to finish. */ - public async forceFinish(): Promise { - await this.iter.return?.(); - return await this.finish(); + public async forceFinish(): Promise { + this.executorRes?.(false); + if (this.decisionPromise) { + await this.decisionPromise; + } + this.finish(); } private handleRequest(event: Event<"|request|">): void { diff --git a/src/ts/battle/agent/maxDamage.ts b/src/ts/battle/agent/maxDamage.ts index e1351f9c..632b471e 100644 --- a/src/ts/battle/agent/maxDamage.ts +++ b/src/ts/battle/agent/maxDamage.ts @@ -5,11 +5,16 @@ import {Rng} from "../../utils/random"; import * as dex from "../dex"; import {ReadonlyBattleState} from "../state"; import {Action} from "./Action"; +import {BattleAgent} from "./BattleAgent"; import {randomAgent} from "./random"; const gens = new Generations(Dex); const psDex = gens.get(4); +// Enforce type compatibility. +const _: BattleAgent = maxDamage; +void _; + /** * BattleAgent that chooses the move with the max expected damage against the * opposing active pokemon. Also chooses switches randomly when needed. @@ -22,7 +27,7 @@ export async function maxDamage( logger?: Logger, random?: Rng, ): Promise { - await randomAgent(state, choices, true /*moveOnly*/, random); + await randomAgent(state, choices, logger, true /*moveOnly*/, random); const {damage, debug} = calcDamge(state, choices); const info = Object.fromEntries( diff --git a/src/ts/battle/agent/random.test.ts b/src/ts/battle/agent/random.test.ts index 1afda443..cbc5e304 100644 --- a/src/ts/battle/agent/random.test.ts +++ b/src/ts/battle/agent/random.test.ts @@ -15,6 +15,7 @@ export const test = () => await randomAgent( null as unknown as ReadonlyBattleState, arr, + undefined /*logger*/, false /*moveOnly*/, random, ); @@ -30,6 +31,7 @@ export const test = () => await randomAgent( null as unknown as ReadonlyBattleState, arr, + undefined /*logger*/, true /*moveOnly*/, random, ); diff --git a/src/ts/battle/agent/random.ts b/src/ts/battle/agent/random.ts index cd38abac..99556399 100644 --- a/src/ts/battle/agent/random.ts +++ b/src/ts/battle/agent/random.ts @@ -1,6 +1,12 @@ +import {Logger} from "../../utils/logging/Logger"; import {Rng, shuffle} from "../../utils/random"; import {ReadonlyBattleState} from "../state"; import {Action} from "./Action"; +import {BattleAgent} from "./BattleAgent"; + +// Enforce type compatibility. +const _: BattleAgent = randomAgent; +void _; /** * BattleAgent that chooses actions randomly. @@ -11,6 +17,7 @@ import {Action} from "./Action"; export async function randomAgent( state: ReadonlyBattleState, choices: Action[], + logger?: Logger, moveOnly?: boolean, random?: Rng, ): Promise { diff --git a/src/ts/battle/parser/BattleParser.ts b/src/ts/battle/parser/BattleParser.ts index d8cd1475..7dc202b1 100644 --- a/src/ts/battle/parser/BattleParser.ts +++ b/src/ts/battle/parser/BattleParser.ts @@ -1,24 +1,21 @@ /** @file Defines the core BattleParser function type. */ +import {Event} from "../../protocol/Event"; import {Logger} from "../../utils/logging/Logger"; import {BattleAgent, Action} from "../agent"; import {BattleState} from "../state"; -import {EventIterator} from "./iterators"; /** * Function type for parsing battle events. * * @template TAgent Battle agent type. - * @template TArgs Additional parameter types. - * @template TResult Result type. - * @param ctx General args. + * @param ctx Battle and parser state, to be persisted between calls. + * @param events Battle event to parse. * @param args Additional args. - * @returns A custom result value to be handled by the caller. */ -export type BattleParser< - TAgent extends BattleAgent = BattleAgent, - TArgs extends unknown[] = unknown[], - TResult = unknown, -> = (ctx: BattleParserContext, ...args: TArgs) => Promise; +export type BattleParser = ( + ctx: BattleParserContext, + event: Event, +) => Promise; /** * Required context arguments for the {@link BattleParser}. @@ -28,8 +25,6 @@ export type BattleParser< export interface BattleParserContext { /** Function that makes the decisions for this battle. */ readonly agent: TAgent; - /** Iterator for getting the next event. */ - readonly iter: EventIterator; /** Logger object. */ readonly logger: Logger; /** diff --git a/src/ts/battle/parser/Context.test.ts b/src/ts/battle/parser/Context.test.ts deleted file mode 100644 index 584508ad..00000000 --- a/src/ts/battle/parser/Context.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** @file Test helper for parsers. */ -import {expect} from "chai"; -import {Logger} from "../../utils/logging/Logger"; -import {Verbose} from "../../utils/logging/Verbose"; -import {BattleAgent, Action} from "../agent"; -import {BattleState} from "../state"; -import {ActionExecutor, ExecutorResult} from "./BattleParser"; -import {StateHelpers} from "./StateHelpers.test"; -import {BattleIterator} from "./iterators"; -import {StartBattleParserArgs} from "./parsing"; - -/** - * Initial context data required to start up the battle parser. - * - * Modifying fields here will reflect on the contents of {@link startArgs}. - */ -export interface InitialContext extends StartBattleParserArgs { - /** Initial args for starting the BattleParser. */ - readonly startArgs: StartBattleParserArgs; - /** - * Agent deconstructed from {@link startArgs}. Can be overridden. - * @override - */ - agent: BattleAgent; - /** - * Logger deconstructed from {@link startArgs}. Can be overridden. - * @override - */ - logger: Logger; - /** - * Executor deconstructed from {@link startArgs}. Can be overridden. - * @override - */ - executor: ActionExecutor; - /** BattleState helper functions. */ - readonly sh: StateHelpers; -} - -/** - * Controls a currently-running {@link BattleParser}. - * - * @template TResult Parser result type - */ -export interface ParserContext { - /** Iterator for sending events to the BattleParser. */ - readonly battleIt: BattleIterator; - /** Return value of the BattleParser. Resolves once the game ends. */ - readonly finish: Promise; -} - -/** - * Creates the initial config for starting BattleParsers. - * - * Must be called from within a mocha `describe()` block. - * - * Note that the {@link BattleState} is constructed with - * {@link BattleState.ourSide} = `"p1"`. - */ -export function createInitialContext(): InitialContext { - let state: BattleState; - - const defaultAgent = async () => - await Promise.reject( - new Error("BattleAgent expected to not be called"), - ); - // TODO: Should logs be tested? - const defaultLogger = new Logger(Logger.null, Verbose.None); - const defaultExecutor: ActionExecutor = async () => - await Promise.reject( - new Error("ActionExecutor expected to not be called"), - ); - const getState = () => state; - const ictx: InitialContext = { - startArgs: { - // Use an additional level of indirection so that agent/sender can - // be overridden by test code. - agent: async (s, choices) => await ictx.agent(s, choices), - logger: new Logger(msg => ictx.logger.logFunc(msg), Verbose.Info), - executor: async choices => await ictx.executor(choices), - getState, - }, - agent: defaultAgent, - logger: defaultLogger, - executor: defaultExecutor, - getState, - sh: new StateHelpers(getState), - }; - - // eslint-disable-next-line mocha/no-top-level-hooks - beforeEach("Reset InitialContext", function () { - ictx.agent = defaultAgent; - ictx.logger = defaultLogger; - ictx.executor = defaultExecutor; - }); - - // eslint-disable-next-line mocha/no-top-level-hooks - beforeEach("Initialize BattleState", function () { - state = new BattleState("username"); - state.started = true; - state.ourSide = "p1"; - }); - - return ictx; -} - -/** Result from {@link setupOverrideAgent}. */ -export interface OverrideAgent { - /** - * Resolves on the next `agent` call. - * - * After awaiting, modify this array then call {@link resolve} to mimic - * {@link BattleAgent} behavior. - */ - readonly choices: () => Promise; - /** Resolves the next `agent` promise. */ - readonly resolve: () => void; -} - -/** - * Adds BattleAgent override functionality to the InitialContext. - * - * Must be called from within a mocha `describe()` block. - */ -export function setupOverrideAgent(ictx: InitialContext) { - let choicesRes: ((choices: Action[]) => void) | null; - - let choices: Promise; - let resolve: (() => void) | null; - - function initAgentPromise() { - choicesRes = null; - choices = new Promise(res => (choicesRes = res)); - } - - // eslint-disable-next-line mocha/no-top-level-hooks - beforeEach("Override agent", function () { - initAgentPromise(); - resolve = null; - ictx.agent = async function overrideAgent(_state, _choices) { - expect(ictx.getState()).to.equal(_state, "Mismatched _state"); - await new Promise(res => { - resolve = res; - expect(choicesRes).to.not.be.null; - choicesRes!(_choices); - initAgentPromise(); // Reinit. - }).finally(() => (resolve = null)); - }; - }); - - return { - choices: async () => await choices, - resolve: () => { - expect(resolve, "choices() wasn't awaited").to.not.be.null; - resolve!(); - }, - }; -} - -/** Result from {@link setupOverrideExecutor}. */ -export interface OverrideExecutor { - /** - * Resolves on the next `executor` call. - * - * After awaiting, call {@link resolve} with an {@link ExecutorResult} value - * to mimic {@link ActionExecutor} behavior. - */ - readonly sent: () => Promise; - /** Resolves the next `executor` promise. */ - readonly resolve: (result: ExecutorResult) => void; -} - -/** - * Adds ChoiceSender override functionality to the InitialContext. - * - * Must be called from within a mocha `describe()` block. - */ -export function setupOverrideExecutor(ictx: InitialContext) { - let executedRes: ((action: Action) => void) | null; - - let executed: Promise; - let resolve: ((result: ExecutorResult) => void) | null; - - function initExecutedPromise() { - executedRes = null; - executed = new Promise(res => (executedRes = res)); - } - - // eslint-disable-next-line mocha/no-top-level-hooks - beforeEach("Override sender", function () { - initExecutedPromise(); - resolve = null; - ictx.executor = async function overrideExecutor(choice) { - try { - return await new Promise(res => { - resolve = res; - expect(executedRes).to.not.be.null; - executedRes!(choice); - initExecutedPromise(); // Reinit. - }); - } finally { - resolve = null; - } - }; - }); - - return { - executed: async () => await executed, - resolve: (result: ExecutorResult) => { - expect(resolve, "executed() wasn't awaited").to.not.be.null; - resolve!(result); - }, - }; -} diff --git a/src/ts/battle/parser/ParserHelpers.test.ts b/src/ts/battle/parser/ParserHelpers.test.ts deleted file mode 100644 index 0fa2fbea..00000000 --- a/src/ts/battle/parser/ParserHelpers.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import {expect} from "chai"; -import {Event} from "../../protocol/Event"; -import {BattleIterator} from "../parser/iterators"; -import {ParserContext} from "./Context.test"; - -// TODO: Should this be merged with ParserContext? -/** - * Helper class for manipulating the {@link ParserContext}. - * - * @template TResult Return type of the BattleParser/SubParser. - */ -export class ParserHelpers { - /** - * Whether the BattleParser threw an exception and it has already been - * handled or tested. This is so that {@link ParserHelpers.close} doesn't - * rethrow the error while awaiting the {@link ParserContext.finish} - * Promise. - */ - private handledError = false; - - /** - * Constructs parser helper functions. - * - * @param pctx Function that gets the {@link ParserContext}. This is called - * each time a method wants to access the ParserContext in order to provide - * a level of indirection in case it gets reassigned later in a separate - * test. - */ - public constructor( - private readonly pctx: () => ParserContext | undefined, - ) {} - - /** - * Fully closes the current BattleParser and resets the state for the next - * test. - * - * Should be invoked at the end of a test or in an {@link afterEach} block. - */ - public async close(): Promise { - try { - await this.pctx()?.battleIt.return?.(); - // The only way for the finish Promise to reject would be if the - // underlying BattleIterators also threw, and so they should've - // been handled via expect() by the time we get to this point. - if (!this.handledError) { - await this.pctx()?.finish; - } else { - await expect(this.guaranteePctx().finish).to.eventually.be - .rejected; - } - } finally { - // Reset error state for the next test. - this.handledError = false; - } - } - - /** - * Handles an event normally. - * - * @param event Event to handle. - */ - public async handle(event: Event): Promise { - const result = await this.next(event); - expect(result).to.not.have.property("done", true); - expect(result).to.have.property("value", undefined); - } - - /** - * Handles an event that should reject and cause the BattleParser to return - * without consuming it. - * - * @param event Event to handle. - */ - public async reject(event: Event): Promise { - const result = await this.next(event); - expect(result).to.have.property("done", true); - expect(result).to.have.property("value", undefined); - } - - /** - * Expects the BattleParser to throw. - * - * @param event Event to handle. - * @param errorCtor Error type. - * @param message Optional error message. - */ - public async error( - errorCtor: ErrorConstructor, - message?: string | RegExp, - ): Promise { - await expect(this.guaranteePctx().finish).to.eventually.be.rejectedWith( - errorCtor, - message, - ); - this.handledError = true; - } - - // TODO: Why not just return TResult and let the caller make its own - // assertions? - /** - * Expects the BattleParser to return after handling all the events or after - * rejecting one. - * - * @param ret Return value to compare, or a callback to verify it. - */ - public async return( - ret: TResult | ((ret: Promise) => PromiseLike), - ): Promise { - if (typeof ret === "function") { - const f = ret as (ret: Promise) => PromiseLike; - await f(this.guaranteePctx().finish); - } else { - await expect(this.guaranteePctx().finish).to.eventually.become(ret); - } - } - - /** - * Calls the ParserContext's {@link BattleIterator.next} while checking for - * errors. - */ - private async next(event: Event): ReturnType { - try { - return await this.guaranteePctx().battleIt.next(event); - } catch (e) { - // Rethrow while setting error state. - // If the caller expected this and handled it, we'll be able to - // continue as normal with #close() expecting the same error. - this.handledError = true; - throw e; - } - } - - /** Wraps an assertion around the ParserContext getter function. */ - private guaranteePctx(): ParserContext { - const pctx = this.pctx(); - if (!pctx) { - throw new Error("ParserContext not initialized"); - } - return pctx; - } -} diff --git a/src/ts/battle/parser/StateHelpers.test.ts b/src/ts/battle/parser/StateHelpers.test.ts deleted file mode 100644 index 0f83318a..00000000 --- a/src/ts/battle/parser/StateHelpers.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import {SideID} from "@pkmn/types"; -import {expect} from "chai"; -import {BattleState} from "../state"; -import {Pokemon} from "../state/Pokemon"; -import {SwitchOptions} from "../state/Team"; -import {smeargle} from "../state/switchOptions.test"; - -/** Helper class for manipulating the {@link BattleState}. */ -export class StateHelpers { - /** - * Constructs state helper functions. - * - * @param state Function that gets the battle state. This is called each - * time a method wants to access it in order to provide a level of - * indirection in case it gets reassigned later in a separate test. - */ - public constructor(private readonly state: () => BattleState) {} - - /** - * Initializes a team of pokemon, some of which may be unknown. The last - * defined one in the array will be switched in if any. - */ - public initTeam( - teamRef: SideID, - options: readonly (SwitchOptions | undefined)[], - ): Pokemon[] { - const team = this.state().getTeam(teamRef); - team.size = options.length; - const result: Pokemon[] = []; - let i = 0; - for (const op of options) { - if (!op) { - continue; - } - const mon = team.switchIn(op); - expect(mon, `Switch-in slot ${i} couldn't be filled`).to.not.be - .null; - result.push(mon!); - ++i; - } - return result; - } - - /** Initializes a team of one pokemon. */ - public initActive(monRef: SideID, options = smeargle, size = 1): Pokemon { - const opt = new Array(size); - opt[0] = options; - return this.initTeam(monRef, opt)[0]; - } -} diff --git a/src/ts/battle/parser/contextHelpers.test.ts b/src/ts/battle/parser/contextHelpers.test.ts new file mode 100644 index 00000000..a0f62c29 --- /dev/null +++ b/src/ts/battle/parser/contextHelpers.test.ts @@ -0,0 +1,141 @@ +import {expect} from "chai"; +import {Logger} from "../../utils/logging/Logger"; +import {Verbose} from "../../utils/logging/Verbose"; +import {Mutable} from "../../utils/types"; +import {Action} from "../agent"; +import {BattleState} from "../state"; +import {BattleParserContext, ExecutorResult} from "./BattleParser"; + +/** Creates a BattleParserContext suitable for tests. */ +export function createTestContext(): Mutable { + const ctx: BattleParserContext = { + agent: async () => + await Promise.reject( + new Error("BattleAgent expected to not be called"), + ), + logger: new Logger(Logger.null, Verbose.None), + executor: async () => + await Promise.reject( + new Error("ActionExecutor expected to not be called"), + ), + state: new BattleState("username"), + }; + ctx.state.started = true; + ctx.state.ourSide = "p1"; + return ctx; +} + +/** Result from {@link setupOverrideAgent}. */ +export interface OverrideAgent { + /** + * Resolves on the next `agent` call. + * + * After awaiting, modify the returned array then call {@link resolve} to + * mimic BattleAgent behavior. + */ + readonly receiveChoices: () => Promise; + /** Resolves the next `agent` promise. */ + readonly resolve: () => void; +} + +/** + * Adds BattleAgent override functionality to the BattleParserContext. + * + * Must be called from within a mocha `describe()` block. + */ +export function setupOverrideAgent( + ctx: () => Mutable, +): OverrideAgent { + let sendChoices: ((choices: Action[]) => void) | null; + let receiveChoices: Promise; + let resolve: (() => void) | null; + + function initReceiveChoices() { + sendChoices = null; + receiveChoices = new Promise( + res => (sendChoices = res), + ).finally(initReceiveChoices); // Reinit for next receive. + } + + // eslint-disable-next-line mocha/no-top-level-hooks + beforeEach("Override agent", function () { + initReceiveChoices(); + resolve = null; + ctx().agent = async function overrideAgent(state, choices) { + expect(ctx().state).to.equal(state, "Mismatched state"); + await new Promise(res => { + resolve = res; + expect(sendChoices).to.not.be.null; + sendChoices!(choices); + }).finally(() => (resolve = null)); + }; + }); + + return { + receiveChoices: async () => await receiveChoices, + resolve: () => { + expect(resolve, "receiveChoices() wasn't called and awaited").to.not + .be.null; + resolve!(); + }, + }; +} + +/** Result from {@link setupOverrideExecutor}. */ +export interface OverrideExecutor { + /** + * Resolves on the next `executor` call. + * + * After awaiting, call {@link resolve} with an {@link ExecutorResult} value + * to mimic ActionExecutor behavior. + */ + readonly receiveAction: () => Promise; + /** Resolves the next `executor` promise. */ + readonly resolve: (result: ExecutorResult) => void; +} + +/** + * Adds ActionExecutor override functionality to the BattleParserContext. + * + * Must be called from within a mocha `describe()` block. + */ +export function setupOverrideExecutor( + ctx: () => Mutable, +): OverrideExecutor { + let sendAction: ((action: Action) => void) | null; + let receiveAction: Promise; + let resolve: ((result: ExecutorResult) => void) | null; + + function initReceiveAction() { + sendAction = null; + receiveAction = new Promise(res => (sendAction = res)).finally( + initReceiveAction, + ); // Reinit for next receive. + } + + // eslint-disable-next-line mocha/no-top-level-hooks + beforeEach("Override sender", function () { + initReceiveAction(); + resolve = null; + ctx().executor = async function overrideExecutor(choice) { + try { + return await new Promise(res => { + resolve = res; + expect(sendAction).to.not.be.null; + sendAction!(choice); + }); + } finally { + resolve = null; + } + }; + }); + + return { + receiveAction: async () => await receiveAction, + resolve: (result: ExecutorResult) => { + expect(resolve, "receiveAction() wasn't called and awaited").to.not + .be.null; + resolve!(result); + }, + }; +} diff --git a/src/ts/battle/parser/events.test.ts b/src/ts/battle/parser/events.test.ts index e62b650d..22fab923 100644 --- a/src/ts/battle/parser/events.test.ts +++ b/src/ts/battle/parser/events.test.ts @@ -3,8 +3,8 @@ import {SideID} from "@pkmn/types"; import {expect} from "chai"; import "mocha"; import {Event} from "../../protocol/Event"; +import {Mutable} from "../../utils/types"; import * as dex from "../dex"; -import {BattleState} from "../state/BattleState"; import {SwitchOptions} from "../state/Team"; import {ReadonlyVolatileStatus} from "../state/VolatileStatus"; import { @@ -15,138 +15,136 @@ import { requestEvent, smeargle, } from "../state/switchOptions.test"; +import {BattleParserContext} from "./BattleParser"; import { - createInitialContext, - ParserContext, + createTestContext, setupOverrideAgent, setupOverrideExecutor, -} from "./Context.test"; -import {ParserHelpers} from "./ParserHelpers.test"; -import {dispatch} from "./events"; +} from "./contextHelpers.test"; +import {handlers} from "./events"; import { toAbilityName, toBoostIDs, toDetails, toEffectName, toFieldCondition, + toFormatName, toHPStatus, + toID, toIdent, toItemName, + toMoveName, toNickname, toNum, + toRequestJSON, + toRule, + toSearchID, + toSeed, toSide, toSideCondition, toSpeciesName, toTypes, - toWeather, - initParser, - toID, - toMoveName, - toRequestJSON, - toSeed, toUsername, - toRule, - toFormatName, - toSearchID, -} from "./helpers.test"; + toWeather, +} from "./protocolHelpers.test"; +import {initActive, initTeam} from "./stateHelpers.test"; +import {createDispatcher} from "./utils"; + +const dispatcher = createDispatcher(handlers); export const test = () => describe("events", function () { - const ictx = createInitialContext(); - const {sh} = ictx; + let ctx: Mutable; - let state: BattleState; - - beforeEach("Extract BattleState", function () { - state = ictx.getState(); - }); - - let pctx: - | ParserContext>> - | undefined; - const ph = new ParserHelpers(() => pctx); - - beforeEach("Initialize base BattleParser", function () { - pctx = initParser(ictx.startArgs, dispatch); + beforeEach("Initialize BattleParserContext", function () { + ctx = createTestContext(); }); - afterEach("Close ParserContext", async function () { - await ph.close().finally(() => (pctx = undefined)); - }); + const handle = async (event: Event) => + void (await expect(dispatcher(ctx, event)).to.eventually.be + .fulfilled); + const reject = async ( + event: Event, + constructor: ErrorConstructor | Error, + expected?: RegExp | string, + ) => + void (await expect( + dispatcher(ctx, event), + ).to.eventually.be.rejectedWith(constructor, expected)); describe("invalid event", function () { - it("Should reject and return null", async function () { - await ph.reject({ + it("Should ignore", async function () { + await handle({ args: ["invalid"], kwArgs: {}, } as unknown as Event); - await ph.return(null); }); }); describe("|init|", function () { it("Should do nothing", async function () { - await ph.handle({args: ["init", "battle"], kwArgs: {}}); - await ph.return(); + await handle({args: ["init", "battle"], kwArgs: {}}); }); }); describe("|player|", function () { it("Should set state.ourSide if username matches", async function () { - state.ourSide = undefined; - await ph.handle({ - args: ["player", "p2", toUsername(state.username), "", ""], + ctx.state.ourSide = undefined; + await handle({ + args: [ + "player", + "p2", + toUsername(ctx.state.username), + "", + "", + ], kwArgs: {}, }); - await ph.return(); - expect(state.ourSide).to.equal("p2"); + expect(ctx.state.ourSide).to.equal("p2"); }); it("Should skip if mentioning different player", async function () { - state.ourSide = undefined; - await ph.handle({ + ctx.state.ourSide = undefined; + await handle({ args: [ "player", "p1", - toUsername(state.username + "1"), + toUsername(ctx.state.username + "1"), "", "", ], kwArgs: {}, }); - await ph.return(); - expect(state.ourSide).to.be.undefined; + expect(ctx.state.ourSide).to.be.undefined; }); }); describe("|teamsize|", function () { it("Should set team size for opponent", async function () { - expect(state.getTeam("p2").size).to.equal(0); - await ph.handle({ + expect(ctx.state.getTeam("p2").size).to.equal(0); + await handle({ args: ["teamsize", "p2", toNum(4)], kwArgs: {}, }); - await ph.return(); - expect(state.getTeam("p2").size).to.equal(4); + expect(ctx.state.getTeam("p2").size).to.equal(4); }); it("Should skip setting team size for client", async function () { - expect(state.getTeam("p1").size).to.equal(0); - await ph.handle({ + expect(ctx.state.getTeam("p1").size).to.equal(0); + await handle({ args: ["teamsize", "p1", toNum(4)], kwArgs: {}, }); - await ph.return(); - expect(state.getTeam("p1").size).to.equal(0); + expect(ctx.state.getTeam("p1").size).to.equal(0); }); it("Should throw if state not fully initialized", async function () { - state.ourSide = undefined; - await ph.reject({ - args: ["teamsize", "p1", toNum(3)], - kwArgs: {}, - }); - await ph.error( + ctx.state.ourSide = undefined; + await reject( + { + args: ["teamsize", "p1", toNum(3)], + kwArgs: {}, + }, Error, "Expected |player| event for client before |teamsize| " + "event", @@ -156,102 +154,91 @@ export const test = () => describe("|gametype|", function () { it("Should do nothing", async function () { - await ph.handle({args: ["gametype", "singles"], kwArgs: {}}); - await ph.return(); + await handle({args: ["gametype", "singles"], kwArgs: {}}); }); }); describe("|gen|", function () { it("Should do nothing", async function () { - await ph.handle({args: ["gen", 4], kwArgs: {}}); - await ph.return(); + await handle({args: ["gen", 4], kwArgs: {}}); }); }); describe("|tier|", function () { it("Should do nothing", async function () { - await ph.handle({ + await handle({ args: ["tier", toFormatName("[Gen 4] Random Battle")], kwArgs: {}, }); - await ph.return(); }); }); describe("|rated|", function () { it("Should do nothing", async function () { - await ph.handle({args: ["rated"], kwArgs: {}}); - await ph.return(); + await handle({args: ["rated"], kwArgs: {}}); }); }); describe("|seed|", function () { it("Should do nothing", async function () { - await ph.handle({args: ["seed", toSeed("abc")], kwArgs: {}}); - await ph.return(); + await handle({args: ["seed", toSeed("abc")], kwArgs: {}}); }); }); describe("|rule|", function () { it("Should do nothing", async function () { - await ph.handle({ + await handle({ args: [ "rule", toRule("Sleep Clause: Limit one foe put to sleep"), ], kwArgs: {}, }); - await ph.return(); }); }); describe("|clearpoke|", function () { it("Should do nothing since unsupported", async function () { - await ph.handle({args: ["clearpoke"], kwArgs: {}}); - await ph.return(); + await handle({args: ["clearpoke"], kwArgs: {}}); }); }); describe("|poke|", function () { it("Should do nothing since unsupported", async function () { - await ph.handle({ + await handle({ args: ["poke", "p1", toDetails(), "item"], kwArgs: {}, }); - await ph.return(); }); }); describe("|teampreview|", function () { it("Should do nothing since unsupported", async function () { - await ph.handle({args: ["teampreview"], kwArgs: {}}); - await ph.return(); + await handle({args: ["teampreview"], kwArgs: {}}); }); }); describe("|updatepoke|", function () { it("Should do nothing since unsupported", async function () { - await ph.handle({ + await handle({ args: ["updatepoke", toIdent("p1"), toDetails()], kwArgs: {}, }); - await ph.return(); }); }); describe("|start|", function () { it("Should start the battle", async function () { - state.started = false; - await ph.handle({args: ["start"], kwArgs: {}}); - await ph.return(); - expect(state.started).to.be.true; + ctx.state.started = false; + await handle({args: ["start"], kwArgs: {}}); + expect(ctx.state.started).to.be.true; }); it("Should throw if state not fully initialized", async function () { - state.started = false; - state.ourSide = undefined; - await ph.reject({args: ["start"], kwArgs: {}}); - await ph.error( + ctx.state.started = false; + ctx.state.ourSide = undefined; + await reject( + {args: ["start"], kwArgs: {}}, Error, "Expected |player| event for client before |start event", ); @@ -260,18 +247,17 @@ export const test = () => describe("|done|", function () { it("Should do nothing", async function () { - await ph.handle({args: ["done"], kwArgs: {}}); - await ph.return(); + await handle({args: ["done"], kwArgs: {}}); }); }); describe("|request|", function () { - const agent = setupOverrideAgent(ictx); - const executor = setupOverrideExecutor(ictx); + const agent = setupOverrideAgent(() => ctx); + const executor = setupOverrideExecutor(() => ctx); describe("requestType = team", function () { it("Should do nothing since unsupported", async function () { - await ph.handle({ + await handle({ args: [ "request", toRequestJSON({ @@ -286,13 +272,12 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); }); }); describe("requestType = move", function () { it("Should update moves and send action", async function () { - const [, , mon] = sh.initTeam("p1", [ + const [, , mon] = initTeam(ctx.state, "p1", [ eevee, ditto, smeargle, @@ -300,7 +285,7 @@ export const test = () => expect(mon.moveset.reveal("ember").pp).to.equal(40); expect(mon.moveset.get("tackle")).to.be.null; - const p = ph.handle( + const p = handle( requestEvent( "move", [ @@ -331,26 +316,27 @@ export const test = () => ), ); - await expect(agent.choices()).to.eventually.have.members([ + await expect( + agent.receiveChoices(), + ).to.eventually.have.members([ "move 1", "move 2", "switch 2", "switch 3", ]); agent.resolve(); - await expect(executor.executed()).to.eventually.equal( + await expect(executor.receiveAction()).to.eventually.equal( "move 1", ); executor.resolve(false /*i.e., accept the action*/); await p; - await ph.return(); expect(mon.moveset.get("ember")!.pp).to.equal(10); expect(mon.moveset.get("tackle")).to.not.be.null; }); it("Should handle lockedmove pp", async function () { - const [, , mon] = sh.initTeam("p1", [ + const [, , mon] = initTeam(ctx.state, "p1", [ eevee, ditto, smeargle, @@ -358,7 +344,7 @@ export const test = () => expect(mon.moveset.reveal("outrage").pp).to.equal(24); expect(mon.moveset.reveal("ember").pp).to.equal(40); - const p = ph.handle( + const p = handle( requestEvent( "move", [ @@ -382,24 +368,23 @@ export const test = () => ); // Note: Only 1 choice so no agent call is expected. - await expect(executor.executed()).to.eventually.equal( + await expect(executor.receiveAction()).to.eventually.equal( "move 1", ); executor.resolve(false /*i.e., accept the action*/); await p; - await ph.return(); expect(mon.moveset.get("outrage")!.pp).to.equal(24); expect(mon.moveset.get("ember")!.pp).to.equal(40); }); it("Should handle switch rejection via trapping ability", async function () { - sh.initTeam("p1", [eevee, ditto, smeargle]); + initTeam(ctx.state, "p1", [eevee, ditto, smeargle]); - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); mon.setAbility("shadowtag"); - const p = ph.handle( + const p = handle( requestEvent("move", benchInfo, { moves: [ { @@ -421,7 +406,7 @@ export const test = () => ); // Execute a switch action. - const c = await agent.choices(); + const c = await agent.receiveChoices(); expect(c).to.have.members([ "move 1", "move 2", @@ -438,23 +423,22 @@ export const test = () => expect(agent.resolve).to.not.be.null; agent.resolve(); // Switch action was rejected due to a trapping ability. - await expect(executor.executed()).to.eventually.equal( + await expect(executor.receiveAction()).to.eventually.equal( "switch 2", ); executor.resolve("trapped"); // Execute a new action after eliminating switch choices. - await expect(executor.executed()).to.eventually.equal( + await expect(executor.receiveAction()).to.eventually.equal( "move 2", ); executor.resolve(false /*i.e., accept the action*/); await p; - await ph.return(); }); it("Should send final choice if all actions were rejected", async function () { - const [, , mon] = sh.initTeam("p1", [ + const [, , mon] = initTeam(ctx.state, "p1", [ eevee, ditto, smeargle, @@ -462,7 +446,7 @@ export const test = () => expect(mon.moveset.reveal("ember").pp).to.equal(40); expect(mon.moveset.get("tackle")).to.be.null; - const p = ph.handle( + const p = handle( requestEvent( "move", [ @@ -493,7 +477,7 @@ export const test = () => ), ); - const choices = await agent.choices(); + const choices = await agent.receiveChoices(); expect(choices).to.have.members([ "move 1", "move 2", @@ -508,11 +492,11 @@ export const test = () => "move 2", ]); agent.resolve(); - await expect(executor.executed()).to.eventually.equal( + await expect(executor.receiveAction()).to.eventually.equal( "move 1", ); executor.resolve(true /*i.e., reject the action*/); - await expect(executor.executed()).to.eventually.equal( + await expect(executor.receiveAction()).to.eventually.equal( "switch 3", ); expect(choices).to.have.members([ @@ -521,24 +505,23 @@ export const test = () => "move 2", ]); executor.resolve(true); - await expect(executor.executed()).to.eventually.equal( + await expect(executor.receiveAction()).to.eventually.equal( "switch 2", ); expect(choices).to.have.members(["switch 2", "move 2"]); executor.resolve(true); // Send last remaining choice. - await expect(executor.executed()).to.eventually.equal( + await expect(executor.receiveAction()).to.eventually.equal( "move 2", ); expect(choices).to.have.members(["move 2"]); executor.resolve(false /*i.e., accept the action*/); await p; - await ph.return(); }); it("Should throw if all actions are rejected", async function () { - const [, , mon] = sh.initTeam("p1", [ + const [, , mon] = initTeam(ctx.state, "p1", [ eevee, ditto, smeargle, @@ -546,47 +529,40 @@ export const test = () => expect(mon.moveset.reveal("ember").pp).to.equal(40); expect(mon.moveset.get("tackle")).to.be.null; - const p = ph - .reject( - requestEvent( - "move", - [ + const p = reject( + requestEvent( + "move", + [ + { + ...benchInfo[0], + moves: [toID("tackle"), toID("ember")], + }, + ...benchInfo.slice(1), + ], + { + moves: [ + { + id: toID("tackle"), + name: toMoveName("tackle"), + pp: 32, + maxpp: 32, + target: "normal", + }, { - ...benchInfo[0], - moves: [toID("tackle"), toID("ember")], + id: toID("ember"), + name: toMoveName("ember"), + pp: 10, + maxpp: 40, + target: "normal", }, - ...benchInfo.slice(1), ], - { - moves: [ - { - id: toID("tackle"), - name: toMoveName("tackle"), - pp: 32, - maxpp: 32, - target: "normal", - }, - { - id: toID("ember"), - name: toMoveName("ember"), - pp: 10, - maxpp: 40, - target: "normal", - }, - ], - }, - ), - ) - .then( - async () => - await ph.error( - Error, - "Final choice 'move 2' was rejected as " + - "'true'", - ), - ); + }, + ), + Error, + "Final choice 'move 2' was rejected as " + "'true'", + ); - const choices = await agent.choices(); + const choices = await agent.receiveChoices(); expect(choices).to.have.members([ "move 1", "move 2", @@ -601,11 +577,11 @@ export const test = () => "move 2", ]); agent.resolve(); - await expect(executor.executed()).to.eventually.equal( + await expect(executor.receiveAction()).to.eventually.equal( "move 1", ); executor.resolve(true /*i.e., reject the action*/); - await expect(executor.executed()).to.eventually.equal( + await expect(executor.receiveAction()).to.eventually.equal( "switch 3", ); expect(choices).to.have.members([ @@ -614,13 +590,13 @@ export const test = () => "move 2", ]); executor.resolve(true); - await expect(executor.executed()).to.eventually.equal( + await expect(executor.receiveAction()).to.eventually.equal( "switch 2", ); expect(choices).to.have.members(["switch 2", "move 2"]); executor.resolve(true); // Send last remaining choice. - await expect(executor.executed()).to.eventually.equal( + await expect(executor.receiveAction()).to.eventually.equal( "move 2", ); expect(choices).to.have.members(["move 2"]); @@ -631,14 +607,14 @@ export const test = () => describe("state.started = false", function () { beforeEach("state.started = false", function () { - state.started = false; + ctx.state.started = false; }); it("Should initialize team", async function () { - const team = state.getTeam("p1"); + const team = ctx.state.getTeam("p1"); expect(team.size).to.equal(0); - await ph.handle( + await handle( requestEvent( "move", [ @@ -685,16 +661,15 @@ export const test = () => }, ), ); - await ph.return(); expect(team.size).to.equal(1); expect(() => team.active).to.not.throw(); }); it("Should initialize team with hiddenpower type and happiness annotations", async function () { - const team = state.getTeam("p1"); + const team = ctx.state.getTeam("p1"); expect(team.size).to.equal(0); - await ph.handle( + await handle( requestEvent( "move", [ @@ -755,7 +730,6 @@ export const test = () => }, ), ); - await ph.return(); expect(team.size).to.equal(1); expect(() => team.active).to.not.throw(); expect(team.active.happiness).to.equal(255); @@ -771,13 +745,13 @@ export const test = () => hpMax: 55, }; - await ph.handle( + await handle( requestEvent("move", [ { active: true, details: toDetails(deoxysdefense), - // Note: PS can sometimes omit the form - // name in the ident. + // Note: PS can sometimes omit the form name + // in the ident. ident: toIdent("p1", { ...deoxysdefense, species: "deoxys", @@ -811,35 +785,32 @@ export const test = () => }, ]), ); - await ph.return(); }); }); }); describe("requestType = switch", function () { it("Should consider only switch actions", async function () { - sh.initTeam("p1", [eevee, ditto, smeargle]); + initTeam(ctx.state, "p1", [eevee, ditto, smeargle]); - const p = ph.handle(requestEvent("switch", benchInfo)); + const p = handle(requestEvent("switch", benchInfo)); - await expect(agent.choices()).to.eventually.have.members([ - "switch 2", - "switch 3", - ]); + await expect( + agent.receiveChoices(), + ).to.eventually.have.members(["switch 2", "switch 3"]); agent.resolve(); - await expect(executor.executed()).to.eventually.equal( + await expect(executor.receiveAction()).to.eventually.equal( "switch 2", ); executor.resolve(false /*i.e., accept the action*/); await p; - await ph.return(); }); }); describe("requestType = wait", function () { it("Should do nothing", async function () { - await ph.handle({ + await handle({ args: [ "request", toRequestJSON({ @@ -850,15 +821,13 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); }); }); }); describe("|turn|", function () { it("Should handle", async function () { - await ph.handle({args: ["turn", toNum(2)], kwArgs: {}}); - await ph.return(); + await handle({args: ["turn", toNum(2)], kwArgs: {}}); }); }); @@ -873,13 +842,12 @@ export const test = () => }); it("Should reveal move and deduct pp", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.moveset.get("tackle")).to.be.null; expect(mon.volatile.lastMove).to.be.null; - sh.initActive("p2"); + initActive(ctx.state, "p2"); - await ph.handle(moveEvent("p1", "tackle")); - await ph.return(); + await handle(moveEvent("p1", "tackle")); const move = mon.moveset.get("tackle"); expect(move).to.not.be.null; expect(move).to.have.property("pp", 55); @@ -888,52 +856,49 @@ export const test = () => }); it("Should not reveal move if from lockedmove", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.moveset.get("tackle")).to.be.null; expect(mon.volatile.lastMove).to.be.null; - sh.initActive("p2"); + initActive(ctx.state, "p2"); - await ph.handle( + await handle( moveEvent("p1", "tackle", { from: toEffectName("lockedmove"), }), ); - await ph.return(); expect(mon.moveset.get("tackle")).to.be.null; expect(mon.volatile.lastMove).to.equal("tackle"); }); it("Should not deduct pp if from lockedmove", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); const move = mon.moveset.reveal("tackle"); expect(move).to.have.property("pp", 56); expect(move).to.have.property("maxpp", 56); expect(mon.volatile.lastMove).to.be.null; - sh.initActive("p2"); + initActive(ctx.state, "p2"); - await ph.handle( + await handle( moveEvent("p1", "tackle", { from: toEffectName("lockedmove"), }), ); - await ph.return(); expect(move).to.have.property("pp", 56); expect(move).to.have.property("maxpp", 56); expect(mon.volatile.lastMove).to.equal("tackle"); }); it("Should still set last move if from pursuit", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.moveset.get("pursuit")).to.be.null; expect(mon.volatile.lastMove).to.be.null; - sh.initActive("p2"); + initActive(ctx.state, "p2"); - await ph.handle( + await handle( moveEvent("p1", "pursuit", { from: toEffectName("pursuit", "move"), }), ); - await ph.return(); expect(mon.moveset.get("pursuit")).to.be.null; expect(mon.volatile.lastMove).to.equal("pursuit"); }); @@ -941,18 +906,17 @@ export const test = () => describe("multi-turn move", function () { describe("rampage move", function () { it("Should start rampage move status", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.volatile.rampage.isActive).to.be.false; - sh.initActive("p2"); + initActive(ctx.state, "p2"); - await ph.handle(moveEvent("p1", "outrage")); - await ph.return(); + await handle(moveEvent("p1", "outrage")); expect(mon.volatile.rampage.isActive).to.be.true; expect(mon.volatile.rampage.type).to.equal("outrage"); }); it("Should continue rampage move status", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.rampage.start("petaldance"); expect(mon.volatile.rampage.isActive).to.be.true; expect(mon.volatile.rampage.type).to.equal( @@ -960,12 +924,11 @@ export const test = () => ); expect(mon.volatile.rampage.turns).to.equal(0); - await ph.handle( + await handle( moveEvent("p1", "petaldance", { from: toEffectName("lockedmove"), }), ); - await ph.return(); expect(mon.volatile.rampage.isActive).to.be.true; expect(mon.volatile.rampage.type).to.equal( "petaldance", @@ -974,161 +937,150 @@ export const test = () => }); it("Should restart rampage if different move", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.rampage.start("outrage"); expect(mon.volatile.rampage.isActive).to.be.true; expect(mon.volatile.rampage.type).to.equal("outrage"); expect(mon.volatile.rampage.turns).to.equal(0); - sh.initActive("p2"); + initActive(ctx.state, "p2"); - await ph.handle(moveEvent("p1", "thrash")); - await ph.return(); + await handle(moveEvent("p1", "thrash")); expect(mon.volatile.rampage.isActive).to.be.true; expect(mon.volatile.rampage.type).to.equal("thrash"); expect(mon.volatile.rampage.turns).to.equal(0); }); it("Should reset rampage if unrelated move", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.rampage.start("thrash"); expect(mon.volatile.rampage.isActive).to.be.true; - await ph.handle(moveEvent("p1", "splash")); - await ph.return(); + await handle(moveEvent("p1", "splash")); expect(mon.volatile.rampage.isActive).to.be.false; }); it("Should reset rampage if notarget", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.rampage.start("outrage"); expect(mon.volatile.rampage.isActive).to.be.true; - await ph.handle( + await handle( moveEvent("p1", "outrage", { from: toEffectName("lockedmove"), notarget: true, }), ); - await ph.return(); expect(mon.volatile.rampage.isActive).to.be.false; }); it("Should not reset rampage if miss", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.rampage.start("petaldance"); expect(mon.volatile.rampage.isActive).to.be.true; - await ph.handle( + await handle( moveEvent("p1", "petaldance", { from: toEffectName("lockedmove"), miss: true, }), ); - await ph.return(); expect(mon.volatile.rampage.isActive).to.be.true; }); }); describe("momentum move", function () { it("Should start momentum move status", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.volatile.momentum.isActive).to.be.false; - sh.initActive("p2"); + initActive(ctx.state, "p2"); - await ph.handle(moveEvent("p1", "rollout")); - await ph.return(); + await handle(moveEvent("p1", "rollout")); expect(mon.volatile.momentum.isActive).to.be.true; expect(mon.volatile.momentum.type).to.equal("rollout"); }); it("Should continue momentum move status", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.momentum.start("iceball"); expect(mon.volatile.momentum.isActive).to.be.true; expect(mon.volatile.momentum.type).to.equal("iceball"); expect(mon.volatile.momentum.turns).to.equal(0); - await ph.handle( + await handle( moveEvent("p1", "iceball", { from: toEffectName("lockedmove"), }), ); - await ph.return(); expect(mon.volatile.momentum.isActive).to.be.true; expect(mon.volatile.momentum.type).to.equal("iceball"); expect(mon.volatile.momentum.turns).to.equal(1); }); it("Should restart momentum if different move", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.momentum.start("rollout"); expect(mon.volatile.momentum.isActive).to.be.true; expect(mon.volatile.momentum.type).to.equal("rollout"); expect(mon.volatile.momentum.turns).to.equal(0); - sh.initActive("p2"); + initActive(ctx.state, "p2"); - await ph.handle(moveEvent("p1", "iceball")); - await ph.return(); + await handle(moveEvent("p1", "iceball")); expect(mon.volatile.momentum.isActive).to.be.true; expect(mon.volatile.momentum.type).to.equal("iceball"); expect(mon.volatile.momentum.turns).to.equal(0); }); it("Should reset momentum if unrelated move", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.momentum.start("iceball"); expect(mon.volatile.momentum.isActive).to.be.true; - await ph.handle(moveEvent("p1", "splash")); - await ph.return(); + await handle(moveEvent("p1", "splash")); expect(mon.volatile.momentum.isActive).to.be.false; }); it("Should reset momentum if notarget", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.momentum.start("rollout"); expect(mon.volatile.momentum.isActive).to.be.true; - await ph.handle( + await handle( moveEvent("p1", "rollout", { from: toEffectName("lockedmove"), notarget: true, }), ); - await ph.return(); expect(mon.volatile.momentum.isActive).to.be.false; }); it("Should reset momentum if miss", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.momentum.start("iceball"); expect(mon.volatile.momentum.isActive).to.be.true; - await ph.handle( + await handle( moveEvent("p1", "iceball", { from: toEffectName("lockedmove"), miss: true, }), ); - await ph.return(); expect(mon.volatile.momentum.isActive).to.be.false; }); }); describe("two-turn move", function () { it("Should release two-turn move", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.twoTurn.start("fly"); expect(mon.volatile.twoTurn.isActive).to.be.true; expect(mon.volatile.twoTurn.type).to.equal("fly"); mon.volatile.twoTurn.tick(); - await ph.handle( + await handle( moveEvent("p1", "fly", { from: toEffectName("lockedmove"), }), ); - await ph.return(); expect(mon.volatile.twoTurn.isActive).to.be.false; }); }); @@ -1137,117 +1089,106 @@ export const test = () => describe("start implicit move statuses", function () { for (const move of ["defensecurl", "minimize"] as const) { it(`Should start ${move}`, async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.volatile[move]).to.be.false; - await ph.handle(moveEvent("p1", move)); - await ph.return(); + await handle(moveEvent("p1", move)); expect(mon.volatile[move]).to.be.true; }); } for (const move of ["healingwish", "lunardance"] as const) { it(`Should start ${move} and set self-switch`, async function () { - const team = sh.initActive("p1").team!; + const team = initActive(ctx.state, "p1").team!; expect(team.status[move]).to.be.false; expect(team.status.selfSwitch).to.be.null; - await ph.handle(moveEvent("p1", move)); - await ph.return(); + await handle(moveEvent("p1", move)); expect(team.status[move]).to.be.true; expect(team.status.selfSwitch).to.be.true; }); } it("Should start wish", async function () { - const team = sh.initActive("p1").team!; + const team = initActive(ctx.state, "p1").team!; expect(team.status.wish.isActive).to.be.false; - await ph.handle(moveEvent("p1", "wish")); - await ph.return(); + await handle(moveEvent("p1", "wish")); expect(team.status.wish.isActive).to.be.true; }); it("Should set self-switch if applicable", async function () { - const team = sh.initActive("p1").team!; + const team = initActive(ctx.state, "p1").team!; expect(team.status.selfSwitch).to.be.null; - await ph.handle(moveEvent("p1", "batonpass")); - await ph.return(); + await handle(moveEvent("p1", "batonpass")); expect(team.status.selfSwitch).to.equal("copyvolatile"); }); }); describe("consume implicit move statuses", function () { it("Should reset micleberry status", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.micleberry = true; - await ph.handle(moveEvent("p1", "splash")); - await ph.return(); + await handle(moveEvent("p1", "splash")); expect(mon.volatile.micleberry).to.be.false; }); it("Should reset single-move statuses", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.destinybond = true; - await ph.handle(moveEvent("p1", "splash")); - await ph.return(); + await handle(moveEvent("p1", "splash")); expect(mon.volatile.destinybond).to.be.false; }); it("Should reset focuspunch status", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.focus = true; - sh.initActive("p2"); + initActive(ctx.state, "p2"); - await ph.handle(moveEvent("p1", "focuspunch")); - await ph.return(); + await handle(moveEvent("p1", "focuspunch")); expect(mon.volatile.focus).to.be.false; }); it("Should reset stall counter if not a stall move", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.stall(true); expect(mon.volatile.stalling).to.be.true; - await ph.handle(moveEvent("p1", "splash")); - await ph.return(); + await handle(moveEvent("p1", "splash")); expect(mon.volatile.stalling).to.be.false; }); it("Should not reset stall counter if using stall move", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.stall(true); expect(mon.volatile.stalling).to.be.true; - await ph.handle(moveEvent("p1", "detect")); - await ph.return(); + await handle(moveEvent("p1", "detect")); expect(mon.volatile.stalling).to.be.true; }); it("Should reset stall counter if stall move failed", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.stall(true); expect(mon.volatile.stalling).to.be.true; // Note: Indicates upcoming |-fail| event in this context. - await ph.handle(moveEvent("p1", "detect", {still: true})); - await ph.return(); + await handle(moveEvent("p1", "detect", {still: true})); expect(mon.volatile.stalling).to.be.false; }); }); describe("pressure", function () { it("Should deduct extra pp if targeting pressure ability holder", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.moveset.get("tackle")).to.be.null; expect(mon.volatile.lastMove).to.be.null; - sh.initActive("p2").setAbility("pressure"); + initActive(ctx.state, "p2").setAbility("pressure"); - await ph.handle(moveEvent("p1", "tackle")); - await ph.return(); + await handle(moveEvent("p1", "tackle")); const move = mon.moveset.get("tackle"); expect(move).to.not.be.null; expect(move).to.have.property("pp", 54); @@ -1256,32 +1197,30 @@ export const test = () => }); it("Should still not deduct pp if from lockedmove", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); const move = mon.moveset.reveal("tackle"); expect(move).to.have.property("pp", 56); expect(move).to.have.property("maxpp", 56); expect(mon.volatile.lastMove).to.be.null; - sh.initActive("p2").setAbility("pressure"); + initActive(ctx.state, "p2").setAbility("pressure"); - await ph.handle( + await handle( moveEvent("p1", "tackle", { from: toEffectName("lockedmove"), }), ); - await ph.return(); expect(move).to.have.property("pp", 56); expect(move).to.have.property("maxpp", 56); expect(mon.volatile.lastMove).to.equal("tackle"); }); it("Should deduct normal pp if not targeting pressure ability holder", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.moveset.get("splash")).to.be.null; expect(mon.volatile.lastMove).to.be.null; - sh.initActive("p2").setAbility("pressure"); + initActive(ctx.state, "p2").setAbility("pressure"); - await ph.handle(moveEvent("p1", "splash")); - await ph.return(); + await handle(moveEvent("p1", "splash")); const move = mon.moveset.get("splash"); expect(move).to.not.be.null; expect(move).to.have.property("pp", 63); @@ -1292,32 +1231,30 @@ export const test = () => describe("called move", function () { it("Should not reveal move", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.moveset.get("tackle")).to.be.null; - await ph.handle( + await handle( moveEvent("p1", "tackle", { from: toEffectName("metronome", "move"), }), ); - await ph.return(); expect(mon.moveset.get("tackle")).to.be.null; }); it("Should not update single-move, focus, lastMove, or stall", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.destinybond = true; mon.volatile.focus = true; expect(mon.volatile.lastMove).to.be.null; mon.volatile.stall(true); expect(mon.volatile.stalling).to.be.true; - await ph.handle( + await handle( moveEvent("p1", "tackle", { from: toEffectName("metronome", "move"), }), ); - await ph.return(); expect(mon.volatile.destinybond).to.be.true; expect(mon.volatile.focus).to.be.true; expect(mon.volatile.lastMove).to.be.null; @@ -1325,15 +1262,14 @@ export const test = () => }); it("Should reveal move if calling from user's moveset via sleeptalk", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.moveset.get("tackle")).to.be.null; - await ph.handle( + await handle( moveEvent("p1", "tackle", { from: toEffectName("sleeptalk", "move"), }), ); - await ph.return(); const move = mon.moveset.get("tackle"); expect(move).to.not.be.null; expect(move).to.have.property("pp", 56); @@ -1341,17 +1277,16 @@ export const test = () => }); it("Should reveal move if calling from target's moveset via mefirst", async function () { - const mon1 = sh.initActive("p1"); + const mon1 = initActive(ctx.state, "p1"); expect(mon1.moveset.get("tackle")).to.be.null; - const mon2 = sh.initActive("p2"); + const mon2 = initActive(ctx.state, "p2"); expect(mon2.moveset.get("tackle")).to.be.null; - await ph.handle( + await handle( moveEvent("p1", "tackle", { from: toEffectName("mefirst", "move"), }), ); - await ph.return(); expect(mon1.moveset.get("tackle")).to.be.null; const move = mon2.moveset.get("tackle"); expect(move).to.not.be.null; @@ -1363,10 +1298,10 @@ export const test = () => describe("|switch|", function () { it("Should handle switch-in", async function () { - sh.initActive("p1"); - sh.initActive("p2", smeargle, 2 /*size*/); + initActive(ctx.state, "p1"); + initActive(ctx.state, "p2", smeargle, 2 /*size*/); - await ph.handle({ + await handle({ args: [ "switch", toIdent("p2", ditto), @@ -1375,16 +1310,15 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); }); }); describe("|drag|", function () { it("Should handle forced switch-in", async function () { - sh.initActive("p1"); - sh.initActive("p2", smeargle, 2 /*size*/); + initActive(ctx.state, "p1"); + initActive(ctx.state, "p2", smeargle, 2 /*size*/); - await ph.handle({ + await handle({ args: [ "drag", toIdent("p2", ditto), @@ -1393,17 +1327,16 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); }); }); describe("|detailschange|", function () { it("Should handle permanent form change", async function () { - const mon = sh.initActive("p1", smeargle); + const mon = initActive(ctx.state, "p1", smeargle); expect(mon.species).to.equal("smeargle"); expect(mon.baseSpecies).to.equal("smeargle"); - await ph.handle({ + await handle({ args: [ "detailschange", toIdent("p1", ditto), @@ -1411,7 +1344,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.species).to.equal("ditto"); expect(mon.baseSpecies).to.equal("ditto"); }); @@ -1419,22 +1351,21 @@ export const test = () => describe("|cant|", function () { it("Should handle inactivity and clear single-move statuses", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.destinybond = true; - await ph.handle({ + await handle({ args: ["cant", toIdent("p1"), "flinch"], kwArgs: {}, }); - await ph.return(); }); it("Should reveal move if mentioned", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.destinybond = true; expect(mon.moveset.get("tackle")).to.be.null; - await ph.handle({ + await handle({ args: [ "cant", toIdent("p1"), @@ -1443,19 +1374,18 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.moveset.get("tackle")).to.not.be.null; }); describe("reason = Damp ability", function () { it("Should reveal blocking ability", async function () { - const mon1 = sh.initActive("p1"); + const mon1 = initActive(ctx.state, "p1"); expect(mon1.moveset.get("selfdestruct")).to.be.null; - const mon2 = sh.initActive("p2"); + const mon2 = initActive(ctx.state, "p2"); expect(mon2.ability).to.be.empty; expect(mon2.baseAbility).to.be.empty; - await ph.handle({ + await handle({ args: [ "cant", toIdent("p1"), @@ -1464,7 +1394,6 @@ export const test = () => ], kwArgs: {of: toIdent("p2")}, }); - await ph.return(); expect(mon1.moveset.get("selfdestruct")).to.not.be.null; expect(mon2.ability).to.equal("damp"); expect(mon2.baseAbility).to.equal("damp"); @@ -1473,26 +1402,25 @@ export const test = () => describe("reason = Focus Punch move", function () { it("Should reset focus status", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.focus = true; - await ph.handle({ + await handle({ args: ["cant", toIdent("p1"), toMoveName("focuspunch")], kwArgs: {}, }); - await ph.return(); expect(mon.volatile.focus).to.be.false; }); }); describe("reason = Imprison move", function () { it("Should reveal move for both sides", async function () { - const us = sh.initActive("p1").moveset; - const them = sh.initActive("p2").moveset; + const us = initActive(ctx.state, "p1").moveset; + const them = initActive(ctx.state, "p2").moveset; expect(us.get("splash")).to.be.null; expect(them.get("splash")).to.be.null; - await ph.handle({ + await handle({ args: [ "cant", toIdent("p2"), @@ -1501,7 +1429,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(us.get("splash")).to.not.be.null; expect(them.get("splash")).to.not.be.null; }); @@ -1509,10 +1436,10 @@ export const test = () => describe("reason = nopp", function () { it("Should not reveal move", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.moveset.get("encore")).to.be.null; - await ph.handle({ + await handle({ args: [ "cant", toIdent("p1"), @@ -1521,36 +1448,33 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.moveset.get("encore")).to.be.null; }); }); describe("reason = recharge", function () { it("Should reset mustRecharge status", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.mustRecharge = true; - await ph.handle({ + await handle({ args: ["cant", toIdent("p1"), "recharge"], kwArgs: {}, }); - await ph.return(); expect(mon.volatile.mustRecharge).to.be.false; }); }); describe("reason = slp", function () { it("Should tick slp turns", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.majorStatus.afflict("slp"); expect(mon.majorStatus.turns).to.equal(1); - await ph.handle({ + await handle({ args: ["cant", toIdent("p1"), "slp"], kwArgs: {}, }); - await ph.return(); expect(mon.majorStatus.turns).to.equal(2); }); }); @@ -1558,12 +1482,12 @@ export const test = () => describe("reason = Truant ability", function () { it("Should flip Truant state", async function () { // First make sure the pokemon has truant. - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.setAbility("truant"); expect(mon.volatile.willTruant).to.be.false; // Also flipped back on postTurn to sync with this event. - await ph.handle({ + await handle({ args: [ "cant", toIdent("p1"), @@ -1571,19 +1495,18 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); // Note: postTurn() will flip this to properly sync. expect(mon.volatile.willTruant).to.be.true; }); it("Should overlap Truant turn with recharge turn", async function () { // First make sure the pokemon has truant. - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.setAbility("truant"); expect(mon.volatile.willTruant).to.be.false; mon.volatile.mustRecharge = true; - await ph.handle({ + await handle({ args: [ "cant", toIdent("p1"), @@ -1591,7 +1514,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.volatile.willTruant).to.be.true; expect(mon.volatile.mustRecharge).to.be.false; }); @@ -1600,22 +1522,21 @@ export const test = () => describe("|faint|", function () { it("Should set hp to 0", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); expect(mon.hp.current).to.equal(100); - await ph.handle({args: ["faint", toIdent("p2")], kwArgs: {}}); - await ph.return(); + await handle({args: ["faint", toIdent("p2")], kwArgs: {}}); expect(mon.hp.current).to.equal(0); }); }); describe("|-formechange|", function () { it("Should handle temporary form change", async function () { - const mon = sh.initActive("p1", smeargle); + const mon = initActive(ctx.state, "p1", smeargle); expect(mon.species).to.equal("smeargle"); expect(mon.baseSpecies).to.equal("smeargle"); - await ph.handle({ + await handle({ args: [ "-formechange", toIdent("p1", smeargle), @@ -1623,18 +1544,17 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.species).to.equal("ditto"); expect(mon.baseSpecies).to.equal("smeargle"); }); it("Should reveal forecast", async function () { - state.status.weather.start("SunnyDay"); - const mon = sh.initActive("p1", castform); + ctx.state.status.weather.start("SunnyDay"); + const mon = initActive(ctx.state, "p1", castform); expect(mon.species).to.equal("castform"); expect(mon.baseSpecies).to.equal("castform"); - await ph.handle({ + await handle({ args: [ "-formechange", toIdent("p1", castform), @@ -1642,7 +1562,6 @@ export const test = () => ], kwArgs: {from: toEffectName("forecast", "ability")}, }); - await ph.return(); expect(mon.species).to.equal("castformsunny"); expect(mon.baseSpecies).to.equal("castform"); expect(mon.ability).to.equal("forecast"); @@ -1651,24 +1570,22 @@ export const test = () => describe("|-fail|", function () { it("Should do nothing", async function () { - await ph.handle({args: ["-fail", toIdent("p1")], kwArgs: {}}); - await ph.return(); + await handle({args: ["-fail", toIdent("p1")], kwArgs: {}}); }); it("Should reveal ability that caused the move failure", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); mon.revealAbility(""); expect(mon.ability).to.be.empty; expect(mon.baseAbility).to.be.empty; - await ph.handle({ + await handle({ args: ["-fail", toIdent("p1"), toEffectName("unboost")], kwArgs: { from: toEffectName("clearbody", "ability"), of: toIdent("p2"), }, }); - await ph.return(); expect(mon.ability).to.equal("clearbody"); expect(mon.baseAbility).to.equal("clearbody"); }); @@ -1676,144 +1593,133 @@ export const test = () => describe("|-block|", function () { it("Should do nothing since unsupported", async function () { - await ph.handle({ + await handle({ args: ["-block", toIdent("p1"), toEffectName("Dynamax")], kwArgs: {}, }); - await ph.return(); }); }); describe("|-notarget|", function () { it("Should do nothing", async function () { - await ph.handle({args: ["-notarget"], kwArgs: {}}); - await ph.return(); + await handle({args: ["-notarget"], kwArgs: {}}); }); }); describe("|-miss|", function () { it("Should do nothing", async function () { - await ph.handle({args: ["-miss", toIdent("p2")], kwArgs: {}}); - await ph.return(); + await handle({args: ["-miss", toIdent("p2")], kwArgs: {}}); }); }); describe("|-damage|", function () { it("Should set hp", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); expect(mon.hp.current).to.equal(100); - await ph.handle({ + await handle({ args: ["-damage", toIdent("p2"), toHPStatus(64)], kwArgs: {}, }); - await ph.return(); expect(mon.hp.current).to.equal(64); }); it("Should reveal ability that caused damage to self", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); mon.revealAbility(""); expect(mon.ability).to.be.empty; expect(mon.baseAbility).to.be.empty; - await ph.handle({ + await handle({ args: ["-damage", toIdent("p2"), toHPStatus(90)], kwArgs: {from: toEffectName("solarpower", "ability")}, }); - await ph.return(); expect(mon.ability).to.equal("solarpower"); expect(mon.baseAbility).to.equal("solarpower"); }); it("Should reveal ability that caused damage to target", async function () { - sh.initActive("p2"); - const mon = sh.initActive("p1"); + initActive(ctx.state, "p2"); + const mon = initActive(ctx.state, "p1"); mon.revealAbility(""); expect(mon.ability).to.be.empty; expect(mon.baseAbility).to.be.empty; - await ph.handle({ + await handle({ args: ["-damage", toIdent("p2"), toHPStatus(90)], kwArgs: { from: toEffectName("roughskin", "ability"), of: toIdent("p1"), }, }); - await ph.return(); expect(mon.ability).to.equal("roughskin"); expect(mon.baseAbility).to.equal("roughskin"); }); it("Should reveal item that caused damage to self", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); expect(mon.item).to.be.empty; - await ph.handle({ + await handle({ args: ["-damage", toIdent("p2"), toHPStatus(90)], kwArgs: {from: toEffectName("lifeorb", "item")}, }); - await ph.return(); expect(mon.item).to.equal("lifeorb"); }); }); describe("|-heal|", function () { it("Should set hp", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); mon.hp.set(43); - await ph.handle({ + await handle({ args: ["-heal", toIdent("p2"), toHPStatus(92)], kwArgs: {}, }); - await ph.return(); expect(mon.hp.current).to.equal(92); }); it("Should reveal ability that caused self-heal", async function () { - state.status.weather.start("Hail"); - const mon = sh.initActive("p2"); + ctx.state.status.weather.start("Hail"); + const mon = initActive(ctx.state, "p2"); mon.hp.set(90); mon.revealAbility(""); expect(mon.ability).to.be.empty; expect(mon.baseAbility).to.be.empty; - await ph.handle({ + await handle({ args: ["-heal", toIdent("p2"), toHPStatus(95)], kwArgs: {from: toEffectName("icebody", "ability")}, }); - await ph.return(); expect(mon.ability).to.equal("icebody"); expect(mon.baseAbility).to.equal("icebody"); }); it("Should reveal item that caused self-heal", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); mon.hp.set(90); expect(mon.item).to.be.empty; - await ph.handle({ + await handle({ args: ["-heal", toIdent("p2"), toHPStatus(95)], kwArgs: {from: toEffectName("leftovers", "item")}, }); - await ph.return(); expect(mon.item).to.equal("leftovers"); }); it("Should consume lunardance status and restore move pp if mentioned", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); mon.team!.status.lunardance = true; mon.hp.set(31); mon.majorStatus.afflict("slp"); const move = mon.moveset.reveal("tackle"); move.pp = 3; - await ph.handle({ + await handle({ args: ["-heal", toIdent("p2"), toHPStatus(100)], kwArgs: {from: toEffectName("lunardance", "move")}, }); - await ph.return(); expect(mon.hp.current).to.equal(100); expect(mon.majorStatus.current).to.be.null; expect(mon.team!.status.lunardance).to.be.false; @@ -1821,34 +1727,32 @@ export const test = () => }); it("Should consume healingwish status if mentioned", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); mon.team!.status.healingwish = true; mon.hp.set(31); mon.majorStatus.afflict("psn"); - await ph.handle({ + await handle({ args: ["-heal", toIdent("p2"), toHPStatus(100)], kwArgs: {from: toEffectName("healingwish", "move")}, }); - await ph.return(); expect(mon.hp.current).to.equal(100); expect(mon.majorStatus.current).to.be.null; expect(mon.team!.status.healingwish).to.be.false; }); it("Should consume wish status if mentioned", async function () { - const [, mon] = sh.initTeam("p2", [ditto, smeargle]); + const [, mon] = initTeam(ctx.state, "p2", [ditto, smeargle]); mon.hp.set(2); - state.getTeam("p2").status.wish.start(); + ctx.state.getTeam("p2").status.wish.start(); - await ph.handle({ + await handle({ args: ["-heal", toIdent("p2"), toHPStatus(100)], kwArgs: { from: toEffectName("wish", "move"), wisher: toNickname("Ditto"), }, }); - await ph.return(); expect(mon.hp.current).to.equal(100); expect(mon.team!.status.wish.isActive).to.be.false; }); @@ -1856,24 +1760,23 @@ export const test = () => describe("|-sethp|", function () { it("Should set hp for one target", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); mon.hp.set(11); - await ph.handle({ + await handle({ args: ["-sethp", toIdent("p2"), toHPStatus(1)], kwArgs: {}, }); - await ph.return(); expect(mon.hp.current).to.equal(1); }); it("Should set hp for two targets", async function () { - const mon1 = sh.initActive("p1"); + const mon1 = initActive(ctx.state, "p1"); mon1.hp.set(16); - const mon2 = sh.initActive("p2"); + const mon2 = initActive(ctx.state, "p2"); mon2.hp.set(11); - await ph.handle({ + await handle({ args: [ "-sethp", toIdent("p2"), @@ -1883,116 +1786,116 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon1.hp.current).to.equal(13); expect(mon2.hp.current).to.equal(19); }); it("Should throw if first health number is invalid", async function () { - sh.initActive("p1"); - sh.initActive("p2"); + initActive(ctx.state, "p1"); + initActive(ctx.state, "p2"); - await ph.reject({ - args: [ - "-sethp", - toIdent("p2"), - toNum(NaN), - toIdent("p1"), - toNum(13), - ], - kwArgs: {}, - }); - await ph.error(Error, "Invalid health number 'NaN'"); + await reject( + { + args: [ + "-sethp", + toIdent("p2"), + toNum(NaN), + toIdent("p1"), + toNum(13), + ], + kwArgs: {}, + }, + Error, + "Invalid health number 'NaN'", + ); }); it("Should throw if second health number is invalid", async function () { - sh.initActive("p1"); - sh.initActive("p2"); + initActive(ctx.state, "p1"); + initActive(ctx.state, "p2"); - await ph.reject({ - args: [ - "-sethp", - toIdent("p2"), - toNum(50), - toIdent("p1"), - toNum(NaN), - ], - kwArgs: {}, - }); - await ph.error(Error, "Invalid health number 'NaN'"); + await reject( + { + args: [ + "-sethp", + toIdent("p2"), + toNum(50), + toIdent("p1"), + toNum(NaN), + ], + kwArgs: {}, + }, + Error, + "Invalid health number 'NaN'", + ); }); }); describe("|-status|", function () { it("Should afflict major status", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.majorStatus.current).to.be.null; - await ph.handle({ + await handle({ args: ["-status", toIdent("p1"), "brn"], kwArgs: {}, }); - await ph.return(); expect(mon.majorStatus.current).to.equal("brn"); }); it("Should reveal ability that caused status", async function () { - sh.initActive("p1"); - const mon = sh.initActive("p2"); + initActive(ctx.state, "p1"); + const mon = initActive(ctx.state, "p2"); mon.revealAbility(""); expect(mon.ability).to.be.empty; expect(mon.baseAbility).to.be.empty; - await ph.handle({ + await handle({ args: ["-status", toIdent("p1"), "psn"], kwArgs: { from: toEffectName("poisonpoint", "ability"), of: toIdent("p2"), }, }); - await ph.return(); expect(mon.ability).to.equal("poisonpoint"); expect(mon.baseAbility).to.equal("poisonpoint"); }); it("Should reveal item that caused status", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.item).to.be.empty; - await ph.handle({ + await handle({ args: ["-status", toIdent("p1"), "tox"], kwArgs: {from: toEffectName("toxicorb", "item")}, }); - await ph.return(); expect(mon.item).to.equal("toxicorb"); }); }); describe("|-curestatus|", function () { it("Should cure major status", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.majorStatus.afflict("frz"); - await ph.handle({ + await handle({ args: ["-curestatus", toIdent("p1"), "frz"], kwArgs: {}, }); - await ph.return(); expect(mon.majorStatus.current).to.be.null; }); it("Should reveal ability that caused self-cure", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.majorStatus.afflict("slp"); mon.revealAbility(""); expect(mon.ability).to.be.empty; expect(mon.baseAbility).to.be.empty; - await ph.handle({ + await handle({ args: ["-curestatus", toIdent("p1"), "slp"], kwArgs: {from: toEffectName("naturalcure", "ability")}, }); - await ph.return(); expect(mon.ability).to.equal("naturalcure"); expect(mon.baseAbility).to.equal("naturalcure"); }); @@ -2001,29 +1904,30 @@ export const test = () => // effect, but the event that announces that effect already takes // care of it. it("Should ignore bench cure for now", async function () { - const [benched] = sh.initTeam("p1", [ditto, smeargle]); + const [benched] = initTeam(ctx.state, "p1", [ditto, smeargle]); benched.majorStatus.afflict("frz"); - await ph.handle({ + await handle({ args: ["-curestatus", toIdent("p1", ditto, null), "frz"], kwArgs: {silent: true}, }); - await ph.return(); expect(benched.majorStatus.current).to.equal("frz"); }); }); describe("|-cureteam|", function () { it("Should cure major status of every pokemon on the team", async function () { - const [bench, active] = sh.initTeam("p1", [ditto, smeargle]); + const [bench, active] = initTeam(ctx.state, "p1", [ + ditto, + smeargle, + ]); bench.majorStatus.afflict("slp"); active.majorStatus.afflict("par"); - await ph.handle({ + await handle({ args: ["-cureteam", toIdent("p1")], kwArgs: {}, }); - await ph.return(); expect(bench.majorStatus.current).to.be.null; expect(active.majorStatus.current).to.be.null; }); @@ -2031,86 +1935,92 @@ export const test = () => describe("|-boost|", function () { it("Should add boost", async function () { - const {boosts} = sh.initActive("p1").volatile; + const {boosts} = initActive(ctx.state, "p1").volatile; boosts.atk = 1; - await ph.handle({ + await handle({ args: ["-boost", toIdent("p1"), "atk", toNum(2)], kwArgs: {}, }); - await ph.return(); expect(boosts.atk).to.equal(3); }); it("Should throw if invalid boost number", async function () { - sh.initActive("p1"); + initActive(ctx.state, "p1"); - await ph.reject({ - args: ["-boost", toIdent("p1"), "atk", toNum(NaN)], - kwArgs: {}, - }); - await ph.error(Error, "Invalid boost num 'NaN'"); + await reject( + { + args: ["-boost", toIdent("p1"), "atk", toNum(NaN)], + kwArgs: {}, + }, + Error, + "Invalid boost num 'NaN'", + ); }); }); describe("|-unboost|", function () { it("Should subtract boost", async function () { - const {boosts} = sh.initActive("p2").volatile; + const {boosts} = initActive(ctx.state, "p2").volatile; boosts.spe = 5; - await ph.handle({ + await handle({ args: ["-unboost", toIdent("p2"), "spe", toNum(4)], kwArgs: {}, }); - await ph.return(); expect(boosts.spe).to.equal(1); }); it("Should throw if invalid unboost number", async function () { - sh.initActive("p1"); + initActive(ctx.state, "p1"); - await ph.reject({ - args: ["-unboost", toIdent("p1"), "atk", toNum(NaN)], - kwArgs: {}, - }); - await ph.error(Error, "Invalid unboost num 'NaN'"); + await reject( + { + args: ["-unboost", toIdent("p1"), "atk", toNum(NaN)], + kwArgs: {}, + }, + Error, + "Invalid unboost num 'NaN'", + ); }); }); describe("|-setboost|", function () { it("Should set boost", async function () { - const {boosts} = sh.initActive("p2").volatile; + const {boosts} = initActive(ctx.state, "p2").volatile; boosts.evasion = -2; - await ph.handle({ + await handle({ args: ["-setboost", toIdent("p2"), "evasion", toNum(2)], kwArgs: {}, }); - await ph.return(); expect(boosts.evasion).to.equal(2); }); it("Should throw if invalid boost number", async function () { - sh.initActive("p2"); + initActive(ctx.state, "p2"); - await ph.reject({ - args: ["-setboost", toIdent("p2"), "spe", toNum(NaN)], - kwArgs: {}, - }); - await ph.error(Error, "Invalid setboost num 'NaN'"); + await reject( + { + args: ["-setboost", toIdent("p2"), "spe", toNum(NaN)], + kwArgs: {}, + }, + Error, + "Invalid setboost num 'NaN'", + ); }); }); describe("|-swapboost|", function () { it("Should swap boosts", async function () { - const us = sh.initActive("p1").volatile.boosts; - const them = sh.initActive("p2").volatile.boosts; + const us = initActive(ctx.state, "p1").volatile.boosts; + const them = initActive(ctx.state, "p2").volatile.boosts; us.accuracy = 4; them.accuracy = 3; them.spd = -1; them.spe = 2; - await ph.handle({ + await handle({ args: [ "-swapboost", toIdent("p1"), @@ -2119,7 +2029,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(us.accuracy).to.equal(3); expect(us.spd).to.equal(0); expect(us.spe).to.equal(2); @@ -2129,8 +2038,8 @@ export const test = () => }); it("Should swap all boosts if none are mentioned", async function () { - const us = sh.initActive("p1").volatile.boosts; - const them = sh.initActive("p2").volatile.boosts; + const us = initActive(ctx.state, "p1").volatile.boosts; + const them = initActive(ctx.state, "p2").volatile.boosts; us.def = 2; us.spa = 1; us.spd = -5; @@ -2142,11 +2051,10 @@ export const test = () => const usOld = {...us}; const themOld = {...them}; - await ph.handle({ + await handle({ args: ["-swapboost", toIdent("p1"), toIdent("p2")], kwArgs: {}, }); - await ph.return(); expect(us).to.deep.equal(themOld); expect(them).to.deep.equal(usOld); }); @@ -2154,15 +2062,14 @@ export const test = () => describe("|-invertboost|", function () { it("Should invert boosts", async function () { - const {boosts} = sh.initActive("p1").volatile; + const {boosts} = initActive(ctx.state, "p1").volatile; boosts.spe = 1; boosts.atk = -1; - await ph.handle({ + await handle({ args: ["-invertboost", toIdent("p1")], kwArgs: {}, }); - await ph.return(); expect(boosts.spe).to.equal(-1); expect(boosts.atk).to.equal(1); }); @@ -2170,15 +2077,14 @@ export const test = () => describe("|-clearboost|", function () { it("Should clear boosts", async function () { - const {boosts} = sh.initActive("p1").volatile; + const {boosts} = initActive(ctx.state, "p1").volatile; boosts.spe = -3; boosts.accuracy = 6; - await ph.handle({ + await handle({ args: ["-clearboost", toIdent("p1")], kwArgs: {}, }); - await ph.return(); expect(boosts.spe).to.equal(0); expect(boosts.accuracy).to.equal(0); }); @@ -2186,13 +2092,12 @@ export const test = () => describe("|-clearallboost|", function () { it("Should clear all boosts from both sides", async function () { - const us = sh.initActive("p1").volatile.boosts; - const them = sh.initActive("p2").volatile.boosts; + const us = initActive(ctx.state, "p1").volatile.boosts; + const them = initActive(ctx.state, "p2").volatile.boosts; us.accuracy = 2; them.spe = -2; - await ph.handle({args: ["-clearallboost"], kwArgs: {}}); - await ph.return(); + await handle({args: ["-clearallboost"], kwArgs: {}}); expect(us.accuracy).to.equal(0); expect(them.spe).to.equal(0); }); @@ -2200,11 +2105,11 @@ export const test = () => describe("|-clearpositiveboost|", function () { it("Should clear positive boosts", async function () { - const {boosts} = sh.initActive("p1").volatile; + const {boosts} = initActive(ctx.state, "p1").volatile; boosts.spd = 3; boosts.def = -1; - await ph.handle({ + await handle({ args: [ "-clearpositiveboost", toIdent("p1"), @@ -2214,7 +2119,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(boosts.spd).to.equal(0); expect(boosts.def).to.equal(-1); }); @@ -2222,15 +2126,14 @@ export const test = () => describe("|-clearnegativeboost|", function () { it("Should clear negative boosts", async function () { - const {boosts} = sh.initActive("p1").volatile; + const {boosts} = initActive(ctx.state, "p1").volatile; boosts.evasion = 2; boosts.spa = -3; - await ph.handle({ + await handle({ args: ["-clearnegativeboost", toIdent("p1")], kwArgs: {}, }); - await ph.return(); expect(boosts.evasion).to.equal(2); expect(boosts.spa).to.equal(0); }); @@ -2238,13 +2141,13 @@ export const test = () => describe("|-copyboost|", function () { it("Should copy boosts", async function () { - const us = sh.initActive("p1").volatile.boosts; - const them = sh.initActive("p2").volatile.boosts; + const us = initActive(ctx.state, "p1").volatile.boosts; + const them = initActive(ctx.state, "p2").volatile.boosts; us.evasion = 3; us.def = -1; them.def = 4; - await ph.handle({ + await handle({ args: [ // Order of idents is [source, target]. "-copyboost", @@ -2254,23 +2157,21 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(us.evasion).to.equal(3); expect(us.def).to.equal(-1); expect(them.def).to.equal(-1); }); it("Should copy all boosts if none are mentioned", async function () { - const us = sh.initActive("p1").volatile.boosts; - const them = sh.initActive("p2").volatile.boosts; + const us = initActive(ctx.state, "p1").volatile.boosts; + const them = initActive(ctx.state, "p2").volatile.boosts; us.atk = 2; them.atk = -2; - await ph.handle({ + await handle({ args: ["-copyboost", toIdent("p1"), toIdent("p2")], kwArgs: {}, }); - await ph.return(); expect(us.atk).to.equal(2); expect(them.atk).to.equal(2); }); @@ -2286,53 +2187,49 @@ export const test = () => }); beforeEach("Assert weather is none initially", function () { - expect(state.status.weather.type).to.equal("none"); + expect(ctx.state.status.weather.type).to.equal("none"); }); it("Should set weather", async function () { - await ph.handle(weatherEvent("Sandstorm")); - await ph.return(); - expect(state.status.weather.type).to.equal("Sandstorm"); - expect(state.status.weather.duration).to.equal(8); - expect(state.status.weather.infinite).to.be.false; + await handle(weatherEvent("Sandstorm")); + expect(ctx.state.status.weather.type).to.equal("Sandstorm"); + expect(ctx.state.status.weather.duration).to.equal(8); + expect(ctx.state.status.weather.infinite).to.be.false; }); it("Should reveal ability that caused weather and infer infinite duration", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); mon.revealAbility(""); expect(mon.ability).to.be.empty; expect(mon.baseAbility).to.be.empty; - await ph.handle( + await handle( weatherEvent("SunnyDay", { from: toEffectName("drought", "ability"), of: toIdent("p2"), }), ); - await ph.return(); expect(mon.ability).to.equal("drought"); expect(mon.baseAbility).to.equal("drought"); - expect(state.status.weather.type).to.equal("SunnyDay"); - expect(state.status.weather.duration).to.equal(8); - expect(state.status.weather.infinite).to.be.true; + expect(ctx.state.status.weather.type).to.equal("SunnyDay"); + expect(ctx.state.status.weather.duration).to.equal(8); + expect(ctx.state.status.weather.infinite).to.be.true; }); it("Should reset weather set to 'none'", async function () { - state.status.weather.start("Hail"); - expect(state.status.weather.type).to.equal("Hail"); + ctx.state.status.weather.start("Hail"); + expect(ctx.state.status.weather.type).to.equal("Hail"); - await ph.handle(weatherEvent("none")); - await ph.return(); - expect(state.status.weather.type).to.equal("none"); + await handle(weatherEvent("none")); + expect(ctx.state.status.weather.type).to.equal("none"); }); it("Should tick weather if [upkeep] suffix", async function () { - state.status.weather.start("RainDance"); - expect(state.status.weather.turns).to.equal(0); + ctx.state.status.weather.start("RainDance"); + expect(ctx.state.status.weather.turns).to.equal(0); - await ph.handle(weatherEvent("RainDance", {upkeep: true})); - await ph.return(); - expect(state.status.weather.turns).to.equal(1); + await handle(weatherEvent("RainDance", {upkeep: true})); + expect(ctx.state.status.weather.turns).to.equal(1); }); }); @@ -2346,18 +2243,17 @@ export const test = () => for (const effect of ["gravity", "trickroom"] as const) { it(`Should ${verb} ${effect}`, async function () { if (!start) { - state.status[effect].start(); + ctx.state.status[effect].start(); } - expect(state.status[effect].isActive).to.be[ + expect(ctx.state.status[effect].isActive).to.be[ start ? "false" : "true" ]; - await ph.handle({ + await handle({ args: [eventName, toFieldCondition(effect)], kwArgs: {}, }); - await ph.return(); - expect(state.status[effect].isActive).to.be[ + expect(ctx.state.status[effect].isActive).to.be[ start ? "true" : "false" ]; }); @@ -2381,7 +2277,7 @@ export const test = () => ] as const) { const condition = toSideCondition(effect); it(`Should ${verb} ${effect}`, async function () { - const ts = state.getTeam("p1").status; + const ts = ctx.state.getTeam("p1").status; if (!start) { ts[effect].start(); } @@ -2389,7 +2285,7 @@ export const test = () => start ? "false" : "true" ]; - await ph.handle({ + await handle({ args: [ eventName, toSide("p1", "player1"), @@ -2397,7 +2293,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(ts[effect].isActive).to.be[ start ? "true" : "false" ]; @@ -2411,13 +2306,13 @@ export const test = () => ] as const) { const condition = toSideCondition(effect); it(`Should ${verb} ${effect}`, async function () { - const {status: ts} = state.getTeam("p1"); + const {status: ts} = ctx.state.getTeam("p1"); if (!start) { ts[effect] = 1; } expect(ts[effect]).to.equal(start ? 0 : 1); - await ph.handle({ + await handle({ args: [ eventName, toSide("p1", "player1"), @@ -2425,7 +2320,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(ts[effect]).to.equal(start ? 1 : 0); }); } @@ -2434,8 +2328,7 @@ export const test = () => describe("|-swapsideconditions|", function () { it("Should do nothing since unsupported", async function () { - await ph.handle({args: ["-swapsideconditions"], kwArgs: {}}); - await ph.return(); + await handle({args: ["-swapsideconditions"], kwArgs: {}}); }); }); @@ -2447,13 +2340,13 @@ export const test = () => describe(name, function () { if (start) { it("Should start flashfire and reveal ability", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.revealAbility(""); expect(mon.ability).to.be.empty; expect(mon.baseAbility).to.be.empty; expect(mon.volatile.flashfire).to.be.false; - await ph.handle({ + await handle({ args: [ "-start", toIdent("p1"), @@ -2461,21 +2354,20 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.ability).to.equal("flashfire"); expect(mon.baseAbility).to.equal("flashfire"); expect(mon.volatile.flashfire).to.be.true; }); it("Should start typechange", async function () { - const mon = sh.initActive("p1", ditto); + const mon = initActive(ctx.state, "p1", ditto); expect(mon.types).to.have.members(["normal", "???"]); expect(mon.baseTypes).to.have.members([ "normal", "???", ]); - await ph.handle({ + await handle({ args: [ "-start", toIdent("p1"), @@ -2484,7 +2376,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.types).to.have.members(["dark", "rock"]); expect(mon.baseTypes).to.have.members([ "normal", @@ -2493,14 +2384,14 @@ export const test = () => }); it("Should truncate typechange if more than 2 types given", async function () { - const mon = sh.initActive("p1", ditto); + const mon = initActive(ctx.state, "p1", ditto); expect(mon.types).to.have.members(["normal", "???"]); expect(mon.baseTypes).to.have.members([ "normal", "???", ]); - await ph.handle({ + await handle({ args: [ "-start", toIdent("p1"), @@ -2509,7 +2400,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.types).to.have.members(["dragon", "ghost"]); expect(mon.baseTypes).to.have.members([ "normal", @@ -2518,14 +2408,14 @@ export const test = () => }); it("Should expand typechange if 1 type given", async function () { - const mon = sh.initActive("p1", ditto); + const mon = initActive(ctx.state, "p1", ditto); expect(mon.types).to.have.members(["normal", "???"]); expect(mon.baseTypes).to.have.members([ "normal", "???", ]); - await ph.handle({ + await handle({ args: [ "-start", toIdent("p1"), @@ -2534,7 +2424,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.types).to.have.members(["psychic", "???"]); expect(mon.baseTypes).to.have.members([ "normal", @@ -2543,18 +2432,17 @@ export const test = () => }); it("Should expand typechange if 0 types given", async function () { - const mon = sh.initActive("p1", ditto); + const mon = initActive(ctx.state, "p1", ditto); expect(mon.types).to.have.members(["normal", "???"]); expect(mon.baseTypes).to.have.members([ "normal", "???", ]); - await ph.handle({ + await handle({ args: ["-start", toIdent("p1"), "typechange"], kwArgs: {}, }); - await ph.return(); expect(mon.types).to.have.members(["???", "???"]); expect(mon.baseTypes).to.have.members([ "normal", @@ -2563,10 +2451,10 @@ export const test = () => }); it("Should count perish", async function () { - const mon = sh.initActive("p1", ditto); + const mon = initActive(ctx.state, "p1", ditto); expect(mon.volatile.perish).to.equal(0); - await ph.handle({ + await handle({ args: [ "-start", toIdent("p1"), @@ -2574,15 +2462,14 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.volatile.perish).to.equal(2); }); it("Should count stockpile", async function () { - const mon = sh.initActive("p1", ditto); + const mon = initActive(ctx.state, "p1", ditto); expect(mon.volatile.stockpile).to.equal(0); - await ph.handle({ + await handle({ args: [ "-start", toIdent("p1"), @@ -2590,12 +2477,11 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.volatile.stockpile).to.equal(1); }); it("Should reveal ability that caused self-effect", async function () { - const mon = sh.initActive("p1", ditto); + const mon = initActive(ctx.state, "p1", ditto); mon.revealAbility(""); expect(mon.ability).to.be.empty; expect(mon.baseAbility).to.be.empty; @@ -2605,7 +2491,7 @@ export const test = () => "???", ]); - await ph.handle({ + await handle({ args: [ "-start", toIdent("p1"), @@ -2616,7 +2502,6 @@ export const test = () => from: toEffectName("colorchange", "ability"), }, }); - await ph.return(); expect(mon.types).to.have.members(["water", "???"]); expect(mon.baseTypes).to.have.members([ "normal", @@ -2625,13 +2510,13 @@ export const test = () => }); it("Should reveal ability that caused effect on target", async function () { - sh.initActive("p1"); - const mon = sh.initActive("p2"); + initActive(ctx.state, "p1"); + const mon = initActive(ctx.state, "p2"); mon.revealAbility(""); expect(mon.ability).to.be.empty; expect(mon.baseAbility).to.be.empty; - await ph.handle({ + await handle({ args: [ "-start", toIdent("p1"), @@ -2642,16 +2527,15 @@ export const test = () => of: toIdent("p2"), }, }); - await ph.return(); expect(mon.ability).to.equal("cutecharm"); expect(mon.baseAbility).to.equal("cutecharm"); }); } else { it("Should end stockpile", async function () { - const mon = sh.initActive("p1", ditto); + const mon = initActive(ctx.state, "p1", ditto); mon.volatile.stockpile = 3; - await ph.handle({ + await handle({ args: [ "-end", toIdent("p1"), @@ -2659,7 +2543,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.volatile.stockpile).to.equal(0); }); } @@ -2680,7 +2563,7 @@ export const test = () => "watersport", ] as const) { it(`Should ${verb} ${effect}`, async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); if (!start) { mon.volatile[effect] = true; } @@ -2688,7 +2571,7 @@ export const test = () => start ? "false" : "true" ]; - await ph.handle({ + await handle({ args: [ eventName, toIdent("p1"), @@ -2696,7 +2579,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.volatile[effect]).to.be[ start ? "true" : "false" ]; @@ -2719,7 +2601,7 @@ export const test = () => ? " and reveal ability" : ""; it(`Should ${verb} ${effect}${also}`, async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); if (!start) { mon.volatile[effect].start(); } else if (effect === "slowstart") { @@ -2731,7 +2613,7 @@ export const test = () => start ? "false" : "true" ]; - await ph.handle({ + await handle({ args: [ eventName, toIdent("p1"), @@ -2739,7 +2621,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); if (start && effect === "slowstart") { expect(mon.ability).to.equal("slowstart"); expect(mon.baseAbility).to.equal("slowstart"); @@ -2751,10 +2632,10 @@ export const test = () => if (start && effect === "confusion") { it("Should reset rampage status if starting confusion due to fatigue", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); mon.volatile.rampage.start("outrage"); - await ph.handle({ + await handle({ args: [ "-start", toIdent("p2"), @@ -2762,14 +2643,13 @@ export const test = () => ], kwArgs: {fatigue: true}, }); - await ph.return(); expect(mon.volatile.rampage.isActive).to.be.false; }); } if (start && effect === "uproar") { it("Should tick uproar if upkeep and already active", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.volatile[effect].isActive).to.be.false; // First start the effect. @@ -2778,7 +2658,7 @@ export const test = () => expect(mon.volatile[effect].turns).to.equal(0); // Then update it. - await ph.handle({ + await handle({ args: [ "-start", toIdent("p1"), @@ -2786,7 +2666,6 @@ export const test = () => ], kwArgs: {upkeep: true}, }); - await ph.return(); expect(mon.volatile[effect].isActive).to.be.true; expect(mon.volatile[effect].turns).to.equal(1); }); @@ -2796,9 +2675,9 @@ export const test = () => // Disable move status. if (start) { it("Should disable move", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); - await ph.handle({ + await handle({ args: [ "-start", toIdent("p2"), @@ -2807,18 +2686,17 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.volatile.disabled.move).to.equal("tackle"); expect(mon.volatile.disabled.ts.isActive).to.be.true; }); } else { it("Should re-enable disabled moves", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); mon.volatile.disableMove("tackle"); expect(mon.volatile.disabled.move).to.equal("tackle"); expect(mon.volatile.disabled.ts.isActive).to.be.true; - await ph.handle({ + await handle({ args: [ "-end", toIdent("p2"), @@ -2826,14 +2704,13 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.volatile.disabled.move).to.be.null; expect(mon.volatile.disabled.ts.isActive).to.be.false; }); } it(`Should ${verb} encore`, async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); if (!start) { mon.volatile.encoreMove("tackle"); expect(mon.volatile.encore.ts.isActive).to.be.true; @@ -2844,7 +2721,7 @@ export const test = () => expect(mon.volatile.encore.move).to.be.null; } - await ph.handle({ + await handle({ args: [ eventName, toIdent("p1"), @@ -2852,7 +2729,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.volatile.encore.ts.isActive).to.be[ start ? "true" : "false" ]; @@ -2865,14 +2741,14 @@ export const test = () => for (const effect of ["foresight", "miracleeye"] as const) { it(`Should ${verb} ${effect}`, async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); if (!start) { mon.volatile.identified = effect; } else { expect(mon.volatile.identified).to.be.null; } - await ph.handle({ + await handle({ args: [ eventName, toIdent("p1"), @@ -2880,7 +2756,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); if (start) { expect(mon.volatile.identified).to.equal(effect); } else { @@ -2892,9 +2767,9 @@ export const test = () => it(`Should ${ start ? "prepare" : "release" } future move`, async function () { - sh.initActive("p1"); - sh.initActive("p2"); - const team = state.getTeam("p1"); + initActive(ctx.state, "p1"); + initActive(ctx.state, "p2"); + const team = ctx.state.getTeam("p1"); if (!start) { team.status.futureMoves.doomdesire.start(); } @@ -2902,7 +2777,7 @@ export const test = () => start ? "false" : "true" ]; - await ph.handle({ + await handle({ args: [ // Note: Start mentions user, end mentions target. eventName, @@ -2911,16 +2786,15 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(team.status.futureMoves.doomdesire.isActive).to.be[ start ? "true" : "false" ]; }); it("Should ignore invalid effect", async function () { - sh.initActive("p1"); + initActive(ctx.state, "p1"); - await ph.handle({ + await handle({ args: [ eventName, toIdent("p1"), @@ -2928,55 +2802,49 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); }); }); } describe("|-crit|", function () { it("Should do nothing", async function () { - await ph.handle({args: ["-crit", toIdent("p2")], kwArgs: {}}); - await ph.return(); + await handle({args: ["-crit", toIdent("p2")], kwArgs: {}}); }); }); describe("|-supereffective|", function () { it("Should do nothing", async function () { - await ph.handle({ + await handle({ args: ["-supereffective", toIdent("p2")], kwArgs: {}, }); - await ph.return(); }); }); describe("|-resisted|", function () { it("Should do nothing", async function () { - await ph.handle({ + await handle({ args: ["-resisted", toIdent("p2")], kwArgs: {}, }); - await ph.return(); }); }); describe("|-immune|", function () { it("Should do nothing", async function () { - await ph.handle({args: ["-immune", toIdent("p2")], kwArgs: {}}); - await ph.return(); + await handle({args: ["-immune", toIdent("p2")], kwArgs: {}}); }); it("Should reveal ability that caused immunity", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); mon.revealAbility(""); expect(mon.ability).to.be.empty; expect(mon.baseAbility).to.be.empty; - await ph.handle({ + await handle({ args: ["-immune", toIdent("p2")], kwArgs: {from: toEffectName("levitate", "ability")}, }); - await ph.return(); expect(mon.ability).to.equal("levitate"); expect(mon.baseAbility).to.equal("levitate"); }); @@ -2984,41 +2852,39 @@ export const test = () => describe("|-item|", function () { it("Should set item", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.item).to.be.empty; - await ph.handle({ + await handle({ args: ["-item", toIdent("p1"), toItemName("mail")], kwArgs: {}, }); - await ph.return(); expect(mon.item).to.equal("mail"); }); it("Should handle recycle effect", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.removeItem("mail"); expect(mon.item).to.equal("none"); expect(mon.lastItem).to.equal("mail"); - await ph.handle({ + await handle({ args: ["-item", toIdent("p1"), toItemName("mail")], kwArgs: {from: toEffectName("recycle", "move")}, }); - await ph.return(); expect(mon.item).to.equal("mail"); expect(mon.lastItem).to.equal("none"); }); it("Should reveal item due to frisk", async function () { - const mon1 = sh.initActive("p1"); + const mon1 = initActive(ctx.state, "p1"); expect(mon1.item).to.be.empty; - const mon2 = sh.initActive("p2"); + const mon2 = initActive(ctx.state, "p2"); mon2.revealAbility(""); expect(mon2.ability).to.be.empty; expect(mon2.baseAbility).to.be.empty; - await ph.handle({ + await handle({ args: ["-item", toIdent("p1"), toItemName("mail")], kwArgs: { from: toEffectName("frisk", "ability"), @@ -3033,35 +2899,33 @@ export const test = () => describe("|-enditem|", function () { it("Should consume item", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.item).to.be.empty; expect(mon.lastItem).to.equal("none"); - await ph.handle({ + await handle({ args: ["-enditem", toIdent("p1"), toItemName("focussash")], kwArgs: {}, }); - await ph.return(); expect(mon.item).to.equal("none"); expect(mon.lastItem).to.equal("focussash"); }); it("Should eat item", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.item).to.be.empty; expect(mon.lastItem).to.equal("none"); - await ph.handle({ + await handle({ args: ["-enditem", toIdent("p1"), toItemName("lumberry")], kwArgs: {eat: true}, }); - await ph.return(); expect(mon.item).to.equal("none"); expect(mon.lastItem).to.equal("lumberry"); }); it("Should ignore resist berry effect", async function () { - await ph.handle({ + await handle({ args: [ "-enditem", toIdent("p1"), @@ -3069,18 +2933,17 @@ export const test = () => ], kwArgs: {weaken: true}, }); - await ph.return(); }); it("Should destroy item if from stealeat effect", async function () { - const mon1 = sh.initActive("p1"); - const mon2 = sh.initActive("p2"); + const mon1 = initActive(ctx.state, "p1"); + const mon2 = initActive(ctx.state, "p2"); expect(mon1.item).to.be.empty; expect(mon1.lastItem).to.equal("none"); expect(mon2.item).to.be.empty; expect(mon2.lastItem).to.equal("none"); - await ph.handle({ + await handle({ args: ["-enditem", toIdent("p1"), toItemName("oranberry")], kwArgs: { from: toEffectName("stealeat"), @@ -3088,7 +2951,6 @@ export const test = () => of: toIdent("p2"), }, }); - await ph.return(); expect(mon1.item).to.equal("none"); expect(mon1.lastItem).to.equal("none"); expect(mon2.item).to.be.empty; @@ -3096,21 +2958,20 @@ export const test = () => }); it("Should destroy item if from item-removal move", async function () { - const mon1 = sh.initActive("p1"); - const mon2 = sh.initActive("p2"); + const mon1 = initActive(ctx.state, "p1"); + const mon2 = initActive(ctx.state, "p2"); expect(mon1.item).to.be.empty; expect(mon1.lastItem).to.equal("none"); expect(mon2.item).to.be.empty; expect(mon2.lastItem).to.equal("none"); - await ph.handle({ + await handle({ args: ["-enditem", toIdent("p1"), toItemName("oranberry")], kwArgs: { from: toEffectName("knockoff", "move"), of: toIdent("p2"), }, }); - await ph.return(); expect(mon1.item).to.equal("none"); expect(mon1.lastItem).to.equal("none"); expect(mon2.item).to.be.empty; @@ -3118,16 +2979,15 @@ export const test = () => }); it("Should consume micleberry status", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.micleberry = true; expect(mon.item).to.be.empty; expect(mon.lastItem).to.equal("none"); - await ph.handle({ + await handle({ args: ["-enditem", toIdent("p1"), toItemName("micleberry")], kwArgs: {}, }); - await ph.return(); expect(mon.volatile.micleberry).to.be.false; expect(mon.item).to.be.empty; expect(mon.lastItem).to.equal("none"); @@ -3136,12 +2996,12 @@ export const test = () => describe("|-ability|", function () { it("Should indicate ability activation", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.revealAbility(""); expect(mon.ability).to.be.empty; expect(mon.baseAbility).to.be.empty; - await ph.handle({ + await handle({ args: [ "-ability", toIdent("p1"), @@ -3149,18 +3009,17 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.ability).to.equal("pressure"); expect(mon.baseAbility).to.equal("pressure"); }); it("Should not set base ability if acquired via effect", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.revealAbility(""); expect(mon.ability).to.be.empty; expect(mon.baseAbility).to.be.empty; - await ph.handle({ + await handle({ args: [ "-ability", toIdent("p1"), @@ -3168,7 +3027,6 @@ export const test = () => ], kwArgs: {from: toEffectName("worryseed", "move")}, }); - await ph.return(); expect(mon.ability).to.equal("insomnia"); expect(mon.baseAbility).to.be.empty; }); @@ -3178,16 +3036,16 @@ export const test = () => // operate under the false assumption that p1 has the ability // directly when it was really traced, and we correct that using // the trace event. - const mon1 = sh.initActive("p1"); + const mon1 = initActive(ctx.state, "p1"); mon1.revealAbility("moldbreaker"); expect(mon1.ability).to.equal("moldbreaker"); expect(mon1.baseAbility).to.equal("moldbreaker"); - const mon2 = sh.initActive("p2"); + const mon2 = initActive(ctx.state, "p2"); mon2.revealAbility(""); expect(mon2.ability).to.be.empty; expect(mon2.baseAbility).to.be.empty; - await ph.handle({ + await handle({ args: [ "-ability", toIdent("p1"), @@ -3198,7 +3056,6 @@ export const test = () => of: toIdent("p2"), }, }); - await ph.return(); expect(mon1.ability).to.equal("moldbreaker"); expect(mon1.baseAbility).to.equal("trace"); expect(mon2.ability).to.equal("moldbreaker"); @@ -3208,25 +3065,24 @@ export const test = () => describe("|-endability|", function () { it("Should start gastroacid status", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); expect(mon.volatile.suppressAbility).to.be.false; - await ph.handle({ + await handle({ args: ["-endability", toIdent("p2")], kwArgs: {}, }); - await ph.return(); expect(mon.volatile.suppressAbility).to.be.true; }); it("Should also reveal ability if specified", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); mon.revealAbility(""); expect(mon.volatile.suppressAbility).to.be.false; expect(mon.ability).to.be.empty; expect(mon.baseAbility).to.be.empty; - await ph.handle({ + await handle({ args: [ "-endability", toIdent("p2"), @@ -3234,7 +3090,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.volatile.suppressAbility).to.be.true; expect(mon.ability).to.equal("frisk"); expect(mon.baseAbility).to.equal("frisk"); @@ -3242,18 +3097,18 @@ export const test = () => describe("skillswap", function () { it("Should reveal and exchange abilities", async function () { - const mon1 = sh.initActive("p1"); + const mon1 = initActive(ctx.state, "p1"); mon1.revealAbility(""); expect(mon1.volatile.suppressAbility).to.be.false; expect(mon1.ability).to.be.empty; expect(mon1.baseAbility).to.be.empty; - const mon2 = sh.initActive("p2"); + const mon2 = initActive(ctx.state, "p2"); mon2.revealAbility(""); expect(mon2.volatile.suppressAbility).to.be.false; expect(mon2.ability).to.be.empty; expect(mon2.baseAbility).to.be.empty; - await ph.handle({ + await handle({ args: [ "-endability", toIdent("p1"), @@ -3261,7 +3116,6 @@ export const test = () => ], kwArgs: {from: toEffectName("skillswap", "move")}, }); - await ph.return(); expect(mon1.volatile.suppressAbility).to.be.false; expect(mon1.ability).to.be.empty; expect(mon1.baseAbility).to.equal("swiftswim"); @@ -3271,11 +3125,7 @@ export const test = () => expect(mon2.ability).to.be.empty; expect(mon2.baseAbility).to.be.empty; - // Restart in order to parse the other skillswap event. - await ph.close().finally(() => (pctx = undefined)); - pctx = initParser(ictx.startArgs, dispatch); - - await ph.handle({ + await handle({ args: [ "-endability", toIdent("p2"), @@ -3283,7 +3133,6 @@ export const test = () => ], kwArgs: {from: toEffectName("skillswap", "move")}, }); - await ph.return(); expect(mon1.volatile.suppressAbility).to.be.false; expect(mon1.ability).to.equal("chlorophyll"); expect(mon1.baseAbility).to.equal("swiftswim"); @@ -3293,18 +3142,18 @@ export const test = () => }); it("Should exchange override abilities", async function () { - const mon1 = sh.initActive("p1"); + const mon1 = initActive(ctx.state, "p1"); mon1.setAbility("swiftswim"); expect(mon1.volatile.suppressAbility).to.be.false; expect(mon1.ability).to.equal("swiftswim"); expect(mon1.baseAbility).to.be.empty; - const mon2 = sh.initActive("p2"); + const mon2 = initActive(ctx.state, "p2"); mon2.setAbility("chlorophyll"); expect(mon2.volatile.suppressAbility).to.be.false; expect(mon2.ability).to.equal("chlorophyll"); expect(mon2.baseAbility).to.be.empty; - await ph.handle({ + await handle({ args: [ "-endability", toIdent("p1"), @@ -3312,7 +3161,6 @@ export const test = () => ], kwArgs: {from: toEffectName("skillswap", "move")}, }); - await ph.return(); expect(mon1.volatile.suppressAbility).to.be.false; expect(mon1.ability).to.equal("swiftswim"); expect(mon1.baseAbility).to.be.empty; @@ -3322,11 +3170,7 @@ export const test = () => expect(mon2.ability).to.equal("chlorophyll"); expect(mon2.baseAbility).to.be.empty; - // Restart in order to parse the other skillswap event. - await ph.close().finally(() => (pctx = undefined)); - pctx = initParser(ictx.startArgs, dispatch); - - await ph.handle({ + await handle({ args: [ "-endability", toIdent("p2"), @@ -3334,7 +3178,6 @@ export const test = () => ], kwArgs: {from: toEffectName("skillswap", "move")}, }); - await ph.return(); expect(mon1.volatile.suppressAbility).to.be.false; expect(mon1.ability).to.equal("chlorophyll"); expect(mon1.baseAbility).to.be.empty; @@ -3347,14 +3190,13 @@ export const test = () => describe("|-transform|", function () { it("Should transform pokemon", async function () { - const us = sh.initActive("p1", smeargle); - const them = sh.initActive("p2", ditto); + const us = initActive(ctx.state, "p1", smeargle); + const them = initActive(ctx.state, "p2", ditto); - await ph.handle({ + await handle({ args: ["-transform", toIdent("p2", ditto), toIdent("p1")], kwArgs: {}, }); - await ph.return(); expect(them.volatile.transformed).to.be.true; expect(them.species).to.equal(us.species); }); @@ -3362,7 +3204,7 @@ export const test = () => describe("|-mega|", function () { it("Should do nothing since unsupported", async function () { - await ph.handle({ + await handle({ args: [ "-mega", toIdent("p1"), @@ -3371,23 +3213,21 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); }); }); describe("|-primal|", function () { it("Should do nothing since unsupported", async function () { - await ph.handle({ + await handle({ args: ["-primal", toIdent("p1"), toItemName("redorb")], kwArgs: {}, }); - await ph.return(); }); }); describe("|-burst|", function () { it("Should do nothing since unsupported", async function () { - await ph.handle({ + await handle({ args: [ "-burst", toIdent("p1"), @@ -3396,24 +3236,21 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); }); }); describe("|-zpower|", function () { it("Should do nothing since unsupported", async function () { - await ph.handle({args: ["-zpower", toIdent("p1")], kwArgs: {}}); - await ph.return(); + await handle({args: ["-zpower", toIdent("p1")], kwArgs: {}}); }); }); describe("|-zbroken|", function () { it("Should do nothing since unsupported", async function () { - await ph.handle({ + await handle({ args: ["-zbroken", toIdent("p1")], kwArgs: {}, }); - await ph.return(); }); }); @@ -3423,7 +3260,7 @@ export const test = () => ["confusion"], ] as const) { it(`Should update ${effect}`, async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.volatile[effect].isActive).to.be.false; // First start the effect. @@ -3432,7 +3269,7 @@ export const test = () => expect(mon.volatile[effect].turns).to.equal(0); // Then update it. - await ph.handle({ + await handle({ args: [ "-activate", toIdent("p1"), @@ -3440,7 +3277,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.volatile[effect].isActive).to.be.true; expect(mon.volatile[effect].turns).to.equal(1); }); @@ -3448,10 +3284,10 @@ export const test = () => for (const [effect, type] of [["charge", "move"]] as const) { it(`Should start ${effect}`, async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.volatile[effect].isActive).to.be.false; - await ph.handle({ + await handle({ args: [ "-activate", toIdent("p1"), @@ -3459,7 +3295,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.volatile[effect].isActive).to.be.true; expect(mon.volatile[effect].turns).to.equal(0); }); @@ -3474,7 +3309,7 @@ export const test = () => "substitute", ] as const) { it(`Should handle blocked effect if ${effect}`, async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); switch (effect) { case "detect": case "endure": @@ -3489,9 +3324,9 @@ export const test = () => mon.volatile[effect] = true; break; } - sh.initActive("p2"); + initActive(ctx.state, "p2"); - await ph.handle({ + await handle({ args: [ "-activate", toIdent("p1"), @@ -3499,18 +3334,17 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); }); if (["detect", "protect"].includes(effect)) { it(`Should reset rampage if blocked by ${effect}`, async function () { - const mon1 = sh.initActive("p1"); + const mon1 = initActive(ctx.state, "p1"); mon1.volatile.rampage.start("thrash"); expect(mon1.volatile.rampage.isActive).to.be.true; - const mon2 = sh.initActive("p2"); + const mon2 = initActive(ctx.state, "p2"); mon2.volatile.stall(true); - await ph.handle({ + await handle({ args: [ "-activate", toIdent("p2"), @@ -3518,20 +3352,19 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon1.volatile.rampage.isActive).to.be.false; }); } } it("Should break stall if feint", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); mon.volatile.stall(true); expect(mon.volatile.stalling).to.be.true; expect(mon.volatile.stallTurns).to.equal(1); // Assume p1 uses feint move. - await ph.handle({ + await handle({ args: [ "-activate", toIdent("p2"), @@ -3539,21 +3372,20 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.volatile.stalling).to.be.false; // Note: Should not reset stall turns. expect(mon.volatile.stallTurns).to.equal(1); }); it("Should activate forewarn ability", async function () { - const mon1 = sh.initActive("p1"); + const mon1 = initActive(ctx.state, "p1"); mon1.revealAbility(""); expect(mon1.ability).to.be.empty; expect(mon1.baseAbility).to.be.empty; - const mon2 = sh.initActive("p2"); + const mon2 = initActive(ctx.state, "p2"); expect(mon2.moveset.get("takedown")).to.be.null; - await ph.handle({ + await handle({ args: [ "-activate", toIdent("p1"), @@ -3562,7 +3394,6 @@ export const test = () => ], kwArgs: {of: toIdent("p2")}, }); - await ph.return(); expect(mon1.ability).to.equal("forewarn"); expect(mon1.baseAbility).to.equal("forewarn"); expect(mon2.moveset.get("takedown")).to.not.be.null; @@ -3570,10 +3401,10 @@ export const test = () => // TODO: Test forewarn move inferences. it("Should fully deplete move pp if grudge", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); expect(mon.moveset.get("splash")).to.be.null; - await ph.handle({ + await handle({ args: [ "-activate", toIdent("p2"), @@ -3582,7 +3413,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); const move = mon.moveset.get("splash"); expect(move).to.not.be.null; expect(move).to.have.property("pp", 0); @@ -3590,11 +3420,14 @@ export const test = () => }); it("Should cure team if healbell", async function () { - const [benched, mon] = sh.initTeam("p1", [ditto, smeargle]); + const [benched, mon] = initTeam(ctx.state, "p1", [ + ditto, + smeargle, + ]); benched.majorStatus.afflict("tox"); mon.majorStatus.afflict("par"); - await ph.handle({ + await handle({ args: [ "-activate", toIdent("p1"), @@ -3602,18 +3435,17 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(benched.majorStatus.current).to.be.null; expect(mon.majorStatus.current).to.be.null; }); it("Should restore 10 move pp if leppaberry", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); const move = mon.moveset.reveal("ember")!; move.pp -= 20; expect(move).to.have.property("pp", move.maxpp - 20); - await ph.handle({ + await handle({ args: [ "-activate", toIdent("p2"), @@ -3622,14 +3454,13 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(move).to.have.property("pp", move.maxpp - 10); }); for (const effect of ["lockon", "mindreader"] as const) { it(`Should set lockon status if ${effect}`, async function () { - const mon1 = sh.initActive("p1"); - const mon2 = sh.initActive("p2"); + const mon1 = initActive(ctx.state, "p1"); + const mon2 = initActive(ctx.state, "p2"); expect(mon1.volatile.lockedOnBy).to.be.null; expect(mon1.volatile.lockOnTarget).to.be.null; expect(mon1.volatile.lockOnTurns.isActive).to.be.false; @@ -3638,7 +3469,7 @@ export const test = () => expect(mon2.volatile.lockOnTurns.isActive).to.be.false; // P1 locks onto p2. - await ph.handle({ + await handle({ args: [ "-activate", toIdent("p1"), @@ -3646,7 +3477,6 @@ export const test = () => ], kwArgs: {of: toIdent("p2")}, }); - await ph.return(); expect(mon1.volatile.lockedOnBy).to.be.null; expect(mon1.volatile.lockOnTarget).to.equal(mon2.volatile); expect(mon1.volatile.lockOnTurns.isActive).to.be.true; @@ -3657,11 +3487,11 @@ export const test = () => } it("Should activate mimic move effect", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); mon.moveset.reveal("mimic"); mon.volatile.lastMove = "mimic"; - await ph.handle({ + await handle({ args: [ "-activate", toIdent("p2"), @@ -3670,7 +3500,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); // Replaces override moveset but not base, so switching will // still restore the original mimic move. expect(mon.moveset.get("splash")).to.not.be.null; @@ -3680,13 +3509,13 @@ export const test = () => }); it("Should activate sketch move effect", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); mon.moveset.reveal("sketch"); mon.volatile.lastMove = "sketch"; // Note(gen4): Same exact event as mimic, differentiator is // lastMove for now. - await ph.handle({ + await handle({ args: [ "-activate", toIdent("p2"), @@ -3695,7 +3524,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); // Works like mimic but also changes base moveset. expect(mon.moveset.get("tackle")).to.not.be.null; expect(mon.moveset.get("sketch")).to.be.null; @@ -3704,13 +3532,13 @@ export const test = () => }); it("Should activate pursuit move effect and reveal move", async function () { - const mon1 = sh.initActive("p1"); + const mon1 = initActive(ctx.state, "p1"); expect(mon1.moveset.get("pursuit")).to.be.null; - const mon2 = sh.initActive("p2"); + const mon2 = initActive(ctx.state, "p2"); expect(mon2.moveset.get("pursuit")).to.be.null; // P1's switch-out is interrupted by p2's pursuit move. - await ph.handle({ + await handle({ args: [ "-activate", toIdent("p1"), @@ -3718,16 +3546,15 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon1.moveset.get("pursuit")).to.be.null; expect(mon2.moveset.get("pursuit")).to.not.be.null; }); it("Should activate snatch move effect", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); mon.volatile.snatch = true; - await ph.handle({ + await handle({ args: [ "-activate", toIdent("p1"), @@ -3735,15 +3562,14 @@ export const test = () => ], kwArgs: {of: toIdent("p2")}, }); - await ph.return(); expect(mon.volatile.snatch).to.be.false; }); it("Should deplete arbitrary move pp if spite", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); expect(mon.moveset.get("splash")).to.be.null; - await ph.handle({ + await handle({ args: [ "-activate", toIdent("p2"), @@ -3753,7 +3579,6 @@ export const test = () => ], kwArgs: {}, } as Event<"|-activate|">); // TODO: Fix protocol typings? - await ph.return(); const move = mon.moveset.get("splash"); expect(move).to.not.be.null; expect(move).to.have.property("pp", 60); @@ -3761,19 +3586,18 @@ export const test = () => }); it("Should start trapped status", async function () { - const mon1 = sh.initActive("p1"); + const mon1 = initActive(ctx.state, "p1"); expect(mon1.volatile.trapped).to.be.null; expect(mon1.volatile.trapping).to.be.null; - const mon2 = sh.initActive("p2"); + const mon2 = initActive(ctx.state, "p2"); expect(mon2.volatile.trapped).to.be.null; expect(mon2.volatile.trapping).to.be.null; // P1 being trapped by p2. - await ph.handle({ + await handle({ args: ["-activate", toIdent("p1"), toEffectName("trapped")], kwArgs: {}, }); - await ph.return(); expect(mon1.volatile.trapped).to.equal(mon2.volatile); expect(mon1.volatile.trapping).to.be.null; expect(mon2.volatile.trapped).to.be.null; @@ -3781,51 +3605,46 @@ export const test = () => }); it("Should ignore invalid effect", async function () { - sh.initActive("p1"); + initActive(ctx.state, "p1"); - await ph.handle({ + await handle({ args: ["-activate", toIdent("p1"), toEffectName("invalid")], kwArgs: {}, }); - await ph.return(); }); it("Should ignore event without ident", async function () { - await ph.handle({ + await handle({ args: ["-activate", "", toEffectName("invalid")], kwArgs: {}, }); - await ph.return(); }); }); describe("|-fieldactivate|", function () { it("Should handle", async function () { - await ph.handle({ + await handle({ args: ["-fieldactivate", toEffectName("payday", "move")], kwArgs: {}, }); - await ph.return(); }); }); describe("|-center|", function () { it("Should do nothing since unsupported", async function () { - await ph.handle({args: ["-center"], kwArgs: {}}); - await ph.return(); + await handle({args: ["-center"], kwArgs: {}}); }); }); describe("|-combine|", function () { it("Should do nothing since unsupported", async function () { - await ph.handle({args: ["-combine"], kwArgs: {}}); - await ph.return(); + await handle({args: ["-combine"], kwArgs: {}}); }); }); describe("|-waiting|", function () { it("Should do nothing since unsupported", async function () { - await ph.handle({ + await handle({ args: [ "-waiting", toIdent("p1"), @@ -3833,68 +3652,63 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); }); }); describe("|-prepare|", function () { it("Should prepare two-turn move", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); expect(mon.volatile.twoTurn.isActive).to.be.false; - await ph.handle({ + await handle({ args: ["-prepare", toIdent("p2"), toMoveName("fly")], kwArgs: {}, }); - await ph.return(); expect(mon.volatile.twoTurn.isActive).to.be.true; expect(mon.volatile.twoTurn.type).to.equal("fly"); }); it("Should ignore non-two-turn move", async function () { - const mon = sh.initActive("p2"); + const mon = initActive(ctx.state, "p2"); expect(mon.volatile.twoTurn.isActive).to.be.false; - await ph.handle({ + await handle({ args: ["-prepare", toIdent("p2"), toMoveName("splash")], kwArgs: {}, }); - await ph.return(); expect(mon.volatile.twoTurn.isActive).to.be.false; }); }); describe("|-mustrecharge|", function () { it("Should indicate recharge", async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.volatile.mustRecharge).to.be.false; - await ph.handle({ + await handle({ args: ["-mustrecharge", toIdent("p1")], kwArgs: {}, }); - await ph.return(); expect(mon.volatile.mustRecharge).to.be.true; }); }); describe("|-hitcount|", function () { it("Should do nothing", async function () { - await ph.handle({ + await handle({ args: ["-hitcount", toIdent("p2"), toNum(4)], kwArgs: {}, }); - await ph.return(); }); }); describe("|-singlemove|", function () { for (const effect of ["destinybond", "grudge", "rage"] as const) { it(`Should start ${effect}`, async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(mon.volatile[effect]).to.be.false; - await ph.handle({ + await handle({ args: [ "-singlemove", toIdent("p1"), @@ -3902,7 +3716,6 @@ export const test = () => ], kwArgs: {}, }); - await ph.return(); expect(mon.volatile[effect]).to.be.true; }); } @@ -3916,14 +3729,13 @@ export const test = () => ): void { const moveName = toMoveName(moveId ?? effect); it(`Should start ${effect}`, async function () { - const mon = sh.initActive("p1"); + const mon = initActive(ctx.state, "p1"); expect(getter(mon.volatile)).to.be.false; - await ph.handle({ + await handle({ args: ["-singleturn", toIdent("p1"), moveName], kwArgs: {}, }); - await ph.return(); expect(getter(mon.volatile)).to.be.true; }); } @@ -3938,8 +3750,7 @@ export const test = () => describe("|-candynamax|", function () { it("Should do nothing since unsupported", async function () { - await ph.handle({args: ["-candynamax", "p1"], kwArgs: {}}); - await ph.return(); + await handle({args: ["-candynamax", "p1"], kwArgs: {}}); }); }); }); diff --git a/src/ts/battle/parser/events.ts b/src/ts/battle/parser/events.ts index 5461f8ec..0ba6632a 100644 --- a/src/ts/battle/parser/events.ts +++ b/src/ts/battle/parser/events.ts @@ -2,53 +2,36 @@ import {Protocol} from "@pkmn/protocol"; import {BoostID, SideID, StatID} from "@pkmn/types"; import {Event} from "../../protocol/Event"; -import {BattleAgent, Action} from "../agent"; +import {Mutable} from "../../utils/types"; +import {Action} from "../agent"; import * as dex from "../dex"; import {toIdName} from "../helpers"; import {Move} from "../state/Move"; import {Pokemon, ReadonlyPokemon} from "../state/Pokemon"; import {SwitchOptions, TeamRevealOptions} from "../state/Team"; import {BattleParserContext, ExecutorResult} from "./BattleParser"; -import {consume, dispatcher, EventHandlerMap, verify} from "./parsing"; - -/** Private mapped type for {@link handlersImpl}. */ -type HandlersImpl = { - -readonly [U in keyof T]: T[U] | "default" | "unsupported"; -}; - -/** Private mapped type for {@link handlersImpl} and {@link handlers}. */ -type HandlerMap = EventHandlerMap; - -/** - * BattleParser handlers for each event type. Larger handler functions or - * parsers that take additional args are moved to a separate file. - * - * If an entry is specified but set to `"default"`, a default handler will be - * assigned to it, making it an event that shouldn't be ignored but has no - * special behavior implemented by its handler. Alternatively, setting it to - * `"unsupported"` will be replaced by a parser that always throws. - */ -const handlersImpl: HandlersImpl = {}; -handlersImpl["|init|"] = async function (ctx) { - const event = await verify(ctx, "|init|"); - // istanbul ignore if: Should never happen. +import { + EventHandlerMap, + defaultParser, + eventParser, + unsupportedParser, +} from "./utils"; + +const handlersImpl: Mutable = {}; +handlersImpl["|init|"] = eventParser("|init|", (ctx, event) => { if (event.args[1] !== "battle") { ctx.logger.error( `Expected |init|battle but got |init|${event.args[1]}`, ); } - await consume(ctx); -}; -handlersImpl["|player|"] = async function (ctx) { - const event = await verify(ctx, "|player|"); +}); +handlersImpl["|player|"] = eventParser("|player|", (ctx, event) => { const [, side, username] = event.args; if (ctx.state.username === username) { ctx.state.ourSide = side; } - await consume(ctx); -}; -handlersImpl["|teamsize|"] = async function (ctx) { - const event = await verify(ctx, "|teamsize|"); +}); +handlersImpl["|teamsize|"] = eventParser("|teamsize|", (ctx, event) => { const [, side, sizeStr] = event.args; if (!ctx.state.ourSide) { throw new Error( @@ -60,54 +43,44 @@ handlersImpl["|teamsize|"] = async function (ctx) { if (ctx.state.ourSide !== side) { ctx.state.getTeam(side).size = Number(sizeStr); } - await consume(ctx); -}; -handlersImpl["|gametype|"] = async function (ctx) { - const event = await verify(ctx, "|gametype|"); - // istanbul ignore if: Should never happen. +}); +handlersImpl["|gametype|"] = eventParser("|gametype|", (ctx, event) => { if (event.args[1] !== "singles") { ctx.logger.error( "Expected |gametype|singles but got " + `|gametype|${event.args[1]}`, ); } - await consume(ctx); -}; -handlersImpl["|gen|"] = async function (ctx) { - const event = await verify(ctx, "|gen|"); - // istanbul ignore if: Should never happen. +}); +handlersImpl["|gen|"] = eventParser("|gen|", (ctx, event) => { if (event.args[1] !== 4) { ctx.logger.error(`Expected |gen|4 but got |gen|${event.args[1]}`); } - await consume(ctx); -}; -handlersImpl["|tier|"] = "default"; -handlersImpl["|rated|"] = "default"; -handlersImpl["|seed|"] = "default"; -handlersImpl["|rule|"] = "default"; +}); +handlersImpl["|tier|"] = defaultParser("|tier|"); +handlersImpl["|rated|"] = defaultParser("|rated|"); +handlersImpl["|seed|"] = defaultParser("|seed|"); +handlersImpl["|rule|"] = defaultParser("|rule|"); // TODO: Support team preview. -handlersImpl["|clearpoke|"] = "unsupported"; -handlersImpl["|poke|"] = "unsupported"; -handlersImpl["|teampreview|"] = "unsupported"; -handlersImpl["|updatepoke|"] = "unsupported"; -handlersImpl["|start|"] = async function (ctx) { - await verify(ctx, "|start|"); +handlersImpl["|clearpoke|"] = unsupportedParser("|clearpoke|"); +handlersImpl["|poke|"] = unsupportedParser("|poke|"); +handlersImpl["|teampreview|"] = unsupportedParser("|teampreview|"); +handlersImpl["|updatepoke|"] = unsupportedParser("|updatepoke|"); +handlersImpl["|start|"] = eventParser("|start|", ctx => { if (!ctx.state.ourSide) { throw new Error( "Expected |player| event for client before |start event", ); } ctx.state.started = true; - await consume(ctx); -}; -handlersImpl["|request|"] = async function (ctx) { +}); +handlersImpl["|request|"] = eventParser("|request|", async (ctx, event) => { // Note: Usually the |request| event is displayed before the game events // that lead up to the state described by the |request| JSON object, but // some logic in the parent BattleHandler reverses that so that the // |request| happens after all the game events. // This allows us to treat |request| as an actual request for a decision // after having parsed all the relevant game events. - const event = await verify(ctx, "|request|"); const [, json] = event.args; const req = Protocol.parseRequest(json); ctx.logger.debug( @@ -130,7 +103,7 @@ handlersImpl["|request|"] = async function (ctx) { // Sanitize variable-type moves. let {id}: {id: string} = moveData; ({id} = sanitizeMoveId(id)); - if (id === "struggle") { + if (["struggle", "recharge"].includes(id)) { continue; } // Note: Can have missing pp/maxpp values, e.g. due to a rampage @@ -172,9 +145,7 @@ handlersImpl["|request|"] = async function (ctx) { ); } } - - await consume(ctx); -}; +}); function initRequest(ctx: BattleParserContext, req: Protocol.Request) { // istanbul ignore if: Should never happen. if (!req.side) { @@ -332,8 +303,8 @@ function getChoices(req: Protocol.Request): Action[] { } for (let i = 0; i < moves.length; ++i) { const move = moves[i]; - // Struggle can always be selected. - if (move.id !== "struggle") { + // Struggle/recharge can always be selected. + if (!["struggle", "recharge"].includes(move.id)) { // Depleted moves can no longer be selected. if (move.pp <= 0) { continue; @@ -374,11 +345,10 @@ async function sendFinalChoice( action: Action, ): Promise { const res = await ctx.executor(action); - if (!res) { - return; + if (res) { + ctx.logger.debug(`Action '${action}' was rejected as '${res}'`); + throw new Error(`Final choice '${action}' was rejected as '${res}'`); } - ctx.logger.debug(`Action '${action}' was rejected as '${res}'`); - throw new Error(`Final choice '${action}' was rejected as '${res}'`); } /** * Calls the BattleAgent to evaluate the available choices and decide what to @@ -425,7 +395,8 @@ async function evaluateChoices( } // Make sure we haven't fallen back to the base case in decide(). - // istanbul ignore if: Should never happen. + // istanbul ignore if: Should never happen (should instead be thrown by + // sendFinalChoice). if (choices.length <= 0) { throw new Error( `Final choice '${lastAction}' rejected as '${result}'`, @@ -437,21 +408,15 @@ async function evaluateChoices( } } } -handlersImpl["|upkeep|"] = async function (ctx) { - await verify(ctx, "|upkeep|"); +handlersImpl["|upkeep|"] = eventParser("|upkeep|", ctx => { ctx.state.postTurn(); - await consume(ctx); -}; -handlersImpl["|turn|"] = async function (ctx) { - const event = await verify(ctx, "|turn|"); +}); +handlersImpl["|turn|"] = eventParser("|turn|", (ctx, event) => { ctx.logger.info(`Turn ${event.args[1]}`); - await consume(ctx); -}; -// Note: Win/tie are handled by the top-level main.ts parser to end the game. -handlersImpl["|win|"] = async () => await Promise.resolve(); -handlersImpl["|tie|"] = async () => await Promise.resolve(); -handlersImpl["|move|"] = async function (ctx) { - const event = await verify(ctx, "|move|"); +}); +handlersImpl["|win|"] = defaultParser("|win|"); +handlersImpl["|tie|"] = defaultParser("|tie|"); +handlersImpl["|move|"] = eventParser("|move|", (ctx, event) => { const [, identStr, moveStr] = event.args; const ident = Protocol.parsePokemonIdent(identStr); const moveId = toIdName(moveStr); @@ -571,9 +536,7 @@ handlersImpl["|move|"] = async function (ctx) { break; } } - - await consume(ctx); -}; +}); function moveTargetsOpponent( move: dex.MoveData, user: ReadonlyPokemon, @@ -608,14 +571,12 @@ function moveTargetsOpponent( } } } -handlersImpl["|switch|"] = async function (ctx) { - await switchEvent(ctx); -}; -handlersImpl["|drag|"] = async function (ctx) { - await switchEvent(ctx); -}; -async function switchEvent(ctx: BattleParserContext): Promise { - const event = await verify(ctx, "|switch|", "|drag|"); +handlersImpl["|switch|"] = eventParser("|switch|", handleSwitch); +handlersImpl["|drag|"] = eventParser("|drag|", handleSwitch); +function handleSwitch( + ctx: BattleParserContext, + event: Event<"|switch|" | "|drag|">, +): void { const [, identStr, detailsStr, healthStr] = event.args; const ident = Protocol.parsePokemonIdent(identStr); const data = Protocol.parseDetails(ident.name, identStr, detailsStr); @@ -637,24 +598,21 @@ async function switchEvent(ctx: BattleParserContext): Promise { `Team '${ident.player}' is full (size=${team.size})`, ); } - - await consume(ctx); } -handlersImpl["|detailschange|"] = async function (ctx) { - const event = await verify(ctx, "|detailschange|"); - const [, identStr, detailsStr] = event.args; - const ident = Protocol.parsePokemonIdent(identStr); - const details = Protocol.parseDetails(ident.name, identStr, detailsStr); - - const formeId = toIdName(details.speciesForme); - const mon = ctx.state.getTeam(ident.player).active; - mon.formChange(formeId, details.level, true /*perm*/); - mon.gender = details.gender ?? "N"; - - await consume(ctx); -}; -handlersImpl["|cant|"] = async function (ctx) { - const event = await verify(ctx, "|cant|"); +handlersImpl["|detailschange|"] = eventParser( + "|detailschange|", + (ctx, event) => { + const [, identStr, detailsStr] = event.args; + const ident = Protocol.parsePokemonIdent(identStr); + const details = Protocol.parseDetails(ident.name, identStr, detailsStr); + + const formeId = toIdName(details.speciesForme); + const mon = ctx.state.getTeam(ident.player).active; + mon.formChange(formeId, details.level, true /*perm*/); + mon.gender = details.gender ?? "N"; + }, +); +handlersImpl["|cant|"] = eventParser("|cant|", (ctx, event) => { const [, identStr, reasonStr, moveStr] = event.args; const reason = Protocol.parseEffect(reasonStr, toIdName); @@ -722,17 +680,13 @@ handlersImpl["|cant|"] = async function (ctx) { mon.moveset.reveal(moveName); } mon.inactive(); - await consume(ctx); -}; -handlersImpl["|faint|"] = async function (ctx) { - const event = await verify(ctx, "|faint|"); +}); +handlersImpl["|faint|"] = eventParser("|faint|", (ctx, event) => { const [, identStr] = event.args; const ident = Protocol.parsePokemonIdent(identStr); ctx.state.getTeam(ident.player).active.hp.set(0); - await consume(ctx); -}; -handlersImpl["|-formechange|"] = async function (ctx) { - const event = await verify(ctx, "|-formechange|"); +}); +handlersImpl["|-formechange|"] = eventParser("|-formechange|", (ctx, event) => { const [, identStr, speciesForme] = event.args; const ident = Protocol.parsePokemonIdent(identStr); const formeId = toIdName(speciesForme); @@ -747,10 +701,8 @@ handlersImpl["|-formechange|"] = async function (ctx) { } mon.formChange(formeId, mon.stats.level); - await consume(ctx); -}; -handlersImpl["|-fail|"] = async function (ctx) { - const event = await verify(ctx, "|-fail|"); +}); +handlersImpl["|-fail|"] = eventParser("|-fail|", (ctx, event) => { if (event.kwArgs.from) { const [, identStr] = event.args; const ident = Protocol.parsePokemonIdent(identStr); @@ -768,19 +720,21 @@ handlersImpl["|-fail|"] = async function (ctx) { mon.revealAbility(from.name); } } - await consume(ctx); -}; -handlersImpl["|-block|"] = "unsupported"; -handlersImpl["|-notarget|"] = "default"; -handlersImpl["|-miss|"] = "default"; -handlersImpl["|-damage|"] = async function (ctx) { - await handleDamage(ctx); -}; -handlersImpl["|-heal|"] = async function (ctx) { - await handleDamage(ctx, true /*heal*/); -}; -async function handleDamage(ctx: BattleParserContext, heal?: boolean) { - const event = await verify(ctx, heal ? "|-heal|" : "|-damage|"); +}); +handlersImpl["|-block|"] = unsupportedParser("|-block|"); +handlersImpl["|-notarget|"] = defaultParser("|-notarget|"); +handlersImpl["|-miss|"] = defaultParser("|-miss|"); +handlersImpl["|-damage|"] = eventParser("|-damage|", (ctx, event) => + handleDamage(ctx, event), +); +handlersImpl["|-heal|"] = eventParser("|-heal|", (ctx, event) => + handleDamage(ctx, event, true /*heal*/), +); +function handleDamage( + ctx: BattleParserContext, + event: Event<"|-damage|" | "|-heal|">, + heal?: boolean, +): void { const [, identStr, healthStr] = event.args; const ident = Protocol.parsePokemonIdent(identStr); const health = Protocol.parseHealth(healthStr); @@ -822,10 +776,8 @@ async function handleDamage(ctx: BattleParserContext, heal?: boolean) { } mon.hp.set(health?.hp ?? 0, health?.maxhp ?? 0); - await consume(ctx); } -handlersImpl["|-sethp|"] = async function (ctx) { - const event = await verify(ctx, "|-sethp|"); +handlersImpl["|-sethp|"] = eventParser("|-sethp|", (ctx, event) => { const [, identStr1, healthStr1, identStr2, healthNumStr2] = event.args; const ident1 = Protocol.parsePokemonIdent(identStr1); @@ -854,11 +806,8 @@ handlersImpl["|-sethp|"] = async function (ctx) { } mon2.hp.set(healthNum2); } - - await consume(ctx); -}; -handlersImpl["|-status|"] = async function (ctx) { - const event = await verify(ctx, "|-status|"); +}); +handlersImpl["|-status|"] = eventParser("|-status|", (ctx, event) => { const [, identStr, statusName] = event.args; const ident = Protocol.parsePokemonIdent(identStr); const mon = ctx.state.getTeam(ident.player).active; @@ -883,15 +832,12 @@ handlersImpl["|-status|"] = async function (ctx) { } } mon.majorStatus.afflict(statusName); - await consume(ctx); -}; -handlersImpl["|-curestatus|"] = async function (ctx) { - const event = await verify(ctx, "|-curestatus|"); +}); +handlersImpl["|-curestatus|"] = eventParser("|-curestatus|", (ctx, event) => { const [, identStr, statusName] = event.args; const ident = Protocol.parsePokemonIdent(identStr); if (!ident.position) { ctx.logger.debug("Ignoring bench cure"); - await consume(ctx); return; } const mon = ctx.state.getTeam(ident.player).active; @@ -910,23 +856,23 @@ handlersImpl["|-curestatus|"] = async function (ctx) { ); } mon.majorStatus.cure(); - await consume(ctx); -}; -handlersImpl["|-cureteam|"] = async function (ctx) { - const event = await verify(ctx, "|-cureteam|"); +}); +handlersImpl["|-cureteam|"] = eventParser("|-cureteam|", (ctx, event) => { const [, identStr] = event.args; const ident = Protocol.parsePokemonIdent(identStr); ctx.state.getTeam(ident.player).cure(); - await consume(ctx); -}; -handlersImpl["|-boost|"] = async function (ctx) { - await handleBoost(ctx); -}; -handlersImpl["|-unboost|"] = async function (ctx) { - await handleBoost(ctx, true /*flip*/); -}; -async function handleBoost(ctx: BattleParserContext, flip?: boolean) { - const event = await verify(ctx, flip ? "|-unboost|" : "|-boost|"); +}); +handlersImpl["|-boost|"] = eventParser("|-boost|", (ctx, event) => + handleBoost(ctx, event), +); +handlersImpl["|-unboost|"] = eventParser("|-unboost|", (ctx, event) => + handleBoost(ctx, event, true /*flip*/), +); +function handleBoost( + ctx: BattleParserContext, + event: Event<"|-boost|" | "|-unboost|">, + flip?: boolean, +): void { const [, identStr, stat, numStr] = event.args; const ident = Protocol.parsePokemonIdent(identStr); const num = Number(numStr); @@ -938,10 +884,8 @@ async function handleBoost(ctx: BattleParserContext, flip?: boolean) { const newBoost = oldBoost + (flip ? -num : num); // Boost is capped at 6. mon.volatile.boosts[stat] = Math.max(-6, Math.min(newBoost, 6)); - await consume(ctx); } -handlersImpl["|-setboost|"] = async function (ctx) { - const event = await verify(ctx, "|-setboost|"); +handlersImpl["|-setboost|"] = eventParser("|-setboost|", (ctx, event) => { const [, identStr, stat, numStr] = event.args; const ident = Protocol.parsePokemonIdent(identStr); const num = Number(numStr); @@ -949,10 +893,8 @@ handlersImpl["|-setboost|"] = async function (ctx) { throw new Error(`Invalid setboost num '${numStr}'`); } ctx.state.getTeam(ident.player).active.volatile.boosts[stat] = num; - await consume(ctx); -}; -handlersImpl["|-swapboost|"] = async function (ctx) { - const event = await verify(ctx, "|-swapboost|"); +}); +handlersImpl["|-swapboost|"] = eventParser("|-swapboost|", (ctx, event) => { const [, identStr1, identStr2, statsStr] = event.args; const ident1 = Protocol.parsePokemonIdent(identStr1); const ident2 = Protocol.parsePokemonIdent(identStr2); @@ -964,11 +906,8 @@ handlersImpl["|-swapboost|"] = async function (ctx) { for (const stat of stats) { [boosts1[stat], boosts2[stat]] = [boosts2[stat], boosts1[stat]]; } - - await consume(ctx); -}; -handlersImpl["|-invertboost|"] = async function (ctx) { - const event = await verify(ctx, "|-invertboost|"); +}); +handlersImpl["|-invertboost|"] = eventParser("|-invertboost|", (ctx, event) => { const [, identStr] = event.args; const ident = Protocol.parsePokemonIdent(identStr); @@ -976,11 +915,8 @@ handlersImpl["|-invertboost|"] = async function (ctx) { for (const stat of dex.boostKeys) { boosts[stat] = -boosts[stat]; } - - await consume(ctx); -}; -handlersImpl["|-clearboost|"] = async function (ctx) { - const event = await verify(ctx, "|-clearboost|"); +}); +handlersImpl["|-clearboost|"] = eventParser("|-clearboost|", (ctx, event) => { const [, identStr] = event.args; const ident = Protocol.parsePokemonIdent(identStr); @@ -988,12 +924,8 @@ handlersImpl["|-clearboost|"] = async function (ctx) { for (const stat of dex.boostKeys) { boosts[stat] = 0; } - - await consume(ctx); -}; -handlersImpl["|-clearallboost|"] = async function (ctx) { - await verify(ctx, "|-clearallboost|"); - +}); +handlersImpl["|-clearallboost|"] = eventParser("|-clearallboost|", ctx => { for (const sideId in ctx.state.teams) { // istanbul ignore if if (!Object.hasOwnProperty.call(ctx.state.teams, sideId)) { @@ -1009,39 +941,36 @@ handlersImpl["|-clearallboost|"] = async function (ctx) { boosts[stat] = 0; } } +}); +handlersImpl["|-clearpositiveboost|"] = eventParser( + "|-clearpositiveboost|", + (ctx, event) => { + const [, identStr] = event.args; + const ident = Protocol.parsePokemonIdent(identStr); - await consume(ctx); -}; -handlersImpl["|-clearpositiveboost|"] = async function (ctx) { - const event = await verify(ctx, "|-clearpositiveboost|"); - const [, identStr] = event.args; - const ident = Protocol.parsePokemonIdent(identStr); - - const {boosts} = ctx.state.getTeam(ident.player).active.volatile; - for (const stat of dex.boostKeys) { - if (boosts[stat] > 0) { - boosts[stat] = 0; + const {boosts} = ctx.state.getTeam(ident.player).active.volatile; + for (const stat of dex.boostKeys) { + if (boosts[stat] > 0) { + boosts[stat] = 0; + } } - } - - await consume(ctx); -}; -handlersImpl["|-clearnegativeboost|"] = async function (ctx) { - const event = await verify(ctx, "|-clearnegativeboost|"); - const [, identStr] = event.args; - const ident = Protocol.parsePokemonIdent(identStr); + }, +); +handlersImpl["|-clearnegativeboost|"] = eventParser( + "|-clearnegativeboost|", + (ctx, event) => { + const [, identStr] = event.args; + const ident = Protocol.parsePokemonIdent(identStr); - const {boosts} = ctx.state.getTeam(ident.player).active.volatile; - for (const stat of dex.boostKeys) { - if (boosts[stat] < 0) { - boosts[stat] = 0; + const {boosts} = ctx.state.getTeam(ident.player).active.volatile; + for (const stat of dex.boostKeys) { + if (boosts[stat] < 0) { + boosts[stat] = 0; + } } - } - - await consume(ctx); -}; -handlersImpl["|-copyboost|"] = async function (ctx) { - const event = await verify(ctx, "|-copyboost|"); + }, +); +handlersImpl["|-copyboost|"] = eventParser("|-copyboost|", (ctx, event) => { const [, identStr1, identStr2, statsStr] = event.args; const ident1 = Protocol.parsePokemonIdent(identStr1); const ident2 = Protocol.parsePokemonIdent(identStr2); @@ -1052,11 +981,8 @@ handlersImpl["|-copyboost|"] = async function (ctx) { for (const stat of stats) { boosts2[stat] = boosts1[stat]; } - - await consume(ctx); -}; -handlersImpl["|-weather|"] = async function (ctx) { - const event = await verify(ctx, "|-weather|"); +}); +handlersImpl["|-weather|"] = eventParser("|-weather|", (ctx, event) => { const [, weatherStr] = event.args; if (event.kwArgs.upkeep) { // istanbul ignore if: Should never happen. @@ -1084,16 +1010,18 @@ handlersImpl["|-weather|"] = async function (ctx) { } ctx.state.status.weather.start(weatherStr as dex.WeatherType, infinite); } - await consume(ctx); -}; -handlersImpl["|-fieldstart|"] = async function (ctx) { - await updateFieldEffect(ctx, true /*start*/); -}; -handlersImpl["|-fieldend|"] = async function (ctx) { - await updateFieldEffect(ctx, false /*start*/); -}; -async function updateFieldEffect(ctx: BattleParserContext, start: boolean) { - const event = await verify(ctx, start ? "|-fieldstart|" : "|-fieldend|"); +}); +handlersImpl["|-fieldstart|"] = eventParser("|-fieldstart|", (ctx, event) => + handleFieldCondition(ctx, event, true /*start*/), +); +handlersImpl["|-fieldend|"] = eventParser("|-fieldend|", (ctx, event) => + handleFieldCondition(ctx, event, false /*start*/), +); +function handleFieldCondition( + ctx: BattleParserContext, + event: Event<"|-fieldstart|" | "|-fieldend|">, + start: boolean, +): void { const [, effectStr] = event.args; const effect = Protocol.parseEffect(effectStr, toIdName); switch (effect.name) { @@ -1104,18 +1032,19 @@ async function updateFieldEffect(ctx: BattleParserContext, start: boolean) { ctx.state.status.trickroom[start ? "start" : "end"](); break; } - await consume(ctx); } -handlersImpl["|-sidestart|"] = async function (ctx) { - await handleSideCondition(ctx, true /*start*/); -}; -handlersImpl["|-sideend|"] = async function (ctx) { - await handleSideCondition(ctx, false /*start*/); -}; -async function handleSideCondition(ctx: BattleParserContext, start: boolean) { - const event = await verify(ctx, start ? "|-sidestart|" : "|-sideend|"); +handlersImpl["|-sidestart|"] = eventParser("|-sidestart|", (ctx, event) => + handleSideCondition(ctx, event, true /*start*/), +); +handlersImpl["|-sideend|"] = eventParser("|-sideend|", (ctx, event) => + handleSideCondition(ctx, event, false /*start*/), +); +function handleSideCondition( + ctx: BattleParserContext, + event: Event<"|-sidestart|" | "|-sideend|">, + start: boolean, +): void { const [, sideStr, effectStr] = event.args; - // Note: parsePokemonIdent() supports side identifiers. const side = Protocol.parsePokemonIdent( sideStr as unknown as Protocol.PokemonIdent, ).player; @@ -1152,11 +1081,11 @@ async function handleSideCondition(ctx: BattleParserContext, start: boolean) { } break; } - await consume(ctx); } -handlersImpl["|-swapsideconditions|"] = "unsupported"; -handlersImpl["|-start|"] = async function (ctx) { - const event = await verify(ctx, "|-start|"); +handlersImpl["|-swapsideconditions|"] = unsupportedParser( + "|-swapsideconditions|", +); +handlersImpl["|-start|"] = eventParser("|-start|", (ctx, event) => { const [, identStr, effectStr, other] = event.args; const ident = Protocol.parsePokemonIdent(identStr); const effect = Protocol.parseEffect(effectStr, toIdName); @@ -1221,10 +1150,8 @@ handlersImpl["|-start|"] = async function (ctx) { ); } } - await consume(ctx); -}; -handlersImpl["|-end|"] = async function (ctx) { - const event = await verify(ctx, "|-end|"); +}); +handlersImpl["|-end|"] = eventParser("|-end|", (ctx, event) => { const [, identStr, effectStr] = event.args; const ident = Protocol.parsePokemonIdent(identStr); const effect = Protocol.parseEffect(effectStr, toIdName); @@ -1236,15 +1163,14 @@ handlersImpl["|-end|"] = async function (ctx) { default: handleStartEndTrivial(ctx, event, ident.player, effect.name); } - await consume(ctx); -}; +}); function handleStartEndTrivial( ctx: BattleParserContext, event: Event<"|-start|" | "|-end|">, side: SideID, effectId: string, other?: string, -) { +): void { const team = ctx.state.getTeam(side); const mon = team.active; const v = mon.volatile; @@ -1336,11 +1262,10 @@ function handleStartEndTrivial( } } } -handlersImpl["|-crit|"] = "default"; -handlersImpl["|-supereffective|"] = "default"; -handlersImpl["|-resisted|"] = "default"; -handlersImpl["|-immune|"] = async function (ctx) { - const event = await verify(ctx, "|-immune|"); +handlersImpl["|-crit|"] = defaultParser("|-crit|"); +handlersImpl["|-supereffective|"] = defaultParser("|-supereffective|"); +handlersImpl["|-resisted|"] = defaultParser("|-resisted|"); +handlersImpl["|-immune|"] = eventParser("|-immune|", (ctx, event) => { if (event.kwArgs.from) { const from = Protocol.parseEffect(event.kwArgs.from, toIdName); if (from.type === "ability") { @@ -1350,10 +1275,8 @@ handlersImpl["|-immune|"] = async function (ctx) { mon.revealAbility(from.name); } } - await consume(ctx); -}; -handlersImpl["|-item|"] = async function (ctx) { - const event = await verify(ctx, "|-item|"); +}); +handlersImpl["|-item|"] = eventParser("|-item|", (ctx, event) => { const [, identStr, itemName] = event.args; const ident = Protocol.parsePokemonIdent(identStr); const itemId = toIdName(itemName); @@ -1382,15 +1305,11 @@ handlersImpl["|-item|"] = async function (ctx) { // Most other unsupported effects are handled as if the item was gained. mon.setItem(itemId); } - await consume(ctx); -}; -handlersImpl["|-enditem|"] = async function (ctx) { - const event = await verify(ctx, "|-enditem|"); - +}); +handlersImpl["|-enditem|"] = eventParser("|-enditem|", (ctx, event) => { // Resist berry effect should already be handled by previous // |-enditem|...|[eat] event. if (event.kwArgs.weaken) { - await consume(ctx); return; } @@ -1414,15 +1333,12 @@ handlersImpl["|-enditem|"] = async function (ctx) { // Must be consuming the status, not the actual berry. if (itemId === "micleberry" && !event.kwArgs.eat && consumed) { mon.volatile.micleberry = false; - await consume(ctx); return; } mon.removeItem(consumed); - await consume(ctx); -}; -handlersImpl["|-ability|"] = async function (ctx) { - const event = await verify(ctx, "|-ability|"); +}); +handlersImpl["|-ability|"] = eventParser("|-ability|", (ctx, event) => { const [, identStr, abilityStr] = event.args; const abilityId = toIdName(abilityStr); const ident = Protocol.parsePokemonIdent(identStr); @@ -1456,11 +1372,8 @@ handlersImpl["|-ability|"] = async function (ctx) { // Assume ability activation without context. holder.revealAbility(abilityId); } - - await consume(ctx); -}; -handlersImpl["|-endability|"] = async function (ctx) { - const event = await verify(ctx, "|-endability|"); +}); +handlersImpl["|-endability|"] = eventParser("|-endability|", (ctx, event) => { const [, identStr, abilityName] = event.args; const ident = Protocol.parsePokemonIdent(identStr); const mon = ctx.state.getTeam(ident.player).active; @@ -1484,35 +1397,29 @@ handlersImpl["|-endability|"] = async function (ctx) { } // Other effects which cause this event usually override the ability // with a separate event right after this. - await consume(ctx); return; } mon.revealAbility(abilityId); } // Assume gastroacid move effect without context. mon.volatile.suppressAbility = true; - await consume(ctx); -}; -handlersImpl["|-transform|"] = async function (ctx) { - const event = await verify(ctx, "|-transform|"); +}); +handlersImpl["|-transform|"] = eventParser("|-transform|", (ctx, event) => { const [, identSourceStr, identTargetStr] = event.args; const identSource = Protocol.parsePokemonIdent(identSourceStr); const identTarget = Protocol.parsePokemonIdent(identTargetStr); ctx.state .getTeam(identSource.player) .active.transform(ctx.state.getTeam(identTarget.player).active); - await consume(ctx); -}; -handlersImpl["|-mega|"] = "unsupported"; -handlersImpl["|-primal|"] = "unsupported"; -handlersImpl["|-burst|"] = "unsupported"; -handlersImpl["|-zpower|"] = "unsupported"; -handlersImpl["|-zbroken|"] = "unsupported"; -handlersImpl["|-activate|"] = async function (ctx) { - const event = await verify(ctx, "|-activate|"); +}); +handlersImpl["|-mega|"] = unsupportedParser("|-mega|"); +handlersImpl["|-primal|"] = unsupportedParser("|-primal|"); +handlersImpl["|-burst|"] = unsupportedParser("|-burst|"); +handlersImpl["|-zpower|"] = unsupportedParser("|-zpower|"); +handlersImpl["|-zbroken|"] = unsupportedParser("|-zbroken|"); +handlersImpl["|-activate|"] = eventParser("|-activate|", (ctx, event) => { const [, identStr, effectStr, other1, other2] = event.args; if (!identStr) { - await consume(ctx); return; } const ident = Protocol.parsePokemonIdent(identStr); @@ -1672,8 +1579,7 @@ handlersImpl["|-activate|"] = async function (ctx) { default: ctx.logger.debug(`Ignoring activate '${effect.name}'`); } - await consume(ctx); -}; +}); function getForewarnPower(move: string): number { const data = dex.moves[move]; // OHKO moves. @@ -1691,12 +1597,11 @@ function getForewarnPower(move: string): number { // Regular base power, eruption/waterspout, and status moves. return data.basePower; } -handlersImpl["|-fieldactivate|"] = "default"; -handlersImpl["|-center|"] = "unsupported"; -handlersImpl["|-combine|"] = "unsupported"; -handlersImpl["|-waiting|"] = "unsupported"; -handlersImpl["|-prepare|"] = async function (ctx) { - const event = await verify(ctx, "|-prepare|"); +handlersImpl["|-fieldactivate|"] = defaultParser("|-fieldactivate|"); +handlersImpl["|-center|"] = unsupportedParser("|-center|"); +handlersImpl["|-combine|"] = unsupportedParser("|-combine|"); +handlersImpl["|-waiting|"] = unsupportedParser("|-waiting|"); +handlersImpl["|-prepare|"] = eventParser("|-prepare|", (ctx, event) => { const [, identStr, moveName] = event.args; const ident = Protocol.parsePokemonIdent(identStr); const moveId = toIdName(moveName); @@ -1705,18 +1610,17 @@ handlersImpl["|-prepare|"] = async function (ctx) { } else { ctx.state.getTeam(ident.player).active.volatile.twoTurn.start(moveId); } - await consume(ctx); -}; -handlersImpl["|-mustrecharge|"] = async function (ctx) { - const event = await verify(ctx, "|-mustrecharge|"); - const [, identStr] = event.args; - const ident = Protocol.parsePokemonIdent(identStr); - ctx.state.getTeam(ident.player).active.volatile.mustRecharge = true; - await consume(ctx); -}; -handlersImpl["|-hitcount|"] = "default"; -handlersImpl["|-singlemove|"] = async function (ctx) { - const event = await verify(ctx, "|-singlemove|"); +}); +handlersImpl["|-mustrecharge|"] = eventParser( + "|-mustrecharge|", + (ctx, event) => { + const [, identStr] = event.args; + const ident = Protocol.parsePokemonIdent(identStr); + ctx.state.getTeam(ident.player).active.volatile.mustRecharge = true; + }, +); +handlersImpl["|-hitcount|"] = defaultParser("|-hitcount|"); +handlersImpl["|-singlemove|"] = eventParser("|-singlemove|", (ctx, event) => { const [, identStr, moveName] = event.args; const ident = Protocol.parsePokemonIdent(identStr); const v = ctx.state.getTeam(ident.player).active.volatile; @@ -1731,10 +1635,8 @@ handlersImpl["|-singlemove|"] = async function (ctx) { v.rage = true; break; } - await consume(ctx); -}; -handlersImpl["|-singleturn|"] = async function (ctx) { - const event = await verify(ctx, "|-singleturn|"); +}); +handlersImpl["|-singleturn|"] = eventParser("|-singleturn|", (ctx, event) => { const [, identStr, effectStr] = event.args; const ident = Protocol.parsePokemonIdent(identStr); const effect = Protocol.parseEffect(effectStr, toIdName); @@ -1757,56 +1659,9 @@ handlersImpl["|-singleturn|"] = async function (ctx) { v.snatch = true; break; } - await consume(ctx); -}; -handlersImpl["|-candynamax|"] = "unsupported"; -handlersImpl["|-terastallize|"] = "unsupported"; - -/** Handlers for all {@link Protocol.ArgName event types}. */ -export const handlers = - // This Object.assign expression is so that the function names appear as if - // they were defined directly as properties of this object so that stack - // traces make more sense. - Object.assign( - {}, - handlersImpl, - // Fill in unimplemented handlers. - ...(Object.keys(Protocol.ARGS) as Protocol.ArgName[]).map(key => - !Object.hasOwnProperty.call(handlersImpl, key) || - handlersImpl[key] === "default" - ? { - // Default parser just consumes the event. - // Note: This is used even if the key was never mentioned - // in this file. - async [key](ctx: BattleParserContext) { - await defaultParser(ctx, key); - }, - } - : handlersImpl[key] === "unsupported" - ? { - // Unsupported parser throws an error. - async [key](ctx: BattleParserContext) { - await unsupportedParser(ctx, key); - }, - } - : // Handler already implemented, don't override it. - undefined, - ), - ) as Required; - -async function defaultParser(ctx: BattleParserContext, key: Protocol.ArgName) { - await verify(ctx, key); - await consume(ctx); -} - -async function unsupportedParser( - ctx: BattleParserContext, - key: Protocol.ArgName, -) { - await verify(ctx, key); - ctx.logger.error(`Unsupported event type: ${key}`); - await consume(ctx); -} +}); +handlersImpl["|-candynamax|"] = unsupportedParser("|-candynamax|"); +handlersImpl["|-terastallize|"] = unsupportedParser("|-terastallize|"); -/** Dispatches base event handler. */ -export const dispatch = dispatcher(handlers); +/** Default handlers for battle events. */ +export const handlers: EventHandlerMap = handlersImpl; diff --git a/src/ts/battle/parser/main.test.ts b/src/ts/battle/parser/gen4.test.ts similarity index 72% rename from src/ts/battle/parser/main.test.ts rename to src/ts/battle/parser/gen4.test.ts index fc0d9b97..82a08949 100644 --- a/src/ts/battle/parser/main.test.ts +++ b/src/ts/battle/parser/gen4.test.ts @@ -1,21 +1,21 @@ import {expect} from "chai"; import "mocha"; -import {BattleState} from "../state"; +import {Event} from "../../protocol/Event"; +import {Mutable} from "../../utils/types"; import { benchInfo, ditto, requestEvent, smeargle, } from "../state/switchOptions.test"; +import {BattleParserContext} from "./BattleParser"; import { - createInitialContext, - ParserContext, + createTestContext, setupOverrideAgent, setupOverrideExecutor, -} from "./Context.test"; -import {ParserHelpers} from "./ParserHelpers.test"; +} from "./contextHelpers.test"; +import {gen4Parser} from "./gen4"; import { - initParser, toDetails, toEffectName, toFormatName, @@ -27,34 +27,23 @@ import { toRequestJSON, toRule, toUsername, -} from "./helpers.test"; -import {main} from "./main"; +} from "./protocolHelpers.test"; export const test = () => - describe("main", function () { - const ictx = createInitialContext(); + describe("gen4", function () { + let ctx: Mutable; - let state: BattleState; - - beforeEach("Extract BattleState", function () { - state = ictx.getState(); - }); - - let pctx: ParserContext>> | undefined; - const ph = new ParserHelpers(() => pctx); - - beforeEach("Initialize main BattleParser", function () { - pctx = initParser(ictx.startArgs, main); - state.started = false; + beforeEach("Initialize BattleParserContext", function () { + ctx = createTestContext(); + ctx.state.started = false; }); - afterEach("Close ParserContext", async function () { - await ph.close().finally(() => (pctx = undefined)); - }); - - const agent = setupOverrideAgent(ictx); + const handle = async (event: Event) => + void (await expect(gen4Parser(ctx, event)).to.eventually.be + .fulfilled); - const executor = setupOverrideExecutor(ictx); + const agent = setupOverrideAgent(() => ctx); + const executor = setupOverrideExecutor(() => ctx); // This is more of an integration test but hard to setup DI/mocking. it("Should handle init and 1st turn and subsequent turns until game-over", async function () { @@ -82,43 +71,43 @@ export const test = () => // Init phase. - const team1 = state.getTeam("p1"); + const team1 = ctx.state.getTeam("p1"); expect(team1.size).to.equal(0); - const team2 = state.getTeam("p2"); + const team2 = ctx.state.getTeam("p2"); expect(team2.size).to.equal(0); - await ph.handle({args: ["init", "battle"], kwArgs: {}}); - await ph.handle({args: ["gametype", "singles"], kwArgs: {}}); - await ph.handle({ + await handle({args: ["init", "battle"], kwArgs: {}}); + await handle({args: ["gametype", "singles"], kwArgs: {}}); + await handle({ args: ["player", "p1", toUsername("username"), "", ""], kwArgs: {}, }); - await ph.handle(req1Event); - await ph.handle({ + await handle(req1Event); + await handle({ args: ["player", "p2", toUsername("player2"), "", ""], kwArgs: {}, }); - await ph.handle({args: ["teamsize", "p1", toNum(2)], kwArgs: {}}); - await ph.handle({args: ["teamsize", "p2", toNum(2)], kwArgs: {}}); - await ph.handle({args: ["gen", 4], kwArgs: {}}); - await ph.handle({args: ["rated"], kwArgs: {}}); - await ph.handle({ + await handle({args: ["teamsize", "p1", toNum(2)], kwArgs: {}}); + await handle({args: ["teamsize", "p2", toNum(2)], kwArgs: {}}); + await handle({args: ["gen", 4], kwArgs: {}}); + await handle({args: ["rated"], kwArgs: {}}); + await handle({ args: ["tier", toFormatName("[Gen 4] Random Battle")], kwArgs: {}, }); - await ph.handle({ + await handle({ args: [ "rule", toRule("Sleep Clause: Limit one foe put to sleep"), ], kwArgs: {}, }); - await ph.handle({args: ["start"], kwArgs: {}}); + await handle({args: ["start"], kwArgs: {}}); expect(team1.size).to.equal(2); expect(team2.size).to.equal(2); // Turn 1: Switch in smeargle on both sides. - await ph.handle({ + await handle({ args: [ "switch", toIdent("p1", smeargle), @@ -127,7 +116,7 @@ export const test = () => ], kwArgs: {}, }); - await ph.handle({ + await handle({ args: [ "switch", toIdent("p2", smeargle), @@ -136,23 +125,25 @@ export const test = () => ], kwArgs: {}, }); - await ph.handle({args: ["turn", toNum(1)], kwArgs: {}}); + await handle({args: ["turn", toNum(1)], kwArgs: {}}); // P1 move request. - const req1 = ph.handle(req1Event); - await expect(agent.choices()).to.eventually.have.members([ + const req1 = handle(req1Event); + await expect(agent.receiveChoices()).to.eventually.have.members([ "move 1", "move 2", "switch 2", ]); agent.resolve(); - await expect(executor.executed()).to.eventually.equal("move 1"); + await expect(executor.receiveAction()).to.eventually.equal( + "move 1", + ); executor.resolve(false /*i.e., accept the choice*/); await req1; // Turn 2: P2 switches out, p1 attacks. - await ph.handle({ + await handle({ args: [ "switch", toIdent("p2", ditto), @@ -161,23 +152,23 @@ export const test = () => ], kwArgs: {}, }); - await ph.handle({ + await handle({ args: ["move", toIdent("p1", smeargle), toMoveName("tackle")], kwArgs: {}, }); - await ph.handle({ + await handle({ args: ["-damage", toIdent("p2", ditto), toHPStatus(50, 100)], kwArgs: {}, }); // Residual. - await ph.handle({ + await handle({ args: ["-heal", toIdent("p2", ditto), toHPStatus(56, 100)], kwArgs: {from: toEffectName("leftovers", "item")}, }); - await ph.handle({args: ["turn", toNum(2)], kwArgs: {}}); + await handle({args: ["turn", toNum(2)], kwArgs: {}}); // P1 move request. - const req2 = ph.handle( + const req2 = handle( requestEvent("move", benchInfo.slice(0, 2), { moves: [ { @@ -197,33 +188,35 @@ export const test = () => ], }), ); - await expect(agent.choices()).to.eventually.have.members([ + await expect(agent.receiveChoices()).to.eventually.have.members([ "move 1", "move 2", "switch 2", ]); agent.resolve(); - await expect(executor.executed()).to.eventually.equal("move 1"); + await expect(executor.receiveAction()).to.eventually.equal( + "move 1", + ); executor.resolve(false /*i.e., accept the choice*/); await req2; // Turn 3: P1 attacks, p2 faints and is forced to switch. - await ph.handle({ + await handle({ args: ["move", toIdent("p1", smeargle), toMoveName("tackle")], kwArgs: {}, }); - await ph.handle({ + await handle({ args: ["-damage", toIdent("p2", ditto), toHPStatus("faint")], kwArgs: {}, }); - await ph.handle({ + await handle({ args: ["faint", toIdent("p2", ditto)], kwArgs: {}, }); // P1 wait request as p2 chooses switch-in. - await ph.handle({args: ["upkeep"], kwArgs: {}}); - await ph.handle({ + await handle({args: ["upkeep"], kwArgs: {}}); + await handle({ args: [ "request", toRequestJSON({ @@ -235,7 +228,7 @@ export const test = () => kwArgs: {}, }); // P2 chose switch-in. - await ph.handle({ + await handle({ args: [ "switch", toIdent("p2", smeargle), @@ -244,10 +237,10 @@ export const test = () => ], kwArgs: {}, }); - await ph.handle({args: ["turn", toNum(3)], kwArgs: {}}); + await handle({args: ["turn", toNum(3)], kwArgs: {}}); // P1 move request. - const req3 = ph.handle( + const req3 = handle( requestEvent("move", benchInfo.slice(0, 2), { moves: [ { @@ -267,35 +260,37 @@ export const test = () => ], }), ); - await expect(agent.choices()).to.eventually.have.members([ + await expect(agent.receiveChoices()).to.eventually.have.members([ "move 1", "move 2", "switch 2", ]); agent.resolve(); - await expect(executor.executed()).to.eventually.equal("move 1"); + await expect(executor.receiveAction()).to.eventually.equal( + "move 1", + ); executor.resolve(false /*i.e., accept the choice*/); await req3; // Turn 4: P2 attacks, p1 faints and is forced to switch. - await ph.handle({ + await handle({ args: ["move", toIdent("p2", smeargle), toMoveName("tackle")], kwArgs: {}, }); - await ph.handle({ + await handle({ args: ["-damage", toIdent("p1", smeargle), toHPStatus("faint")], kwArgs: {}, }); - await ph.handle({ + await handle({ args: ["faint", toIdent("p1", smeargle)], kwArgs: {}, }); // P1 chooses switch-in. - await ph.handle({args: ["upkeep"], kwArgs: {}}); + await handle({args: ["upkeep"], kwArgs: {}}); // P1 switch request. - const req4s = ph.handle( + const req4s = handle( requestEvent("switch", [ {...benchInfo[0], condition: toHPStatus("faint")}, benchInfo[1], @@ -303,12 +298,14 @@ export const test = () => ); // Note: BattleAgent isn't invoked since there's only 1 switch // choice. - await expect(executor.executed()).to.eventually.equal("switch 2"); + await expect(executor.receiveAction()).to.eventually.equal( + "switch 2", + ); executor.resolve(false /*i.e., accept the choice*/); await req4s; // P1 chose switch-in. - await ph.handle({ + await handle({ args: [ "switch", toIdent("p1", ditto), @@ -317,10 +314,10 @@ export const test = () => ], kwArgs: {}, }); - await ph.handle({args: ["turn", toNum(4)], kwArgs: {}}); + await handle({args: ["turn", toNum(4)], kwArgs: {}}); // P1 move request. - const req4 = ph.handle( + const req4 = handle( requestEvent( "move", [ @@ -345,24 +342,26 @@ export const test = () => ), ); // Note: BattleAgent isn't invoked since there's only 1 move choice. - await expect(executor.executed()).to.eventually.equal("move 1"); + await expect(executor.receiveAction()).to.eventually.equal( + "move 1", + ); executor.resolve(false); // I.e., accept the choice await req4; // Turn 5: P2 attacks again, p1 faints again, game over. - await ph.handle({ + await handle({ args: ["move", toIdent("p2", smeargle), toMoveName("tackle")], kwArgs: {}, }); - await ph.handle({ + await handle({ args: ["-damage", toIdent("p1", ditto), toHPStatus("faint")], kwArgs: {}, }); - await ph.handle({ + await handle({ args: ["faint", toIdent("p1", ditto)], kwArgs: {}, }); - await ph.handle({args: ["win", toUsername("player2")], kwArgs: {}}); + await handle({args: ["win", toUsername("player2")], kwArgs: {}}); }); }); diff --git a/src/ts/battle/parser/gen4.ts b/src/ts/battle/parser/gen4.ts new file mode 100644 index 00000000..a3457108 --- /dev/null +++ b/src/ts/battle/parser/gen4.ts @@ -0,0 +1,14 @@ +import {Event} from "../../protocol/Event"; +import {BattleParserContext} from "./BattleParser"; +import {handlers} from "./events"; +import {createDispatcher} from "./utils"; + +/** BattleParser for Gen 4 battles. */ +export async function gen4Parser( + ctx: BattleParserContext, + event: Event, +): Promise { + await dispatcher(ctx, event); +} + +const dispatcher = createDispatcher(handlers); diff --git a/src/ts/battle/parser/index.test.ts b/src/ts/battle/parser/index.test.ts index f6d276ab..ca06ae59 100644 --- a/src/ts/battle/parser/index.test.ts +++ b/src/ts/battle/parser/index.test.ts @@ -1,9 +1,9 @@ import "mocha"; import * as events from "./events.test"; -import * as main from "./main.test"; +import * as gen4 from "./gen4.test"; export const test = () => describe("parser", function () { events.test(); - main.test(); + gen4.test(); }); diff --git a/src/ts/battle/parser/iterators.ts b/src/ts/battle/parser/iterators.ts deleted file mode 100644 index 6e503777..00000000 --- a/src/ts/battle/parser/iterators.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** @file Defines iterator types used by BattleParsers. */ -import {Event} from "../../protocol/Event"; - -/** - * Holds two corresponding iterators, one for sending BattleEvents and the other - * for receiving them. - */ -export class IteratorPair { - /** Event iterator for receiving events in a BattleParserContext. */ - public readonly eventIt: EventIterator; - /** Battle iterator for sending events to the BattleParser. */ - public readonly battleIt: BattleIterator; - - private nextEventPromise: Promise | null = null; - private nextEventRes: ((event?: Event) => void) | null = null; - - private battlePromise: Promise | null = null; - private battleRes: ((done: boolean | PromiseLike) => void) | null = - null; - - /** - * Creates a pair of corresponding AsyncIterators, one for sending - * BattleEvents to the BattleParser and one for receiving them in a - * BattleParserContext. - * - * Note that `#next()` or `#peek()` cannot be called on a single iterator - * more than once if the first call hadn't resolved yet. - */ - public constructor() { - this.eventIt = { - next: async () => await this.eventNext(), - peek: async () => await this.eventPeek(), - return: async () => await this.eventReturn(), - }; - this.battleIt = { - next: async (...args) => await this.battleNext(...args), - return: async () => await this.battleReturn(), - }; - } - - /** Implementation for {@link EventIterator.next}. */ - private async eventNext(): Promise> { - // Indicate that we're receiving the next event - if (this.battleRes) { - this.battleRes(false /*done*/); - } else { - this.battlePromise = Promise.resolve(false); - } - - // Wait for a response or consume the cached response - this.nextEventPromise ??= new Promise(res => (this.nextEventRes = res)); - const event = await this.nextEventPromise.finally( - () => (this.nextEventPromise = this.nextEventRes = null), - ); - - if (!event) { - return {value: undefined, done: true}; - } - return {value: event}; - } - - /** Implementation for {@link EventIterator.peek}. */ - private async eventPeek(): Promise> { - // Wait for a response and cache it, or get the cached response. - this.nextEventPromise ??= new Promise(res => (this.nextEventRes = res)); - const event = await this.nextEventPromise.finally( - () => (this.nextEventRes = null), - ); - - if (!event) { - return {value: undefined, done: true}; - } - return {value: event}; - } - - /** Implementation for {@link EventIterator.return}. */ - private async eventReturn(): Promise> { - this.disableEvent(); - - // Resolve any pending iterator calls so they don't hang. - this.nextEventRes?.(); - await this.battleIt.return?.(); - - return {value: undefined, done: true}; - } - - /** Disables the EventIterator and activates cleanup. */ - private disableEvent() { - this.eventIt.next = - this.eventIt.peek = - this.eventIt.return = - this.eventIt.throw = - async () => - await Promise.resolve({value: undefined, done: true}); - } - - /** Implementation for {@link BattleIterator.next}. */ - private async battleNext( - event?: Event, - ): Promise> { - // Send the next event. - if (this.nextEventRes) { - this.nextEventRes(event); - } else { - this.nextEventPromise = Promise.resolve(event); - } - - // Wait for a response or consume the cached response. - this.battlePromise ??= new Promise(res => (this.battleRes = res)); - const done = await this.battlePromise.finally( - () => (this.battlePromise = this.battleRes = null), - ); - - return {value: undefined, done}; - } - - /** Implementation for {@link BattleIterator.return}. */ - private async battleReturn(): Promise> { - this.disableBattle(); - - // Resolve any pending battleIt.next() calls. - this.battleRes?.(true /*done*/); - - // Make sure the corresponding iterator doesn't hang. - await this.eventIt.return?.(); - - return {value: undefined, done: true}; - } - - /** Disables the BattleIterator and activates cleanup. */ - private disableBattle() { - this.battleIt.next = - this.battleIt.return = - this.battleIt.throw = - async () => - await Promise.resolve({value: undefined, done: true}); - } -} - -/** - * Iterator for receiving the next event, includeing a peek operation. - * - * Calling {@link return} will call the same respective method of the - * corresponding {@link BattleIterator} and resolve/reject any pending - * {@link next}/{@link peek} promises. - */ -export interface EventIterator - extends PeekableAsyncIterator { - /** - * Peeks at the next event. - * - * @override - */ - peek: () => Promise>; -} - -/** - * Iterator for sending the next event to the BattleParser. - * - * Calling {@link next} will resolve once the corresponding - * {@link EventIterator} consumes it via {@link EventIterator.next}. - * - * Calling {@link return} will call the same respective method of the - * corresponding {@link BattleIterator} and resolve/reject any pending - * {@link next} promises. - */ -export type BattleIterator = AsyncIterator; - -/** AsyncIterator with peek operation. */ -interface PeekableAsyncIterator - extends AsyncIterator { - /** Gets the next `T`/`TReturn` without consuming it. */ - peek: () => Promise>; -} diff --git a/src/ts/battle/parser/main.ts b/src/ts/battle/parser/main.ts deleted file mode 100644 index e18de75c..00000000 --- a/src/ts/battle/parser/main.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {BattleParserContext} from "./BattleParser"; -import {dispatch} from "./events"; -import {consume, eventLoop, tryVerify} from "./parsing"; - -/** Main entry point for the gen4 parser. */ -export async function main(ctx: BattleParserContext): Promise { - await eventLoop(ctx, dispatch); - - if (await tryVerify(ctx, "|win|", "|tie|")) { - await consume(ctx); - } -} diff --git a/src/ts/battle/parser/parsing.ts b/src/ts/battle/parser/parsing.ts deleted file mode 100644 index de4e56b2..00000000 --- a/src/ts/battle/parser/parsing.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** @file Useful BattleParser-related helper functions. */ -import {Protocol} from "@pkmn/protocol"; -import {Event} from "../../protocol/Event"; -import {WrappedError} from "../../utils/errors/WrappedError"; -import {BattleAgent} from "../agent"; -import {BattleState} from "../state"; -import {BattleParser, BattleParserContext} from "./BattleParser"; -import {BattleIterator, IteratorPair} from "./iterators"; - -/** - * Config for {@link startBattleParser}. - * - * @template TAgent Battle agent type. - */ -export interface StartBattleParserArgs - extends Omit, "iter" | "state"> { - /** - * Gets or constructs the battle state tracker object that will be used by - * the BattleParser. Only called once. - */ - readonly getState: () => BattleState; -} - -/** - * Initializes a BattleParser. - * - * @template TAgent Battle agent type. - * @template TArgs Additional parameter types. - * @template TResult Result type. - * @param cfg Parser config. - * @param parser Parser function to call. - * @param args Additional args to supply to the parser. - * @returns An iterator for sending TEvents to the BattleParser, as well as a - * Promise that resolves when the BattleParser returns or throws. Note that if - * one of the iterators throws then the promise returned by this function will - * throw the same error, which must be caught immediately or the process will - * log an uncaught promise rejection (which can crash Workers during training). - */ -export function startBattleParser< - TAgent extends BattleAgent = BattleAgent, - TArgs extends unknown[] = unknown[], - TResult = unknown, ->( - cfg: StartBattleParserArgs, - parser: BattleParser, - ...args: TArgs -): {iter: BattleIterator; finish: Promise} { - const {eventIt, battleIt} = new IteratorPair(); - const ctx: BattleParserContext = { - agent: cfg.agent, - iter: eventIt, - logger: cfg.logger, - executor: cfg.executor, - state: cfg.getState(), - }; - const finish = (async function asyncBattleParserCtx() { - try { - return await parser(ctx, ...args); - } catch (e) { - // Wrap the error here so that the stack trace of whoever's awaiting - // the finish promise is included. - throw new WrappedError( - e as Error, - msg => "BattleParser error: " + msg, - ); - } finally { - // Resolve any pending iterator.next() calls. - await eventIt.return?.(); - await battleIt.return?.(); - } - })(); - return {iter: battleIt, finish}; -} - -/** - * Maps an event type to a BattleParser handler. - * - * @template TAgent Battle agent type. - * @template TArgs Additional parameter types. - * @template TResult Result type. - */ -export type EventHandlerMap< - TAgent extends BattleAgent = BattleAgent, - TArgs extends unknown[] = unknown[], - TResult = unknown, -> = { - readonly [_ in Protocol.ArgName]?: BattleParser; -}; - -/** - * Creates a BattleParser that dispatches to an appropriate event handler using - * the given map, or can return `null` if there are no events left or there is - * no handler defined to handle it. - * - * @template TAgent Battle agent type. - * @template TArgs Additional parameter types. - * @template TResult Result type. - * @param handlers Map of event handlers. - */ -export function dispatcher< - TAgent extends BattleAgent = BattleAgent, - TArgs extends unknown[] = unknown[], - TResult = unknown, ->( - handlers: EventHandlerMap, -): BattleParser { - return async function eventDispatcher( - ctx: BattleParserContext, - ...args: TArgs - ): Promise { - const event = await tryPeek(ctx); - if (!event) { - return null; - } - const key = Protocol.key(event.args); - if (!key) { - return null; - } - const handler = handlers[key]; - if (!handler) { - return null; - } - return await handler(ctx, ...args); - }; -} - -/** - * Keeps calling a BattleParser with the given args until it doesn't consume an - * event or until the end of the event stream. - * - * @template TAgent Battle agent type. - * @template TArgs Additional parameter types. - * @template TResult Result type. - * @param ctx Parser context. - * @param parser Parser function to use. - * @param args Args to supply to the parser. - * @returns All of the returned `TResult`s in an array. - */ -export async function eventLoop< - TAgent extends BattleAgent = BattleAgent, - TArgs extends unknown[] = unknown[], - TResult = unknown, ->( - ctx: BattleParserContext, - parser: BattleParser, - ...args: TArgs -): Promise { - const results: TResult[] = []; - - while (true) { - // No more events to parse. - const preEvent = await tryPeek(ctx); - if (!preEvent) { - break; - } - - results.push(await parser(ctx, ...args)); - - // Can't parse any more events. - const postEvent = await tryPeek(ctx); - if (preEvent === postEvent) { - break; - } - } - return results; -} - -/** - * Peeks at the next event. - * - * @template TAgent Battle agent type. - * @param ctx Parser context. - * @throws Error if there are no events left. - */ -export async function peek(ctx: BattleParserContext): Promise { - const event = await tryPeek(ctx); - if (!event) { - throw new Error("Expected event"); - } - return event; -} - -/** - * Peeks at the next event. - * - * @param ctx Parser context. - * @returns The next event, or `undefined` if there are no events left. - */ -export async function tryPeek( - ctx: BattleParserContext, -): Promise { - const result = await ctx.iter.peek(); - return result.done ? undefined : result.value; -} - -/** - * Peeks and verifies the next event according to the given event type. - * - * @template TName Event type identifier. - * @param ctx Parser context. - * @param expectedKey Expected event type. - * @returns The next event if it matches, `null` if it doesn't match, or - * `undefined` if there are no events left. - */ -export async function tryVerify( - ctx: BattleParserContext, - ...expectedKeys: TName[] -): Promise | null | undefined> { - const event = await tryPeek(ctx); - if (!event) { - return; - } - - const key = Protocol.key(event.args); - if (!key || !expectedKeys.includes(key as TName)) { - return null; - } - return event as Event; -} - -/** - * Peeks and verifies the next event according to the given event type. - * - * @template TName Event type identifier. - * @param ctx Parser context. - * @param expectedKey Expected event type. - * @throws Error if the event type doesn't match or if there are no events left. - */ -export async function verify( - ctx: BattleParserContext, - ...expectedKeys: TName[] -): Promise> { - const event = await peek(ctx); - - const key = Protocol.key(event.args); - if (!key || !expectedKeys.includes(key as TName)) { - throw new Error( - "Invalid event: Expected type " + - `[${expectedKeys.map(k => `'${k}'`).join(", ")}] but got ` + - `'${key}'`, - ); - } - return event as Event; -} - -/** - * Consumes an event. - * - * @template TAgent Battle agent type. - * @param ctx Parser context. - * @throws Error if there are no events left. - */ -export async function consume( - ctx: BattleParserContext, -): Promise { - const result = await ctx.iter.next(); - if (result.done) { - throw new Error("Expected event"); - } - return result.value; -} diff --git a/src/ts/battle/parser/helpers.test.ts b/src/ts/battle/parser/protocolHelpers.test.ts similarity index 70% rename from src/ts/battle/parser/helpers.test.ts rename to src/ts/battle/parser/protocolHelpers.test.ts index cfb1a6d6..68d17da4 100644 --- a/src/ts/battle/parser/helpers.test.ts +++ b/src/ts/battle/parser/protocolHelpers.test.ts @@ -1,4 +1,3 @@ -/** @file Helpers for unit testing BattleParsers. */ import {Protocol} from "@pkmn/protocol"; import { FieldCondition, @@ -8,62 +7,9 @@ import { TypeName, Weather, } from "@pkmn/types"; -import {BattleAgent} from "../agent"; import * as dex from "../dex"; import {toIdName} from "../helpers"; import {smeargle} from "../state/switchOptions.test"; -import {BattleParser} from "./BattleParser"; -import {ParserContext} from "./Context.test"; -import {startBattleParser, StartBattleParserArgs} from "./parsing"; - -/** - * Starts a {@link BattleParser}. - * - * @param startArgs Arguments for starting the BattleParser. - * @param parser Parser to call immediately, just after constructing the - * BattleState and BattleIterators. - * @returns An appropriate {@link ParserContext} for the constructed - * BattleParser. - */ -export function initParser< - TArgs extends unknown[] = unknown[], - TResult = unknown, ->( - startArgs: StartBattleParserArgs, - parser: BattleParser, - ...args: TArgs -): ParserContext { - const {iter, finish} = startBattleParser(startArgs, parser, ...args); - return {battleIt: iter, finish}; -} - -/** - * Returns a function that calls the {@link BattleParser} within a - * self-contained {@link BattleParserContext} and returns the associated - * {@link ParserContext}. Can be called multiple times with different args to - * start a new ParserContext. This function is a curried version of - * {@link initParser} with deferred arguments and {@link BattleState} - * construction. - * - * @param startArgs Initial arguments for starting the BattleParser. - * @param stateCtor Function to get the initial battle state that the parser - * will use when the returned function is called. - * @param parser Parser to call when the returned function is called. - * @returns A function that takes the rest of the BattleParser's custom `TArgs` - * before calling `initParser()`. - */ -export function setupBattleParser< - TArgs extends unknown[] = unknown[], - TResult = unknown, ->( - startArgs: StartBattleParserArgs, - parser: BattleParser, -): (...args: TArgs) => ParserContext { - return (...args: TArgs): ParserContext => - initParser(startArgs, parser, ...args); -} - -//#region Protocol helpers. // Match with protocol type names. /* eslint-disable @typescript-eslint/naming-convention */ @@ -232,5 +178,3 @@ export function toNickname(name: string): Protocol.Nickname { } /* eslint-enable @typescript-eslint/naming-convention */ - -//#endregion diff --git a/src/ts/battle/parser/stateHelpers.test.ts b/src/ts/battle/parser/stateHelpers.test.ts new file mode 100644 index 00000000..339dec02 --- /dev/null +++ b/src/ts/battle/parser/stateHelpers.test.ts @@ -0,0 +1,43 @@ +import {SideID} from "@pkmn/types"; +import {expect} from "chai"; +import {BattleState} from "../state"; +import {Pokemon} from "../state/Pokemon"; +import {SwitchOptions} from "../state/Team"; +import {smeargle} from "../state/switchOptions.test"; + +/** + * Initializes a team of pokemon, some of which may be unknown. The last + * defined one in the array will be switched in if any. + */ +export function initTeam( + state: BattleState, + teamRef: SideID, + options: readonly (SwitchOptions | undefined)[], +): Pokemon[] { + const team = state.getTeam(teamRef); + team.size = options.length; + const result: Pokemon[] = []; + let i = 0; + for (const op of options) { + if (!op) { + continue; + } + const mon = team.switchIn(op); + expect(mon, `Switch-in slot ${i} couldn't be filled`).to.not.be.null; + result.push(mon!); + ++i; + } + return result; +} + +/** Initializes a team of one pokemon. */ +export function initActive( + state: BattleState, + monRef: SideID, + options = smeargle, + size = 1, +): Pokemon { + const opt = new Array(size); + opt[0] = options; + return initTeam(state, monRef, opt)[0]; +} diff --git a/src/ts/battle/parser/utils.ts b/src/ts/battle/parser/utils.ts new file mode 100644 index 00000000..db8d19ae --- /dev/null +++ b/src/ts/battle/parser/utils.ts @@ -0,0 +1,97 @@ +/** @file Useful BattleParser-related helper functions. */ +import {Protocol} from "@pkmn/protocol"; +import {Event} from "../../protocol/Event"; +import {BattleAgent} from "../agent"; +import {BattleParser, BattleParserContext} from "./BattleParser"; + +/** + * Maps an event type to a BattleParser handler. + * + * @template TAgent Battle agent type. + * @template TArgs Additional parameter types. + * @template TResult Result type. + */ +export type EventHandlerMap = { + readonly [_ in Protocol.ArgName]?: BattleParser; +}; + +/** + * Creates a BattleParser that dispatches to an appropriate event handler using + * the given map. Does nothing on unknown events. + * + * @template TAgent Battle agent type. + * @template TArgs Additional parameter types. + * @template TResult Result type. + * @param handlers Map of event handlers. + */ +export function createDispatcher( + handlers: EventHandlerMap, +): BattleParser { + return async function eventDispatcher( + ctx: BattleParserContext, + event: Event, + ): Promise { + const key = Protocol.key(event.args); + if (!key) { + return; + } + const handler = handlers[key]; + if (!handler) { + return; + } + await handler(ctx, event); + }; +} + +/** Creates a BattleParser that does nothing but verifies a given event type. */ +export function defaultParser( + key: TName, +): BattleParser { + return eventParser(key, async () => await Promise.resolve()); +} + +/** Creates a BattleParser that logs an error for an unsupported event type. */ +export function unsupportedParser( + key: TName, +): BattleParser { + return eventParser(key, async ctx => { + ctx.logger.error(`Unsupported event type '${key}'`); + return await Promise.resolve(); + }); +} + +/** Creates a BattleParser for a specific event type. */ +export function eventParser< + TName extends Protocol.ArgName, + TAgent extends BattleAgent = BattleAgent, +>( + key: TName, + f: ( + ctx: BattleParserContext, + event: Event, + ) => void | Promise, +): BattleParser { + return async function eventParserImpl(ctx, event) { + verify(key, event); + return await f(ctx, event); + }; +} + +/** + * Asserts an event's type. + * + * @template TName Expected event type. + * @param expectedKey Expected event type. + * @param event Actual event. + */ +export function verify( + expectedKey: TName, + event: Event, +): asserts event is Event { + const key = Protocol.key(event.args); + if (key !== expectedKey) { + throw new Error( + `Expected event type '${expectedKey}' but got '${key}'`, + ); + } +} diff --git a/src/ts/battle/state/switchOptions.test.ts b/src/ts/battle/state/switchOptions.test.ts index e240c2d9..d194a3a2 100644 --- a/src/ts/battle/state/switchOptions.test.ts +++ b/src/ts/battle/state/switchOptions.test.ts @@ -9,7 +9,7 @@ import { toSearchID, toSpeciesName, toUsername, -} from "../parser/helpers.test"; +} from "../parser/protocolHelpers.test"; import {SwitchOptions} from "./Team"; // TODO: May need to move this to a separate folder or remove some members. diff --git a/src/ts/battle/worker/ExperienceBattleParser.ts b/src/ts/battle/worker/ExperienceBattleParser.ts new file mode 100644 index 00000000..a3116c75 --- /dev/null +++ b/src/ts/battle/worker/ExperienceBattleParser.ts @@ -0,0 +1,118 @@ +import {Event} from "../../protocol/Event"; +import {Logger} from "../../utils/logging/Logger"; +import {BattleAgent, Action} from "../agent"; +import { + ActionExecutor, + BattleParser, + BattleParserContext, +} from "../parser/BattleParser"; +import * as rewards from "./rewards"; + +/** BattleAgent that takes additional info for experience generation. */ +export type ExperienceBattleAgent = BattleAgent< + TInfo, + [lastAction?: Action, reward?: number] +>; + +/** Result from parsing and extracting experience info. */ +export interface ExperienceBattleParserResult { + /** Final action. Not provided if the game was truncated. */ + action?: Action; + /** Final reward. Not provided if the game was truncated. */ + reward?: number; + /** Whether the battle properly ended in a win, loss, or tie. */ + terminated?: boolean; +} + +/** + * Enforces {@link ExperienceBattleAgent} when using an + * {@link ExperienceBattleParser}. + */ +export type ExperienceBattleParserContext = + BattleParserContext; + +/** + * Wraps a BattleParser to track rewards/decisions and emit experience data. + * + * Parser implementation requires an {@link ExperienceBattleAgent}. + */ +export class ExperienceBattleParser { + private action: Action | undefined; + private reward = 0; + private terminated = false; + + /** + * Creates an ExperienceBattleParser. + * + * @param parser Parser function to wrap. + * @param username Client's username to parse game-over reward. + */ + public constructor( + private readonly parser: BattleParser, + private readonly username: string, + ) {} + + /** {@link BattleParser} implementation. */ + public async parse( + ctx: ExperienceBattleParserContext, + event: Event, + ): Promise { + await this.parser( + { + ...ctx, + agent: this.overrideAgent(ctx.agent), + executor: this.overrideExecutor(ctx.executor), + }, + event, + ); + switch (event.args[0]) { + case "win": + // Add win/loss reward. + this.reward += + event.args[1] === this.username + ? rewards.win + : rewards.lose; + this.terminated = true; + break; + case "tie": + this.reward += rewards.tie; + this.terminated = true; + break; + default: + } + } + + /** Collects final experience data. */ + public finish(logger?: Logger): ExperienceBattleParserResult { + if (!this.terminated) { + // Game was truncated due to max turn limit or error. + logger?.debug("Truncated, no final reward"); + return {}; + } + logger?.debug(`Final reward = ${this.reward}`); + return {action: this.action, reward: this.reward, terminated: true}; + } + + private overrideAgent(agent: ExperienceBattleAgent): BattleAgent { + return async (state, choices, logger) => { + // Provide additional info to the ExperienceAgent. + const lastAction = this.action; + this.action = undefined; + const lastReward = this.reward; + this.reward = 0; + logger?.debug(`Reward = ${lastReward}`); + return await agent(state, choices, logger, lastAction, lastReward); + }; + } + + private overrideExecutor(executor: ActionExecutor): ActionExecutor { + return async choice => { + const r = await executor(choice); + if (!r) { + // Extract the last choice that was accepted. + this.action = choice; + } + return r; + }; + } +} diff --git a/src/ts/battle/worker/battle.ts b/src/ts/battle/worker/battle.ts index 448759e5..c4aa5969 100644 --- a/src/ts/battle/worker/battle.ts +++ b/src/ts/battle/worker/battle.ts @@ -1,11 +1,11 @@ -import * as stream from "stream"; import {TeamGenerators} from "@pkmn/randoms"; -import {BattleStreams, PRNGSeed, Teams} from "@pkmn/sim"; +import {BattleStreams, PRNGSeed, Streams, Teams} from "@pkmn/sim"; import {SideID} from "@pkmn/types"; -import {HaltEvent, RoomEvent} from "../../protocol/Event"; -import {EventParser} from "../../protocol/EventParser"; +import {RoomEvent} from "../../protocol/Event"; +import {protocolParser} from "../../protocol/parser"; import {Sender} from "../../psbot/PsBot"; import {DeferredFile} from "../../utils/DeferredFile"; +import {WrappedError} from "../../utils/errors/WrappedError"; import {LogFunc, Logger} from "../../utils/logging/Logger"; import {Verbose} from "../../utils/logging/Verbose"; import {wrapTimeout} from "../../utils/timeout"; @@ -18,16 +18,10 @@ Teams.setGeneratorFactory(TeamGenerators); /** Identifier type for the sides of a battle. */ export type PlayerSide = Exclude; -/** - * Options for {@link simulateBattle}. - * - * @param TResult Result from each player's {@link BattleParser}. - */ -export interface BattleOptions< - TResult extends {[P in PlayerSide]: unknown} = {[P in PlayerSide]: unknown}, -> { +/** Options for {@link simulateBattle}. */ +export interface BattleOptions { /** Player configs. */ - readonly players: {readonly [P in PlayerSide]: PlayerOptions}; + readonly players: {readonly [P in PlayerSide]: PlayerOptions}; /** * Maximum amount of turns until the game is truncated. The * {@link PlayerOptions.agent BattleAgents} will not be called at the end of @@ -50,6 +44,12 @@ export interface BattleOptions< readonly onlyLogOnError?: boolean; /** Seed for the battle PRNG. */ readonly seed?: PRNGSeed; + /** + * Timeout in milliseconds for processing battle-related actions and events. + * Used for catching rare async bugs or timing out BattleAgent + * communications. + */ + readonly timeoutMs?: number; } /** @@ -57,13 +57,13 @@ export interface BattleOptions< * * @template TResult Parser result type. */ -export interface PlayerOptions { +export interface PlayerOptions { /** Player name. */ readonly name: string; /** Battle decision-maker. */ readonly agent: BattleAgent; /** Battle event parser. Responsible for calling the {@link agent}. */ - readonly parser: BattleParser; + readonly parser: BattleParser; /** Seed for generating the random team. */ readonly seed?: PRNGSeed; } @@ -73,18 +73,17 @@ export interface PlayerOptions { * * @template TResult Result from each player's {@link BattleParser}. */ -export interface BattleResult< - TResult extends {[P in PlayerSide]: unknown} = {[P in PlayerSide]: unknown}, -> { +export interface BattleResult { /** Side of the winner if it's not a tie. */ winner?: PlayerSide; - /** - * Results from each player's {@link PlayerOptions.parser BattleParser}. May - * not be fully defined if an {@link err error} was encountered. - */ - players: {[P in PlayerSide]?: TResult[P]}; /** Whether the game was truncated due to max turn limit. */ truncated?: boolean; + /** + * Path to the file containing game logs if enabled. Should be equal to + * {@link BattleOptions.logPath} if specified, otherwise points to a temp + * file. + */ + logPath?: string; /** * If an exception was thrown during the game, store it here instead of * propagating it through the pipeline. @@ -95,20 +94,18 @@ export interface BattleResult< /** Temp log file template. */ const template = "psbattle-XXXXXX"; -/** Timeout for catching rare hanging promise bugs. */ -const timeoutMs = 600_000; /*10min*/ - /** * Runs a simulated PS battle. * * @template TResult Result from each player's {@link BattleParser}. */ -export async function simulateBattle< - TResult extends {[P in PlayerSide]: unknown} = {[P in PlayerSide]: unknown}, ->(options: BattleOptions): Promise> { +export async function simulateBattle( + options: BattleOptions, +): Promise { + let logPath: string | undefined; const file = new DeferredFile(); - if (options.logPath && !options.onlyLogOnError) { - await file.ensure(options.logPath, template); + if (!options.onlyLogOnError) { + logPath = await file.ensure(options.logPath, template); } // Setup logger. @@ -128,137 +125,56 @@ export async function simulateBattle< ); const gamePromises: Promise[] = []; - let truncated: boolean | undefined; - let winner: PlayerSide | undefined; - const players: {[P in PlayerSide]?: TResult[P]} = {}; - for (const id of ["p1", "p2"] as const) { - const playerLog = logger.addPrefix( - `${id}(${options.players[id].name}): `, - ); + let truncated: boolean | undefined; + const truncate = async () => { + truncated = true; + if (!battleStream.atEOF) { + await battleStream.writeEnd(); + } + }; - const sender: Sender = (...responses) => { - for (const res of responses) { - // Extract choice from args. - // Format: |/choose - if (res.startsWith("|/choose ")) { - const choice = res.substring("|/choose ".length); - if (!battleStream.atEOF) { - void streams[id].write(choice); - } else { - playerLog.error("Can't send choice: At end of stream"); - return false; + let winner: PlayerSide | undefined; + gamePromises.push( + (async function omniscientStreamHandler() { + try { + const winnerName = await omniscientEventPipeline( + streams.omniscient, + options.timeoutMs, + ); + for (const id of ["p1", "p2"] as const) { + if (winnerName === options.players[id].name) { + winner = id; } } + } catch (err) { + await truncate(); + logPath = await file.ensure(options.logPath, template); + handleEventPipelineError(err as Error, logger, logPath); } - return true; - }; - - const driver = new BattleDriver({ - username: options.players[id].name, - agent: options.players[id].agent, - parser: options.players[id].parser, - sender, - logger: playerLog, - }); - - // Setup battle event pipeline. - - const battleTextStream = streams[id]; - const eventParser = new EventParser( - playerLog.addPrefix("EventParser: "), - ); + })(), + ); - gamePromises.push( - stream.promises.pipeline(battleTextStream, eventParser), + for (const id of ["p1", "p2"] as const) { + const playerLog = logger.addPrefix( + `${id}(${options.players[id].name}): `, ); - // Start event loop for this side of the battle. - - // Note: Keep this separate from the above pipeline promise since for - // some reason it causes the whole worker process to crash when an - // error is encountered due to the underlying handler.finish() promise - // rejecting before the method itself can be called/caught. gamePromises.push( - (async function playerLoop() { - let loopErr: Error | undefined; + (async function playerStreamHandler() { try { - for await (const event of eventParser) { - try { - if (truncated) { - // Note: We must explicitly end the stream - // before we're allowed to prematurely exit the - // loop, otherwise the stream's destroy() - // method will be called implicitly by the async - // iterator and will throw a really cryptic - // AbortError. - eventParser.end(); - break; - } - const e = event as RoomEvent | HaltEvent; - if (e.args[0] === "turn") { - if ( - options.maxTurns && - Number(e.args[1]) >= options.maxTurns - ) { - playerLog.info( - "Max turns reached; truncating", - ); - if (!battleStream.atEOF) { - await battleStream.writeEnd(); - } - truncated = true; - eventParser.end(); - break; - } - } else if (e.args[0] === "win") { - const [, winnerName] = e.args; - if (winnerName === options.players[id].name) { - winner = id; - } - } else if (e.args[0] === "halt") { - driver.halt(); - continue; - } - await wrapTimeout( - async () => await driver.handle(e as RoomEvent), - timeoutMs, - ); - } catch (e) { - eventParser.end(); - throw e; - } - } - } catch (e) { - // Log game errors and leave a new exception specifying - // where to find it. - loopErr = e as Error; - logError(playerLog, battleStream, loopErr); - throwLog(await file.ensure(options.logPath, template)); - } finally { - playerLog.info("Finishing"); - try { - await wrapTimeout(async () => { - if (loopErr ?? truncated) { - players[id] = await driver.forceFinish(); - } else { - players[id] = await driver.finish(); - } - }, timeoutMs); - } catch (e) { - if (loopErr !== e) { - logError(playerLog, battleStream, e as Error); - } else { - playerLog.debug( - "Same error encountered while finishing", - ); - } - if (!loopErr) { - throwLog( - await file.ensure(options.logPath, template), - ); - } - } + await playerEventPipeline( + streams[id], + options.players[id], + playerLog, + options.maxTurns, + truncate, + options.timeoutMs, + ); + } catch (err) { + await truncate(); + logPath = await file.ensure(options.logPath, template); + handleEventPipelineError(err as Error, playerLog, logPath); } })(), ); @@ -267,18 +183,21 @@ export async function simulateBattle< name: options.players[id].name, ...(options.players[id].seed && {seed: options.players[id].seed}), }; - await battleStream.write( - `>player ${id} ${JSON.stringify(playerOptions)}`, - ); playerLog.debug( `Setting up player with options: ${JSON.stringify(playerOptions)}`, ); + await battleStream.write( + `>player ${id} ${JSON.stringify(playerOptions)}`, + ); } // Capture the first game error so we can notify the main thread. let err: Error | undefined; try { await Promise.all(gamePromises); + if (!battleStream.atEOF) { + await battleStream.writeEnd(); + } } catch (e) { err = e as Error; } @@ -301,30 +220,160 @@ export async function simulateBattle< await file.finish(); return { winner, - players, ...(truncated && {truncated: true}), + ...(logPath && {logPath}), ...(err && {err}), }; } -/** Swallows an error into the logger then stops the BattleStream. */ -function logError( +/** Wraps the error for top-level and/or points to log file in the err msg. */ +function handleEventPipelineError( + err: Error, logger: Logger, - battleStream: BattleStreams.BattleStream, - err?: Error, -): void { - if (err) { - logger.error(err.stack ?? err.toString()); - } + logPath?: string, +): never { + logger.error(err.stack ?? err.toString()); logger.info("Error encountered; truncating"); - if (!battleStream.atEOF) { - void battleStream.writeEnd(); + if (logPath) { + throw new Error( + `${simulateBattle.name}() encountered an error; check ${logPath} ` + + "for details", + ); } + throw new WrappedError( + err, + msg => `${simulateBattle.name}() encountered an error: ${msg}`, + ); } -function throwLog(logPath?: string): never { - throw new Error( - "simulateBattle() encountered an error" + - (logPath ? `; check ${logPath} for details` : ""), - ); +/** + * Processes the omniscient battle stream and returns the name of the winner if + * there was one. + */ +async function omniscientEventPipeline( + battleTextStream: Streams.ObjectReadWriteStream, + timeoutMs?: number, +): Promise { + const maybeTimeout: (p: () => Promise) => Promise = timeoutMs + ? async p => await wrapTimeout(p, timeoutMs) + : async p => await p(); + + let winner: string | undefined; + let chunk: string | null | undefined; + while ( + (chunk = await maybeTimeout(async () => await battleTextStream.read())) + ) { + for (const event of protocolParser(chunk)) { + if (event.args[0] === "win") { + [, winner] = event.args; + } + } + } + return winner; +} + +async function playerEventPipeline( + battleTextStream: Streams.ObjectReadWriteStream, + options: PlayerOptions, + logger: Logger, + maxTurns?: number, + truncate?: () => Promise, + timeoutMs?: number, +): Promise { + const sender = createSender(battleTextStream, logger); + const driver = new BattleDriver({ + username: options.name, + agent: options.agent, + parser: options.parser, + sender, + logger, + }); + + const maybeTimeout: (p: () => Promise) => Promise = timeoutMs + ? async p => await wrapTimeout(p, timeoutMs) + : async p => await p(); + + let truncated = false; + let loopErr: Error | undefined; + try { + let chunk: string | null | undefined; + while ( + (chunk = await maybeTimeout( + async () => await battleTextStream.read(), + )) + ) { + logger.debug(`Received:\n${chunk}`); + for (const event of protocolParser(chunk)) { + if (event.args[0] === "halt") { + driver.halt(); + continue; + } + await maybeTimeout( + async () => await driver.handle(event as RoomEvent), + ); + if ( + event.args[0] === "turn" && + maxTurns && + Number(event.args[1]) >= maxTurns + ) { + logger.info(`Reached max turn ${maxTurns}; truncating`); + truncated = true; + break; + } + } + if (truncated) { + break; + } + } + } catch (err) { + loopErr = err as Error; + truncated = true; + throw err; + } finally { + try { + if (truncated) { + await maybeTimeout( + async () => + await Promise.all([truncate?.(), driver.forceFinish()]), + ); + } else { + driver.finish(); + } + } catch (err) { + if (loopErr) { + // Preserve and bubble up original error. + logger.error( + `Error while finishing: ${ + (err as Error).stack ?? (err as Error).toString() + }`, + ); + // eslint-disable-next-line no-unsafe-finally + throw loopErr; + } + // eslint-disable-next-line no-unsafe-finally + throw err; + } + } +} + +function createSender( + battleTextStream: Streams.ObjectReadWriteStream, + logger: Logger, +): Sender { + return (...responses) => { + for (const res of responses) { + // Extract choice from args. + // Format: |/choose + if (res.startsWith("|/choose ")) { + const choice = res.substring("|/choose ".length); + if (!battleTextStream.atEOF) { + void battleTextStream.write(choice); + } else { + logger.error("Can't send choice: At end of stream"); + return false; + } + } + } + return true; + }; } diff --git a/src/ts/battle/worker/experienceBattleParser.ts b/src/ts/battle/worker/experienceBattleParser.ts deleted file mode 100644 index 823597f4..00000000 --- a/src/ts/battle/worker/experienceBattleParser.ts +++ /dev/null @@ -1,127 +0,0 @@ -import {BattleAgent, Action} from "../agent"; -import {BattleParser} from "../parser/BattleParser"; -import * as rewards from "./rewards"; - -/** - * Typing for experience-tracking battle parser. - * - * @template TArgs Wrapped parser args. - * @template TResult Wrapped parser result. - */ -export type ExperienceBattleParser< - TArgs extends unknown[] = unknown[], - TResult = unknown, -> = BattleParser< - ExperienceBattleAgent, - TArgs, - ExperienceBattleParserResult ->; - -/** BattleAgent that takes additional info for experience generation. */ -export type ExperienceBattleAgent = BattleAgent< - TInfo, - [lastAction?: Action, reward?: number] ->; - -/** - * Result from parsing and extracting experience info. - * - * @template TResult Result of wrapped parser. - */ -export interface ExperienceBattleParserResult { - /** Result of wrapped parser. */ - result: TResult; - /** Final action. Not provided if the game was truncated. */ - action?: Action; - /** Final reward. Not provided if the game was truncated. */ - reward?: number; - /** Whether the battle properly ended in a win, loss, or tie. */ - terminated?: boolean; -} - -/** - * Wraps a BattleParser to track rewards/decisions and emit experience data. - * - * Returned wrapper requires an {@link ExperienceBattleAgent}. - * - * @template TArgs Parser arguments. - * @template TResult Parser return type. - * @param parser Parser function to wrap. - * @param username Client's username to parse game-over reward. - * @returns The wrapped BattleParser function. - */ -export function experienceBattleParser< - TArgs extends unknown[] = unknown[], - TResult = unknown, ->( - parser: BattleParser, - username: string, -): ExperienceBattleParser { - return async function experienceBattleParserImpl(ctx, ...args: TArgs) { - let action: Action | undefined; - let reward = 0; - let terminated = false; - const result = await parser( - { - ...ctx, - // Provide additional info to the ExperienceAgent. - async agent(state, choices, logger) { - const lastAction = action; - action = undefined; - const lastReward = reward; - reward = 0; - ctx.logger.debug(`Reward = ${lastReward}`); - return await ctx.agent( - state, - choices, - logger, - lastAction, - lastReward, - ); - }, - // Override event iterator for reward tracking. - iter: { - ...ctx.iter, - async next() { - // Observe events before the parser consumes them. - const r = await ctx.iter.next(); - if (r.done) { - return r; - } - switch (r.value.args[0]) { - case "win": - // Add win/loss reward. - reward += - r.value.args[1] === username - ? rewards.win - : rewards.lose; - terminated = true; - break; - case "tie": - reward += rewards.tie; - terminated = true; - break; - default: - } - return r; - }, - }, - async executor(choice) { - const r = await ctx.executor(choice); - if (!r) { - // Extract the last choice that was accepted. - action = choice; - } - return r; - }, - }, - ...args, - ); - if (!terminated) { - // Game was truncated due to max turn limit or error. - return {result}; - } - ctx.logger.debug(`Final reward = ${reward}`); - return {result, action, reward, terminated: true}; - }; -} diff --git a/src/ts/battle/worker/protocol.ts b/src/ts/battle/worker/protocol.ts index 05016798..c8801de1 100644 --- a/src/ts/battle/worker/protocol.ts +++ b/src/ts/battle/worker/protocol.ts @@ -1,4 +1,8 @@ -/** @file Describes the JSON protocol for the BattleWorker. */ +/** + * @file Describes the JSON protocol for the BattleWorker. + * + * MUST keep this in sync with src/py/environments/utils/protocol.py. + */ import {PRNGSeed} from "@pkmn/sim"; import {Action} from "../agent"; import {PlayerSide} from "./battle"; @@ -32,6 +36,11 @@ export interface BattleRequest { onlyLogOnError?: boolean; /** Seed for battle engine. */ seed?: PRNGSeed; + /** + * Timeout in milliseconds for processing battle-related actions and events. + * Used for catching rare async bugs. + */ + timeoutMs?: number; } /** Options for configuring an agent to use in battle */ @@ -64,6 +73,8 @@ export interface BattleReply { winner?: PlayerSide; /** Whether the battle was truncated due to max turn limit or error. */ truncated?: boolean; + /** Resolved path to the log file. */ + logPath?: string; /** Captured exception with stack trace if it was thrown during the game. */ err?: string; } diff --git a/src/ts/battle/worker/worker.ts b/src/ts/battle/worker/worker.ts index 8005cf9e..898fed8e 100644 --- a/src/ts/battle/worker/worker.ts +++ b/src/ts/battle/worker/worker.ts @@ -5,15 +5,16 @@ import {rng} from "../../utils/random"; import {Action, BattleAgent} from "../agent"; import {maxDamage} from "../agent/maxDamage.js"; import {randomAgent} from "../agent/random.js"; -import {main} from "../parser/main"; +import {BattleParser} from "../parser/BattleParser"; +import {gen4Parser} from "../parser/gen4"; import {ReadonlyBattleState} from "../state"; import {stateEncoder} from "../state/encoder"; import {UsageStats, lookup} from "../usage"; -import {PlayerOptions, simulateBattle} from "./battle"; import { - ExperienceBattleParserResult, - experienceBattleParser, -} from "./experienceBattleParser"; + ExperienceBattleAgent, + ExperienceBattleParser, +} from "./ExperienceBattleParser"; +import {PlayerOptions, simulateBattle} from "./battle"; import { AgentFinalRequest, AgentReply, @@ -64,11 +65,15 @@ class BattleWorker { context, routingId: workerId, linger: 0, + receiveHighWaterMark: 0, + sendHighWaterMark: 0, }); this.agentSock = new zmq.Dealer({ context, routingId: workerId, linger: 0, + receiveHighWaterMark: 0, + sendHighWaterMark: 0, }); } @@ -181,38 +186,36 @@ class BattleWorker { throw new Error(`Battle '${req.id}' is already running`); } this.agentReplyCallbacks.set(req.id, new Map()); - let rep: BattleReply; - try { - const result = await simulateBattle({ - players: { - p1: this.configurePlayer(req.agents.p1, req.id), - p2: this.configurePlayer(req.agents.p2, req.id), - }, - ...(req.maxTurns && {maxTurns: req.maxTurns}), - ...(req.logPath && {logPath: req.logPath}), - ...(req.onlyLogOnError && {onlyLogOnError: true}), - ...(req.seed && {seed: req.seed}), - }); - rep = { - type: "battle", - id: req.id, - agents: {p1: req.agents.p1.name, p2: req.agents.p2.name}, - ...(result.winner !== undefined && { - winner: result.winner, - }), - ...(result.truncated && {truncated: true}), - ...(result.err && { - err: result.err.stack ?? result.err.toString(), - }), - }; - } catch (err) { - rep = { - type: "battle", - id: req.id, - agents: {p1: req.agents.p1.name, p2: req.agents.p2.name}, - err: (err as Error).stack ?? (err as Error).toString(), - }; - } + const {options: p1, cleanup: cleanup1} = this.configurePlayer( + req.agents.p1, + req.id, + ); + const {options: p2, cleanup: cleanup2} = this.configurePlayer( + req.agents.p2, + req.id, + ); + const result = await simulateBattle({ + players: {p1, p2}, + ...(req.maxTurns && {maxTurns: req.maxTurns}), + ...(req.logPath && {logPath: req.logPath}), + ...(req.onlyLogOnError && {onlyLogOnError: true}), + ...(req.seed && {seed: req.seed}), + ...(req.timeoutMs && {timeoutMs: req.timeoutMs}), + }); + const rep: BattleReply = { + type: "battle", + id: req.id, + agents: {p1: req.agents.p1.name, p2: req.agents.p2.name}, + ...(result.winner !== undefined && { + winner: result.winner, + }), + ...(result.truncated && {truncated: true}), + ...(result.logPath && {logPath: result.logPath}), + ...(result.err && { + err: result.err.stack ?? result.err.toString(), + }), + }; + await Promise.all([cleanup1?.(), cleanup2?.()]); await this.battleSock.send(JSON.stringify(rep)); } @@ -220,65 +223,73 @@ class BattleWorker { private configurePlayer( options: BattleAgentOptions, battle: string, - ): PlayerOptions { + ): {options: PlayerOptions; cleanup?: () => Promise} { if (options.type !== "model") { // Custom agent that doesn't rely on model agent server. return { - name: options.name, - agent: BattleWorker.getCustomAgent( - options.type, - options.randSeed, - ), - async parser(ctx) { - await main(ctx); - return undefined; + options: { + name: options.name, + agent: BattleWorker.getCustomAgent( + options.type, + options.randSeed, + ), + parser: gen4Parser, + ...(options.teamSeed && {seed: options.teamSeed}), }, - ...(options.teamSeed && {seed: options.teamSeed}), }; } - return { - name: options.name, - agent: async ( - state: ReadonlyBattleState, - choices: Action[], - logger?: Logger, - lastAction?: Action, - reward?: number, - ) => - await this.socketAgent( + + const agent: ExperienceBattleAgent = async ( + state: ReadonlyBattleState, + choices: Action[], + logger?: Logger, + lastAction?: Action, + reward?: number, + ) => + await this.socketAgent( + battle, + options.model!, + state, + choices, + logger, + lastAction, + reward, + ); + let parser: BattleParser = gen4Parser; + let cleanup: () => Promise; + if (options.experience) { + const expParser = new ExperienceBattleParser(parser, options.name); + parser = async (ctx, event) => await expParser.parse(ctx, event); + cleanup = async () => { + const result = expParser.finish(); + const req: AgentFinalRequest = { + type: "agent_final", battle, - options.model!, - state, - choices, - logger, - lastAction, - reward, - ), - parser: async ctx => { - let result: ExperienceBattleParserResult | undefined; - try { - if (!options.experience) { - return await main(ctx); - } - result = await experienceBattleParser( - main, - options.name, - )(ctx); - } finally { - const req: AgentFinalRequest = { - type: "agent_final", - battle, - name: options.model!, - ...(result && { - action: result.action, - reward: result.reward, - ...(result.terminated && {terminated: true}), - }), - }; - await this.agentSock.send(JSON.stringify(req)); - } + name: options.model!, + action: result.action, + reward: result.reward, + ...(result.terminated && {terminated: true}), + }; + await this.agentSock.send(JSON.stringify(req)); + }; + } else { + cleanup = async () => { + const req: AgentFinalRequest = { + type: "agent_final", + battle, + name: options.model!, + }; + await this.agentSock.send(JSON.stringify(req)); + }; + } + return { + options: { + name: options.name, + agent, + parser, + ...(options.teamSeed && {seed: options.teamSeed}), }, - ...(options.teamSeed && {seed: options.teamSeed}), + cleanup, }; } @@ -348,18 +359,20 @@ class BattleWorker { return async (state, choices, logger) => await maxDamage(state, choices, logger, random); case "random_move": - return async (state, choices) => + return async (state, choices, logger) => await randomAgent( state, choices, + logger, true /*moveOnly*/, random, ); case "random": - return async (state, choices) => + return async (state, choices, logger) => await randomAgent( state, choices, + logger, false /*moveOnly*/, random, ); diff --git a/src/ts/protocol/EventParser.test.ts b/src/ts/protocol/EventParser.test.ts deleted file mode 100644 index da6bf42f..00000000 --- a/src/ts/protocol/EventParser.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import {expect} from "chai"; -import "mocha"; -import {HaltEvent, RoomEvent} from "./Event"; -import {EventParser} from "./EventParser"; - -export const test = () => - describe("EventParser", function () { - const roomid = "some-room-name"; - - let parser: EventParser; - - beforeEach("Initialize EventParser", function () { - parser = new EventParser(); - }); - - afterEach("Destroy EventParser", function () { - parser.destroy(); - }); - - it("Should parse events from room", async function () { - parser.write(`>${roomid}\n|init|battle\n|start\n`); - parser.end(); - const events: (RoomEvent | HaltEvent)[] = []; - for await (const event of parser) { - events.push(event as RoomEvent | HaltEvent); - } - expect(events).to.have.deep.members([ - {roomid, args: ["init", "battle"], kwArgs: {}}, - {roomid, args: ["start"], kwArgs: {}}, - // Halt event after every chunk. - {roomid, args: ["halt"], kwArgs: {}}, - ]); - }); - - it("Should still parse events without room", async function () { - parser.write("|init|battle\n|start\n"); - parser.end(); - const events: (RoomEvent | HaltEvent)[] = []; - for await (const event of parser) { - events.push(event as RoomEvent | HaltEvent); - } - expect(events).to.have.deep.members([ - {roomid: "", args: ["init", "battle"], kwArgs: {}}, - {roomid: "", args: ["start"], kwArgs: {}}, - {roomid: "", args: ["halt"], kwArgs: {}}, - ]); - }); - - it("Should parse multiple chunks", async function () { - const roomid2 = roomid + "2"; - parser.write(`>${roomid}\n|init|battle\n|start\n`); - parser.write(`>${roomid2}\n|upkeep\n|-weather|SunnyDay|[upkeep]\n`); - parser.end(); - const events: (RoomEvent | HaltEvent)[] = []; - for await (const event of parser) { - events.push(event as RoomEvent | HaltEvent); - } - expect(events).to.have.deep.members([ - {roomid, args: ["init", "battle"], kwArgs: {}}, - {roomid, args: ["start"], kwArgs: {}}, - {roomid, args: ["halt"], kwArgs: {}}, - {roomid: roomid2, args: ["upkeep"], kwArgs: {}}, - { - roomid: roomid2, - args: ["-weather", "SunnyDay"], - kwArgs: {upkeep: true}, - }, - {roomid: roomid2, args: ["halt"], kwArgs: {}}, - ]); - }); - }); diff --git a/src/ts/protocol/EventParser.ts b/src/ts/protocol/EventParser.ts deleted file mode 100644 index e1a47510..00000000 --- a/src/ts/protocol/EventParser.ts +++ /dev/null @@ -1,43 +0,0 @@ -import {Transform, TransformCallback} from "stream"; -import {Protocol} from "@pkmn/protocol"; -import {Logger} from "../utils/logging/Logger"; -import {HaltEvent, RoomEvent} from "./Event"; - -/** - * Transform stream that parses PS protocol events in chunks. Takes individual - * strings in object mode and outputs {@link RoomEvent}s. - */ -export class EventParser extends Transform { - /** - * Creates an EventParser. - * - * @param logger Optional logger object. - */ - public constructor(private readonly logger?: Logger) { - super({objectMode: true}); - } - - public override _transform( - chunk: string, - encoding: BufferEncoding, - callback: TransformCallback, - ): void { - try { - this.logger?.info(`Received:\n${chunk}`); - const rooms = new Set(); - for (const msg of Protocol.parse(chunk) as Generator) { - this.push(msg); - rooms.add(msg.roomid); - } - // Also send a "halt" signal after parsing a block. - for (const roomid of rooms) { - const msg: HaltEvent = {roomid, args: ["halt"], kwArgs: {}}; - this.push(msg); - } - } catch (e) { - // istanbul ignore next: Should never happen. - return callback(e as Error); - } - callback(); - } -} diff --git a/src/ts/protocol/index.test.ts b/src/ts/protocol/index.test.ts index ed48ad6c..98f48cdc 100644 --- a/src/ts/protocol/index.test.ts +++ b/src/ts/protocol/index.test.ts @@ -1,7 +1,7 @@ import "mocha"; -import * as eventParser from "./EventParser.test"; +import * as parser from "./parser.test"; export const test = () => describe("protocol", function () { - eventParser.test(); + parser.test(); }); diff --git a/src/ts/protocol/parser.test.ts b/src/ts/protocol/parser.test.ts new file mode 100644 index 00000000..4cd692d7 --- /dev/null +++ b/src/ts/protocol/parser.test.ts @@ -0,0 +1,29 @@ +import {expect} from "chai"; +import "mocha"; +import {protocolParser} from "./parser"; + +export const test = () => + describe("parser", function () { + const roomid = "some-room-name"; + + it("Should parse events from room", function () { + expect( + protocolParser(`>${roomid}\n|init|battle\n|start\n`), + ).to.have.deep.members([ + {roomid, args: ["init", "battle"], kwArgs: {}}, + {roomid, args: ["start"], kwArgs: {}}, + // Halt event after every chunk. + {roomid, args: ["halt"], kwArgs: {}}, + ]); + }); + + it("Should still parse events without roomid", function () { + expect( + protocolParser("|init|battle\n|start\n"), + ).to.have.deep.members([ + {roomid: "", args: ["init", "battle"], kwArgs: {}}, + {roomid: "", args: ["start"], kwArgs: {}}, + {roomid: "", args: ["halt"], kwArgs: {}}, + ]); + }); + }); diff --git a/src/ts/protocol/parser.ts b/src/ts/protocol/parser.ts new file mode 100644 index 00000000..5f32556c --- /dev/null +++ b/src/ts/protocol/parser.ts @@ -0,0 +1,20 @@ +import {Protocol} from "@pkmn/protocol"; +import {HaltEvent, RoomEvent} from "./Event"; + +/** Parses a chunk of the PS protocol events from the server. */ +export function protocolParser(chunk: string): (RoomEvent | HaltEvent)[] { + const events: (RoomEvent | HaltEvent)[] = []; + const rooms = new Set(); + for (const event of Protocol.parse(chunk)) { + events.push(event); + rooms.add(event.roomid); + } + for (const roomid of rooms) { + // Also send a "halt" signal after parsing a block in each room. + // Note: Protocol should only really allow one roomid per chunk but just + // in case. + const event: HaltEvent = {roomid, args: ["halt"], kwArgs: {}}; + events.push(event); + } + return events; +} diff --git a/src/ts/psbot/FakeRoomHandler.test.ts b/src/ts/psbot/FakeRoomHandler.test.ts index 8f74a30f..40493982 100644 --- a/src/ts/psbot/FakeRoomHandler.test.ts +++ b/src/ts/psbot/FakeRoomHandler.test.ts @@ -7,4 +7,7 @@ export class FakeRoomHandler implements RoomHandler { /** @override */ public halt() {} + + /** @override */ + public finish() {} } diff --git a/src/ts/psbot/PsBot.test.ts b/src/ts/psbot/PsBot.test.ts index 95d277bc..7c57cf69 100644 --- a/src/ts/psbot/PsBot.test.ts +++ b/src/ts/psbot/PsBot.test.ts @@ -1,3 +1,5 @@ +import {Protocol} from "@pkmn/protocol"; +import {ID} from "@pkmn/types"; import {expect} from "chai"; import "mocha"; import {IUtf8Message} from "websocket"; @@ -42,8 +44,13 @@ export const test = () => }); it(`Should accept ${format} challenges`, async function () { - server.sendToClient(`|updatechallenges|\ -{"challengesFrom":{"${username}":"${format}"},"challengeTo":null}`); + const challenges = { + challengesFrom: {[username as ID]: format}, + challengeTo: null, + } as Protocol.Challenges; + server.sendToClient( + `|updatechallenges|${JSON.stringify(challenges)}`, + ); const msg = await server.nextMessage(); expect(msg.type).to.equal("utf8"); @@ -53,8 +60,13 @@ export const test = () => }); it(`Should not accept unsupported challenges`, async function () { - server.sendToClient(`|updatechallenges|\ -{"challengesFrom":{"${username}":"notarealformat"},"challengeTo":null}`); + const challenges = { + challengesFrom: {[username as ID]: "notarealformat"}, + challengeTo: null, + } as Protocol.Challenges; + server.sendToClient( + `|updatechallenges|${JSON.stringify(challenges)}`, + ); const msg = await server.nextMessage(); expect(msg.type).to.equal("utf8"); diff --git a/src/ts/psbot/PsBot.ts b/src/ts/psbot/PsBot.ts index a8bddcd8..373776bd 100644 --- a/src/ts/psbot/PsBot.ts +++ b/src/ts/psbot/PsBot.ts @@ -4,7 +4,7 @@ import {Action, Actions} from "@pkmn/login"; import {Protocol} from "@pkmn/protocol"; import {client as WSClient} from "websocket"; import {HaltEvent, RoomEvent} from "../protocol/Event"; -import {EventParser} from "../protocol/EventParser"; +import {protocolParser} from "../protocol/parser"; import {Logger} from "../utils/logging/Logger"; import {LoginConfig} from "./config"; import * as handlers from "./handlers"; @@ -56,10 +56,6 @@ export class PsBot { /** Used for handling global PS events. */ private readonly globalHandler = new handlers.global.GlobalHandler(); - /** Stream used for parsing PS protocol events. */ - private readonly parser = new EventParser( - this.logger.addPrefix("EventParser: "), - ); /** * Creates a PsBot. @@ -84,11 +80,6 @@ export class PsBot { this.globalHandler.updateUser = username => this.updateUser(username); this.globalHandler.respondToChallenge = (user, format) => this.respondToChallenge(user, format); - - // Setup async event parser. - // TODO: Tie this to a method that can be awaited after setting up the - // PsBot. - void this.parserReadLoop(); } /** @@ -180,7 +171,16 @@ export class PsBot { ); connection.on("message", data => { if (data.type === "utf8" && data.utf8Data) { - this.parser.write(data.utf8Data); + void (async () => { + this.logger.debug(`Received:\n${data.utf8Data}`); + for (const event of protocolParser(data.utf8Data)) { + await this.dispatch(event); + } + })().catch(err => + this.logger.error( + (err as Error).stack ?? (err as Error).toString(), + ), + ); } }); @@ -209,30 +209,22 @@ export class PsBot { } } - /** - * Sets up a read loop from the EventParser stream and dispatches parsed - * events to their respective room handlers. - */ - private async parserReadLoop(): Promise { - for await (const event of this.parser) { - await this.dispatch(event as RoomEvent | HaltEvent); - } - } - /** Handles parsed protocol events received from the PS serer. */ private async dispatch({ roomid, args, kwArgs, }: RoomEvent | HaltEvent): Promise { + let handler = this.rooms.get(roomid); + if (args[0] === "deinit") { // The roomid defaults to lobby if the |deinit event didn't come // from a room. + await handler?.finish(); this.rooms.delete(roomid || ("lobby" as Protocol.RoomID)); return; } - let handler = this.rooms.get(roomid); if (!handler) { // First msg when joining a battle room must be an |init|battle // event. diff --git a/src/ts/psbot/handlers/BattleHandler.ts b/src/ts/psbot/handlers/BattleHandler.ts index 16cd8435..8968364b 100644 --- a/src/ts/psbot/handlers/BattleHandler.ts +++ b/src/ts/psbot/handlers/BattleHandler.ts @@ -9,23 +9,24 @@ import {RoomHandler} from "./RoomHandler"; * @template TAgent Battle agent type. * @template TResult Parser result type. */ -export class BattleHandler< - TAgent extends BattleAgent = BattleAgent, - TResult = unknown, -> implements RoomHandler +export class BattleHandler + implements RoomHandler { /** Creates a BattleHandler. */ - public constructor( - private readonly driver: BattleDriver, - ) {} + public constructor(private readonly driver: BattleDriver) {} /** @override */ public async handle(event: Event): Promise { - return await this.driver.handle(event); + await this.driver.handle(event); } /** @override */ public halt(): void { - return this.driver.halt(); + this.driver.halt(); + } + + /** @override */ + public finish(): void { + this.driver.finish(); } } diff --git a/src/ts/psbot/handlers/GlobalHandler.ts b/src/ts/psbot/handlers/GlobalHandler.ts index b7f15fed..1d06a2a4 100644 --- a/src/ts/psbot/handlers/GlobalHandler.ts +++ b/src/ts/psbot/handlers/GlobalHandler.ts @@ -43,6 +43,9 @@ export class GlobalHandler implements RoomHandler, Protocol.Handler { /** @override */ public halt(): void {} + /** @override */ + public finish(): void {} + // List taken from Protocol.GlobalArgs. public "|popup|"(args: Args["|popup|"]) { diff --git a/src/ts/psbot/handlers/RoomHandler.ts b/src/ts/psbot/handlers/RoomHandler.ts index 25a10ebb..84691141 100644 --- a/src/ts/psbot/handlers/RoomHandler.ts +++ b/src/ts/psbot/handlers/RoomHandler.ts @@ -6,4 +6,6 @@ export interface RoomHandler { readonly handle: (event: Event) => void | Promise; /** Handles a halt signal after parsing a block of events. */ readonly halt: () => void | Promise; + /** Final cleanup step. */ + readonly finish: () => void | Promise; } diff --git a/src/ts/psbot/handlers/wrappers.ts b/src/ts/psbot/handlers/wrappers.ts new file mode 100644 index 00000000..34a9b96e --- /dev/null +++ b/src/ts/psbot/handlers/wrappers.ts @@ -0,0 +1,15 @@ +import {RoomHandler} from "./RoomHandler"; + +export function wrapFinish( + handler: RoomHandler, + onFinish: () => void | Promise, +): RoomHandler { + return { + handle: handler.handle.bind(handler), + halt: handler.halt.bind(handler), + finish: async () => { + await handler.finish(); + await onFinish(); + }, + }; +} diff --git a/src/ts/psbot/runner.ts b/src/ts/psbot/runner.ts index e460f870..48595bbe 100644 --- a/src/ts/psbot/runner.ts +++ b/src/ts/psbot/runner.ts @@ -4,13 +4,14 @@ import * as path from "path"; import * as yaml from "yaml"; import {BattleDriver} from "../battle/BattleDriver"; import {localizeAction} from "../battle/agent/localAction"; -import {main} from "../battle/parser/main"; +import {gen4Parser} from "../battle/parser/gen4"; import {lookup} from "../battle/usage"; import {ModelServer} from "../model/serve"; import {Logger} from "../utils/logging/Logger"; import {PsBot} from "./PsBot"; import {PsBotConfig} from "./config"; import {BattleHandler} from "./handlers/BattleHandler"; +import {wrapFinish} from "./handlers/wrappers"; const projectDir = path.resolve(__dirname, "..", "..", ".."); const defaultConfigPath = path.resolve(projectDir, "config", "psbot.yml"); @@ -50,10 +51,7 @@ void (async function psBotRunner() { bot.acceptChallenges("gen4randombattle", async (room, user, sender) => { const driver = new BattleDriver({ username: user, - async parser(ctx) { - await main(ctx); - await modelServer.cleanup(room /*key*/); - }, + parser: gen4Parser, async agent(state, choices, agentLogger) { agentLogger?.debug(`State:\n${state.toString()}`); const prediction = await modelServer.predict( @@ -86,11 +84,14 @@ void (async function psBotRunner() { sender, logger: logger.addPrefix(`BattleHandler(${room}): `), }); - // Make sure ports aren't dangling. - void driver.finish().catch(() => {}); const handler = new BattleHandler(driver); - return await Promise.resolve(handler); + return await Promise.resolve( + wrapFinish( + handler, + async () => await modelServer.cleanup(room /*key*/), + ), + ); }); logger.debug("Ready"); diff --git a/src/ts/utils/timeout.ts b/src/ts/utils/timeout.ts index 50d346d8..e4fee86c 100644 --- a/src/ts/utils/timeout.ts +++ b/src/ts/utils/timeout.ts @@ -1,4 +1,4 @@ -import {setTimeout} from "timers"; +import {clearTimeout, setTimeout} from "timers"; /** * Wraps a Promise in a timeout. @@ -12,14 +12,13 @@ export async function wrapTimeout( p: () => Promise, ms: number, ): Promise { - return await new Promise((res, rej) => { - const timer = setTimeout( - () => rej(new Error(`Timeout exceeded: ${ms}ms`)), - ms, - ); - void p() - .then(value => res(value)) - .catch(reason => rej(reason)) - .finally(() => clearTimeout(timer)); - }); + let timeoutRes: () => void; + const timeoutPromise = new Promise(res => (timeoutRes = res)); + const timeout = setTimeout(() => timeoutRes(), ms); + return await Promise.race([ + p().finally(() => clearTimeout(timeout)), + timeoutPromise.then(() => { + throw new Error(`Timeout exceeded: ${ms}ms`); + }), + ]); } diff --git a/src/ts/utils/types.ts b/src/ts/utils/types.ts new file mode 100644 index 00000000..f00e7bbb --- /dev/null +++ b/src/ts/utils/types.ts @@ -0,0 +1,3 @@ +export type Mutable = { + -readonly [U in keyof T]: T[U]; +};