refactor(three-monks): Replace commands with phase wrappers

This commit is contained in:
hyper 2026-07-01 16:25:41 +08:00
parent 6fa1f511bd
commit b038388deb
7 changed files with 78 additions and 95 deletions

View File

@ -1,34 +0,0 @@
import { defineComponent } from "../../src/component";
import type { Entity, World } from "../../src/index";
import type { CommandQueue } from "../../src/commands/index";
import type { ActionKind } from "./components";
import { selectAction } from "./rules";
export interface SelectionNotifier {
notifySelectionChanged(): void;
}
export const SelectActionCard = defineComponent("threeMonks.selectActionCard", {
player: 0 as Entity,
kind: "rest" as ActionKind,
});
export function registerCommands(
world: World,
commands: CommandQueue,
flow?: SelectionNotifier,
): void {
commands.handle(SelectActionCard, (command) => {
selectAction(world, command.player, command.kind);
flow?.notifySelectionChanged();
});
}
export function issueSelectAction(
world: World,
player: Entity,
kind: ActionKind,
): Entity {
const command = world.spawn();
world.add(command, SelectActionCard, { player, kind });
return command;
}

View File

@ -71,7 +71,8 @@
## 设计实现 ## 设计实现
- `components.ts`: 游戏数据与状态 - `components.ts`: 游戏数据与状态
- `commands.ts`: 玩家命令与规则命令实现
- `gameflow.ts`: 规则流程行为树 - `gameflow.ts`: 规则流程行为树
- `rules.ts`: 规则引擎(阶段包装器 + 核心结算函数)
- `logging.ts`: 日志输出 - `logging.ts`: 日志输出
- `simulate.ts`: 对局模拟与统计 - `simulate.ts`: 对局模拟与统计
- `random-playthrough.ts`: 单局详细回放

View File

@ -9,7 +9,15 @@ import {
type TaskRunner, type TaskRunner,
} from "../../src/bt/index"; } from "../../src/bt/index";
import { allPlayersSelected, hasWinner } from "./components"; import { allPlayersSelected, hasWinner } from "./components";
import { beginSelectionPhase, prepareNextRound, resolveRound } from "./rules"; import {
beginSelectionPhase,
chantPhase,
exchangePhase,
fetchWaterPhase,
drinkPhase,
endOfRoundPhase,
prepareNextRound,
} from "./rules";
export interface ThreeMonksFlow { export interface ThreeMonksFlow {
runner: TaskRunner; runner: TaskRunner;
@ -41,7 +49,11 @@ export function createThreeMonksFlow(world: World): ThreeMonksFlow {
control.succeed(); control.succeed();
} }
}), }),
action((world) => resolveRound(world)), action((world) => chantPhase(world)),
action((world) => fetchWaterPhase(world)),
action((world) => exchangePhase(world)),
action((world) => drinkPhase(world)),
action((world) => endOfRoundPhase(world)),
action((world) => prepareNextRound(world)), action((world) => prepareNextRound(world)),
]), ]),
), ),

View File

