feat(examples): Add Three Monks game example

This commit is contained in:
hyper 2026-07-01 15:44:37 +08:00
parent 0eb3defc62
commit 572b473089
10 changed files with 1626 additions and 7 deletions

View File

@ -0,0 +1,34 @@
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

@ -0,0 +1,219 @@
import { defineComponent } from "../../src/component";
import type { Entity, World } from "../../src/index";
import { query } from "../../src/query";
export type ActionKind = "fetchWater" | "exchange" | "drink" | "chant" | "rest";
export type ActionZone = "hand" | "selected" | "cooldown";
export type ToolKind =
| "woodenFish"
| "bucket"
| "bottle"
| "woodenBucket"
| "bigBowl"
| "bigBucket"
| "mouse"
| "shoulderPole"
| "waterJar"
| "ladle";
export type Phase = "setup" | "selecting" | "resolving" | "gameOver";
export const ACTION_KINDS: readonly ActionKind[] = [
"fetchWater",
"exchange",
"drink",
"chant",
"rest",
] as const;
export const TOOL_KINDS: readonly ToolKind[] = [
"woodenFish",
"bucket",
"bottle",
"woodenBucket",
"bigBowl",
"bigBucket",
"mouse",
"shoulderPole",
"waterJar",
"ladle",
] as const;
export const CARRYING_TOOLS = new Set<ToolKind>([
"bucket",
"woodenBucket",
"bigBucket",
"shoulderPole",
"ladle",
]);
export const Player = defineComponent("threeMonks.player", {
seat: 0,
name: "",
water: 0,
});
export const ActionCard = defineComponent("threeMonks.actionCard", {
owner: 0 as Entity,
kind: "rest" as ActionKind,
zone: "hand" as ActionZone,
});
export const Tool = defineComponent("threeMonks.tool", {
owner: 0 as Entity,
kind: "bucket" as ToolKind,
water: 0,
});
export const Table = defineComponent("threeMonks.table", {
centralWater: 0,
round: 1,
});
export const WoodenFishMarker = defineComponent("threeMonks.woodenFishMarker", {
holder: 0 as Entity,
});
export const GameState = defineComponent("threeMonks.gameState", {
phase: "setup" as Phase,
winner: 0 as Entity | 0,
message: "",
});
export type RandomFn = () => number;
export function setupGame(
world: World,
playerNames: readonly string[],
random: RandomFn = Math.random,
): Entity[] {
if (playerNames.length < 3 || playerNames.length > 8) {
throw new Error("Three Monks requires 3-8 players");
}
const players: Entity[] = [];
for (let seat = 0; seat < playerNames.length; seat++) {
const player = world.spawn();
world.add(player, Player, { seat, name: playerNames[seat], water: 2 });
players.push(player);
for (const kind of ACTION_KINDS) {
const card = world.spawn();
world.add(card, ActionCard, { owner: player, kind, zone: "hand" });
}
}
const tools = dealBalancedTools(players.length, random);
for (let i = 0; i < players.length; i++) {
const tool = world.spawn();
world.add(tool, Tool, { owner: players[i], kind: tools[i], water: 0 });
}
const holder = players[Math.floor(random() * players.length)];
world.addSingleton(Table, { centralWater: 0, round: 1 });
world.addSingleton(WoodenFishMarker, { holder });
world.addSingleton(GameState, {
phase: "selecting",
winner: 0,
message: "Choose an action card.",
});
return players;
}
export function getPlayersInSeatOrder(world: World): Entity[] {
return [...world.query(query(Player))].sort(
(a, b) => world.get(a, Player).seat - world.get(b, Player).seat,
);
}
export function getPlayersFromMarker(world: World): Entity[] {
const players = getPlayersInSeatOrder(world);
if (players.length === 0) return [];
const holder = world.getSingleton(WoodenFishMarker).holder;
const index = Math.max(0, players.indexOf(holder));
return [...players.slice(index), ...players.slice(0, index)];
}
export function getLeftPlayer(world: World, player: Entity): Entity {
const players = getPlayersInSeatOrder(world);
const index = players.indexOf(player);
if (index < 0) throw new Error("Player is not seated");
return players[(index + 1) % players.length];
}
export function getRightPlayer(world: World, player: Entity): Entity {
const players = getPlayersInSeatOrder(world);
const index = players.indexOf(player);
if (index < 0) throw new Error("Player is not seated");
return players[(index - 1 + players.length) % players.length];
}
export function getToolOf(world: World, player: Entity): Entity {
for (const tool of world.query(query(Tool))) {
if (world.get(tool, Tool).owner === player) return tool;
}
throw new Error("Player does not have a tool");
}
export function getActionCard(
world: World,
player: Entity,
kind: ActionKind,
): Entity | null {
for (const card of world.query(query(ActionCard))) {
const data = world.get(card, ActionCard);
if (data.owner === player && data.kind === kind) return card;
}
return null;
}
export function getSelectedAction(
world: World,
player: Entity,
): ActionKind | null {
for (const card of world.query(query(ActionCard))) {
const data = world.get(card, ActionCard);
if (data.owner === player && data.zone === "selected") return data.kind;
}
return null;
}
export function allPlayersSelected(world: World): boolean {
return getPlayersInSeatOrder(world).every(
(player) => getSelectedAction(world, player) !== null,
);
}
export function hasWinner(world: World): boolean {
return world.getSingleton(GameState).winner !== 0;
}
function dealBalancedTools(playerCount: number, random: RandomFn): ToolKind[] {
const carrying = shuffle(
TOOL_KINDS.filter((kind) => CARRYING_TOOLS.has(kind)),
random,
);
const nonCarrying = shuffle(
TOOL_KINDS.filter((kind) => !CARRYING_TOOLS.has(kind)),
random,
);
const carryingCount = Math.ceil(playerCount / 2);
const tools = [
...carrying.slice(0, carryingCount),
...nonCarrying.slice(0, playerCount - carryingCount),
];
return shuffle(tools, random);
}
function shuffle<T>(items: T[], random: RandomFn): T[] {
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
return items;
}

View File

