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

View File

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

View File

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

View File

@ -83,19 +83,47 @@ export function collectProposals(world: World): RoundProposals {
return proposals;
}
export function resolveRound(world: World, stats?: ToolEffectRecorder): void {
const state = world.getSingleton(GameState);
if (state.phase === "gameOver") return;
function isProposed(world: World, kind: ActionKind): boolean {
for (const player of getPlayersInSeatOrder(world)) {
if (getSelectedAction(world, player) === kind) return true;
}
return false;
}
state.phase = "resolving";
const proposals = collectProposals(world);
function isGameOver(world: World): boolean {
return world.getSingleton(GameState).phase === "gameOver";
}
if (proposals.chant) resolveChant(world, stats);
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;
// ── Phase wrappers (each checks proposal + gameOver, then delegates) ──
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);
checkWinner(world);
}

View File

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

View File

@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import { World, query, type Entity } from "../src/index";
import { CommandQueue } from "../src/commands/index";
import { generateRandomPlayLog } from "../examples/three-monks/random-playthrough";
import {
ActionCard,
@ -18,10 +17,6 @@ import {
type ActionKind,
type ToolKind,
} from "../examples/three-monks/components";
import {
issueSelectAction,
registerCommands,
} from "../examples/three-monks/commands";
import { createThreeMonksFlow } from "../examples/three-monks/gameflow";
import {
prepareNextRound,
@ -112,18 +107,17 @@ describe("Three Monks action selection", () => {
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 commands = new CommandQueue(world);
const flow = createThreeMonksFlow(world);
registerCommands(world, commands, flow);
flow.start();
flow.runner.tick();
for (const player of players) {
issueSelectAction(world, player, "rest");
commands.execute();
selectAction(world, player, "rest");
flow.notifySelectionChanged();
flow.runner.tick();
}
for (const player of players) {