はじめに
前回の記事: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
掲載コードはリファクタリング後の実装に対応しています。