0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

イベント駆動開発を学びたい(Step6:役割別に整理したバトル実装)

0
Posted at

はじめに

前回の記事:Step5・実行順序に続き、今回は役割ごとに整理したバトル実装を掲載します。

1対1・1ターン・固定20ダメージの学習用バトルです。せいでんきの成否は引数で指定し、実際の発動確率やタイプ相性は扱いません。

ディレクトリ構成

.
├── package.json
├── tsconfig.json
├── src/
│   ├── main.ts                    表示用の実行入口
│   └── battle/
│       ├── README.md              コードの読み方
│       ├── scenarios/             対戦条件の組み立て
│       │   ├── standard.ts        ガブリアス対カバルドン
│       │   └── static.ts          せいでんき・クラボのみ・優先度比較
│       ├── flow/                  バトル全体の進行
│       │   ├── battle-flow.ts     開始・登場・ターン実行
│       │   └── turn.ts            選択・行動順決定・攻撃・終了
│       ├── domain/                バトルの状態とルール
│       │   ├── types.ts           Pokemon・Action・BattleState
│       │   ├── pokemon.ts         ポケモン検索・名前
│       │   ├── actions.ts         行動選択・順序決定・攻撃
│       │   ├── status.ts          まひ付与・治療
│       │   └── handlers.ts        せいでんき・クラボのみ
│       └── engine/                処理を動かす仕組み
│           ├── section.ts         Section実行・上限・トレース
│           ├── event.ts           通知・ハンドラ登録・優先度
│           └── order.ts           スタックとキューの比較教材
└── tests/
    ├── step1.test.ts
    ├── step2.test.ts
    ├── step3.test.ts
    ├── step4.test.ts
    └── step5.test.ts

読む順番は scenarios → flow → domain → engine。engine は BattleState などの型を参照する、このバトル用の実行基盤です。order.ts は比較教材で、バトルの実行には接続していません。

シーケンス図

バトル全体

Sectionの実行は同じRunnerを経由します。ターン境界は名前ではなく scope: "turn" で指定します。

せいでんきからクラボのみまで

通知の途中でSectionを同期実行し、子の処理がすべて終わってから呼び出し元へ戻ります。

優先度シナリオ

runPriorityScenario() は確認ハンドラを最初に登録します。priorityは小さい順、同じ値なら登録順です。入れ子の通知は外側の次のハンドラより先に完了します。

コード全量

以下は src/ 配下のTypeScript全13ファイルです。ファイルパスごとに全文を掲載します。

src/main.ts

import { runStaticScenario } from "./battle/scenarios/static.ts";
import type { BattleState } from "./battle/domain/types.ts";

/** 実行ログを表示用に区切り、末尾へ最終HPを付ける。区切りの空行はログ自体には含めない。 */
export function formatBattleResult(state: BattleState): string {
  const sections: string[][] = [];
  let current: string[] = [];

  const flush = (): void => {
    if (current.length === 0) {
      return;
    }
    sections.push(current);
    current = [];
  };

  for (const line of state.logs) {
    const startsSection =
      line.startsWith("===") || line === "行動順:" || /の.+!$/.test(line);
    if (startsSection) {
      flush();
    }
    current.push(line);
    if (line.startsWith("===")) {
      flush();
    }
  }
  flush();

  const hpLines = state.pokemon.map(
    (pokemon) => `${pokemon.name} HP:${pokemon.currentHp} / ${pokemon.maxHp}`,
  );

  return `${sections.map((section) => section.join("\n")).join("\n\n")}\n\n${hpLines.join("\n")}`;
}


console.log(
  formatBattleResult(
    runStaticScenario({ staticActivates: true, attackerItem: "cheri" }),
  ),
);

src/battle/scenarios/standard.ts

import { createEventBus, type EventBus } from "../engine/event.ts";
import { createBattleFlow, runNewBattle } from "../flow/battle-flow.ts";
import { POKEMON_NAMES } from "../domain/pokemon.ts";
import type { Section } from "../engine/section.ts";
import { createTurnSection } from "../flow/turn.ts";
import type { BattleState, Pokemon } from "../domain/types.ts";

function createGarchomp(): Pokemon {
  return {
    id: "garchomp",
    name: POKEMON_NAMES.garchomp,
    currentHp: 100,
    maxHp: 100,
    speed: 120,
  };
}

