2026-07-01 15:44:37 +08:00
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
|
import { World, query, type Entity } from "../src/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 { 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");
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-01 16:25:41 +08:00
|
|
|
it("behavior tree completes a round after all players select", () => {
|
2026-07-01 15:44:37 +08:00
|
|
|
const { world, players } = setup();
|
|
|
|
|
const flow = createThreeMonksFlow(world);
|
|
|
|
|
|
|
|
|
|
flow.start();
|
|
|
|
|
flow.runner.tick();
|
|
|
|
|
|
|
|
|
|
for (const player of players) {
|
2026-07-01 16:25:41 +08:00
|
|
|
selectAction(world, player, "rest");
|
|
|
|
|
flow.notifySelectionChanged();
|
|
|
|
|
flow.runner.tick();
|
2026-07-01 15:44:37 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
});
|
|
|
|
|
});
|