@ -9,15 +9,14 @@ import {
} from "./components"; } from "./components";
import { import {
beginSelectionPhase, beginSelectionPhase,
checkWinner,
collectProposals, collectProposals,
prepareNextRound, prepareNextRound,
resolveChant,
resolveDrink,
resolveEndOfRoundTools,
resolveExchange,
resolveFetchWater,
selectAction, selectAction,
chantPhase,
fetchWaterPhase,
exchangePhase,
drinkPhase,
endOfRoundPhase,
} from "./rules"; } from "./rules";
import { import {
ACTION_LABELS, ACTION_LABELS,
@ -82,24 +81,13 @@ export function generateRandomPlayLog(
const proposals = collectProposals(world); const proposals = collectProposals(world);
log.add(` 本轮行动阶段:${formatProposals(proposals)}`); log.add(` 本轮行动阶段:${formatProposals(proposals)}`);
if (proposals.chant) { applyLoggedPhase(log, world, "念经", () => chantPhase(world));
applyLoggedPhase(log, world, "念经", () => resolveChant(world)); applyLoggedPhase(log, world, "挑水", () => fetchWaterPhase(world));
} applyLoggedPhase(log, world, "交换", () => exchangePhase(world));
if (proposals.fetchWater) { applyLoggedPhase(log, world, "喝水", () => drinkPhase(world));
applyLoggedPhase(log, world, "挑水", () => resolveFetchWater(world));
}
if (proposals.exchange) {
applyLoggedPhase(log, world, "交换", () => resolveExchange(world));
}
if (proposals.drink) {
applyLoggedPhase(log, world, "喝水", () => resolveDrink(world));
}
if (world.getSingleton(GameState).phase === "gameOver") break; if (world.getSingleton(GameState).phase === "gameOver") break;
applyLoggedPhase(log, world, "轮末道具", () => applyLoggedPhase(log, world, "轮末道具", () => endOfRoundPhase(world));
resolveEndOfRoundTools(world),
);
checkWinner(world);
if (world.getSingleton(GameState).phase === "gameOver") break; if (world.getSingleton(GameState).phase === "gameOver") break;
prepareNextRound(world); prepareNextRound(world);

View File

@ -83,19 +83,47 @@ export function collectProposals(world: World): RoundProposals {
return proposals; return proposals;
} }
export function resolveRound(world: World, stats?: ToolEffectRecorder): void { function isProposed(world: World, kind: ActionKind): boolean {
const state = world.getSingleton(GameState); for (const player of getPlayersInSeatOrder(world)) {
if (state.phase === "gameOver") return; if (getSelectedAction(world, player) === kind) return true;
}
return false;
}
state.phase = "resolving"; function isGameOver(world: World): boolean {
const proposals = collectProposals(world); return world.getSingleton(GameState).phase === "gameOver";
}
if (proposals.chant) resolveChant(world, stats); // ── Phase wrappers (each checks proposal + gameOver, then delegates) ──
if (proposals.fetchWater) resolveFetchWater(world, stats);
if (proposals.exchange) resolveExchange(world, stats);
if (proposals.drink) resolveDrink(world, stats);
if (world.getSingleton(GameState).phase === "gameOver") return;
export function chantPhase(world: World, stats?: ToolEffectRecorder): void {
if (isGameOver(world) || !isProposed(world, "chant")) return;
resolveChant(world, stats);
}
export function fetchWaterPhase(
world: World,
stats?: ToolEffectRecorder,
): void {
if (isGameOver(world) || !isProposed(world, "fetchWater")) return;
resolveFetchWater(world, stats);
}
export function exchangePhase(world: World, stats?: ToolEffectRecorder): void {
if (isGameOver(world) || !isProposed(world, "exchange")) return;
resolveExchange(world, stats);
}
export function drinkPhase(world: World, stats?: ToolEffectRecorder): void {
if (isGameOver(world) || !isProposed(world, "drink")) return;
resolveDrink(world, stats);
}
export function endOfRoundPhase(
world: World,
stats?: ToolEffectRecorder,
): void {
if (isGameOver(world)) return;
resolveEndOfRoundTools(world, stats); resolveEndOfRoundTools(world, stats);
checkWinner(world); checkWinner(world);
} }

View File

@ -5,21 +5,18 @@ import {
Player, Player,
Table, Table,
setupGame, setupGame,
getPlayersInSeatOrder,
type ActionKind, type ActionKind,
} from "./components"; } from "./components";
import { ToolEffectStats, formatToolEffectSummaries } from "./stats"; import { ToolEffectStats, formatToolEffectSummaries } from "./stats";
import { import {
beginSelectionPhase, beginSelectionPhase,
checkWinner,
collectProposals,
prepareNextRound, prepareNextRound,
resolveChant,
resolveDrink,
resolveEndOfRoundTools,
resolveExchange,
resolveFetchWater,
selectAction, selectAction,
chantPhase,
fetchWaterPhase,
exchangePhase,
drinkPhase,
endOfRoundPhase,
} from "./rules"; } from "./rules";
function mulberry32(seed: number): () => number { function mulberry32(seed: number): () => number {
@ -75,16 +72,13 @@ function runSingleGame(seed: number, stats?: ToolEffectStats): GameStats {
selectAction(world, player, action); selectAction(world, player, action);
} }
const proposals = collectProposals(world); chantPhase(world, stats);
fetchWaterPhase(world, stats);
if (proposals.chant) resolveChant(world, stats); exchangePhase(world, stats);
if (proposals.fetchWater) resolveFetchWater(world, stats); drinkPhase(world, stats);
if (proposals.exchange) resolveExchange(world, stats);
if (proposals.drink) resolveDrink(world, stats);
if (world.getSingleton(GameState).phase === "gameOver") break; if (world.getSingleton(GameState).phase === "gameOver") break;
resolveEndOfRoundTools(world, stats); endOfRoundPhase(world, stats);
checkWinner(world);
if (world.getSingleton(GameState).phase === "gameOver") break; if (world.getSingleton(GameState).phase === "gameOver") break;
prepareNextRound(world); prepareNextRound(world);

View File

@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { World, query, type Entity } from "../src/index"; import { World, query, type Entity } from "../src/index";
import { CommandQueue } from "../src/commands/index";
import { generateRandomPlayLog } from "../examples/three-monks/random-playthrough"; import { generateRandomPlayLog } from "../examples/three-monks/random-playthrough";
import { import {
ActionCard, ActionCard,
@ -18,10 +17,6 @@ import {
type ActionKind, type ActionKind,
type ToolKind, type ToolKind,
} from "../examples/three-monks/components"; } from "../examples/three-monks/components";
import {
issueSelectAction,
registerCommands,
} from "../examples/three-monks/commands";
import { createThreeMonksFlow } from "../examples/three-monks/gameflow"; import { createThreeMonksFlow } from "../examples/three-monks/gameflow";
import { import {
prepareNextRound, prepareNextRound,
@ -112,18 +107,17 @@ describe("Three Monks action selection", () => {
expect(getSelectedAction(world, players[0])).toBe("rest"); expect(getSelectedAction(world, players[0])).toBe("rest");
}); });
it("command selection notifies gameflow and completes the round once all players select", () => { it("behavior tree completes a round after all players select", () => {
const { world, players } = setup(); const { world, players } = setup();
const commands = new CommandQueue(world);
const flow = createThreeMonksFlow(world); const flow = createThreeMonksFlow(world);
registerCommands(world, commands, flow);
flow.start(); flow.start();
flow.runner.tick(); flow.runner.tick();
for (const player of players) { for (const player of players) {
issueSelectAction(world, player, "rest"); selectAction(world, player, "rest");
commands.execute(); flow.notifySelectionChanged();
flow.runner.tick();
} }
for (const player of players) { for (const player of players) {