function createHippowdon(): Pokemon {
  return {
    id: "hippowdon",
    name: POKEMON_NAMES.hippowdon,
    currentHp: 100,
    maxHp: 100,
    speed: 60,
  };
}

export function createBattleSection(events?: EventBus): Section {
  const bus = events ?? createEventBus();
  return createBattleFlow(
    [
      { name: POKEMON_NAMES.garchomp, create: createGarchomp },
      { name: POKEMON_NAMES.hippowdon, create: createHippowdon },
    ],
    createTurnSection(bus),
  );
}

export function runBattle(events?: EventBus): BattleState {
  return runNewBattle(createBattleSection(events));
}

src/battle/scenarios/static.ts

import { createEventBus, type EventBus, type EventHandler } from "../engine/event.ts";
import { registerStaticHandlers } from "../domain/handlers.ts";
import { createBattleFlow, runNewBattle } from "../flow/battle-flow.ts";
import { POKEMON_NAMES } from "../domain/pokemon.ts";
import type { Section } from "../engine/section.ts";
import { createTurnSection } from "../flow/turn.ts";
import type { Action, BattleState, Item, Pokemon } from "../domain/types.ts";

export type StaticScenarioOptions = {
  staticActivates: boolean;
  attackerItem: Item | undefined;
};

function createCharizard(item: Item | undefined): Pokemon {
  return {
    id: "charizard",
    name: POKEMON_NAMES.charizard,
    currentHp: 100,
    maxHp: 100,
    speed: 100,
    item,
  };
}

function createPikachu(): Pokemon {
  return {
    id: "pikachu",
    name: POKEMON_NAMES.pikachu,
    currentHp: 100,
    maxHp: 100,
    speed: 90,
    ability: "static",
  };
}

export function selectStaticActions(state: BattleState): Action[] {
  return state.pokemon.map((pokemon) => {
    const move = pokemon.id === "charizard" ? "ほのおのパンチ" : "でんきショック";
    const contact = pokemon.id === "charizard";
    state.logs.push(`${pokemon.name}:${move}`);
    return {
      actorId: pokemon.id,
      targetId: pokemon.id === "charizard" ? "pikachu" : "charizard",
      move,
      contact,
    };
  });
}

export function createStaticBattleSection(
  events: EventBus,
  options: StaticScenarioOptions,
): Section {
  return createBattleFlow(
    [
      { name: POKEMON_NAMES.charizard, create: () => createCharizard(options.attackerItem) },
      { name: POKEMON_NAMES.pikachu, create: createPikachu },
    ],
    createTurnSection(events, selectStaticActions),
  );
}

function createLateContactHandler(priority: number): EventHandler {
  return {
    name: "lateContact",
    kind: "afterDamage",
    priority,
    handle(event, state) {
      if (event.kind !== "afterDamage") {
        return;
      }
      if (!event.contact) {
        return;
      }
      state.logs.push("接触の確認が終わった");
    },
  };
}

export function runPriorityScenario(options?: {
  latePriority?: number;
  trace?: string[];
}): BattleState {
  const events = createEventBus();
  events.register(createLateContactHandler(options?.latePriority ?? 1));
  registerStaticHandlers(events, true);
  return runNewBattle(
    createStaticBattleSection(events, { staticActivates: true, attackerItem: "cheri" }),
    options?.trace,
  );
}

export function runStaticScenario(
  options: StaticScenarioOptions & { trace?: string[] },
): BattleState {
  const events = createEventBus();
  registerStaticHandlers(events, options.staticActivates);
  return runNewBattle(createStaticBattleSection(events, options), options.trace);
}

src/battle/flow/battle-flow.ts

import { createSectionRunner, type Section } from "../engine/section.ts";
import type { BattleState, Pokemon } from "../domain/types.ts";

function createBattleStartSection(): Section {
  return {
    name: "BattleStart",
    run(state) {
      state.logs.push("=== バトル開始 ===");
      return state;
    },
  };
}

function createAppearPokemonSection(name: string, createPokemon: () => Pokemon): Section {
  return {
    name: `AppearPokemon:${name}`,
    run(state) {
      const pokemon = createPokemon();
      state.pokemon.push(pokemon);
      state.logs.push(`${pokemon.name}が登場`);
      return state;
    },
  };
}