@ -13,9 +13,12 @@
每轮游戏进行以下阶段: 每轮游戏进行以下阶段:
- 出牌阶段:每名玩家各选择一张手牌扣下,同时翻开。将上一轮打出的牌收回手牌。 - 出牌阶段:每名玩家各选择一张手牌扣下,同时翻开。将上一轮打出的牌收回手牌。
- 行动阶段:依次进行有玩家提议进行的行动阶段。 - 行动阶段:依次进行有玩家提议进行的行动阶段。
- 念经阶段:
- 参与玩家:提议念经的玩家。
- 将木鱼标记交给最后一名念经的玩家。
- 挑水阶段: - 挑水阶段:
- 参与玩家:持有挑水道具,且没有休息的玩家。 - 参与玩家:持有挑水道具,且没有休息的玩家。
- 每名参与玩家将 2 个水标记放在挑水道具上。 - 每名参与玩家将 2 个自己的水标记放在挑水道具上;若水不足,则不放
- 从供应堆拿取玩家人数减去参与玩家数的水标记,放入桌面中央。 - 从供应堆拿取玩家人数减去参与玩家数的水标记,放入桌面中央。
- 交换阶段: - 交换阶段:
- 参与玩家:没有提议念经的玩家。 - 参与玩家:没有提议念经的玩家。
@ -25,21 +28,19 @@
- 参与玩家:所有玩家。 - 参与玩家:所有玩家。
- 每名参与玩家依次从桌面中央获得 1 个水标记。 - 每名参与玩家依次从桌面中央获得 1 个水标记。
- 喝水时,可以从自己面前的道具上获得最多 2 个水标记。 - 喝水时,可以从自己面前的道具上获得最多 2 个水标记。
- 念经阶段:
- 参与玩家:提议念经的玩家。
- 将木鱼标记交给第一名念经的玩家。
游戏开始时,随机挑选一名玩家获得木鱼标记。 游戏开始时,每名玩家获得 2 个水标记,然后随机挑选一名玩家获得木鱼标记。
所有的结算从持有木鱼标记的玩家开始顺时针依次进行。 所有的结算从持有木鱼标记的玩家开始顺时针依次进行。
## 规则细化 ## 规则细化
- 行动阶段按固定顺序结算:挑水 → 交换 → 喝水 → 念经。只要任意玩家提议某阶段,该阶段本轮会结算一次。 - 行动阶段按固定顺序结算:念经 → 挑水 → 交换 → 喝水。只要任意玩家提议某阶段,该阶段本轮会结算一次。
- 出牌时,上一轮打出的行动牌本轮不可选择;所有玩家完成本轮选择后,上一轮行动牌回到手牌,本轮行动牌成为下一轮不可选择的牌。 - 出牌时,上一轮打出的行动牌本轮不可选择;所有玩家完成本轮选择后,上一轮行动牌回到手牌,本轮行动牌成为下一轮不可选择的牌。
- 游戏开始时洗混 10 张道具牌,每名玩家随机获得 1 张;未使用的道具牌不进入本局。 - 游戏开始时洗混 10 张道具牌,每名玩家随机获得 1 张;未使用的道具牌不进入本局。发给玩家的道具牌中,挑水道具始终占一半;玩家人数为奇数时,挑水道具数量向上取整。
- 喝水阶段中,玩家按木鱼标记开始的顺时针顺序逐个结算。玩家喝水后若达到 10 口水,立即获胜;若多人同阶段可能达到 10结算顺序靠前者先胜利。 - 喝水阶段中,玩家按木鱼标记开始的顺时针顺序逐个结算。玩家喝水后若达到 10 口水,立即获胜;若多人同阶段可能达到 10结算顺序靠前者先胜利。
- 木鱼道具在一轮结束时将木鱼标记交给其拥有者,会覆盖本轮念经阶段得到的木鱼标记。 - 木鱼道具在一轮结束时将木鱼标记交给其拥有者,会覆盖本轮念经阶段得到的木鱼标记。
- 挑水时,水从参与玩家已喝到的水移动到其挑水道具上;若玩家的水不足以支付需要放置的数量,则本次不移动水,因此该玩家和其道具上的水总量不变。
- 净瓶的水来自供应堆。 - 净瓶的水来自供应堆。
- 老鼠在念经时总共移动 1 个水标记:从左右相邻玩家任一有水的道具上移动 1 个水到老鼠上。 - 老鼠在念经时总共移动 1 个水标记:从左右相邻玩家任一有水的道具上移动 1 个水到老鼠上。
- 大碗在其拥有者喝水时,若本次没有从大碗上喝到水,则可从桌面中央将 1 个水标记放到大碗上。 - 大碗在其拥有者喝水时,若本次没有从大碗上喝到水,则可从桌面中央将 1 个水标记放到大碗上。
@ -72,3 +73,5 @@
- `components.ts`: 游戏数据与状态 - `components.ts`: 游戏数据与状态
- `commands.ts`: 玩家命令与规则命令实现 - `commands.ts`: 玩家命令与规则命令实现
- `gameflow.ts`: 规则流程行为树 - `gameflow.ts`: 规则流程行为树
- `logging.ts`: 日志输出
- `simulate.ts`: 对局模拟与统计

View File

@ -0,0 +1,59 @@
import type { World } from "../../src/index";
import {
action,
buildTree,
sequential,
wait,
whilst,
type TaskControl,
type TaskRunner,
} from "../../src/bt/index";
import { allPlayersSelected, hasWinner } from "./components";
import { beginSelectionPhase, prepareNextRound, resolveRound } from "./rules";
export interface ThreeMonksFlow {
runner: TaskRunner;
start(): void;
notifySelectionChanged(): void;
}
export function createThreeMonksFlow(world: World): ThreeMonksFlow {
let selectionControl: TaskControl | null = null;
const completeSelectionIfReady = () => {
if (selectionControl && allPlayersSelected(world)) {
const control = selectionControl;
selectionControl = null;
control.succeed();
}
};
const runner = buildTree(
world,
whilst(
(world) => !hasWinner(world),
sequential([
action((world) => beginSelectionPhase(world)),
wait((world, _entity, control) => {
selectionControl = control;
if (allPlayersSelected(world)) {
selectionControl = null;
control.succeed();
}
}),
action((world) => resolveRound(world)),
action((world) => prepareNextRound(world)),
]),
),
);
return {
runner,
start() {
runner.schedule(runner.root!);
},
notifySelectionChanged() {
completeSelectionIfReady();
},
};
}

View File

