diff --git a/examples/three-monks/commands.ts b/examples/three-monks/commands.ts new file mode 100644 index 0000000..b45d33e --- /dev/null +++ b/examples/three-monks/commands.ts @@ -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; +} diff --git a/examples/three-monks/components.ts b/examples/three-monks/components.ts new file mode 100644 index 0000000..44c8232 --- /dev/null +++ b/examples/three-monks/components.ts @@ -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([ + "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(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; +} diff --git a/examples/three-monks/design.md b/examples/three-monks/design.md index 1f67706..f438888 100644 --- a/examples/three-monks/design.md +++ b/examples/three-monks/design.md @@ -13,9 +13,12 @@ 每轮游戏进行以下阶段: - 出牌阶段:每名玩家各选择一张手牌扣下,同时翻开。将上一轮打出的牌收回手牌。 - 行动阶段:依次进行有玩家提议进行的行动阶段。 + - 念经阶段: + - 参与玩家:提议念经的玩家。 + - 将木鱼标记交给最后一名念经的玩家。 - 挑水阶段: - 参与玩家:持有挑水道具,且没有休息的玩家。 - - 每名参与玩家将 2 个水标记放在挑水道具上。 + - 每名参与玩家将 2 个自己的水标记放在挑水道具上;若水不足,则不放。 - 从供应堆拿取玩家人数减去参与玩家数的水标记,放入桌面中央。 - 交换阶段: - 参与玩家:没有提议念经的玩家。 @@ -25,21 +28,19 @@ - 参与玩家:所有玩家。 - 每名参与玩家依次从桌面中央获得 1 个水标记。 - 喝水时,可以从自己面前的道具上获得最多 2 个水标记。 - - 念经阶段: - - 参与玩家:提议念经的玩家。 - - 将木鱼标记交给第一名念经的玩家。 -游戏开始时,随机挑选一名玩家获得木鱼标记。 +游戏开始时,每名玩家获得 2 个水标记,然后随机挑选一名玩家获得木鱼标记。 所有的结算从持有木鱼标记的玩家开始顺时针依次进行。 ## 规则细化 -- 行动阶段按固定顺序结算:挑水 → 交换 → 喝水 → 念经。只要任意玩家提议某阶段,该阶段本轮会结算一次。 +- 行动阶段按固定顺序结算:念经 → 挑水 → 交换 → 喝水。只要任意玩家提议某阶段,该阶段本轮会结算一次。 - 出牌时,上一轮打出的行动牌本轮不可选择;所有玩家完成本轮选择后,上一轮行动牌回到手牌,本轮行动牌成为下一轮不可选择的牌。 -- 游戏开始时洗混 10 张道具牌,每名玩家随机获得 1 张;未使用的道具牌不进入本局。 +- 游戏开始时洗混 10 张道具牌,每名玩家随机获得 1 张;未使用的道具牌不进入本局。发给玩家的道具牌中,挑水道具始终占一半;玩家人数为奇数时,挑水道具数量向上取整。 - 喝水阶段中,玩家按木鱼标记开始的顺时针顺序逐个结算。玩家喝水后若达到 10 口水,立即获胜;若多人同阶段可能达到 10,结算顺序靠前者先胜利。 - 木鱼道具在一轮结束时将木鱼标记交给其拥有者,会覆盖本轮念经阶段得到的木鱼标记。 +- 挑水时,水从参与玩家已喝到的水移动到其挑水道具上;若玩家的水不足以支付需要放置的数量,则本次不移动水,因此该玩家和其道具上的水总量不变。 - 净瓶的水来自供应堆。 - 老鼠在念经时总共移动 1 个水标记:从左右相邻玩家任一有水的道具上移动 1 个水到老鼠上。 - 大碗在其拥有者喝水时,若本次没有从大碗上喝到水,则可从桌面中央将 1 个水标记放到大碗上。 @@ -72,3 +73,5 @@ - `components.ts`: 游戏数据与状态 - `commands.ts`: 玩家命令与规则命令实现 - `gameflow.ts`: 规则流程行为树 +- `logging.ts`: 日志输出 +- `simulate.ts`: 对局模拟与统计 diff --git a/examples/three-monks/gameflow.ts b/examples/three-monks/gameflow.ts new file mode 100644 index 0000000..3bdd723 --- /dev/null +++ b/examples/three-monks/gameflow.ts @@ -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(); + }, + }; +} diff --git a/examples/three-monks/logging.ts b/examples/three-monks/logging.ts new file mode 100644 index 0000000..88179dd --- /dev/null +++ b/examples/three-monks/logging.ts @@ -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 = { + fetchWater: "挑水", + exchange: "交换", + drink: "喝水", + chant: "念经", + rest: "休息", +}; + +export const TOOL_LABELS: Record = { + 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; +} diff --git a/examples/three-monks/random-playthrough.log b/examples/three-monks/random-playthrough.log new file mode 100644 index 0000000..b74d885 --- /dev/null +++ b/examples/three-monks/random-playthrough.log @@ -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) | 行动=休息 diff --git a/examples/three-monks/random-playthrough.ts b/examples/three-monks/random-playthrough.ts new file mode 100644 index 0000000..4c65aa2 --- /dev/null +++ b/examples/three-monks/random-playthrough.ts @@ -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(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); +} diff --git a/examples/three-monks/rules.ts b/examples/three-monks/rules.ts new file mode 100644 index 0000000..781d2d4 --- /dev/null +++ b/examples/three-monks/rules.ts @@ -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!`; +} diff --git a/examples/three-monks/simulate.ts b/examples/three-monks/simulate.ts new file mode 100644 index 0000000..555a420 --- /dev/null +++ b/examples/three-monks/simulate.ts @@ -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(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(); +} diff --git a/test/three-monks.test.ts b/test/three-monks.test.ts new file mode 100644 index 0000000..2235629 --- /dev/null +++ b/test/three-monks.test.ts @@ -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]); + }); +});