// シナリオ固有の構成を受け取り、開始から1ターンまでの進行を共用する。
export function createBattleFlow(
  participants: readonly { name: string; create: () => Pokemon }[],
  turn: Section,
): Section {
  return {
    name: "Battle",
    run(state, runner) {
      return runner.runAll(
        [
          createBattleStartSection(),
          ...participants.map(({ name, create }) => createAppearPokemonSection(name, create)),
          turn,
        ],
        state,
      );
    },
  };
}

export function runNewBattle(battle: Section, trace?: string[]): BattleState {
  const state: BattleState = { turn: 0, pokemon: [], logs: [] };
  return createSectionRunner(trace).run(battle, state);
}

src/battle/flow/turn.ts

import {
  createDecideActionOrderSection,
  createExecuteActionsSection,
  createSelectActionsSection,
  selectActions,
  type SelectActions,
  type ActionBuffer,
} from "../domain/actions.ts";
import { createEventBus, type EventBus } from "../engine/event.ts";
import { createSectionRunner, type Section } from "../engine/section.ts";
import type { BattleState } from "../domain/types.ts";

function createTurnStartSection(): Section {
  return {
    name: "TurnStart",
    run(state) {
      state.turn += 1;
      state.logs.push(`=== ターン${state.turn} 開始 ===`);
      return state;
    },
  };
}

function createTurnEndSection(): Section {
  return {
    name: "TurnEnd",
    run(state) {
      state.logs.push(`=== ターン${state.turn} 終了 ===`);
      return state;
    },
  };
}

export function createTurnSection(
  events?: EventBus,
  select: SelectActions = selectActions,
): Section {
  const bus = events ?? createEventBus();
  return {
    name: "Turn",
    scope: "turn",
    run(state, runner) {
      const buffer: ActionBuffer = { actions: [] };
      return runner.runAll(
        [
          createTurnStartSection(),
          createSelectActionsSection(buffer, select),
          createDecideActionOrderSection(buffer),
          createExecuteActionsSection(buffer, bus),
          createTurnEndSection(),
        ],
        state,
      );
    },
  };
}

export function runTurn(state: BattleState, events?: EventBus): BattleState {
  const runner = createSectionRunner();
  return runner.run(createTurnSection(events), state);
}

src/battle/domain/types.ts

export type PokemonId = "garchomp" | "hippowdon" | "charizard" | "pikachu";

export type Ability = "static";

export type Item = "cheri";

export type Status = "paralysis";

export type Pokemon = {
  id: PokemonId;
  name: string;
  currentHp: number;
  maxHp: number;
  speed: number;
  ability?: Ability;
  item?: Item;
  status?: Status;
};

export type Action = {
  actorId: PokemonId;
  targetId: PokemonId;
  move: string;
  contact?: boolean;
};

export type BattleState = {
  turn: number;
  pokemon: Pokemon[];
  logs: string[];
};

src/battle/domain/pokemon.ts

import type { BattleState, Pokemon, PokemonId } from "./types.ts";

export const POKEMON_NAMES: Record<PokemonId, string> = {
  garchomp: "ガブリアス",
  hippowdon: "カバルドン",
  charizard: "リザードン",
  pikachu: "ピカチュウ",
};

export function findPokemon(state: BattleState, id: PokemonId): Pokemon {
  const pokemon = state.pokemon.find((candidate) => candidate.id === id);
  if (pokemon === undefined) {
    throw new Error(`ポケモンが見つかりません: ${id}`);
  }
  return pokemon;
}

src/battle/domain/actions.ts

import { createEventBus, type BattleEvent, type EventBus } from "../engine/event.ts";
import { createSectionRunner, type Section, type SectionRunner } from "../engine/section.ts";
import { findPokemon, POKEMON_NAMES } from "./pokemon.ts";
import type { Action, BattleState } from "./types.ts";

const MOVE_EARTHQUAKE = "じしん";
const BASE_DAMAGE = 20;

export type ActionBuffer = {
  actions: Action[];
};

export type SelectActions = (state: BattleState) => Action[];