@ -0,0 +1,208 @@
import { query, type Entity, type World } from "../../src/index";
import {
ActionCard,
GameState,
Player,
Table,
Tool,
WoodenFishMarker,
getPlayersInSeatOrder,
getSelectedAction,
getToolOf,
type ActionKind,
type ToolKind,
} from "./components";
import type { RoundProposals } from "./rules";
export const ACTION_LABELS: Record<ActionKind, string> = {
fetchWater: "挑水",
exchange: "交换",
drink: "喝水",
chant: "念经",
rest: "休息",
};
export const TOOL_LABELS: Record<ToolKind, string> = {
woodenFish: "木鱼",
bucket: "水桶",
bottle: "净瓶",
woodenBucket: "木桶",
bigBowl: "大碗",
bigBucket: "大桶",
mouse: "老鼠",
shoulderPole: "扁担",
waterJar: "水缸",
ladle: "水瓢",
};
export interface PlayerSnapshot {
entity: Entity;
name: string;
seat: number;
water: number;
action: ActionKind | null;
tool: ToolKind;
toolWater: number;
hasMarker: boolean;
}
export interface GameSnapshot {
round: number;
centralWater: number;
phase: string;
winner: Entity | 0;
players: PlayerSnapshot[];
}
export class GameLog {
private readonly lines: string[] = [];
add(line = ""): void {
this.lines.push(line);
}
section(title: string): void {
if (this.lines.length > 0) this.lines.push("");
this.lines.push(title);
}
entries(): readonly string[] {
return this.lines;
}
toString(): string {
return this.lines.join("\n");
}
}
export function snapshotGame(world: World): GameSnapshot {
const table = world.getSingleton(Table);
const marker = world.getSingleton(WoodenFishMarker).holder;
const state = world.getSingleton(GameState);
return {
round: table.round,
centralWater: table.centralWater,
phase: state.phase,
winner: state.winner,
players: getPlayersInSeatOrder(world).map((player) => {
const playerData = world.get(player, Player);
const toolData = world.get(getToolOf(world, player), Tool);
return {
entity: player,
name: playerData.name,
seat: playerData.seat,
water: playerData.water,
action: getSelectedAction(world, player),
tool: toolData.kind,
toolWater: toolData.water,
hasMarker: player === marker,
};
}),
};
}
export function formatSnapshot(snapshot: GameSnapshot): string[] {
return [
`${snapshot.round} 轮 | 中央水=${snapshot.centralWater} | 阶段=${formatPhase(snapshot.phase)}`,
...snapshot.players.map(formatPlayerSnapshot),
];
}
export function formatPlayerSnapshot(player: PlayerSnapshot): string {
const marker = player.hasMarker ? " 🪵" : "";
const action = player.action ? ` | 行动=${ACTION_LABELS[player.action]}` : "";
return `${player.name}${marker}: 已喝=${player.water}, 道具=${TOOL_LABELS[player.tool]}(${player.toolWater})${action}`;
}
export function formatProposals(proposals: RoundProposals): string {
const phases: string[] = [];
if (proposals.chant) phases.push("念经");
if (proposals.fetchWater) phases.push("挑水");
if (proposals.exchange) phases.push("交换");
if (proposals.drink) phases.push("喝水");
return phases.length > 0 ? phases.join(" → ") : "无行动阶段";
}
export function formatAvailableActions(world: World, player: Entity): string {
const actions: ActionKind[] = [];
for (const card of world.query(query(ActionCard))) {
const data = world.get(card, ActionCard);
if (data.owner === player && data.zone === "hand") actions.push(data.kind);
}
return actions.map((action) => ACTION_LABELS[action]).join("、");
}
export function logSnapshot(log: GameLog, world: World): void {
for (const line of formatSnapshot(snapshotGame(world))) {
log.add(line);
}
}
export function logPhaseDelta(
log: GameLog,
label: string,
before: GameSnapshot,
after: GameSnapshot,
): void {
const changes: string[] = [];
if (before.centralWater !== after.centralWater) {
changes.push(`中央水 ${before.centralWater}${after.centralWater}`);
}
for (const beforePlayer of before.players) {
const afterPlayer = after.players.find(
(p) => p.entity === beforePlayer.entity,
)!;
const playerChanges: string[] = [];
if (beforePlayer.water !== afterPlayer.water) {
playerChanges.push(`已喝 ${beforePlayer.water}${afterPlayer.water}`);
}
if (
beforePlayer.tool !== afterPlayer.tool ||
beforePlayer.toolWater !== afterPlayer.toolWater
) {
playerChanges.push(
`道具 ${TOOL_LABELS[beforePlayer.tool]}(${beforePlayer.toolWater})→${TOOL_LABELS[afterPlayer.tool]}(${afterPlayer.toolWater})`,
);
}
if (beforePlayer.hasMarker !== afterPlayer.hasMarker) {
playerChanges.push(
afterPlayer.hasMarker ? "获得木鱼标记" : "失去木鱼标记",
);
}
if (playerChanges.length > 0) {
changes.push(`${afterPlayer.name}: ${playerChanges.join("")}`);
}
}
if (changes.length === 0) {
log.add(` ${label}: 无变化`);
} else {
log.add(` ${label}: ${changes.join("")}`);
}
}
export function formatPhase(phase: string): string {
switch (phase) {
case "setup":
return "设置";
case "selecting":
return "出牌";
case "resolving":
return "结算";
case "gameOver":
return "游戏结束";
default:
return phase;
}
}
export function winnerName(world: World): string | null {
const winner = world.getSingleton(GameState).winner;
if (winner === 0) return null;
return world.get(winner, Player).name;
}

View File