export function selectActions(state: BattleState): Action[] {
  return state.pokemon.map((pokemon) => {
    const target = state.pokemon.find((candidate) => candidate.id !== pokemon.id);
    if (target === undefined) {
      throw new Error("対象ポケモンが見つかりません");
    }

    const action: Action = {
      actorId: pokemon.id,
      targetId: target.id,
      move: MOVE_EARTHQUAKE,
    };
    state.logs.push(`${pokemon.name}:${action.move}`);
    return action;
  });
}

export function decideActionOrder(actions: Action[], state: BattleState): Action[] {
  const ordered = [...actions].sort((left, right) => {
    return findPokemon(state, right.actorId).speed - findPokemon(state, left.actorId).speed;
  });

  state.logs.push("行動順:");
  for (const [index, action] of ordered.entries()) {
    state.logs.push(`${index + 1}. ${findPokemon(state, action.actorId).name}`);
  }

  return ordered;
}

export function executeAction(
  state: BattleState,
  action: Action,
  events?: EventBus,
  runner?: SectionRunner,
): BattleState {
  const bus = events ?? createEventBus();
  const activeRunner = runner ?? createSectionRunner();
  const actor = findPokemon(state, action.actorId);
  const target = findPokemon(state, action.targetId);
  const contact = action.contact ?? false;

  state.logs.push(`${actor.name}の${action.move}!`);

  const damageEvent: BattleEvent = {
    kind: "beforeDamage",
    actorId: actor.id,
    targetId: target.id,
    move: action.move,
    damage: BASE_DAMAGE,
    contact,
  };
  bus.emit(damageEvent, state, activeRunner);

  target.currentHp -= damageEvent.damage;
  state.logs.push(`${target.name}に${damageEvent.damage}ダメージ!`);

  bus.emit(
    {
      kind: "afterDamage",
      actorId: actor.id,
      targetId: target.id,
      move: action.move,
      damage: damageEvent.damage,
      contact,
    },
    state,
    activeRunner,
  );

  return state;
}

export function createSelectActionsSection(
  buffer: ActionBuffer,
  select: SelectActions = selectActions,
): Section {
  return {
    name: "SelectActions",
    run(state) {
      buffer.actions = select(state);
      return state;
    },
  };
}

export function createDecideActionOrderSection(buffer: ActionBuffer): Section {
  return {
    name: "DecideActionOrder",
    run(state) {
      buffer.actions = decideActionOrder(buffer.actions, state);
      return state;
    },
  };
}

export function createExecuteActionSection(action: Action, events?: EventBus): Section {
  return {
    name: `ExecuteAction:${POKEMON_NAMES[action.actorId]}`,
    run(state, runner) {
      return executeAction(state, action, events, runner);
    },
  };
}

export function createExecuteActionsSection(buffer: ActionBuffer, events?: EventBus): Section {
  return {
    name: "ExecuteActions",
    run(state, runner) {
      const sections = buffer.actions.map((action) => createExecuteActionSection(action, events));
      return runner.runAll(sections, state);
    },
  };
}

src/battle/domain/status.ts

import { findPokemon, POKEMON_NAMES } from "./pokemon.ts";
import type { EventBus } from "../engine/event.ts";
import type { Section } from "../engine/section.ts";
import type { PokemonId } from "./types.ts";

export function createApplyStatusSection(pokemonId: PokemonId, events: EventBus): Section {
  return {
    name: `ApplyStatus:${POKEMON_NAMES[pokemonId]}`,
    run(state, runner) {
      const pokemon = findPokemon(state, pokemonId);
      if (pokemon.status === "paralysis") {
        return state;
      }
      const previousStatus = pokemon.status;
      pokemon.status = "paralysis";
      state.logs.push(`${pokemon.name}はまひになった`);
      events.emit(
        {
          kind: "statusChanged",
          pokemonId,
          previousStatus,
          status: "paralysis",
        },
        state,
        runner,
      );
      return state;
    },
  };
}

export function createCureStatusSection(pokemonId: PokemonId, events: EventBus): Section {
  return {
    name: `CureStatus:${POKEMON_NAMES[pokemonId]}`,
    run(state, runner) {
      const pokemon = findPokemon(state, pokemonId);
      if (pokemon.status !== "paralysis") {
        return state;
      }
      const previousStatus = pokemon.status;
      pokemon.status = undefined;
      state.logs.push(`${pokemon.name}のまひが治った`);
      events.emit(
        {
          kind: "statusChanged",
          pokemonId,
          previousStatus,
          status: undefined,
        },
        state,
        runner,
      );
      return state;
    },
  };
}

src/battle/domain/handlers.ts

import { findPokemon } from "./pokemon.ts";
import type { EventBus, EventHandler } from "../engine/event.ts";
import { createApplyStatusSection, createCureStatusSection } from "./status.ts";

export function createStaticHandler(staticActivates: boolean, events: EventBus): EventHandler {
  return {
    name: "static",
    kind: "afterDamage",
    handle(event, state, runner) {
      if (event.kind !== "afterDamage") {
        return;
      }
      if (!event.contact || !staticActivates) {
        return;
      }
      const defender = findPokemon(state, event.targetId);
      if (defender.ability !== "static") {
        return;
      }
      const attacker = findPokemon(state, event.actorId);
      state.logs.push(`${defender.name}のせいでんき!`);
      runner.run(createApplyStatusSection(attacker.id, events), state);
    },
  };
}

export function createCheriHandler(events: EventBus): EventHandler {
  return {
    name: "cheri",
    kind: "statusChanged",
    handle(event, state, runner) {
      if (event.kind !== "statusChanged" || event.status !== "paralysis") {
        return;
      }
      const pokemon = findPokemon(state, event.pokemonId);
      if (pokemon.status !== "paralysis" || pokemon.item !== "cheri") {
        return;
      }
      pokemon.item = undefined;
      state.logs.push(`${pokemon.name}はクラボのみを使った`);
      runner.run(createCureStatusSection(pokemon.id, events), state);
    },
  };
}

export function registerStaticHandlers(events: EventBus, staticActivates: boolean): void {
  events.register(createStaticHandler(staticActivates, events));
  events.register(createCheriHandler(events));
}

src/battle/engine/section.ts

import type { BattleState } from "../domain/types.ts";

const EXECUTION_LIMIT = 32;

export type Section = {
  name: string;
  /** ターン計測の境界。表示名とは独立し、開始・終了はRunnerが管理する。 */
  readonly scope?: "turn";
  run: (state: BattleState, runner: SectionRunner) => BattleState;
};

export type SectionRunner = {
  run: (section: Section, state: BattleState) => BattleState;
  runAll: (sections: readonly Section[], state: BattleState) => BattleState;
  beginTurn: () => void;
  endTurn: () => void;
  countEvent: (kind: string, state: BattleState) => void;
};

export function createSectionRunner(trace?: string[]): SectionRunner {
  let active = false;
  let sectionRuns = 0;
  let eventEmits = 0;

  const runner: SectionRunner = {
    beginTurn() {
      if (active) {
        throw new Error("ターン実行中にTurnを開始できません");
      }
      sectionRuns = 0;
      eventEmits = 0;
      active = true;
    },
    endTurn() {
      active = false;
    },
    countEvent(kind, state) {
      eventEmits += 1;
      if (eventEmits > EXECUTION_LIMIT) {
        const message = `1ターンのEvent発火数が上限${EXECUTION_LIMIT}を超えました: ${kind}`;
        state.logs.push(message);
        throw new Error(message);
      }
    },
    run(section, state) {
      trace?.push(section.name);
      const isTurn = section.scope === "turn";
      if (isTurn) {
        // 再入で開始に失敗した場合、外側のターンを終了させない。
        runner.beginTurn();
      } else {
        sectionRuns += 1;
        if (sectionRuns > EXECUTION_LIMIT) {
          const message = `1ターンのSection実行数が上限${EXECUTION_LIMIT}を超えました: ${section.name}`;
          state.logs.push(message);
          throw new Error(message);
        }
      }
      try {
        const result = section.run(state, runner);
        if (result !== state) {
          throw new Error(`${section.name} が別の BattleState を返しました`);
        }
        return result;
      } finally {
        if (isTurn) runner.endTurn();
      }
    },
    runAll(sections, state) {
      for (const section of sections) {
        runner.run(section, state);
      }
      return state;
    },
  };
  return runner;
}

src/battle/engine/event.ts

import type { SectionRunner } from "./section.ts";
import type { BattleState, PokemonId, Status } from "../domain/types.ts";