@ -0,0 +1,235 @@
三个和尚随机对局(种子=20260701
玩家:慧空、明心、了尘、净远
初始状态:
第 1 轮 | 中央水=0 | 阶段=出牌
慧空: 已喝=2, 道具=老鼠(0)
明心: 已喝=2, 道具=水瓢(0)
了尘: 已喝=2, 道具=木桶(0)
净远 🪵: 已喝=2, 道具=水缸(0)
第 1 轮
慧空 选择 挑水(可选:挑水、交换、喝水、念经、休息)
明心 选择 休息(可选:挑水、交换、喝水、念经、休息)
了尘 选择 交换(可选:挑水、交换、喝水、念经、休息)
净远 选择 喝水(可选:挑水、交换、喝水、念经、休息)
本轮行动阶段:挑水 → 交换 → 喝水
挑水: 中央水 0→3了尘: 已喝 2→0道具 木桶(0)→木桶(2)
交换: 慧空: 道具 老鼠(0)→水缸(0);明心: 道具 水瓢(0)→老鼠(0);了尘: 道具 木桶(2)→水瓢(0);净远: 道具 水缸(0)→木桶(2)
喝水: 中央水 3→0明心: 已喝 2→3了尘: 已喝 0→1净远: 已喝 2→5道具 木桶(2)→木桶(0)
轮末道具: 无变化
轮末状态:
第 2 轮 | 中央水=0 | 阶段=出牌
慧空: 已喝=2, 道具=水缸(0)
明心: 已喝=3, 道具=老鼠(0)
了尘: 已喝=1, 道具=水瓢(0)
净远 🪵: 已喝=5, 道具=木桶(0)
第 2 轮
慧空 选择 念经(可选:交换、喝水、念经、休息)
明心 选择 交换(可选:挑水、交换、喝水、念经)
了尘 选择 休息(可选:挑水、喝水、念经、休息)
净远 选择 交换(可选:挑水、交换、念经、休息)
本轮行动阶段:念经 → 交换
念经: 慧空: 获得木鱼标记;净远: 失去木鱼标记
交换: 明心: 道具 老鼠(0)→木桶(0);了尘: 道具 水瓢(0)→老鼠(0);净远: 道具 木桶(0)→水瓢(0)
轮末道具: 无变化
轮末状态:
第 3 轮 | 中央水=0 | 阶段=出牌
慧空 🪵: 已喝=2, 道具=水缸(0)
明心: 已喝=3, 道具=木桶(0)
了尘: 已喝=1, 道具=老鼠(0)
净远: 已喝=5, 道具=水瓢(0)
第 3 轮
慧空 选择 交换(可选:挑水、交换、喝水、休息)
明心 选择 喝水(可选:挑水、喝水、念经、休息)
了尘 选择 交换(可选:挑水、交换、喝水、念经)
净远 选择 挑水(可选:挑水、喝水、念经、休息)
本轮行动阶段:挑水 → 交换 → 喝水
挑水: 中央水 0→1净远: 已喝 5→3道具 水瓢(0)→水瓢(3)
交换: 慧空: 道具 水缸(0)→水瓢(3);明心: 道具 木桶(0)→水缸(0);了尘: 道具 老鼠(0)→木桶(0);净远: 道具 水瓢(3)→老鼠(0)
喝水: 中央水 1→0慧空: 已喝 2→5道具 水瓢(3)→水瓢(1);明心: 已喝 3→4
轮末道具: 无变化
轮末状态:
第 4 轮 | 中央水=0 | 阶段=出牌
慧空 🪵: 已喝=5, 道具=水瓢(1)
明心: 已喝=4, 道具=水缸(0)
了尘: 已喝=1, 道具=木桶(0)
净远: 已喝=3, 道具=老鼠(0)
第 4 轮
慧空 选择 念经(可选:挑水、喝水、念经、休息)
明心 选择 挑水(可选:挑水、交换、念经、休息)
了尘 选择 休息(可选:挑水、喝水、念经、休息)
净远 选择 休息(可选:交换、喝水、念经、休息)
本轮行动阶段:念经 → 挑水
念经: 无变化
挑水: 中央水 0→2慧空: 已喝 5→3道具 水瓢(1)→水瓢(4)
轮末道具: 无变化
轮末状态:
第 5 轮 | 中央水=2 | 阶段=出牌
慧空 🪵: 已喝=3, 道具=水瓢(4)
明心: 已喝=4, 道具=水缸(0)
了尘: 已喝=1, 道具=木桶(0)
净远: 已喝=3, 道具=老鼠(0)
第 5 轮
慧空 选择 休息(可选:挑水、交换、喝水、休息)
明心 选择 喝水(可选:交换、喝水、念经、休息)
了尘 选择 挑水(可选:挑水、交换、喝水、念经)
净远 选择 念经(可选:挑水、交换、喝水、念经)
本轮行动阶段:念经 → 挑水 → 喝水
念经: 慧空: 道具 水瓢(4)→水瓢(3),失去木鱼标记;净远: 道具 老鼠(0)→老鼠(1),获得木鱼标记
挑水: 中央水 2→5
喝水: 中央水 5→1慧空: 已喝 3→6道具 水瓢(3)→水瓢(1);明心: 已喝 4→6了尘: 已喝 1→2净远: 已喝 3→5道具 老鼠(1)→老鼠(0)
轮末道具: 无变化
轮末状态:
第 6 轮 | 中央水=1 | 阶段=出牌
慧空: 已喝=6, 道具=水瓢(1)
明心: 已喝=6, 道具=水缸(0)
了尘: 已喝=2, 道具=木桶(0)
净远 🪵: 已喝=5, 道具=老鼠(0)
第 6 轮
慧空 选择 念经(可选:挑水、交换、喝水、念经)
明心 选择 挑水(可选:挑水、交换、念经、休息)
了尘 选择 交换(可选:交换、喝水、念经、休息)
净远 选择 挑水(可选:挑水、交换、喝水、休息)
本轮行动阶段:念经 → 挑水 → 交换
念经: 慧空: 获得木鱼标记;净远: 失去木鱼标记
挑水: 中央水 1→2慧空: 已喝 6→4道具 水瓢(1)→水瓢(4)
交换: 明心: 道具 水缸(0)→老鼠(0);了尘: 道具 木桶(0)→水缸(0);净远: 道具 老鼠(0)→木桶(0)
轮末道具: 无变化
轮末状态:
第 7 轮 | 中央水=2 | 阶段=出牌
慧空 🪵: 已喝=4, 道具=水瓢(4)
明心: 已喝=6, 道具=老鼠(0)
了尘: 已喝=2, 道具=水缸(0)
净远: 已喝=5, 道具=木桶(0)
第 7 轮
慧空 选择 喝水(可选:挑水、交换、喝水、休息)
明心 选择 休息(可选:交换、喝水、念经、休息)
了尘 选择 休息(可选:挑水、喝水、念经、休息)
净远 选择 休息(可选:交换、喝水、念经、休息)
本轮行动阶段:喝水
喝水: 中央水 2→0慧空: 已喝 4→7道具 水瓢(4)→水瓢(2);明心: 已喝 6→7
轮末道具: 无变化
轮末状态:
第 8 轮 | 中央水=0 | 阶段=出牌
慧空 🪵: 已喝=7, 道具=水瓢(2)
明心: 已喝=7, 道具=老鼠(0)
了尘: 已喝=2, 道具=水缸(0)
净远: 已喝=5, 道具=木桶(0)
第 8 轮
慧空 选择 休息(可选:挑水、交换、念经、休息)
明心 选择 挑水(可选:挑水、交换、喝水、念经)
了尘 选择 挑水(可选:挑水、交换、喝水、念经)
净远 选择 挑水(可选:挑水、交换、喝水、念经)
本轮行动阶段:挑水
挑水: 中央水 0→3净远: 已喝 5→3道具 木桶(0)→木桶(2)
轮末道具: 无变化
轮末状态:
第 9 轮 | 中央水=3 | 阶段=出牌
慧空 🪵: 已喝=7, 道具=水瓢(2)
明心: 已喝=7, 道具=老鼠(0)
了尘: 已喝=2, 道具=水缸(0)
净远: 已喝=3, 道具=木桶(2)
第 9 轮
慧空 选择 交换(可选:挑水、交换、喝水、念经)
明心 选择 休息(可选:交换、喝水、念经、休息)
了尘 选择 交换(可选:交换、喝水、念经、休息)
净远 选择 交换(可选:交换、喝水、念经、休息)
本轮行动阶段:交换
交换: 慧空: 道具 水瓢(2)→木桶(2);明心: 道具 老鼠(0)→水瓢(2);了尘: 道具 水缸(0)→老鼠(0);净远: 道具 木桶(2)→水缸(0)
轮末道具: 无变化
轮末状态:
第 10 轮 | 中央水=3 | 阶段=出牌
慧空 🪵: 已喝=7, 道具=木桶(2)
明心: 已喝=7, 道具=水瓢(2)
了尘: 已喝=2, 道具=老鼠(0)
净远: 已喝=3, 道具=水缸(0)
第 10 轮
慧空 选择 喝水(可选:挑水、喝水、念经、休息)
明心 选择 交换(可选:挑水、交换、喝水、念经)
了尘 选择 念经(可选:挑水、喝水、念经、休息)
净远 选择 挑水(可选:挑水、喝水、念经、休息)
本轮行动阶段:念经 → 挑水 → 交换 → 喝水
念经: 慧空: 失去木鱼标记;明心: 道具 水瓢(2)→水瓢(1);了尘: 道具 老鼠(0)→老鼠(1),获得木鱼标记
挑水: 中央水 3→4明心: 已喝 7→5道具 水瓢(1)→水瓢(4)
交换: 慧空: 道具 木桶(2)→水缸(0);明心: 道具 水瓢(4)→木桶(2);净远: 道具 水缸(0)→水瓢(4)
喝水: 中央水 4→0慧空: 已喝 7→9明心: 已喝 5→8道具 木桶(2)→木桶(0);了尘: 已喝 2→4道具 老鼠(1)→老鼠(0);净远: 已喝 3→6道具 水瓢(4)→水瓢(2)
轮末道具: 无变化
轮末状态:
第 11 轮 | 中央水=0 | 阶段=出牌
慧空: 已喝=9, 道具=水缸(0)
明心: 已喝=8, 道具=木桶(0)
了尘 🪵: 已喝=4, 道具=老鼠(0)
净远: 已喝=6, 道具=水瓢(2)
第 11 轮
慧空 选择 挑水(可选:挑水、交换、念经、休息)
明心 选择 喝水(可选:挑水、喝水、念经、休息)
了尘 选择 休息(可选:挑水、交换、喝水、休息)
净远 选择 休息(可选:交换、喝水、念经、休息)
本轮行动阶段:挑水 → 喝水
挑水: 中央水 0→3明心: 已喝 8→6道具 木桶(0)→木桶(2)
喝水: 中央水 3→0明心: 已喝 6→9道具 木桶(2)→木桶(0);了尘: 已喝 4→5净远: 已喝 6→9道具 水瓢(2)→水瓢(0)
轮末道具: 无变化
轮末状态:
第 12 轮 | 中央水=0 | 阶段=出牌
慧空: 已喝=9, 道具=水缸(0)
明心: 已喝=9, 道具=木桶(0)
了尘 🪵: 已喝=5, 道具=老鼠(0)
净远: 已喝=9, 道具=水瓢(0)
第 12 轮
慧空 选择 休息(可选:交换、喝水、念经、休息)
明心 选择 挑水(可选:挑水、交换、念经、休息)
了尘 选择 挑水(可选:挑水、交换、喝水、念经)
净远 选择 念经(可选:挑水、交换、喝水、念经)
本轮行动阶段:念经 → 挑水
念经: 了尘: 失去木鱼标记;净远: 获得木鱼标记
挑水: 中央水 0→1净远: 已喝 9→7道具 水瓢(0)→水瓢(3)
轮末道具: 无变化
轮末状态:
第 13 轮 | 中央水=1 | 阶段=出牌
慧空: 已喝=9, 道具=水缸(0)
明心: 已喝=9, 道具=木桶(0)
了尘: 已喝=5, 道具=老鼠(0)
净远 🪵: 已喝=7, 道具=水瓢(3)
第 13 轮
慧空 选择 交换(可选:挑水、交换、喝水、念经)
明心 选择 交换(可选:交换、喝水、念经、休息)
了尘 选择 念经(可选:交换、喝水、念经、休息)
净远 选择 交换(可选:挑水、交换、喝水、休息)
本轮行动阶段:念经 → 交换
念经: 了尘: 道具 老鼠(0)→老鼠(1),获得木鱼标记;净远: 道具 水瓢(3)→水瓢(2),失去木鱼标记
交换: 慧空: 道具 水缸(0)→水瓢(2);明心: 道具 木桶(0)→水缸(0);净远: 道具 水瓢(2)→木桶(0)
轮末道具: 无变化
轮末状态:
第 14 轮 | 中央水=1 | 阶段=出牌
慧空: 已喝=9, 道具=水瓢(2)
明心: 已喝=9, 道具=水缸(0)
了尘 🪵: 已喝=5, 道具=老鼠(1)
净远: 已喝=7, 道具=木桶(0)
第 14 轮
慧空 选择 喝水(可选:挑水、喝水、念经、休息)
明心 选择 喝水(可选:挑水、喝水、念经、休息)
了尘 选择 休息(可选:挑水、交换、喝水、休息)
净远 选择 休息(可选:挑水、喝水、念经、休息)
本轮行动阶段:喝水
喝水: 中央水 1→0慧空: 已喝 9→11道具 水瓢(2)→水瓢(0);了尘: 已喝 5→7道具 老鼠(1)→老鼠(0)
游戏结束
胜者:慧空
第 14 轮 | 中央水=0 | 阶段=游戏结束
慧空: 已喝=11, 道具=水瓢(0) | 行动=喝水
明心: 已喝=9, 道具=水缸(0) | 行动=喝水
了尘 🪵: 已喝=7, 道具=老鼠(0) | 行动=休息
净远: 已喝=7, 道具=木桶(0) | 行动=休息

View File

@ -0,0 +1,163 @@
import { World, query, type Entity } from "../../src/index";
import {
ActionCard,
GameState,
Player,
Table,
setupGame,
type ActionKind,
} from "./components";
import {
beginSelectionPhase,
checkWinner,
collectProposals,
prepareNextRound,
resolveChant,
resolveDrink,
resolveEndOfRoundTools,
resolveExchange,
resolveFetchWater,
selectAction,
} from "./rules";
import {
ACTION_LABELS,
GameLog,
formatAvailableActions,
formatProposals,
logPhaseDelta,
logSnapshot,
snapshotGame,
winnerName,
} from "./logging";
export interface RandomPlaythroughOptions {
seed?: number;
playerNames?: readonly string[];
maxRounds?: number;
}
export interface RandomPlaythroughResult {
world: World;
log: string;
roundsPlayed: number;
}
export function generateRandomPlayLog(
options: RandomPlaythroughOptions = {},
): RandomPlaythroughResult {
const seed = options.seed ?? 20260701;
const random = mulberry32(seed);
const playerNames = options.playerNames ?? ["慧空", "明心", "了尘", "净远"];
const maxRounds = options.maxRounds ?? 200;
const world = new World();
const players = setupGame(world, playerNames, random);
const log = new GameLog();
log.section(`三个和尚随机对局(种子=${seed}`);
log.add(`玩家:${playerNames.join("、")}`);
log.add("初始状态:");
logSnapshot(log, world);
let roundsPlayed = 0;
while (world.getSingleton(GameState).phase !== "gameOver") {
if (roundsPlayed >= maxRounds) {
throw new Error(`随机对局未能在 ${maxRounds} 轮内结束`);
}
const round = world.getSingleton(Table).round;
log.section(`${round}`);
beginSelectionPhase(world);
for (const player of players) {
const action = pickRandom(availableActions(world, player), random);
const playerName = world.get(player, Player).name;
log.add(
` ${playerName} 选择 ${ACTION_LABELS[action]}(可选:${formatAvailableActions(world, player)}`,
);
selectAction(world, player, action);
}
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));
}
if (world.getSingleton(GameState).phase === "gameOver") break;
applyLoggedPhase(log, world, "轮末道具", () =>
resolveEndOfRoundTools(world),
);
checkWinner(world);
if (world.getSingleton(GameState).phase === "gameOver") break;
prepareNextRound(world);
log.add(" 轮末状态:");
logSnapshot(log, world);
roundsPlayed++;
}
roundsPlayed++;
log.section("游戏结束");
log.add(`胜者:${winnerName(world)}`);
logSnapshot(log, world);
return { world, log: log.toString(), roundsPlayed };
}
function applyLoggedPhase(
log: GameLog,
world: World,
label: string,
phase: () => void,
): void {
const before = snapshotGame(world);
phase();
const after = snapshotGame(world);
logPhaseDelta(log, label, before, after);
}
function availableActions(world: World, player: Entity): ActionKind[] {
const actions: ActionKind[] = [];
for (const card of world.query(query(ActionCard))) {
const data = world.get(card, ActionCard);
if (data.owner === player && data.zone === "hand") {
actions.push(data.kind);
}
}
if (actions.length === 0) {
throw new Error(`${world.get(player, Player).name} 没有可选行动牌`);
}
return actions;
}
function pickRandom<T>(items: readonly T[], random: () => number): T {
return items[Math.floor(random() * items.length)];
}
function mulberry32(seed: number): () => number {
let state = seed >>> 0;
return () => {
state += 0x6d2b79f5;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
if (process.argv[1]?.endsWith("random-playthrough.ts")) {
const result = generateRandomPlayLog();
console.log(result.log);
}

View File

@ -0,0 +1,295 @@
import type { Entity, World } from "../../src/index";
import { query } from "../../src/query";
import {
ActionCard,
CARRYING_TOOLS,
GameState,
Player,
Table,
Tool,
WoodenFishMarker,
allPlayersSelected,
getActionCard,
getLeftPlayer,
getPlayersFromMarker,
getPlayersInSeatOrder,
getRightPlayer,
getSelectedAction,
getToolOf,
type ActionKind,
} from "./components";
export interface RoundProposals {
fetchWater: boolean;
exchange: boolean;
drink: boolean;
chant: boolean;
}
export function beginSelectionPhase(world: World): void {
const state = world.getSingleton(GameState);
if (state.phase !== "gameOver") {
state.phase = "selecting";
state.message = `Round ${world.getSingleton(Table).round}: choose an action card.`;
}
}
export function selectAction(
world: World,
player: Entity,
kind: ActionKind,
): void {
const state = world.getSingleton(GameState);
if (state.phase !== "selecting") return;
if (!world.has(player, Player)) throw new Error("Invalid player");
const card = getActionCard(world, player, kind);
if (!card) throw new Error("Player does not have that action card");
const cardData = world.get(card, ActionCard);
if (cardData.zone === "cooldown") {
throw new Error("Cannot select the action card played last round");
}
for (const other of world.query(query(ActionCard))) {
const data = world.get(other, ActionCard);
if (data.owner === player && data.zone === "selected") {
data.zone = "hand";
}
}
cardData.zone = "selected";
if (allPlayersSelected(world)) {
state.message = "All players selected. Resolving round.";
}
}
export function collectProposals(world: World): RoundProposals {
const proposals: RoundProposals = {
fetchWater: false,
exchange: false,
drink: false,
chant: false,
};
for (const player of getPlayersInSeatOrder(world)) {
const action = getSelectedAction(world, player);
if (!action || action === "rest") continue;
proposals[action] = true;
}
return proposals;
}
export function resolveRound(world: World): void {
const state = world.getSingleton(GameState);
if (state.phase === "gameOver") return;
state.phase = "resolving";
const proposals = collectProposals(world);
if (proposals.chant) resolveChant(world);
if (proposals.fetchWater) resolveFetchWater(world);
if (proposals.exchange) resolveExchange(world);
if (proposals.drink) resolveDrink(world);
if (world.getSingleton(GameState).phase === "gameOver") return;
resolveEndOfRoundTools(world);
checkWinner(world);
}
export function prepareNextRound(world: World): void {
const state = world.getSingleton(GameState);
if (state.phase === "gameOver") return;
for (const card of world.query(query(ActionCard))) {
const data = world.get(card, ActionCard);
if (data.zone === "cooldown") data.zone = "hand";
}
for (const card of world.query(query(ActionCard))) {
const data = world.get(card, ActionCard);
if (data.zone === "selected") data.zone = "cooldown";
}
world.getSingleton(Table).round++;
state.phase = "selecting";
state.message = `Round ${world.getSingleton(Table).round}: choose an action card.`;
}
export function resolveFetchWater(world: World): void {
const table = world.getSingleton(Table);
const players = getPlayersInSeatOrder(world);
const participants = players.filter((player) => {
const action = getSelectedAction(world, player);
const tool = world.get(getToolOf(world, player), Tool);
return (
action !== null && action !== "rest" && CARRYING_TOOLS.has(tool.kind)
);
});
for (const player of participants) {
const toolEntity = getToolOf(world, player);
const tool = world.get(toolEntity, Tool);
let placed = 2;
if (tool.kind === "bucket") placed = 1;
if (tool.kind === "woodenBucket" && participants.length > 1) placed = 0;
const playerData = world.get(player, Player);
if (playerData.water >= placed) {
playerData.water -= placed;
tool.water += placed;
}
}
table.centralWater += players.length - participants.length;
for (const player of participants) {
const tool = world.get(getToolOf(world, player), Tool);
if (tool.kind === "ladle" && table.centralWater > 0) {
table.centralWater--;
tool.water++;
}
}
}
export function resolveExchange(world: World): void {
const participants = getPlayersInSeatOrder(world).filter(
(player) => getSelectedAction(world, player) !== "chant",
);
if (participants.length <= 1) return;
const table = world.getSingleton(Table);
const tools = participants.map((player) => getToolOf(world, player));
for (const toolEntity of tools) {
const tool = world.get(toolEntity, Tool);
if (tool.kind === "shoulderPole" && tool.water > 0) {
table.centralWater += tool.water;
tool.water = 0;
}
}
for (let i = 0; i < participants.length; i++) {
const giverTool = tools[i];
const receiver = participants[(i + 1) % participants.length];
world.get(giverTool, Tool).owner = receiver;
}
}
export function resolveDrink(world: World): void {
for (const player of getPlayersFromMarker(world)) {
if (!canParticipateInDrink(world, player)) continue;
const table = world.getSingleton(Table);
const playerData = world.get(player, Player);
const tool = world.get(getToolOf(world, player), Tool);
let drankFromTool = 0;
if (table.centralWater > 0) {
table.centralWater--;
playerData.water++;
}
const maxFromTool =
tool.kind === "bigBucket" ? tool.water : Math.min(2, tool.water);
if (maxFromTool > 0) {
tool.water -= maxFromTool;
playerData.water += maxFromTool;
drankFromTool = maxFromTool;
}
if (tool.kind === "waterJar") {
playerData.water++;
}
if (
tool.kind === "bigBowl" &&
drankFromTool === 0 &&
table.centralWater > 0
) {
table.centralWater--;
tool.water++;
}
if (playerData.water >= 10) {
setWinner(world, player);
return;
}
}
}
export function resolveChant(world: World): void {
const chanters = getPlayersFromMarker(world).filter(
(player) => getSelectedAction(world, player) === "chant",
);
if (chanters.length === 0) return;
world.getSingleton(WoodenFishMarker).holder = chanters[chanters.length - 1];
for (const player of chanters) {
const toolEntity = getToolOf(world, player);
const tool = world.get(toolEntity, Tool);
if (tool.kind === "bottle") {
tool.water++;
} else if (tool.kind === "mouse") {
moveOneNeighborWaterTo(world, player, toolEntity);
}
}
}
export function resolveEndOfRoundTools(world: World): void {
for (const toolEntity of world.query(query(Tool))) {
const tool = world.get(toolEntity, Tool);
if (tool.kind === "woodenFish") {
world.getSingleton(WoodenFishMarker).holder = tool.owner;
return;
}
}
}
export function checkWinner(world: World): Entity | null {
for (const player of getPlayersFromMarker(world)) {
if (world.get(player, Player).water >= 10) {
setWinner(world, player);
return player;
}
}
return null;
}
function canParticipateInDrink(world: World, player: Entity): boolean {
const tool = world.get(getToolOf(world, player), Tool);
if (tool.kind !== "waterJar") return true;
return getSelectedAction(world, player) === "drink";
}
function moveOneNeighborWaterTo(
world: World,
player: Entity,
destinationTool: Entity,
): void {
const neighbors = [
getLeftPlayer(world, player),
getRightPlayer(world, player),
];
for (const neighbor of neighbors) {
const neighborToolEntity = getToolOf(world, neighbor);
const neighborTool = world.get(neighborToolEntity, Tool);
if (neighborTool.water > 0) {
neighborTool.water--;
world.get(destinationTool, Tool).water++;
return;
}
}
}
function setWinner(world: World, player: Entity): void {
const state = world.getSingleton(GameState);
state.phase = "gameOver";
state.winner = player;
state.message = `${world.get(player, Player).name} wins!`;
}

View File

@ -0,0 +1,129 @@
import { World, query, type Entity } from "../../src/index";
import {
ActionCard,
GameState,
Player,
Table,
setupGame,
getPlayersInSeatOrder,
type ActionKind,
} from "./components";
import {
beginSelectionPhase,
checkWinner,
collectProposals,
prepareNextRound,
resolveChant,
resolveDrink,
resolveEndOfRoundTools,
resolveExchange,
resolveFetchWater,
selectAction,
} from "./rules";
function mulberry32(seed: number): () => number {
let state = seed >>> 0;
return () => {
state += 0x6d2b79f5;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function availableActions(world: World, player: Entity): ActionKind[] {
const actions: ActionKind[] = [];
for (const card of world.query(query(ActionCard))) {
const data = world.get(card, ActionCard);
if (data.owner === player && data.zone === "hand") {
actions.push(data.kind);
}
}
return actions;
}
function pickRandom<T>(items: readonly T[], random: () => number): T {
return items[Math.floor(random() * items.length)];
}
interface GameStats {
rounds: number;
waterDiff: number;
}
function runSingleGame(seed: number): GameStats {
const random = mulberry32(seed);
const world = new World();
const playerNames = ["慧空", "明心", "了尘", "净远"];
const players = setupGame(world, playerNames, random);
let roundsPlayed = 0;
const maxRounds = 1000;
while (world.getSingleton(GameState).phase !== "gameOver") {
if (roundsPlayed >= maxRounds) {
throw new Error(`Game did not finish within ${maxRounds} rounds`);
}
beginSelectionPhase(world);
for (const player of players) {
const actions = availableActions(world, player);
const action = pickRandom(actions, random);
selectAction(world, player, action);
}
const proposals = collectProposals(world);
if (proposals.chant) resolveChant(world);
if (proposals.fetchWater) resolveFetchWater(world);
if (proposals.exchange) resolveExchange(world);
if (proposals.drink) resolveDrink(world);
if (world.getSingleton(GameState).phase === "gameOver") break;
resolveEndOfRoundTools(world);
checkWinner(world);
if (world.getSingleton(GameState).phase === "gameOver") break;
prepareNextRound(world);
roundsPlayed++;
}
const finalRound = world.getSingleton(Table).round;
const waterCounts = players.map((p) => world.get(p, Player).water);
const maxWater = Math.max(...waterCounts);
const minWater = Math.min(...waterCounts);
return {
rounds: finalRound,
waterDiff: maxWater - minWater,
};
}
export function runSimulation(gameCount = 100, startSeed = 20260701): void {
let totalRounds = 0;
let totalWaterDiff = 0;
console.log(`开始模拟 ${gameCount} 局游戏...`);
for (let i = 0; i < gameCount; i++) {
const seed = startSeed + i;
const stats = runSingleGame(seed);
totalRounds += stats.rounds;
totalWaterDiff += stats.waterDiff;
}
const avgRounds = totalRounds / gameCount;
const avgWaterDiff = totalWaterDiff / gameCount;
console.log("\n================ 统计结果 ================");
console.log(`模拟总局数: ${gameCount}`);
console.log(`结束时平均轮数: ${avgRounds.toFixed(2)}`);
console.log(`结束时首尾玩家平均水差距: ${avgWaterDiff.toFixed(2)} 口水`);
console.log("==========================================");
}
if (process.argv[1]?.endsWith("simulate.ts")) {
runSimulation();
}

274
test/three-monks.test.ts Normal file
View File

@ -0,0 +1,274 @@
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,
CARRYING_TOOLS,
GameState,
Player,
Table,
Tool,
WoodenFishMarker,
getActionCard,
getPlayersInSeatOrder,
getSelectedAction,
getToolOf,
setupGame,
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,
resolveChant,
resolveEndOfRoundTools,
resolveDrink,
resolveExchange,
resolveFetchWater,
selectAction,
} from "../examples/three-monks/rules";
function setup(names = ["A", "B", "C"]): { world: World; players: Entity[] } {
const world = new World();
const players = setupGame(world, names);
return { world, players };
}
function setTool(
world: World,
player: Entity,
kind: ToolKind,
water = 0,
): Entity {
const tool = getToolOf(world, player);
world.set(tool, Tool, { owner: player, kind, water });
return tool;
}
function selectAll(world: World, actions: readonly ActionKind[]): void {
const players = getPlayersInSeatOrder(world);
for (let i = 0; i < players.length; i++) {
selectAction(world, players[i], actions[i]);
}
}
describe("Three Monks random logging", () => {
it("generates a complete deterministic random playthrough log", () => {
const result = generateRandomPlayLog({ seed: 20260701 });
expect(result.log).toContain("三个和尚随机对局");
expect(result.log).toContain("游戏结束");
expect(result.log).toContain("胜者:慧空");
expect(result.roundsPlayed).toBe(14);
});
});
describe("Three Monks setup", () => {
it("requires 3-8 players", () => {
expect(() => setupGame(new World(), ["A", "B"])).toThrow();
expect(() =>
setupGame(new World(), ["1", "2", "3", "4", "5", "6", "7", "8", "9"]),
).toThrow();
});
it("creates players, action cards, tools, table state, and wooden fish marker", () => {
const { world, players } = setup();
expect(players).toHaveLength(3);
expect([...world.query(query(Player))]).toHaveLength(3);
expect(players.map((player) => world.get(player, Player).water)).toEqual([
2, 2, 2,
]);
expect([...world.query(query(ActionCard))]).toHaveLength(15);
const tools = [...world.query(query(Tool))].map(
(tool) => world.get(tool, Tool).kind,
);
expect(tools).toHaveLength(3);
expect(tools.filter((kind) => CARRYING_TOOLS.has(kind))).toHaveLength(2);
expect(world.hasSingleton(Table)).toBe(true);
expect(world.hasSingleton(GameState)).toBe(true);
expect(players).toContain(world.getSingleton(WoodenFishMarker).holder);
});
});
describe("Three Monks action selection", () => {
it("selects an available card and prevents selecting cooldown", () => {
const { world, players } = setup();
selectAction(world, players[0], "fetchWater");
expect(getSelectedAction(world, players[0])).toBe("fetchWater");
prepareNextRound(world);
const card = getActionCard(world, players[0], "fetchWater")!;
expect(world.get(card, ActionCard).zone).toBe("cooldown");
expect(() => selectAction(world, players[0], "fetchWater")).toThrow();
selectAction(world, players[0], "rest");
expect(getSelectedAction(world, players[0])).toBe("rest");
});
it("command selection notifies gameflow and completes the round once 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();
}
for (const player of players) {
const rest = getActionCard(world, player, "rest")!;
expect(world.get(rest, ActionCard).zone).toBe("cooldown");
}
expect(world.getSingleton(Table).round).toBe(2);
});
});
describe("Three Monks phase rules", () => {
it("fetch water moves player water to carrying tools and applies modifiers", () => {
const { world, players } = setup();
world.get(players[0], Player).water = 1;
setTool(world, players[0], "bucket");
setTool(world, players[1], "woodenBucket");
setTool(world, players[2], "ladle");
selectAll(world, ["fetchWater", "fetchWater", "rest"]);
resolveFetchWater(world);
expect(world.get(players[0], Player).water).toBe(0);
expect(world.get(getToolOf(world, players[0]), Tool).water).toBe(1);
expect(world.get(getToolOf(world, players[1]), Tool).water).toBe(0);
expect(world.get(getToolOf(world, players[2]), Tool).water).toBe(0);
expect(world.getSingleton(Table).centralWater).toBe(1);
});
it("fetch water does not move water when the player cannot pay", () => {
const { world, players } = setup();
world.get(players[0], Player).water = 0;
setTool(world, players[0], "bucket");
selectAll(world, ["fetchWater", "rest", "rest"]);
resolveFetchWater(world);
expect(world.get(players[0], Player).water).toBe(0);
expect(world.get(getToolOf(world, players[0]), Tool).water).toBe(0);
expect(world.getSingleton(Table).centralWater).toBe(2);
});
it("ladle moves one central water after fetching", () => {
const { world, players } = setup();
world.get(players[0], Player).water = 2;
setTool(world, players[0], "ladle");
setTool(world, players[1], "woodenFish");
setTool(world, players[2], "bottle");
selectAll(world, ["fetchWater", "rest", "rest"]);
resolveFetchWater(world);
expect(world.get(players[0], Player).water).toBe(0);
expect(world.get(getToolOf(world, players[0]), Tool).water).toBe(3);
expect(world.getSingleton(Table).centralWater).toBe(1);
});
it("exchange excludes chant proposers and shoulder pole dumps water", () => {
const { world, players } = setup();
const shoulderPole = setTool(world, players[0], "shoulderPole", 3);
const bottle = setTool(world, players[1], "bottle");
const bucket = setTool(world, players[2], "bucket");
selectAll(world, ["exchange", "chant", "exchange"]);
resolveExchange(world);
expect(world.getSingleton(Table).centralWater).toBe(3);
expect(world.get(shoulderPole, Tool).water).toBe(0);
expect(world.get(shoulderPole, Tool).owner).toBe(players[2]);
expect(world.get(bucket, Tool).owner).toBe(players[0]);
expect(world.get(bottle, Tool).owner).toBe(players[1]);
});
it("drink resolves in wooden fish order and first player to 10 wins immediately", () => {
const { world, players } = setup();
world.setSingleton(WoodenFishMarker, { holder: players[1] });
world.getSingleton(Table).centralWater = 3;
world.get(players[1], Player).water = 9;
setTool(world, players[0], "bucket");
setTool(world, players[1], "bucket");
setTool(world, players[2], "bucket");
selectAll(world, ["drink", "drink", "drink"]);
resolveDrink(world);
expect(world.getSingleton(GameState).winner).toBe(players[1]);
expect(world.get(players[2], Player).water).toBe(2);
});
it("water jar participates in drink only when its owner proposed drink", () => {
const { world, players } = setup();
world.getSingleton(Table).centralWater = 3;
setTool(world, players[0], "waterJar");
setTool(world, players[1], "bucket");
setTool(world, players[2], "bucket");
selectAll(world, ["rest", "drink", "drink"]);
resolveDrink(world);
expect(world.get(players[0], Player).water).toBe(2);
expect(world.get(players[1], Player).water).toBe(3);
});
it("big bowl stores central water if the owner did not drink from it", () => {
const { world, players } = setup();
world.setSingleton(WoodenFishMarker, { holder: players[0] });
world.getSingleton(Table).centralWater = 2;
setTool(world, players[0], "bigBowl", 0);
setTool(world, players[1], "bucket");
setTool(world, players[2], "bucket");
selectAll(world, ["drink", "drink", "drink"]);
resolveDrink(world);
expect(world.get(players[0], Player).water).toBe(3);
expect(world.get(getToolOf(world, players[0]), Tool).water).toBe(1);
expect(world.getSingleton(Table).centralWater).toBe(0);
});
it("chant gives marker to last chanter and applies bottle/mouse effects", () => {
const { world, players } = setup(["A", "B", "C", "D"]);
world.setSingleton(WoodenFishMarker, { holder: players[0] });
setTool(world, players[0], "bucket", 1);
setTool(world, players[1], "bottle", 0);
const mouse = setTool(world, players[2], "mouse", 0);
setTool(world, players[3], "bucket", 1);
selectAll(world, ["rest", "chant", "chant", "rest"]);
resolveChant(world);
expect(world.getSingleton(WoodenFishMarker).holder).toBe(players[2]);
expect(world.get(getToolOf(world, players[1]), Tool).water).toBe(1);
expect(world.get(mouse, Tool).water).toBe(1);
});
it("wooden fish tool overrides chant marker at end of round", () => {
const { world, players } = setup();
setTool(world, players[0], "woodenFish");
setTool(world, players[1], "bottle");
setTool(world, players[2], "bucket");
selectAll(world, ["rest", "chant", "rest"]);
resolveChant(world);
expect(world.getSingleton(WoodenFishMarker).holder).toBe(players[1]);
resolveEndOfRoundTools(world);
expect(world.getSingleton(WoodenFishMarker).holder).toBe(players[0]);
});
});