export type DamageEvent = {
  kind: "beforeDamage" | "afterDamage";
  actorId: PokemonId;
  targetId: PokemonId;
  move: string;
  damage: number;
  contact: boolean;
};

export type StatusChangedEvent = {
  readonly kind: "statusChanged";
  readonly pokemonId: PokemonId;
  readonly previousStatus: Status | undefined;
  readonly status: Status | undefined;
};

export type BattleEvent = DamageEvent | StatusChangedEvent;

export type EventKind = BattleEvent["kind"];

export type EventHandler = {
  name: string;
  kind: EventKind;
  priority?: number;
  handle: (event: BattleEvent, state: BattleState, runner: SectionRunner) => void;
};

export type EventBus = {
  register: (handler: EventHandler) => void;
  emit: (event: BattleEvent, state: BattleState, runner: SectionRunner) => void;
};

function readPriority(handler: EventHandler): number {
  const priority = handler.priority ?? 0;
  if (!Number.isSafeInteger(priority)) {
    throw new Error(`EventHandlerのpriorityは安全な整数で指定してください: ${handler.name}`);
  }
  return priority;
}

export function createEventBus(): EventBus {
  const handlers: EventHandler[] = [];
  let emitDepth = 0;

  return {
    register(handler) {
      if (emitDepth > 0) {
        throw new Error("実行中にEventHandlerは登録できません");
      }
      readPriority(handler);
      handlers.push(handler);
    },
    emit(event, state, runner) {
      runner.countEvent(event.kind, state);
      const ordered = handlers
        .filter((handler) => handler.kind === event.kind)
        .map((handler, index) => ({ handler, priority: readPriority(handler), index }));
      ordered.sort((left, right) => left.priority - right.priority || left.index - right.index);
      emitDepth += 1;
      try {
        for (const entry of ordered) {
          entry.handler.handle(event, state, runner);
        }
      } finally {
        emitDepth -= 1;
      }
    },
  };
}

src/battle/engine/order.ts

export type OrderTask = {
  name: string;
  run: (schedule: (task: OrderTask) => void) => void;
};

export function runAsStack(tasks: readonly OrderTask[]): string[] {
  const pending = [...tasks];
  const names: string[] = [];
  const runTask = (task: OrderTask): void => {
    names.push(task.name);
    task.run((child) => {
      runTask(child);
    });
  };
  for (const task of pending) {
    runTask(task);
  }
  return names;
}

export function runAsQueue(tasks: readonly OrderTask[]): string[] {
  const queue = [...tasks];
  const names: string[] = [];
  while (queue.length > 0) {
    const task = queue.shift();
    if (task === undefined) {
      break;
    }
    names.push(task.name);
    task.run((child) => {
      queue.push(child);
    });
  }
  return names;
}

実行設定

Node.js 22.18以上で実行します。確認環境はNode.js 26です。

package.json

{
  "name": "edd_study",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22.18.0"
  },
  "scripts": {
    "start": "node src/main.ts",
    "test": "node --test tests/step1.test.ts tests/step2.test.ts tests/step3.test.ts tests/step4.test.ts tests/step5.test.ts",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "@types/node": "^24.9.1",
    "typescript": "^5.9.3"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "allowImportingTsExtensions": true,
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "verbatimModuleSyntax": true,
    "noUncheckedIndexedAccess": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts", "tests/**/*.ts"]
}

実行結果

npm install
npm start

npm start は、せいでんき成功・クラボのみ所持の連鎖シナリオを実行します。

=== バトル開始 ===

リザードンが登場
ピカチュウが登場

=== ターン1 開始 ===

リザードン:ほのおのパンチ
ピカチュウ:でんきショック

行動順:
1. リザードン
2. ピカチュウ

リザードンのほのおのパンチ!
ピカチュウに20ダメージ!

ピカチュウのせいでんき!
リザードンはまひになった
リザードンはクラボのみを使った
リザードンのまひが治った

ピカチュウのでんきショック!
リザードンに20ダメージ!

=== ターン1 終了 ===

リザードン HP:80 / 100
ピカチュウ HP:80 / 100

テスト5ファイルはリポジトリのtestsにあります。テストも配置すると、次のコマンドで検証できます。

npm test
npm run typecheck

掲載コードはリファクタリング後の実装に対応しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?