Compare commits
6
Commits
1253bf82d2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b038388deb | ||
|
|
6fa1f511bd | ||
|
|
572b473089 | ||
|
|
0eb3defc62 | ||
|
|
9bf0e5e049 | ||
|
|
204b9100e6 |
@@ -397,37 +397,44 @@ Behaviour trees control game flow by composing tasks into a tree. Each node in t
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { defineComponent, entity } from "ecs-observable";
|
import { defineComponent, entity } from "ecs-observable";
|
||||||
import { buildTree, Cancel, leaf, parallel, repeat, sequential } from "ecs-observable/bt";
|
import { buildTree, Cancel, action, wait, parallel, cycle, whilst, sequential } from "ecs-observable/bt";
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Leaf patterns
|
#### Task patterns
|
||||||
|
|
||||||
**One-shot** — just return. Implicit success.
|
**Action** — runs immediately. Normal return = success.
|
||||||
```ts
|
```ts
|
||||||
leaf(() => { doWork(); })
|
action(() => { doWork(); })
|
||||||
```
|
```
|
||||||
|
|
||||||
**Fail** — throw any error.
|
**Fail** — throw any error.
|
||||||
```ts
|
```ts
|
||||||
leaf(() => { throw new Error("bad"); })
|
action(() => { throw new Error("bad"); })
|
||||||
```
|
```
|
||||||
|
|
||||||
**Cancel** — throw the `Cancel` symbol.
|
**Cancel** — throw the `Cancel` symbol.
|
||||||
```ts
|
```ts
|
||||||
leaf(() => { throw Cancel; })
|
action(() => { throw Cancel; })
|
||||||
```
|
```
|
||||||
|
|
||||||
**Ongoing** — generator function. Each `yield` suspends until next tick. The yielded value is the delay in ms (or nothing for next frame). Completion = success.
|
**Wait** — starts once and stays running until external code completes it.
|
||||||
```ts
|
```ts
|
||||||
leaf(function* tickTimer() {
|
wait((world, entity, task) => {
|
||||||
while (true) {
|
startAnimation(() => task.succeed());
|
||||||
const dt: number = yield; // delta time from runner.tick(dt)
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Whilst** — runs its child while a condition is true, yielding at tick boundaries between successful iterations.
|
||||||
|
```ts
|
||||||
|
whilst(
|
||||||
|
() => true,
|
||||||
|
action((_world, _entity, dt) => {
|
||||||
timer.accumulator += dt;
|
timer.accumulator += dt;
|
||||||
if (timer.accumulator >= timer.interval) {
|
if (timer.accumulator >= timer.interval) {
|
||||||
// ... act ...
|
// ... act ...
|
||||||
}
|
}
|
||||||
}
|
}),
|
||||||
})
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Composite nodes
|
#### Composite nodes
|
||||||
@@ -437,7 +444,8 @@ sequential([a, b, c]) // left-to-right, all must succeed
|
|||||||
selector([a, b, c]) // left-to-right, first success wins
|
selector([a, b, c]) // left-to-right, first success wins
|
||||||
parallel([a, b, c]) // all at once, all must succeed
|
parallel([a, b, c]) // all at once, all must succeed
|
||||||
random([a, b, c]) // pick one child each activation
|
random([a, b, c]) // pick one child each activation
|
||||||
repeat(a) // decorator — re-run child forever
|
cycle(a) // scheduling boundary — re-run child on future ticks
|
||||||
|
whilst(test, a) // conditional scheduling boundary
|
||||||
```
|
```
|
||||||
|
|
||||||
Non-task entity children are materialized but ignored by the runner:
|
Non-task entity children are materialized but ignored by the runner:
|
||||||
@@ -447,8 +455,8 @@ const Label = defineComponent("label", { value: "" });
|
|||||||
|
|
||||||
sequential([
|
sequential([
|
||||||
entity(Label, { value: "main sequence" }),
|
entity(Label, { value: "main sequence" }),
|
||||||
leaf(handleInput),
|
action(handleInput),
|
||||||
leaf(render),
|
action(render),
|
||||||
])
|
])
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -458,16 +466,16 @@ sequential([
|
|||||||
const runner = buildTree(
|
const runner = buildTree(
|
||||||
world,
|
world,
|
||||||
parallel([
|
parallel([
|
||||||
leaf(function* physicsLoop() {
|
whilst(
|
||||||
while (true) {
|
() => true,
|
||||||
const dt: number = yield;
|
action((_world, _entity, dt) => {
|
||||||
updatePhysics(dt);
|
updatePhysics(dt);
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
repeat(
|
),
|
||||||
|
cycle(
|
||||||
sequential([
|
sequential([
|
||||||
leaf(() => { handleInput(); }),
|
action(() => { handleInput(); }),
|
||||||
leaf(() => { render(); }),
|
action(() => { render(); }),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|||||||
+15
-14
@@ -3,11 +3,11 @@
|
|||||||
// Architecture:
|
// Architecture:
|
||||||
// Behaviour Tree (buildTree) — controls game flow:
|
// Behaviour Tree (buildTree) — controls game flow:
|
||||||
// parallel
|
// parallel
|
||||||
// ├── dealerPlay (leaf) — generator loop, auto-plays dealer hand
|
// ├── dealerPlay (action) — generator loop, auto-plays dealer hand
|
||||||
// └── repeat
|
// └── cycle
|
||||||
// └── seq (sequential)
|
// └── seq (sequential)
|
||||||
// ├── handleInput (leaf) — reads queued commands
|
// ├── handleInput (action) — reads queued commands
|
||||||
// └── render (leaf) — draws via blessed
|
// └── render (action) — draws via blessed
|
||||||
//
|
//
|
||||||
// CommandQueue — processes input:
|
// CommandQueue — processes input:
|
||||||
// Keyboard → spawn command entities → CommandQueue.execute()
|
// Keyboard → spawn command entities → CommandQueue.execute()
|
||||||
@@ -25,10 +25,11 @@
|
|||||||
import { World } from "../../src/index";
|
import { World } from "../../src/index";
|
||||||
import {
|
import {
|
||||||
buildTree,
|
buildTree,
|
||||||
leaf,
|
action,
|
||||||
parallel,
|
parallel,
|
||||||
repeat,
|
cycle,
|
||||||
sequential,
|
sequential,
|
||||||
|
whilst,
|
||||||
} from "../../src/bt/index";
|
} from "../../src/bt/index";
|
||||||
import { CommandQueue } from "../../src/commands/index";
|
import { CommandQueue } from "../../src/commands/index";
|
||||||
|
|
||||||
@@ -72,11 +73,11 @@ registerCommands(world, commands, cards);
|
|||||||
const runner = buildTree(
|
const runner = buildTree(
|
||||||
world,
|
world,
|
||||||
parallel([
|
parallel([
|
||||||
leaf(function* dealerPlay() {
|
whilst(
|
||||||
while (true) {
|
() => true,
|
||||||
yield;
|
action(() => {
|
||||||
const phase = world.getSingleton(GamePhase);
|
const phase = world.getSingleton(GamePhase);
|
||||||
if (phase.phase !== "dealerTurn") continue;
|
if (phase.phase !== "dealerTurn") return;
|
||||||
|
|
||||||
if (dealerShouldHit(cards.getHand(InDealerHand))) {
|
if (dealerShouldHit(cards.getHand(InDealerHand))) {
|
||||||
const cardEntity = cards.drawCard();
|
const cardEntity = cards.drawCard();
|
||||||
@@ -86,14 +87,14 @@ const runner = buildTree(
|
|||||||
} else {
|
} else {
|
||||||
resolveRound(world, cards);
|
resolveRound(world, cards);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
repeat(
|
),
|
||||||
|
cycle(
|
||||||
sequential([
|
sequential([
|
||||||
leaf(() => {
|
action(() => {
|
||||||
commands.execute();
|
commands.execute();
|
||||||
}),
|
}),
|
||||||
leaf(() => {
|
action(() => {
|
||||||
render(world, ui);
|
render(world, ui);
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
|
|||||||
+15
-14
@@ -3,11 +3,11 @@
|
|||||||
// Architecture:
|
// Architecture:
|
||||||
// Behaviour Tree (buildTree) — controls game flow:
|
// Behaviour Tree (buildTree) — controls game flow:
|
||||||
// parallel
|
// parallel
|
||||||
// ├── gravityTick (leaf) — generator loop, auto-drop piece on timer
|
// ├── gravityTick (action) — generator loop, auto-drop piece on timer
|
||||||
// └── repeat
|
// └── cycle
|
||||||
// └── seq (sequential)
|
// └── seq (sequential)
|
||||||
// ├── handleInput (leaf) — reads queued commands
|
// ├── handleInput (action) — reads queued commands
|
||||||
// └── render (leaf) — draws via blessed
|
// └── render (action) — draws via blessed
|
||||||
//
|
//
|
||||||
// CommandQueue — processes input:
|
// CommandQueue — processes input:
|
||||||
// Keyboard → spawn command entities → CommandQueue.execute()
|
// Keyboard → spawn command entities → CommandQueue.execute()
|
||||||
@@ -22,10 +22,11 @@
|
|||||||
import { World } from "../../src/index";
|
import { World } from "../../src/index";
|
||||||
import {
|
import {
|
||||||
buildTree,
|
buildTree,
|
||||||
leaf,
|
action,
|
||||||
parallel,
|
parallel,
|
||||||
repeat,
|
cycle,
|
||||||
sequential,
|
sequential,
|
||||||
|
whilst,
|
||||||
} from "../../src/bt/index";
|
} from "../../src/bt/index";
|
||||||
import { CommandQueue } from "../../src/commands/index";
|
import { CommandQueue } from "../../src/commands/index";
|
||||||
|
|
||||||
@@ -73,11 +74,11 @@ pieces.spawnPiece();
|
|||||||
const runner = buildTree(
|
const runner = buildTree(
|
||||||
world,
|
world,
|
||||||
parallel([
|
parallel([
|
||||||
leaf(function* gravityTick() {
|
whilst(
|
||||||
while (true) {
|
() => true,
|
||||||
const dt: number = yield;
|
action((_world, _entity, dt) => {
|
||||||
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
|
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
const timer = world.getSingleton(TickTimer);
|
const timer = world.getSingleton(TickTimer);
|
||||||
timer.accumulator += dt;
|
timer.accumulator += dt;
|
||||||
@@ -93,14 +94,14 @@ const runner = buildTree(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
repeat(
|
),
|
||||||
|
cycle(
|
||||||
sequential([
|
sequential([
|
||||||
leaf(() => {
|
action(() => {
|
||||||
commands.execute();
|
commands.execute();
|
||||||
}),
|
}),
|
||||||
leaf(() => {
|
action(() => {
|
||||||
render(world, ui);
|
render(world, ui);
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 三个和尚
|
||||||
|
|
||||||
|
三个和尚是 3-8 人进行的卡牌游戏。
|
||||||
|
|
||||||
|
玩家在游戏中扮演挑水生活的和尚。但是挑水的人越多,挑到的水却越少。
|
||||||
|
|
||||||
|
首先喝到 10 口水的玩家赢得游戏胜利。
|
||||||
|
|
||||||
|
## 游戏流程
|
||||||
|
|
||||||
|
游戏按轮进行。
|
||||||
|
|
||||||
|
每轮游戏进行以下阶段:
|
||||||
|
- 出牌阶段:每名玩家各选择一张手牌扣下,同时翻开。将上一轮打出的牌收回手牌。
|
||||||
|
- 行动阶段:依次进行有玩家提议进行的行动阶段。
|
||||||
|
- 念经阶段:
|
||||||
|
- 参与玩家:提议念经的玩家。
|
||||||
|
- 将木鱼标记交给最后一名念经的玩家。
|
||||||
|
- 挑水阶段:
|
||||||
|
- 参与玩家:持有挑水道具,且没有休息的玩家。
|
||||||
|
- 每名参与玩家将 2 个自己的水标记放在挑水道具上;若水不足,则不放。
|
||||||
|
- 从供应堆拿取玩家人数减去参与玩家数的水标记,放入桌面中央。
|
||||||
|
- 交换阶段:
|
||||||
|
- 参与玩家:没有提议念经的玩家。
|
||||||
|
- 每名参与玩家将自己的道具交换给左侧参与玩家。
|
||||||
|
- 道具上的水标记一同交换。
|
||||||
|
- 喝水阶段:
|
||||||
|
- 参与玩家:所有玩家。
|
||||||
|
- 每名参与玩家依次从桌面中央获得 1 个水标记。
|
||||||
|
- 喝水时,可以从自己面前的道具上获得最多 2 个水标记。
|
||||||
|
|
||||||
|
游戏开始时,每名玩家获得 2 个水标记,然后随机挑选一名玩家获得木鱼标记。
|
||||||
|
|
||||||
|
所有的结算从持有木鱼标记的玩家开始顺时针依次进行。
|
||||||
|
|
||||||
|
## 规则细化
|
||||||
|
|
||||||
|
- 行动阶段按固定顺序结算:念经 → 挑水 → 交换 → 喝水。只要任意玩家提议某阶段,该阶段本轮会结算一次。
|
||||||
|
- 出牌时,上一轮打出的行动牌本轮不可选择;所有玩家完成本轮选择后,上一轮行动牌回到手牌,本轮行动牌成为下一轮不可选择的牌。
|
||||||
|
- 游戏开始时洗混 10 张道具牌,每名玩家随机获得 1 张;未使用的道具牌不进入本局。发给玩家的道具牌中,挑水道具始终占一半;玩家人数为奇数时,挑水道具数量向上取整。
|
||||||
|
- 喝水阶段中,玩家按木鱼标记开始的顺时针顺序逐个结算。玩家喝水后若达到 10 口水,立即获胜;若多人同阶段可能达到 10,结算顺序靠前者先胜利。
|
||||||
|
- 木鱼道具在一轮结束时将木鱼标记交给其拥有者,会覆盖本轮念经阶段得到的木鱼标记。
|
||||||
|
- 挑水时,水从参与玩家已喝到的水移动到其挑水道具上;若玩家的水不足以支付需要放置的数量,则本次不移动水,因此该玩家和其道具上的水总量不变。
|
||||||
|
- 净瓶的水来自供应堆。
|
||||||
|
- 老鼠在念经时总共移动 1 个水标记:从左右相邻玩家任一有水的道具上移动 1 个水到老鼠上。
|
||||||
|
- 大碗在其拥有者喝水时,若本次没有从大碗上喝到水,则可从桌面中央将 1 个水标记放到大碗上。
|
||||||
|
- 水缸拥有者只有自己提议喝水时才参与喝水阶段;参与时从供应堆额外获得 1 口水。
|
||||||
|
|
||||||
|
## 游戏配件
|
||||||
|
|
||||||
|
玩家配件(8 组)
|
||||||
|
- 5x 行动牌:
|
||||||
|
- 提议挑水/交换/喝水/念经:若有任何玩家打出,本轮会进行此阶段。
|
||||||
|
- 休息:不参与挑水阶段。
|
||||||
|
|
||||||
|
公共配件:
|
||||||
|
- 若干水标记物
|
||||||
|
- 木鱼标记
|
||||||
|
- 10x 道具牌
|
||||||
|
- 木鱼:一轮结束时,获得木鱼标记。
|
||||||
|
- 水桶:挑水道具。挑水时只需将 1 个自己的水放在桶里。
|
||||||
|
- 净瓶:念经时,在净瓶上放一个水标记。
|
||||||
|
- 木桶:挑水道具。若有其他玩家参与挑水,无需将水放在桶里。
|
||||||
|
- 大碗:喝水时若未从大碗里喝到水,可从桌面中央将 1 个水标记放在大碗上。
|
||||||
|
- 大桶:挑水道具。喝水时可从桶里喝任意口水。
|
||||||
|
- 老鼠:念经时,可从两侧玩家的道具上将一个水移动到老鼠上。
|
||||||
|
- 扁担:挑水道具。被交换时,将扁担上的水放回桌面中央。
|
||||||
|
- 水缸:喝水时从供应堆额外获得一口水。必须自己提议喝水才能参与喝水。
|
||||||
|
- 水瓢:挑水道具。挑水后,从中央将一个水放在瓢里。
|
||||||
|
|
||||||
|
## 设计实现
|
||||||
|
|
||||||
|
- `components.ts`: 游戏数据与状态
|
||||||
|
- `gameflow.ts`: 规则流程行为树
|
||||||
|
- `rules.ts`: 规则引擎(阶段包装器 + 核心结算函数)
|
||||||
|
- `logging.ts`: 日志输出
|
||||||
|
- `simulate.ts`: 对局模拟与统计
|
||||||
|
- `random-playthrough.ts`: 单局详细回放
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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,
|
||||||
|
chantPhase,
|
||||||
|
exchangePhase,
|
||||||
|
fetchWaterPhase,
|
||||||
|
drinkPhase,
|
||||||
|
endOfRoundPhase,
|
||||||
|
prepareNextRound,
|
||||||
|
} 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) => chantPhase(world)),
|
||||||
|
action((world) => fetchWaterPhase(world)),
|
||||||
|
action((world) => exchangePhase(world)),
|
||||||
|
action((world) => drinkPhase(world)),
|
||||||
|
action((world) => endOfRoundPhase(world)),
|
||||||
|
action((world) => prepareNextRound(world)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
runner,
|
||||||
|
start() {
|
||||||
|
runner.schedule(runner.root!);
|
||||||
|
},
|
||||||
|
notifySelectionChanged() {
|
||||||
|
completeSelectionIfReady();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) | 行动=休息
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { World, query, type Entity } from "../../src/index";
|
||||||
|
import {
|
||||||
|
ActionCard,
|
||||||
|
GameState,
|
||||||
|
Player,
|
||||||
|
Table,
|
||||||
|
setupGame,
|
||||||
|
type ActionKind,
|
||||||
|
} from "./components";
|
||||||
|
import {
|
||||||
|
beginSelectionPhase,
|
||||||
|
collectProposals,
|
||||||
|
prepareNextRound,
|
||||||
|
selectAction,
|
||||||
|
chantPhase,
|
||||||
|
fetchWaterPhase,
|
||||||
|
exchangePhase,
|
||||||
|
drinkPhase,
|
||||||
|
endOfRoundPhase,
|
||||||
|
} 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)}`);
|
||||||
|
|
||||||
|
applyLoggedPhase(log, world, "念经", () => chantPhase(world));
|
||||||
|
applyLoggedPhase(log, world, "挑水", () => fetchWaterPhase(world));
|
||||||
|
applyLoggedPhase(log, world, "交换", () => exchangePhase(world));
|
||||||
|
applyLoggedPhase(log, world, "喝水", () => drinkPhase(world));
|
||||||
|
if (world.getSingleton(GameState).phase === "gameOver") break;
|
||||||
|
|
||||||
|
applyLoggedPhase(log, world, "轮末道具", () => endOfRoundPhase(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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import type { Entity, World } from "../../src/index";
|
||||||
|
import { query } from "../../src/query";
|
||||||
|
import type { ToolEffectRecorder } from "./stats";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProposed(world: World, kind: ActionKind): boolean {
|
||||||
|
for (const player of getPlayersInSeatOrder(world)) {
|
||||||
|
if (getSelectedAction(world, player) === kind) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGameOver(world: World): boolean {
|
||||||
|
return world.getSingleton(GameState).phase === "gameOver";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase wrappers (each checks proposal + gameOver, then delegates) ──
|
||||||
|
|
||||||
|
export function chantPhase(world: World, stats?: ToolEffectRecorder): void {
|
||||||
|
if (isGameOver(world) || !isProposed(world, "chant")) return;
|
||||||
|
resolveChant(world, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchWaterPhase(
|
||||||
|
world: World,
|
||||||
|
stats?: ToolEffectRecorder,
|
||||||
|
): void {
|
||||||
|
if (isGameOver(world) || !isProposed(world, "fetchWater")) return;
|
||||||
|
resolveFetchWater(world, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exchangePhase(world: World, stats?: ToolEffectRecorder): void {
|
||||||
|
if (isGameOver(world) || !isProposed(world, "exchange")) return;
|
||||||
|
resolveExchange(world, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drinkPhase(world: World, stats?: ToolEffectRecorder): void {
|
||||||
|
if (isGameOver(world) || !isProposed(world, "drink")) return;
|
||||||
|
resolveDrink(world, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endOfRoundPhase(
|
||||||
|
world: World,
|
||||||
|
stats?: ToolEffectRecorder,
|
||||||
|
): void {
|
||||||
|
if (isGameOver(world)) return;
|
||||||
|
resolveEndOfRoundTools(world, stats);
|
||||||
|
checkWinner(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
stats?: ToolEffectRecorder,
|
||||||
|
): 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;
|
||||||
|
stats?.record(tool.kind, "挑水", {
|
||||||
|
playerWaterDelta: -placed,
|
||||||
|
ownToolWaterDelta: placed,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
stats?.record(tool.kind, "挑水", {}, "水不足,未放水");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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++;
|
||||||
|
stats?.record(tool.kind, "挑水", {
|
||||||
|
ownToolWaterDelta: 1,
|
||||||
|
centralWaterDelta: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveExchange(
|
||||||
|
world: World,
|
||||||
|
stats?: ToolEffectRecorder,
|
||||||
|
): 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) {
|
||||||
|
const dumped = tool.water;
|
||||||
|
table.centralWater += dumped;
|
||||||
|
tool.water = 0;
|
||||||
|
stats?.record(tool.kind, "交换", {
|
||||||
|
ownToolWaterDelta: -dumped,
|
||||||
|
centralWaterDelta: dumped,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, stats?: ToolEffectRecorder): 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;
|
||||||
|
stats?.record(tool.kind, "喝水", {
|
||||||
|
playerWaterDelta: maxFromTool,
|
||||||
|
ownToolWaterDelta: -maxFromTool,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.kind === "waterJar") {
|
||||||
|
playerData.water++;
|
||||||
|
stats?.record(tool.kind, "喝水", {
|
||||||
|
playerWaterDelta: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
tool.kind === "bigBowl" &&
|
||||||
|
drankFromTool === 0 &&
|
||||||
|
table.centralWater > 0
|
||||||
|
) {
|
||||||
|
table.centralWater--;
|
||||||
|
tool.water++;
|
||||||
|
stats?.record(tool.kind, "喝水", {
|
||||||
|
ownToolWaterDelta: 1,
|
||||||
|
centralWaterDelta: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerData.water >= 10) {
|
||||||
|
setWinner(world, player);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveChant(world: World, stats?: ToolEffectRecorder): 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++;
|
||||||
|
stats?.record(tool.kind, "念经", {
|
||||||
|
ownToolWaterDelta: 1,
|
||||||
|
});
|
||||||
|
} else if (tool.kind === "mouse") {
|
||||||
|
const moved = moveOneNeighborWaterTo(world, player, toolEntity);
|
||||||
|
if (moved) {
|
||||||
|
stats?.record(tool.kind, "念经", {
|
||||||
|
ownToolWaterDelta: 1,
|
||||||
|
otherToolWaterDelta: -1,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
stats?.record(tool.kind, "念经", {}, "相邻道具无水可偷");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEndOfRoundTools(
|
||||||
|
world: World,
|
||||||
|
stats?: ToolEffectRecorder,
|
||||||
|
): 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;
|
||||||
|
stats?.record(tool.kind, "轮末", {}, "获得木鱼标记");
|
||||||
|
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,
|
||||||
|
): boolean {
|
||||||
|
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 true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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!`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { World, query, type Entity } from "../../src/index";
|
||||||
|
import {
|
||||||
|
ActionCard,
|
||||||
|
GameState,
|
||||||
|
Player,
|
||||||
|
Table,
|
||||||
|
setupGame,
|
||||||
|
type ActionKind,
|
||||||
|
} from "./components";
|
||||||
|
import { ToolEffectStats, formatToolEffectSummaries } from "./stats";
|
||||||
|
import {
|
||||||
|
beginSelectionPhase,
|
||||||
|
prepareNextRound,
|
||||||
|
selectAction,
|
||||||
|
chantPhase,
|
||||||
|
fetchWaterPhase,
|
||||||
|
exchangePhase,
|
||||||
|
drinkPhase,
|
||||||
|
endOfRoundPhase,
|
||||||
|
} 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, stats?: ToolEffectStats): 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
chantPhase(world, stats);
|
||||||
|
fetchWaterPhase(world, stats);
|
||||||
|
exchangePhase(world, stats);
|
||||||
|
drinkPhase(world, stats);
|
||||||
|
if (world.getSingleton(GameState).phase === "gameOver") break;
|
||||||
|
|
||||||
|
endOfRoundPhase(world, stats);
|
||||||
|
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;
|
||||||
|
const toolStats = new ToolEffectStats();
|
||||||
|
|
||||||
|
console.log(`开始模拟 ${gameCount} 局游戏...`);
|
||||||
|
|
||||||
|
for (let i = 0; i < gameCount; i++) {
|
||||||
|
const seed = startSeed + i;
|
||||||
|
const stats = runSingleGame(seed, toolStats);
|
||||||
|
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("==========================================");
|
||||||
|
console.log("\n道具效果统计(总变化量):");
|
||||||
|
for (const line of formatToolEffectSummaries(toolStats)) {
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1]?.endsWith("simulate.ts")) {
|
||||||
|
runSimulation();
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { ToolKind } from "./components";
|
||||||
|
import { TOOL_LABELS } from "./logging";
|
||||||
|
|
||||||
|
export interface ToolEffectDelta {
|
||||||
|
playerWaterDelta?: number;
|
||||||
|
ownToolWaterDelta?: number;
|
||||||
|
otherToolWaterDelta?: number;
|
||||||
|
centralWaterDelta?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolEffectRecord extends Required<ToolEffectDelta> {
|
||||||
|
tool: ToolKind;
|
||||||
|
phase: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolEffectSummary extends Required<ToolEffectDelta> {
|
||||||
|
tool: ToolKind;
|
||||||
|
activations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolEffectRecorder {
|
||||||
|
record(tool: ToolKind, phase: string, delta?: ToolEffectDelta, description?: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ToolEffectStats implements ToolEffectRecorder {
|
||||||
|
private readonly summaries = new Map<ToolKind, ToolEffectSummary>();
|
||||||
|
private readonly records: ToolEffectRecord[] = [];
|
||||||
|
|
||||||
|
record(
|
||||||
|
tool: ToolKind,
|
||||||
|
phase: string,
|
||||||
|
delta: ToolEffectDelta = {},
|
||||||
|
description?: string,
|
||||||
|
): void {
|
||||||
|
const record: ToolEffectRecord = {
|
||||||
|
tool,
|
||||||
|
phase,
|
||||||
|
description,
|
||||||
|
playerWaterDelta: delta.playerWaterDelta ?? 0,
|
||||||
|
ownToolWaterDelta: delta.ownToolWaterDelta ?? 0,
|
||||||
|
otherToolWaterDelta: delta.otherToolWaterDelta ?? 0,
|
||||||
|
centralWaterDelta: delta.centralWaterDelta ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.records.push(record);
|
||||||
|
|
||||||
|
const summary = this.summaries.get(tool) ?? {
|
||||||
|
tool,
|
||||||
|
activations: 0,
|
||||||
|
playerWaterDelta: 0,
|
||||||
|
ownToolWaterDelta: 0,
|
||||||
|
otherToolWaterDelta: 0,
|
||||||
|
centralWaterDelta: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
summary.activations++;
|
||||||
|
summary.playerWaterDelta += record.playerWaterDelta;
|
||||||
|
summary.ownToolWaterDelta += record.ownToolWaterDelta;
|
||||||
|
summary.otherToolWaterDelta += record.otherToolWaterDelta;
|
||||||
|
summary.centralWaterDelta += record.centralWaterDelta;
|
||||||
|
this.summaries.set(tool, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecords(): readonly ToolEffectRecord[] {
|
||||||
|
return this.records;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSummaries(): ToolEffectSummary[] {
|
||||||
|
return [...this.summaries.values()].sort((a, b) =>
|
||||||
|
TOOL_LABELS[a.tool].localeCompare(TOOL_LABELS[b.tool], "zh-Hans-CN"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatToolEffectSummaries(stats: ToolEffectStats): string[] {
|
||||||
|
const summaries = stats.getSummaries();
|
||||||
|
if (summaries.length === 0) return ["无道具效果记录"];
|
||||||
|
|
||||||
|
return [
|
||||||
|
"道具 | 触发次数 | 玩家水变化 | 自身道具水变化 | 其他道具水变化 | 中央水变化",
|
||||||
|
"--- | ---: | ---: | ---: | ---: | ---:",
|
||||||
|
...summaries.map((summary) =>
|
||||||
|
[
|
||||||
|
TOOL_LABELS[summary.tool],
|
||||||
|
summary.activations,
|
||||||
|
summary.playerWaterDelta,
|
||||||
|
summary.ownToolWaterDelta,
|
||||||
|
summary.otherToolWaterDelta,
|
||||||
|
summary.centralWaterDelta,
|
||||||
|
].join(" | "),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
+19
-4
@@ -11,16 +11,31 @@ export {
|
|||||||
export type { TaskKind } from "./task";
|
export type { TaskKind } from "./task";
|
||||||
|
|
||||||
export { TaskRunner } from "./runner";
|
export { TaskRunner } from "./runner";
|
||||||
export type { LeafHandler, TerminalHandler } from "./runner";
|
export type {
|
||||||
|
ActionHandler,
|
||||||
|
WaitHandler,
|
||||||
|
ConditionHandler,
|
||||||
|
TaskControl,
|
||||||
|
TerminalHandler,
|
||||||
|
} from "./runner";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
buildTree,
|
buildTree,
|
||||||
Cancel,
|
Cancel,
|
||||||
leaf,
|
action,
|
||||||
|
wait,
|
||||||
|
whilst,
|
||||||
sequential,
|
sequential,
|
||||||
parallel,
|
parallel,
|
||||||
selector,
|
selector,
|
||||||
random,
|
random,
|
||||||
repeat,
|
cycle,
|
||||||
|
} from "./tree-def";
|
||||||
|
export type {
|
||||||
|
TreeDef,
|
||||||
|
TaskEntityDef,
|
||||||
|
TaskMeta,
|
||||||
|
ActionFn,
|
||||||
|
WaitFn,
|
||||||
|
ConditionFn,
|
||||||
} from "./tree-def";
|
} from "./tree-def";
|
||||||
export type { TreeDef, TaskEntityDef, LeafTaskMeta, LeafFn } from "./tree-def";
|
|
||||||
|
|||||||
+242
-119
@@ -9,11 +9,34 @@ import {
|
|||||||
Cancelled,
|
Cancelled,
|
||||||
TERMINAL_TAGS,
|
TERMINAL_TAGS,
|
||||||
ChildOf,
|
ChildOf,
|
||||||
|
Cancel,
|
||||||
} from "./task";
|
} from "./task";
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────
|
||||||
/** Callback invoked for each leaf task that becomes Scheduled. */
|
/** Control object passed to wait tasks so they can complete themselves. */
|
||||||
export type LeafHandler = (world: World, entity: Entity, dt: number) => void;
|
export interface TaskControl {
|
||||||
|
succeed(): void;
|
||||||
|
fail(): void;
|
||||||
|
cancel(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Callback invoked when an action task starts executing. */
|
||||||
|
export type ActionHandler = (world: World, entity: Entity, dt: number) => void;
|
||||||
|
|
||||||
|
/** Callback invoked when a wait task starts executing. */
|
||||||
|
export type WaitHandler = (
|
||||||
|
world: World,
|
||||||
|
entity: Entity,
|
||||||
|
control: TaskControl,
|
||||||
|
dt: number,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/** Callback invoked by whilst tasks before each iteration. */
|
||||||
|
export type ConditionHandler = (
|
||||||
|
world: World,
|
||||||
|
entity: Entity,
|
||||||
|
dt: number,
|
||||||
|
) => boolean;
|
||||||
|
|
||||||
/** Callback invoked when a task reaches a terminal status. */
|
/** Callback invoked when a task reaches a terminal status. */
|
||||||
export type TerminalHandler = (
|
export type TerminalHandler = (
|
||||||
@@ -68,34 +91,29 @@ function parentOf(world: World, child: Entity): Entity | null {
|
|||||||
/**
|
/**
|
||||||
* Push-based behaviour-tree runner.
|
* Push-based behaviour-tree runner.
|
||||||
*
|
*
|
||||||
* Only tasks tagged with `Scheduled` are processed each tick.
|
* Tasks tagged with `Scheduled` define tick boundaries. Once a scheduled
|
||||||
* When a task finishes, it notifies its parent, which may schedule
|
* task starts, child/parent propagation is immediate until a running wait task
|
||||||
* the next child (sequential), aggregate results (parallel), or
|
* or an explicit scheduling boundary (`cycle`, `whilst`) yields to a future tick.
|
||||||
* propagate upward.
|
|
||||||
*
|
*
|
||||||
* Leaves are dispatched to a user-provided `onLeaf` callback.
|
* Action, wait, and whilst condition callbacks are supplied by `buildTree` or
|
||||||
* Terminal results are dispatched to a user-provided `onTerminal` callback.
|
* assigned directly when using `TaskRunner` manually.
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const runner = new TaskRunner(world);
|
|
||||||
* runner.onLeaf = (world, entity) => {
|
|
||||||
* // do the leaf's work, then call:
|
|
||||||
* runner.succeed(entity);
|
|
||||||
* };
|
|
||||||
*
|
|
||||||
* // Each frame:
|
|
||||||
* runner.tick();
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export class TaskRunner {
|
export class TaskRunner {
|
||||||
private _world: World;
|
private _world: World;
|
||||||
|
private _executing = new Set<Entity>();
|
||||||
|
private _currentDt = 0;
|
||||||
|
|
||||||
/** Root task entity, set by `buildTree` for convenience. */
|
/** Root task entity, set by `buildTree` for convenience. */
|
||||||
root?: Entity;
|
root?: Entity;
|
||||||
|
|
||||||
/** Called when a leaf task becomes Scheduled. */
|
/** Called when an action task starts executing. */
|
||||||
onLeaf: LeafHandler = () => {};
|
onAction: ActionHandler = () => {};
|
||||||
|
|
||||||
|
/** Called when a wait task starts executing. */
|
||||||
|
onWait: WaitHandler = () => {};
|
||||||
|
|
||||||
|
/** Called by whilst tasks before each iteration. */
|
||||||
|
onCondition: ConditionHandler = () => false;
|
||||||
|
|
||||||
/** Called when any task reaches a terminal status. */
|
/** Called when any task reaches a terminal status. */
|
||||||
onTerminal: TerminalHandler = () => {};
|
onTerminal: TerminalHandler = () => {};
|
||||||
@@ -107,36 +125,46 @@ export class TaskRunner {
|
|||||||
// ── Public API ────────────────────────────────────
|
// ── Public API ────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process all Scheduled tasks.
|
* Process tasks scheduled for this tick.
|
||||||
*
|
*
|
||||||
* Call once per frame. Only entities with `Scheduled` are touched.
|
* Call once per frame. Tasks scheduled while a tick is already in progress are
|
||||||
|
* deferred until the next tick boundary, but parent/child propagation caused
|
||||||
|
* by terminal status changes is immediate.
|
||||||
*
|
*
|
||||||
* @param dt Delta time in milliseconds since last tick.
|
* @param dt Delta time in milliseconds since last tick.
|
||||||
*/
|
*/
|
||||||
tick(dt: number = 0): void {
|
tick(dt: number = 0): void {
|
||||||
|
const previousDt = this._currentDt;
|
||||||
|
this._currentDt = dt;
|
||||||
|
|
||||||
|
try {
|
||||||
const scheduled = [...this._world.query(query(Task, Scheduled))];
|
const scheduled = [...this._world.query(query(Task, Scheduled))];
|
||||||
for (const entity of scheduled) {
|
for (const entity of scheduled) {
|
||||||
|
if (!this._world.has(entity, Scheduled)) continue;
|
||||||
this._world.remove(entity, Scheduled);
|
this._world.remove(entity, Scheduled);
|
||||||
this._execute(entity, dt);
|
this._execute(entity, dt);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
this._currentDt = previousDt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark a leaf task as succeeded and propagate upward. */
|
/** Mark a task as succeeded and propagate upward. */
|
||||||
succeed(entity: Entity): void {
|
succeed(entity: Entity): void {
|
||||||
this._finish(entity, Succeeded);
|
this._finish(entity, Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark a leaf task as failed and propagate upward. */
|
/** Mark a task as failed and propagate upward. */
|
||||||
fail(entity: Entity): void {
|
fail(entity: Entity): void {
|
||||||
this._finish(entity, Failed);
|
this._finish(entity, Failed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel a task and all its descendants. */
|
/** Cancel a task and all its descendants. */
|
||||||
cancel(entity: Entity): void {
|
cancel(entity: Entity): void {
|
||||||
this._cancelTree(entity);
|
this._cancelTree(entity, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Schedule a task for execution next tick. */
|
/** Schedule a task for execution at the next tick boundary. */
|
||||||
schedule(entity: Entity): void {
|
schedule(entity: Entity): void {
|
||||||
if (this._world.has(entity, Task)) {
|
if (this._world.has(entity, Task)) {
|
||||||
this._world.add(entity, Scheduled);
|
this._world.add(entity, Scheduled);
|
||||||
@@ -152,83 +180,131 @@ export class TaskRunner {
|
|||||||
// ── Internal execution ────────────────────────────
|
// ── Internal execution ────────────────────────────
|
||||||
|
|
||||||
private _execute(entity: Entity, dt: number): void {
|
private _execute(entity: Entity, dt: number): void {
|
||||||
const t = this._world.get(entity, Task);
|
if (!this._world.has(entity, Task) || this._executing.has(entity)) return;
|
||||||
|
|
||||||
|
const t = this._world.get(entity, Task);
|
||||||
|
this._executing.add(entity);
|
||||||
|
|
||||||
|
try {
|
||||||
switch (t.kind) {
|
switch (t.kind) {
|
||||||
case "leaf":
|
case "action":
|
||||||
this._executeLeaf(entity, dt);
|
this._executeAction(entity, dt);
|
||||||
|
break;
|
||||||
|
case "wait":
|
||||||
|
this._executeWait(entity, dt);
|
||||||
break;
|
break;
|
||||||
case "sequential":
|
case "sequential":
|
||||||
this._executeSequential(entity);
|
this._executeSequential(entity, dt);
|
||||||
break;
|
break;
|
||||||
case "parallel":
|
case "parallel":
|
||||||
this._executeParallel(entity);
|
this._executeParallel(entity, dt);
|
||||||
break;
|
break;
|
||||||
case "random":
|
case "random":
|
||||||
this._executeRandom(entity);
|
this._executeRandom(entity, dt);
|
||||||
break;
|
break;
|
||||||
case "repeat":
|
case "cycle":
|
||||||
this._executeRepeat(entity);
|
this._executeCycle(entity, dt);
|
||||||
|
break;
|
||||||
|
case "whilst":
|
||||||
|
this._executeWhilst(entity, dt);
|
||||||
break;
|
break;
|
||||||
case "selector":
|
case "selector":
|
||||||
this._executeSelector(entity);
|
this._executeSelector(entity, dt);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
this._executing.delete(entity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _executeLeaf(entity: Entity, dt: number): void {
|
private _executeChild(entity: Entity, dt: number): void {
|
||||||
|
if (
|
||||||
|
!this._world.has(entity, Task) ||
|
||||||
|
isTerminal(this._world, entity) ||
|
||||||
|
this._world.has(entity, Running)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._world.has(entity, Scheduled)) {
|
||||||
|
this._world.remove(entity, Scheduled);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._execute(entity, dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _executeAction(entity: Entity, dt: number): void {
|
||||||
this._world.add(entity, Running);
|
this._world.add(entity, Running);
|
||||||
this.onLeaf(this._world, entity, dt);
|
try {
|
||||||
|
this.onAction(this._world, entity, dt);
|
||||||
|
if (!isTerminal(this._world, entity)) {
|
||||||
|
this.succeed(entity);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this._finishFromThrown(entity, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _executeSequential(entity: Entity): void {
|
private _executeWait(entity: Entity, dt: number): void {
|
||||||
const children = childrenOf(this._world, entity);
|
this._world.add(entity, Running);
|
||||||
|
const control: TaskControl = {
|
||||||
|
succeed: () => this.succeed(entity),
|
||||||
|
fail: () => this.fail(entity),
|
||||||
|
cancel: () => this.cancel(entity),
|
||||||
|
};
|
||||||
|
|
||||||
// Find the first non-terminal child
|
try {
|
||||||
for (const child of children) {
|
this.onWait(this._world, entity, control, dt);
|
||||||
if (isTerminal(this._world, child)) {
|
} catch (err) {
|
||||||
const status = terminalStatus(this._world, child)!;
|
this._finishFromThrown(entity, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _executeSequential(entity: Entity, dt: number): void {
|
||||||
|
for (const child of childrenOf(this._world, entity)) {
|
||||||
|
let status = terminalStatus(this._world, child);
|
||||||
if (status === "failed" || status === "cancelled") {
|
if (status === "failed" || status === "cancelled") {
|
||||||
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
|
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// succeeded — continue to next child
|
if (status === "succeeded") continue;
|
||||||
continue;
|
|
||||||
|
this._executeChild(child, dt);
|
||||||
|
|
||||||
|
status = terminalStatus(this._world, child);
|
||||||
|
if (status === "failed" || status === "cancelled") {
|
||||||
|
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (status === "succeeded") continue;
|
||||||
|
|
||||||
// Found a child that hasn't run yet — schedule it
|
|
||||||
this._world.add(child, Scheduled);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All children succeeded
|
|
||||||
this._finish(entity, Succeeded);
|
this._finish(entity, Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _executeParallel(entity: Entity): void {
|
private _executeParallel(entity: Entity, dt: number): void {
|
||||||
const children = childrenOf(this._world, entity);
|
|
||||||
let allDone = true;
|
let allDone = true;
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of childrenOf(this._world, entity)) {
|
||||||
if (isTerminal(this._world, child)) {
|
let status = terminalStatus(this._world, child);
|
||||||
const status = terminalStatus(this._world, child)!;
|
|
||||||
if (status === "failed" || status === "cancelled") {
|
if (status === "failed" || status === "cancelled") {
|
||||||
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
|
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// succeeded — this child is done
|
if (status === "succeeded") continue;
|
||||||
continue;
|
|
||||||
|
this._executeChild(child, dt);
|
||||||
|
|
||||||
|
status = terminalStatus(this._world, child);
|
||||||
|
if (status === "failed" || status === "cancelled") {
|
||||||
|
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (status === "succeeded") continue;
|
||||||
|
|
||||||
allDone = false;
|
allDone = false;
|
||||||
|
|
||||||
// Schedule if not already running or scheduled
|
|
||||||
if (
|
|
||||||
!this._world.has(child, Running) &&
|
|
||||||
!this._world.has(child, Scheduled)
|
|
||||||
) {
|
|
||||||
this._world.add(child, Scheduled);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allDone) {
|
if (allDone) {
|
||||||
@@ -236,12 +312,12 @@ export class TaskRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _executeRandom(entity: Entity): void {
|
private _executeRandom(entity: Entity, dt: number): void {
|
||||||
// Single pass: check terminals and collect eligible children
|
|
||||||
const eligible: Entity[] = [];
|
const eligible: Entity[] = [];
|
||||||
|
|
||||||
for (const child of childrenOf(this._world, entity)) {
|
for (const child of childrenOf(this._world, entity)) {
|
||||||
if (isTerminal(this._world, child)) {
|
const status = terminalStatus(this._world, child);
|
||||||
const status = terminalStatus(this._world, child)!;
|
if (status) {
|
||||||
this._finish(
|
this._finish(
|
||||||
entity,
|
entity,
|
||||||
status === "succeeded"
|
status === "succeeded"
|
||||||
@@ -252,73 +328,116 @@ export class TaskRunner {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (!this._world.has(child, Running)) {
|
||||||
!this._world.has(child, Running) &&
|
|
||||||
!this._world.has(child, Scheduled)
|
|
||||||
) {
|
|
||||||
eligible.push(child);
|
eligible.push(child);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eligible.length > 0) {
|
if (eligible.length === 0) return;
|
||||||
|
|
||||||
const pick = eligible[Math.floor(Math.random() * eligible.length)];
|
const pick = eligible[Math.floor(Math.random() * eligible.length)];
|
||||||
this._world.add(pick, Scheduled);
|
this._executeChild(pick, dt);
|
||||||
|
|
||||||
|
const status = terminalStatus(this._world, pick);
|
||||||
|
if (status) {
|
||||||
|
this._finish(
|
||||||
|
entity,
|
||||||
|
status === "succeeded"
|
||||||
|
? Succeeded
|
||||||
|
: status === "cancelled"
|
||||||
|
? Cancelled
|
||||||
|
: Failed,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// If no eligible children (all running), wait for one to finish
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _executeRepeat(entity: Entity): void {
|
private _executeCycle(entity: Entity, dt: number): void {
|
||||||
const children = [...childrenOf(this._world, entity)];
|
const child = this._firstTaskChild(entity);
|
||||||
|
if (!child) return;
|
||||||
|
|
||||||
// Repeat expects exactly one child
|
this._executeChild(child, dt);
|
||||||
if (children.length === 0) return;
|
|
||||||
|
|
||||||
const child = children[0];
|
|
||||||
|
|
||||||
// If child reached a terminal, reset the entire subtree and schedule again
|
|
||||||
if (isTerminal(this._world, child)) {
|
if (isTerminal(this._world, child)) {
|
||||||
clearSubtree(this._world, child);
|
clearSubtree(this._world, child);
|
||||||
this._world.add(child, Scheduled);
|
this._world.add(entity, Scheduled);
|
||||||
|
}
|
||||||
|
// Cycle itself never terminates — it just creates tick-boundary cycles.
|
||||||
|
}
|
||||||
|
|
||||||
|
private _executeWhilst(entity: Entity, dt: number): void {
|
||||||
|
let condition: boolean;
|
||||||
|
try {
|
||||||
|
condition = this.onCondition(this._world, entity, dt);
|
||||||
|
} catch (err) {
|
||||||
|
this._finishFromThrown(entity, err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule child if not already running or scheduled
|
if (!condition) {
|
||||||
if (
|
|
||||||
!this._world.has(child, Running) &&
|
|
||||||
!this._world.has(child, Scheduled)
|
|
||||||
) {
|
|
||||||
this._world.add(child, Scheduled);
|
|
||||||
}
|
|
||||||
// Repeat itself never terminates — it just keeps the child going
|
|
||||||
}
|
|
||||||
|
|
||||||
private _executeSelector(entity: Entity): void {
|
|
||||||
const children = childrenOf(this._world, entity);
|
|
||||||
|
|
||||||
// Find the first non-terminal child
|
|
||||||
for (const child of children) {
|
|
||||||
if (isTerminal(this._world, child)) {
|
|
||||||
const status = terminalStatus(this._world, child)!;
|
|
||||||
if (status === "succeeded") {
|
|
||||||
// First success — selector succeeds
|
|
||||||
this._finish(entity, Succeeded);
|
this._finish(entity, Succeeded);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// failed or cancelled — continue to next child
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Found a child that hasn't run yet — schedule it
|
const child = this._firstTaskChild(entity);
|
||||||
this._world.add(child, Scheduled);
|
if (!child) {
|
||||||
|
this._finish(entity, Succeeded);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._executeChild(child, dt);
|
||||||
|
|
||||||
|
const status = terminalStatus(this._world, child);
|
||||||
|
if (status === "succeeded") {
|
||||||
|
clearSubtree(this._world, child);
|
||||||
|
this._world.add(entity, Scheduled);
|
||||||
|
} else if (status === "failed") {
|
||||||
|
this._finish(entity, Failed);
|
||||||
|
} else if (status === "cancelled") {
|
||||||
|
this._finish(entity, Cancelled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _executeSelector(entity: Entity, dt: number): void {
|
||||||
|
for (const child of childrenOf(this._world, entity)) {
|
||||||
|
let status = terminalStatus(this._world, child);
|
||||||
|
if (status === "succeeded") {
|
||||||
|
this._finish(entity, Succeeded);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === "failed" || status === "cancelled") continue;
|
||||||
|
|
||||||
|
this._executeChild(child, dt);
|
||||||
|
|
||||||
|
status = terminalStatus(this._world, child);
|
||||||
|
if (status === "succeeded") {
|
||||||
|
this._finish(entity, Succeeded);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === "failed" || status === "cancelled") continue;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All children failed
|
|
||||||
this._finish(entity, Failed);
|
this._finish(entity, Failed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _firstTaskChild(entity: Entity): Entity | null {
|
||||||
|
for (const child of childrenOf(this._world, entity)) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Completion propagation ────────────────────────
|
// ── Completion propagation ────────────────────────
|
||||||
|
|
||||||
|
private _finishFromThrown(entity: Entity, err: unknown): void {
|
||||||
|
if (err === Cancel) {
|
||||||
|
this.cancel(entity);
|
||||||
|
} else {
|
||||||
|
this.fail(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _finish(
|
private _finish(
|
||||||
entity: Entity,
|
entity: Entity,
|
||||||
tag: typeof Succeeded | typeof Failed | typeof Cancelled,
|
tag: typeof Succeeded | typeof Failed | typeof Cancelled,
|
||||||
@@ -331,30 +450,34 @@ export class TaskRunner {
|
|||||||
const status = terminalStatus(this._world, entity)!;
|
const status = terminalStatus(this._world, entity)!;
|
||||||
this.onTerminal(this._world, entity, status);
|
this.onTerminal(this._world, entity, status);
|
||||||
|
|
||||||
// Notify parent
|
|
||||||
const parent = parentOf(this._world, entity);
|
const parent = parentOf(this._world, entity);
|
||||||
if (parent) {
|
if (parent && !this._executing.has(parent)) {
|
||||||
this._world.add(parent, Scheduled);
|
if (this._world.has(parent, Scheduled)) {
|
||||||
|
this._world.remove(parent, Scheduled);
|
||||||
|
}
|
||||||
|
this._execute(parent, this._currentDt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _cancelTree(entity: Entity): void {
|
private _cancelTree(entity: Entity, notifyParent: boolean): void {
|
||||||
if (!this._world.has(entity, Task)) return;
|
if (!this._world.has(entity, Task)) return;
|
||||||
|
|
||||||
// Cancel children first
|
// Cancel children first without repeatedly waking this node while its
|
||||||
|
// subtree is still being cancelled.
|
||||||
for (const child of childrenOf(this._world, entity)) {
|
for (const child of childrenOf(this._world, entity)) {
|
||||||
this._cancelTree(child);
|
this._cancelTree(child, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel this node
|
|
||||||
clearStatus(this._world, entity);
|
clearStatus(this._world, entity);
|
||||||
this._world.add(entity, Cancelled);
|
this._world.add(entity, Cancelled);
|
||||||
this.onTerminal(this._world, entity, "cancelled");
|
this.onTerminal(this._world, entity, "cancelled");
|
||||||
|
|
||||||
// Notify parent
|
|
||||||
const parent = parentOf(this._world, entity);
|
const parent = parentOf(this._world, entity);
|
||||||
if (parent) {
|
if (notifyParent && parent && !this._executing.has(parent)) {
|
||||||
this._world.add(parent, Scheduled);
|
if (this._world.has(parent, Scheduled)) {
|
||||||
|
this._world.remove(parent, Scheduled);
|
||||||
|
}
|
||||||
|
this._execute(parent, this._currentDt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-10
@@ -1,30 +1,41 @@
|
|||||||
import { defineComponent } from "../component";
|
import { defineComponent } from "../component";
|
||||||
import { defineRelationship } from "../relationship";
|
import { defineRelationship } from "../relationship";
|
||||||
|
|
||||||
|
// ── Cancel ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Throw from an action or wait starter to cancel that task and its subtree. */
|
||||||
|
export const Cancel: unique symbol = Symbol("task.cancel");
|
||||||
|
|
||||||
// ── Task component ────────────────────────────────────
|
// ── Task component ────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Core component for behaviour-tree tasks.
|
* Core component for behaviour-tree tasks.
|
||||||
*
|
*
|
||||||
* `kind` determines how the task evaluates its children:
|
* `kind` determines how the task evaluates:
|
||||||
* - `"leaf"` — terminal node; external logic drives it to completion.
|
* - `"action"` — runs immediately and succeeds when its function returns.
|
||||||
|
* - `"wait"` — starts once and remains running until external code completes it.
|
||||||
* - `"sequential"` — runs children one at a time, left to right.
|
* - `"sequential"` — runs children one at a time, left to right.
|
||||||
* Succeeds when all children succeed; fails when any child fails.
|
* Succeeds when all children succeed; fails when any child fails.
|
||||||
* - `"parallel"` — schedules all children at once.
|
* - `"parallel"` — starts all children at once.
|
||||||
* Succeeds when all children succeed; fails when any child fails.
|
* Succeeds when all children succeed; fails when any child fails.
|
||||||
* - `"random"` — picks one child at random each time it's scheduled.
|
* - `"random"` — picks one child at random each time it runs.
|
||||||
* Succeeds/fails with that child's result.
|
* Succeeds/fails with that child's result.
|
||||||
* - `"repeat"` — runs its single child. When the child finishes, resets it
|
* - `"cycle"` — runs its single child. When the child finishes, resets it
|
||||||
* and runs again. Never terminates on its own (only via cancel).
|
* and schedules the next run for a future tick boundary. Never terminates
|
||||||
|
* on its own (only via cancel).
|
||||||
|
* - `"whilst"` — runs its single child while its condition is true.
|
||||||
|
* Succeeds when the condition becomes false; fails/cancels with its child.
|
||||||
* - `"selector"` — runs children left to right. Succeeds on the first
|
* - `"selector"` — runs children left to right. Succeeds on the first
|
||||||
* child that succeeds; fails only if all children fail.
|
* child that succeeds; fails only if all children fail.
|
||||||
*/
|
*/
|
||||||
export const Task = defineComponent("task", {
|
export const Task = defineComponent("task", {
|
||||||
kind: "leaf" as
|
kind: "action" as
|
||||||
| "leaf"
|
| "action"
|
||||||
|
| "wait"
|
||||||
| "sequential"
|
| "sequential"
|
||||||
| "parallel"
|
| "parallel"
|
||||||
| "random"
|
| "random"
|
||||||
| "repeat"
|
| "cycle"
|
||||||
|
| "whilst"
|
||||||
| "selector",
|
| "selector",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,7 +45,7 @@ export type TaskKind = (typeof Task.type)["kind"];
|
|||||||
/** A task that should be executed this tick. */
|
/** A task that should be executed this tick. */
|
||||||
export const Scheduled = defineComponent("scheduled", {});
|
export const Scheduled = defineComponent("scheduled", {});
|
||||||
|
|
||||||
/** A task that is currently executing (multi-frame leaves). */
|
/** A task that is currently executing or waiting for completion. */
|
||||||
export const Running = defineComponent("running", {});
|
export const Running = defineComponent("running", {});
|
||||||
|
|
||||||
/** The task completed successfully. */
|
/** The task completed successfully. */
|
||||||
|
|||||||
+112
-102
@@ -1,35 +1,32 @@
|
|||||||
import type { World, Entity } from "../index";
|
import type { World, Entity } from "../index";
|
||||||
import type { EntityDef, EntityDefChild } from "../entity-tree";
|
import type { EntityDef, EntityDefChild } from "../entity-tree";
|
||||||
import { Task, ChildOf } from "./task";
|
import { Task, ChildOf, Cancel } from "./task";
|
||||||
import { TaskRunner } from "./runner";
|
import { TaskRunner, type TaskControl } from "./runner";
|
||||||
|
|
||||||
// ── Cancel ────────────────────────────────────────────
|
export { Cancel };
|
||||||
|
|
||||||
/**
|
// ── Task callback types ───────────────────────────────
|
||||||
* Throw this inside a leaf `run` function to cancel the leaf and its subtree.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* leaf(() => { throw Cancel; })
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const Cancel: unique symbol = Symbol("leaf.cancel");
|
|
||||||
|
|
||||||
// ── Tree definition ───────────────────────────────────
|
/** Runs immediately. Return = success, throw = failure, throw Cancel = cancel. */
|
||||||
|
export type ActionFn = (world: World, entity: Entity, dt: number) => void;
|
||||||
|
|
||||||
/** A leaf function — plain or generator. */
|
/** Starts a task that remains Running until completed by the supplied control. */
|
||||||
export type LeafFn =
|
export type WaitFn = (
|
||||||
| ((world: World, dt: number) => void)
|
world: World,
|
||||||
| (() => Generator<number | void, void, number>);
|
entity: Entity,
|
||||||
|
control: TaskControl,
|
||||||
|
dt: number,
|
||||||
|
) => void;
|
||||||
|
|
||||||
export interface LeafTaskMeta {
|
/** Controls a `whilst` task. False means the loop has completed successfully. */
|
||||||
readonly run: LeafFn;
|
export type ConditionFn = (world: World, entity: Entity, dt: number) => boolean;
|
||||||
}
|
|
||||||
|
|
||||||
export type TaskEntityDef = EntityDef<
|
export type TaskMeta =
|
||||||
typeof Task.type,
|
| { readonly mode: "action"; readonly run: ActionFn }
|
||||||
LeafTaskMeta | undefined
|
| { readonly mode: "wait"; readonly start?: WaitFn }
|
||||||
>;
|
| { readonly mode: "whilst"; readonly condition: ConditionFn };
|
||||||
|
|
||||||
|
export type TaskEntityDef = EntityDef<typeof Task.type, TaskMeta | undefined>;
|
||||||
|
|
||||||
/** Behaviour-tree definition accepted by `buildTree`. */
|
/** Behaviour-tree definition accepted by `buildTree`. */
|
||||||
export type TreeDef = TaskEntityDef;
|
export type TreeDef = TaskEntityDef;
|
||||||
@@ -43,7 +40,7 @@ function compactChildren(children: readonly EntityDefChild[]): EntityDef[] {
|
|||||||
function task(
|
function task(
|
||||||
kind: typeof Task.type.kind,
|
kind: typeof Task.type.kind,
|
||||||
children: readonly EntityDefChild[] = [],
|
children: readonly EntityDefChild[] = [],
|
||||||
meta?: LeafTaskMeta,
|
meta?: TaskMeta,
|
||||||
): TaskEntityDef {
|
): TaskEntityDef {
|
||||||
return {
|
return {
|
||||||
kind: "entity",
|
kind: "entity",
|
||||||
@@ -54,12 +51,38 @@ function task(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a leaf task entity definition. */
|
/** Create an action task entity definition. */
|
||||||
export function leaf(
|
export function action(
|
||||||
run: LeafFn,
|
run: ActionFn,
|
||||||
children: readonly EntityDefChild[] = [],
|
children: readonly EntityDefChild[] = [],
|
||||||
): TaskEntityDef {
|
): TaskEntityDef {
|
||||||
return task("leaf", children, { run });
|
return task("action", children, { mode: "action", run });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a wait task entity definition.
|
||||||
|
*
|
||||||
|
* `wait()` creates a task that simply becomes Running. External systems can
|
||||||
|
* complete it with `runner.succeed(entity)`, `runner.fail(entity)`, or
|
||||||
|
* `runner.cancel(entity)`.
|
||||||
|
*/
|
||||||
|
export function wait(children?: readonly EntityDefChild[]): TaskEntityDef;
|
||||||
|
export function wait(
|
||||||
|
start: WaitFn,
|
||||||
|
children?: readonly EntityDefChild[],
|
||||||
|
): TaskEntityDef;
|
||||||
|
export function wait(
|
||||||
|
startOrChildren?: WaitFn | readonly EntityDefChild[],
|
||||||
|
children: readonly EntityDefChild[] = [],
|
||||||
|
): TaskEntityDef {
|
||||||
|
const hasChildrenAsFirstArg = Array.isArray(startOrChildren);
|
||||||
|
const start = hasChildrenAsFirstArg
|
||||||
|
? undefined
|
||||||
|
: (startOrChildren as WaitFn | undefined);
|
||||||
|
return task("wait", hasChildrenAsFirstArg ? startOrChildren : children, {
|
||||||
|
mode: "wait",
|
||||||
|
start,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a sequential task entity definition. */
|
/** Create a sequential task entity definition. */
|
||||||
@@ -91,44 +114,60 @@ export function random(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a repeat task entity definition.
|
* Create a cycle task entity definition.
|
||||||
*
|
*
|
||||||
* `repeat(child)` and `repeat([child, metadataEntity])` are both supported.
|
* `cycle(child)` and `cycle([child, metadataEntity])` are both supported.
|
||||||
* The runner operates only on child entities that have the `Task` component.
|
* The runner operates only on child entities that have the `Task` component.
|
||||||
*/
|
*/
|
||||||
export function repeat(child: EntityDef): TaskEntityDef;
|
export function cycle(child: EntityDef): TaskEntityDef;
|
||||||
export function repeat(children?: readonly EntityDefChild[]): TaskEntityDef;
|
export function cycle(children?: readonly EntityDefChild[]): TaskEntityDef;
|
||||||
export function repeat(
|
export function cycle(
|
||||||
childOrChildren: EntityDef | readonly EntityDefChild[] = [],
|
childOrChildren: EntityDef | readonly EntityDefChild[] = [],
|
||||||
): TaskEntityDef {
|
): TaskEntityDef {
|
||||||
return task(
|
return task(
|
||||||
"repeat",
|
"cycle",
|
||||||
Array.isArray(childOrChildren) ? childOrChildren : [childOrChildren],
|
Array.isArray(childOrChildren) ? childOrChildren : [childOrChildren],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a conditional loop task entity definition.
|
||||||
|
*
|
||||||
|
* Runs its child while `condition` returns true. When the child succeeds, the
|
||||||
|
* child subtree is reset and `whilst` schedules itself for the next tick
|
||||||
|
* boundary. When `condition` returns false, `whilst` succeeds.
|
||||||
|
*/
|
||||||
|
export function whilst(condition: ConditionFn, child: EntityDef): TaskEntityDef;
|
||||||
|
export function whilst(
|
||||||
|
condition: ConditionFn,
|
||||||
|
children?: readonly EntityDefChild[],
|
||||||
|
): TaskEntityDef;
|
||||||
|
export function whilst(
|
||||||
|
condition: ConditionFn,
|
||||||
|
childOrChildren: EntityDef | readonly EntityDefChild[] = [],
|
||||||
|
): TaskEntityDef {
|
||||||
|
return task(
|
||||||
|
"whilst",
|
||||||
|
Array.isArray(childOrChildren) ? childOrChildren : [childOrChildren],
|
||||||
|
{ mode: "whilst", condition },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Builder ───────────────────────────────────────────
|
// ── Builder ───────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Materialize a behaviour-tree definition into ECS entities and return a
|
* Materialize a behaviour-tree definition into ECS entities and return a
|
||||||
* fully-wired `TaskRunner`.
|
* fully-wired `TaskRunner`.
|
||||||
*
|
*
|
||||||
* Definitions are `EntityDef` trees produced by the task factories (`leaf`,
|
* Definitions are `EntityDef` trees produced by the task factories (`action`,
|
||||||
* `sequential`, `parallel`, `selector`, `random`, `repeat`) and generic
|
* `wait`, `sequential`, `parallel`, `selector`, `random`, `cycle`, `whilst`)
|
||||||
* single-component entity factories. Non-task child entities are materialized
|
* and generic single-component entity factories. Non-task child entities are
|
||||||
* into the ECS tree but ignored by `TaskRunner` execution.
|
* materialized into the ECS tree but ignored by `TaskRunner` execution.
|
||||||
*
|
|
||||||
* Leaf `run` functions:
|
|
||||||
* - **Plain function** — runs once per tick. `return` = success. `throw` = fail.
|
|
||||||
* `throw Cancel` = cancel.
|
|
||||||
* - **Generator function** — each `yield` suspends until next tick. The value
|
|
||||||
* yielded is the desired delay in ms (or `undefined` for next frame).
|
|
||||||
* Generator completion = success. `throw` = fail. `throw Cancel` = cancel.
|
|
||||||
*/
|
*/
|
||||||
export function buildTree(world: World, def: TreeDef): TaskRunner {
|
export function buildTree(world: World, def: TreeDef): TaskRunner {
|
||||||
const leafHandlers = new Map<Entity, LeafFn>();
|
const actions = new Map<Entity, ActionFn>();
|
||||||
// Track generator iterators for multi-frame leaves
|
const waits = new Map<Entity, WaitFn>();
|
||||||
const generators = new Map<Entity, Generator<number | void, void, number>>();
|
const conditions = new Map<Entity, ConditionFn>();
|
||||||
|
|
||||||
function build(def: EntityDef, parent?: Entity): Entity {
|
function build(def: EntityDef, parent?: Entity): Entity {
|
||||||
const entity = world.spawn();
|
const entity = world.spawn();
|
||||||
@@ -140,12 +179,22 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
|
|||||||
|
|
||||||
if (def.component === Task) {
|
if (def.component === Task) {
|
||||||
const taskData = world.get(entity, Task);
|
const taskData = world.get(entity, Task);
|
||||||
if (taskData.kind === "leaf") {
|
const meta = def.meta as TaskMeta | undefined;
|
||||||
const run = (def.meta as LeafTaskMeta | undefined)?.run;
|
|
||||||
if (!run) {
|
if (taskData.kind === "action") {
|
||||||
throw new Error("Leaf task entity is missing a run function");
|
if (meta?.mode !== "action") {
|
||||||
|
throw new Error("Action task entity is missing an action function");
|
||||||
}
|
}
|
||||||
leafHandlers.set(entity, run);
|
actions.set(entity, meta.run);
|
||||||
|
} else if (taskData.kind === "wait") {
|
||||||
|
if (meta?.mode === "wait" && meta.start) {
|
||||||
|
waits.set(entity, meta.start);
|
||||||
|
}
|
||||||
|
} else if (taskData.kind === "whilst") {
|
||||||
|
if (meta?.mode !== "whilst") {
|
||||||
|
throw new Error("Whilst task entity is missing a condition function");
|
||||||
|
}
|
||||||
|
conditions.set(entity, meta.condition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,56 +214,17 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
|
|||||||
const runner = new TaskRunner(world);
|
const runner = new TaskRunner(world);
|
||||||
runner.root = root;
|
runner.root = root;
|
||||||
|
|
||||||
runner.onLeaf = (_w, entity, dt) => {
|
runner.onAction = (world, entity, dt) => {
|
||||||
const handler = leafHandlers.get(entity);
|
actions.get(entity)?.(world, entity, dt);
|
||||||
if (!handler) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if this leaf has an active generator
|
|
||||||
let gen = generators.get(entity);
|
|
||||||
|
|
||||||
if (gen) {
|
|
||||||
// Resume existing generator
|
|
||||||
const result = gen.next(dt);
|
|
||||||
if (result.done) {
|
|
||||||
generators.delete(entity);
|
|
||||||
runner.succeed(entity);
|
|
||||||
}
|
|
||||||
// If not done, leaf stays Running — nothing to do
|
|
||||||
} else {
|
|
||||||
// First invocation — call the handler
|
|
||||||
const ret = handler(_w, dt);
|
|
||||||
|
|
||||||
// Check if it returned a generator
|
|
||||||
if (ret != null && typeof (ret as any).next === "function") {
|
|
||||||
const gen = ret as Generator<number | void, void, number>;
|
|
||||||
generators.set(entity, gen);
|
|
||||||
const result = gen.next(dt);
|
|
||||||
if (result.done) {
|
|
||||||
generators.delete(entity);
|
|
||||||
runner.succeed(entity);
|
|
||||||
}
|
|
||||||
// Not done → leaf stays Running
|
|
||||||
} else {
|
|
||||||
// Plain function — returned undefined → success
|
|
||||||
runner.succeed(entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Clean up generator if one was active
|
|
||||||
generators.delete(entity);
|
|
||||||
|
|
||||||
if (err === Cancel) {
|
|
||||||
runner.cancel(entity);
|
|
||||||
} else {
|
|
||||||
runner.fail(entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
runner.onTerminal = (_w, entity) => {
|
runner.onWait = (world, entity, control, dt) => {
|
||||||
// Clean up generator when a leaf reaches terminal by external means
|
waits.get(entity)?.(world, entity, control, dt);
|
||||||
generators.delete(entity);
|
};
|
||||||
|
|
||||||
|
runner.onCondition = (world, entity, dt) => {
|
||||||
|
const condition = conditions.get(entity);
|
||||||
|
return condition ? condition(world, entity, dt) : false;
|
||||||
};
|
};
|
||||||
|
|
||||||
return runner;
|
return runner;
|
||||||
|
|||||||
+316
-244
@@ -10,15 +10,18 @@ import {
|
|||||||
ChildOf,
|
ChildOf,
|
||||||
TaskRunner,
|
TaskRunner,
|
||||||
buildTree,
|
buildTree,
|
||||||
leaf,
|
Cancel,
|
||||||
repeat,
|
action,
|
||||||
|
wait,
|
||||||
|
cycle,
|
||||||
|
whilst,
|
||||||
sequential,
|
sequential,
|
||||||
} from "../src/bt/index";
|
} from "../src/bt/index";
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────
|
||||||
function makeLeaf(world: World, parent?: Entity): Entity {
|
function makeWait(world: World, parent?: Entity): Entity {
|
||||||
const e = world.spawn();
|
const e = world.spawn();
|
||||||
world.add(e, Task, { kind: "leaf" });
|
world.add(e, Task, { kind: "wait" });
|
||||||
if (parent) world.relate(e, ChildOf, parent);
|
if (parent) world.relate(e, ChildOf, parent);
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
@@ -44,9 +47,9 @@ function makeRandom(world: World, parent?: Entity): Entity {
|
|||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRepeat(world: World, parent?: Entity): Entity {
|
function makeCycle(world: World, parent?: Entity): Entity {
|
||||||
const e = world.spawn();
|
const e = world.spawn();
|
||||||
world.add(e, Task, { kind: "repeat" });
|
world.add(e, Task, { kind: "cycle" });
|
||||||
if (parent) world.relate(e, ChildOf, parent);
|
if (parent) world.relate(e, ChildOf, parent);
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
@@ -69,9 +72,9 @@ describe("Entity task factories", () => {
|
|||||||
world,
|
world,
|
||||||
sequential([
|
sequential([
|
||||||
entity(Label, { value: "sequence metadata" }),
|
entity(Label, { value: "sequence metadata" }),
|
||||||
leaf(() => calls.push("a")),
|
action(() => calls.push("a")),
|
||||||
entity(Label, { value: "between leaves" }),
|
entity(Label, { value: "between leaves" }),
|
||||||
leaf(() => calls.push("b")),
|
action(() => calls.push("b")),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -90,16 +93,132 @@ describe("Entity task factories", () => {
|
|||||||
expect(world.has(root, Succeeded)).toBe(true);
|
expect(world.has(root, Succeeded)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("repeat works with component child entities beside its task child", () => {
|
it("action succeeds immediately when it returns", () => {
|
||||||
const Label = defineComponent("repeatLabel", { value: "" });
|
const world = new World();
|
||||||
|
let receivedDt = 0;
|
||||||
|
let receivedEntity: Entity | undefined;
|
||||||
|
|
||||||
|
const runner = buildTree(
|
||||||
|
world,
|
||||||
|
action((_world, entity, dt) => {
|
||||||
|
receivedEntity = entity;
|
||||||
|
receivedDt = dt;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
runner.schedule(runner.root!);
|
||||||
|
runner.tick(16);
|
||||||
|
|
||||||
|
expect(receivedEntity).toBe(runner.root);
|
||||||
|
expect(receivedDt).toBe(16);
|
||||||
|
expect(world.has(runner.root!, Succeeded)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("action fails or cancels when it throws", () => {
|
||||||
|
const world = new World();
|
||||||
|
const failed = buildTree(
|
||||||
|
world,
|
||||||
|
action(() => {
|
||||||
|
throw new Error("bad");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const cancelled = buildTree(
|
||||||
|
world,
|
||||||
|
action(() => {
|
||||||
|
throw Cancel;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
failed.schedule(failed.root!);
|
||||||
|
failed.tick();
|
||||||
|
cancelled.schedule(cancelled.root!);
|
||||||
|
cancelled.tick();
|
||||||
|
|
||||||
|
expect(world.has(failed.root!, Failed)).toBe(true);
|
||||||
|
expect(world.has(cancelled.root!, Cancelled)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wait can complete itself through task control", () => {
|
||||||
|
const world = new World();
|
||||||
|
const runner = buildTree(
|
||||||
|
world,
|
||||||
|
wait((_world, _entity, task) => {
|
||||||
|
task.succeed();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
runner.schedule(runner.root!);
|
||||||
|
runner.tick();
|
||||||
|
|
||||||
|
expect(world.has(runner.root!, Succeeded)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wait without a starter remains running", () => {
|
||||||
|
const world = new World();
|
||||||
|
const runner = buildTree(world, wait());
|
||||||
|
|
||||||
|
runner.schedule(runner.root!);
|
||||||
|
runner.tick();
|
||||||
|
|
||||||
|
expect(world.has(runner.root!, Running)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("whilst loops at tick boundaries until its condition is false", () => {
|
||||||
|
const world = new World();
|
||||||
|
let count = 0;
|
||||||
|
const runner = buildTree(
|
||||||
|
world,
|
||||||
|
whilst(
|
||||||
|
() => count < 3,
|
||||||
|
action(() => {
|
||||||
|
count++;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
runner.schedule(runner.root!);
|
||||||
|
runner.tick();
|
||||||
|
expect(count).toBe(1);
|
||||||
|
expect(world.has(runner.root!, Scheduled)).toBe(true);
|
||||||
|
|
||||||
|
runner.tick();
|
||||||
|
expect(count).toBe(2);
|
||||||
|
|
||||||
|
runner.tick();
|
||||||
|
expect(count).toBe(3);
|
||||||
|
|
||||||
|
runner.tick();
|
||||||
|
expect(world.has(runner.root!, Succeeded)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("whilst propagates child failure", () => {
|
||||||
|
const world = new World();
|
||||||
|
const runner = buildTree(
|
||||||
|
world,
|
||||||
|
whilst(
|
||||||
|
() => true,
|
||||||
|
action(() => {
|
||||||
|
throw new Error("bad");
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
runner.schedule(runner.root!);
|
||||||
|
runner.tick();
|
||||||
|
|
||||||
|
expect(world.has(runner.root!, Failed)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cycle works with component child entities beside its task child", () => {
|
||||||
|
const Label = defineComponent("cycleLabel", { value: "" });
|
||||||
const world = new World();
|
const world = new World();
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
|
|
||||||
const runner = buildTree(
|
const runner = buildTree(
|
||||||
world,
|
world,
|
||||||
repeat([
|
cycle([
|
||||||
entity(Label, { value: "repeat metadata" }),
|
entity(Label, { value: "cycle metadata" }),
|
||||||
leaf(() => {
|
action(() => {
|
||||||
calls++;
|
calls++;
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
@@ -112,8 +231,8 @@ describe("Entity task factories", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Leaf tasks ──────────────────────────────────────
|
// ── Wait tasks ──────────────────────────────────────
|
||||||
describe("Leaf tasks", () => {
|
describe("Wait tasks", () => {
|
||||||
let world: World;
|
let world: World;
|
||||||
let runner: TaskRunner;
|
let runner: TaskRunner;
|
||||||
|
|
||||||
@@ -122,79 +241,79 @@ describe("Leaf tasks", () => {
|
|||||||
runner = new TaskRunner(world);
|
runner = new TaskRunner(world);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onLeaf when a leaf is scheduled and ticked", () => {
|
it("calls onWait when a wait task is scheduled and ticked", () => {
|
||||||
const leaf = makeLeaf(world);
|
const action = makeWait(world);
|
||||||
const calls: Entity[] = [];
|
const calls: Entity[] = [];
|
||||||
|
|
||||||
runner.onLeaf = (_w, e) => calls.push(e);
|
runner.onWait = (_w, e) => calls.push(e);
|
||||||
runner.schedule(leaf);
|
runner.schedule(action);
|
||||||
runner.tick();
|
runner.tick();
|
||||||
|
|
||||||
expect(calls).toEqual([leaf]);
|
expect(calls).toEqual([action]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks leaf as Running after tick", () => {
|
it("marks wait task as Running after tick", () => {
|
||||||
const leaf = makeLeaf(world);
|
const action = makeWait(world);
|
||||||
runner.schedule(leaf);
|
runner.schedule(action);
|
||||||
runner.tick();
|
runner.tick();
|
||||||
|
|
||||||
expect(world.has(leaf, Running)).toBe(true);
|
expect(world.has(action, Running)).toBe(true);
|
||||||
expect(world.has(leaf, Scheduled)).toBe(false);
|
expect(world.has(action, Scheduled)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("succeed() marks leaf as Succeeded", () => {
|
it("succeed() marks wait task as Succeeded", () => {
|
||||||
const leaf = makeLeaf(world);
|
const action = makeWait(world);
|
||||||
runner.schedule(leaf);
|
runner.schedule(action);
|
||||||
runner.tick();
|
runner.tick();
|
||||||
runner.succeed(leaf);
|
runner.succeed(action);
|
||||||
|
|
||||||
expect(world.has(leaf, Succeeded)).toBe(true);
|
expect(world.has(action, Succeeded)).toBe(true);
|
||||||
expect(world.has(leaf, Running)).toBe(false);
|
expect(world.has(action, Running)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fail() marks leaf as Failed", () => {
|
it("fail() marks wait task as Failed", () => {
|
||||||
const leaf = makeLeaf(world);
|
const action = makeWait(world);
|
||||||
runner.schedule(leaf);
|
runner.schedule(action);
|
||||||
runner.tick();
|
runner.tick();
|
||||||
runner.fail(leaf);
|
runner.fail(action);
|
||||||
|
|
||||||
expect(world.has(leaf, Failed)).toBe(true);
|
expect(world.has(action, Failed)).toBe(true);
|
||||||
expect(world.has(leaf, Running)).toBe(false);
|
expect(world.has(action, Running)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cancel() marks leaf as Cancelled", () => {
|
it("cancel() marks wait task as Cancelled", () => {
|
||||||
const leaf = makeLeaf(world);
|
const action = makeWait(world);
|
||||||
runner.schedule(leaf);
|
runner.schedule(action);
|
||||||
runner.tick();
|
runner.tick();
|
||||||
runner.cancel(leaf);
|
runner.cancel(action);
|
||||||
|
|
||||||
expect(world.has(leaf, Cancelled)).toBe(true);
|
expect(world.has(action, Cancelled)).toBe(true);
|
||||||
expect(world.has(leaf, Running)).toBe(false);
|
expect(world.has(action, Running)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("onTerminal is called when leaf finishes", () => {
|
it("onTerminal is called when wait task finishes", () => {
|
||||||
const leaf = makeLeaf(world);
|
const action = makeWait(world);
|
||||||
const terminals: { entity: Entity; status: string }[] = [];
|
const terminals: { entity: Entity; status: string }[] = [];
|
||||||
|
|
||||||
runner.onTerminal = (_w, e, s) => terminals.push({ entity: e, status: s });
|
runner.onTerminal = (_w, e, s) => terminals.push({ entity: e, status: s });
|
||||||
runner.schedule(leaf);
|
runner.schedule(action);
|
||||||
runner.tick();
|
runner.tick();
|
||||||
runner.succeed(leaf);
|
runner.succeed(action);
|
||||||
|
|
||||||
expect(terminals).toEqual([{ entity: leaf, status: "succeeded" }]);
|
expect(terminals).toEqual([{ entity: action, status: "succeeded" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reset() clears all status tags", () => {
|
it("reset() clears all status tags", () => {
|
||||||
const leaf = makeLeaf(world);
|
const action = makeWait(world);
|
||||||
runner.schedule(leaf);
|
runner.schedule(action);
|
||||||
runner.tick();
|
runner.tick();
|
||||||
runner.succeed(leaf);
|
runner.succeed(action);
|
||||||
|
|
||||||
runner.reset(leaf);
|
runner.reset(action);
|
||||||
|
|
||||||
expect(world.has(leaf, Succeeded)).toBe(false);
|
expect(world.has(action, Succeeded)).toBe(false);
|
||||||
expect(world.has(leaf, Running)).toBe(false);
|
expect(world.has(action, Running)).toBe(false);
|
||||||
expect(world.has(leaf, Scheduled)).toBe(false);
|
expect(world.has(action, Scheduled)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,44 +329,34 @@ describe("Sequential tasks", () => {
|
|||||||
|
|
||||||
it("runs children one at a time in order", () => {
|
it("runs children one at a time in order", () => {
|
||||||
const seq = makeSequential(world);
|
const seq = makeSequential(world);
|
||||||
const a = makeLeaf(world, seq);
|
const a = makeWait(world, seq);
|
||||||
const b = makeLeaf(world, seq);
|
const b = makeWait(world, seq);
|
||||||
const c = makeLeaf(world, seq);
|
const c = makeWait(world, seq);
|
||||||
|
|
||||||
const leafCalls: Entity[] = [];
|
const leafCalls: Entity[] = [];
|
||||||
runner.onLeaf = (_w, e) => leafCalls.push(e);
|
runner.onWait = (_w, e) => leafCalls.push(e);
|
||||||
|
|
||||||
runner.schedule(seq);
|
runner.schedule(seq);
|
||||||
runner.tick(); // schedules first child
|
|
||||||
expect(world.has(a, Scheduled)).toBe(true);
|
|
||||||
expect(world.has(b, Scheduled)).toBe(false);
|
|
||||||
expect(world.has(c, Scheduled)).toBe(false);
|
|
||||||
|
|
||||||
runner.tick(); // runs a
|
|
||||||
runner.succeed(a);
|
|
||||||
|
|
||||||
// parent should be re-scheduled
|
|
||||||
runner.tick(); // schedules next child
|
|
||||||
expect(world.has(b, Scheduled)).toBe(true);
|
|
||||||
|
|
||||||
runner.tick(); // runs b
|
|
||||||
runner.succeed(b);
|
|
||||||
|
|
||||||
runner.tick(); // schedules c
|
|
||||||
expect(world.has(c, Scheduled)).toBe(true);
|
|
||||||
|
|
||||||
runner.tick(); // runs c
|
|
||||||
runner.succeed(c);
|
|
||||||
|
|
||||||
// parent should now be scheduled and succeed
|
|
||||||
runner.tick();
|
runner.tick();
|
||||||
|
expect(world.has(a, Running)).toBe(true);
|
||||||
|
expect(world.has(b, Running)).toBe(false);
|
||||||
|
expect(world.has(c, Running)).toBe(false);
|
||||||
|
|
||||||
|
runner.succeed(a);
|
||||||
|
expect(world.has(b, Running)).toBe(true);
|
||||||
|
|
||||||
|
runner.succeed(b);
|
||||||
|
expect(world.has(c, Running)).toBe(true);
|
||||||
|
|
||||||
|
runner.succeed(c);
|
||||||
expect(world.has(seq, Succeeded)).toBe(true);
|
expect(world.has(seq, Succeeded)).toBe(true);
|
||||||
|
expect(leafCalls).toEqual([a, b, c]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails immediately when a child fails", () => {
|
it("fails immediately when a child fails", () => {
|
||||||
const seq = makeSequential(world);
|
const seq = makeSequential(world);
|
||||||
const a = makeLeaf(world, seq);
|
const a = makeWait(world, seq);
|
||||||
const b = makeLeaf(world, seq);
|
const b = makeWait(world, seq);
|
||||||
|
|
||||||
runner.schedule(seq);
|
runner.schedule(seq);
|
||||||
runner.tick(); // schedules a
|
runner.tick(); // schedules a
|
||||||
@@ -264,8 +373,8 @@ describe("Sequential tasks", () => {
|
|||||||
|
|
||||||
it("succeeds when all children succeed", () => {
|
it("succeeds when all children succeed", () => {
|
||||||
const seq = makeSequential(world);
|
const seq = makeSequential(world);
|
||||||
const a = makeLeaf(world, seq);
|
const a = makeWait(world, seq);
|
||||||
const b = makeLeaf(world, seq);
|
const b = makeWait(world, seq);
|
||||||
|
|
||||||
runner.schedule(seq);
|
runner.schedule(seq);
|
||||||
runner.tick(); // schedules a
|
runner.tick(); // schedules a
|
||||||
@@ -282,20 +391,20 @@ describe("Sequential tasks", () => {
|
|||||||
it("propagates terminal to grandparent", () => {
|
it("propagates terminal to grandparent", () => {
|
||||||
const root = makeSequential(world);
|
const root = makeSequential(world);
|
||||||
const child = makeSequential(world, root);
|
const child = makeSequential(world, root);
|
||||||
const leaf = makeLeaf(world, child);
|
const action = makeWait(world, child);
|
||||||
|
|
||||||
const terminals: Entity[] = [];
|
const terminals: Entity[] = [];
|
||||||
runner.onTerminal = (_w, e) => terminals.push(e);
|
runner.onTerminal = (_w, e) => terminals.push(e);
|
||||||
|
|
||||||
runner.schedule(root);
|
runner.schedule(root);
|
||||||
runner.tick(); // schedules child
|
runner.tick(); // schedules child
|
||||||
runner.tick(); // schedules leaf
|
runner.tick(); // schedules action
|
||||||
runner.tick(); // runs leaf
|
runner.tick(); // runs action
|
||||||
runner.succeed(leaf);
|
runner.succeed(action);
|
||||||
runner.tick(); // child succeeds
|
runner.tick(); // child succeeds
|
||||||
runner.tick(); // root succeeds
|
runner.tick(); // root succeeds
|
||||||
|
|
||||||
expect(terminals).toEqual([leaf, child, root]);
|
expect(terminals).toEqual([action, child, root]);
|
||||||
expect(world.has(root, Succeeded)).toBe(true);
|
expect(world.has(root, Succeeded)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -319,24 +428,24 @@ describe("Parallel tasks", () => {
|
|||||||
runner = new TaskRunner(world);
|
runner = new TaskRunner(world);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("schedules all children at once", () => {
|
it("starts all children at once", () => {
|
||||||
const par = makeParallel(world);
|
const par = makeParallel(world);
|
||||||
const a = makeLeaf(world, par);
|
const a = makeWait(world, par);
|
||||||
const b = makeLeaf(world, par);
|
const b = makeWait(world, par);
|
||||||
const c = makeLeaf(world, par);
|
const c = makeWait(world, par);
|
||||||
|
|
||||||
runner.schedule(par);
|
runner.schedule(par);
|
||||||
runner.tick();
|
runner.tick();
|
||||||
|
|
||||||
expect(world.has(a, Scheduled)).toBe(true);
|
expect(world.has(a, Running)).toBe(true);
|
||||||
expect(world.has(b, Scheduled)).toBe(true);
|
expect(world.has(b, Running)).toBe(true);
|
||||||
expect(world.has(c, Scheduled)).toBe(true);
|
expect(world.has(c, Running)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("succeeds when all children succeed", () => {
|
it("succeeds when all children succeed", () => {
|
||||||
const par = makeParallel(world);
|
const par = makeParallel(world);
|
||||||
const a = makeLeaf(world, par);
|
const a = makeWait(world, par);
|
||||||
const b = makeLeaf(world, par);
|
const b = makeWait(world, par);
|
||||||
|
|
||||||
runner.schedule(par);
|
runner.schedule(par);
|
||||||
runner.tick(); // schedules both
|
runner.tick(); // schedules both
|
||||||
@@ -365,8 +474,8 @@ describe("Parallel tasks", () => {
|
|||||||
|
|
||||||
it("fails immediately when any child fails", () => {
|
it("fails immediately when any child fails", () => {
|
||||||
const par = makeParallel(world);
|
const par = makeParallel(world);
|
||||||
const a = makeLeaf(world, par);
|
const a = makeWait(world, par);
|
||||||
const b = makeLeaf(world, par);
|
const b = makeWait(world, par);
|
||||||
|
|
||||||
runner.schedule(par);
|
runner.schedule(par);
|
||||||
runner.tick(); // schedules both
|
runner.tick(); // schedules both
|
||||||
@@ -401,27 +510,25 @@ describe("Random tasks", () => {
|
|||||||
|
|
||||||
it("picks one child and succeeds/fails with it", () => {
|
it("picks one child and succeeds/fails with it", () => {
|
||||||
const rand = makeRandom(world);
|
const rand = makeRandom(world);
|
||||||
const a = makeLeaf(world, rand);
|
const a = makeWait(world, rand);
|
||||||
const b = makeLeaf(world, rand);
|
const b = makeWait(world, rand);
|
||||||
|
|
||||||
runner.schedule(rand);
|
runner.schedule(rand);
|
||||||
runner.tick();
|
runner.tick();
|
||||||
|
|
||||||
// Exactly one child should be scheduled
|
// Exactly one child should be running
|
||||||
const scheduled = [world.has(a, Scheduled), world.has(b, Scheduled)];
|
const running = [world.has(a, Running), world.has(b, Running)];
|
||||||
expect(scheduled.filter(Boolean)).toHaveLength(1);
|
expect(running.filter(Boolean)).toHaveLength(1);
|
||||||
|
|
||||||
const picked = world.has(a, Scheduled) ? a : b;
|
const picked = world.has(a, Running) ? a : b;
|
||||||
runner.tick(); // runs picked leaf
|
|
||||||
runner.succeed(picked);
|
runner.succeed(picked);
|
||||||
|
|
||||||
runner.tick(); // random sees child done → succeeds
|
|
||||||
expect(world.has(rand, Succeeded)).toBe(true);
|
expect(world.has(rand, Succeeded)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails when picked child fails", () => {
|
it("fails when picked child fails", () => {
|
||||||
const rand = makeRandom(world);
|
const rand = makeRandom(world);
|
||||||
const a = makeLeaf(world, rand);
|
const a = makeWait(world, rand);
|
||||||
|
|
||||||
runner.schedule(rand);
|
runner.schedule(rand);
|
||||||
runner.tick(); // schedules a (only child)
|
runner.tick(); // schedules a (only child)
|
||||||
@@ -444,8 +551,8 @@ describe("Random tasks", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Repeat ──────────────────────────────────────────
|
// ── Cycle ──────────────────────────────────────────
|
||||||
describe("Repeat tasks", () => {
|
describe("Cycle tasks", () => {
|
||||||
let world: World;
|
let world: World;
|
||||||
let runner: TaskRunner;
|
let runner: TaskRunner;
|
||||||
|
|
||||||
@@ -455,110 +562,98 @@ describe("Repeat tasks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("re-runs child after it succeeds", () => {
|
it("re-runs child after it succeeds", () => {
|
||||||
const rep = makeRepeat(world);
|
const cyc = makeCycle(world);
|
||||||
const leaf = makeLeaf(world, rep);
|
const action = makeWait(world, cyc);
|
||||||
|
|
||||||
let leafCount = 0;
|
let waitCount = 0;
|
||||||
runner.onLeaf = () => leafCount++;
|
runner.onWait = () => waitCount++;
|
||||||
|
|
||||||
runner.schedule(rep);
|
runner.schedule(cyc);
|
||||||
|
|
||||||
// First run
|
runner.tick();
|
||||||
runner.tick(); // repeat schedules leaf
|
expect(waitCount).toBe(1);
|
||||||
runner.tick(); // leaf runs
|
runner.succeed(action);
|
||||||
expect(leafCount).toBe(1);
|
|
||||||
runner.succeed(leaf);
|
|
||||||
|
|
||||||
// Repeat re-scheduled
|
// Completion schedules the cycle node at a tick boundary.
|
||||||
runner.tick(); // repeat sees leaf done, resets and schedules again
|
expect(world.has(cyc, Scheduled)).toBe(true);
|
||||||
runner.tick(); // leaf runs again
|
runner.tick();
|
||||||
expect(leafCount).toBe(2);
|
expect(waitCount).toBe(2);
|
||||||
runner.succeed(leaf);
|
runner.succeed(action);
|
||||||
|
|
||||||
// And again
|
runner.tick();
|
||||||
runner.tick(); // repeat resets
|
expect(waitCount).toBe(3);
|
||||||
runner.tick(); // leaf runs
|
|
||||||
expect(leafCount).toBe(3);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("re-runs child after it fails", () => {
|
it("re-runs child after it fails", () => {
|
||||||
const rep = makeRepeat(world);
|
const cyc = makeCycle(world);
|
||||||
const leaf = makeLeaf(world, rep);
|
const action = makeWait(world, cyc);
|
||||||
|
|
||||||
let leafCount = 0;
|
let waitCount = 0;
|
||||||
runner.onLeaf = () => leafCount++;
|
runner.onWait = () => waitCount++;
|
||||||
|
|
||||||
runner.schedule(rep);
|
runner.schedule(cyc);
|
||||||
runner.tick(); // repeat schedules leaf
|
runner.tick();
|
||||||
runner.tick(); // leaf runs
|
runner.fail(action);
|
||||||
runner.fail(leaf);
|
|
||||||
|
|
||||||
// Repeat re-scheduled, resets leaf
|
expect(world.has(cyc, Scheduled)).toBe(true);
|
||||||
runner.tick(); // repeat resets leaf
|
runner.tick();
|
||||||
runner.tick(); // leaf runs again
|
expect(waitCount).toBe(2);
|
||||||
expect(leafCount).toBe(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never terminates on its own", () => {
|
it("never terminates on its own", () => {
|
||||||
const rep = makeRepeat(world);
|
const cyc = makeCycle(world);
|
||||||
const leaf = makeLeaf(world, rep);
|
const action = makeWait(world, cyc);
|
||||||
|
|
||||||
runner.schedule(rep);
|
runner.schedule(cyc);
|
||||||
runner.tick(); // schedules leaf
|
runner.tick();
|
||||||
runner.tick(); // runs leaf
|
runner.succeed(action);
|
||||||
runner.succeed(leaf);
|
|
||||||
|
|
||||||
// After many cycles, repeat is still not terminal
|
// After many cycles, cycle is still not terminal
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
runner.tick(); // repeat resets + schedules leaf
|
runner.tick();
|
||||||
runner.tick(); // leaf runs
|
runner.succeed(action);
|
||||||
runner.succeed(leaf);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(world.has(rep, Succeeded)).toBe(false);
|
expect(world.has(cyc, Succeeded)).toBe(false);
|
||||||
expect(world.has(rep, Failed)).toBe(false);
|
expect(world.has(cyc, Failed)).toBe(false);
|
||||||
expect(world.has(rep, Cancelled)).toBe(false);
|
expect(world.has(cyc, Cancelled)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can be cancelled", () => {
|
it("can be cancelled", () => {
|
||||||
const rep = makeRepeat(world);
|
const cyc = makeCycle(world);
|
||||||
const leaf = makeLeaf(world, rep);
|
const action = makeWait(world, cyc);
|
||||||
|
|
||||||
runner.schedule(rep);
|
runner.schedule(cyc);
|
||||||
runner.tick(); // schedules leaf
|
runner.tick();
|
||||||
runner.tick(); // runs leaf
|
|
||||||
|
|
||||||
runner.cancel(rep);
|
runner.cancel(cyc);
|
||||||
|
|
||||||
expect(world.has(rep, Cancelled)).toBe(true);
|
expect(world.has(cyc, Cancelled)).toBe(true);
|
||||||
expect(world.has(leaf, Cancelled)).toBe(true);
|
expect(world.has(action, Cancelled)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("empty repeat does nothing", () => {
|
it("empty cycle does nothing", () => {
|
||||||
const rep = makeRepeat(world);
|
const cyc = makeCycle(world);
|
||||||
|
|
||||||
runner.schedule(rep);
|
runner.schedule(cyc);
|
||||||
runner.tick();
|
runner.tick();
|
||||||
|
|
||||||
// No child, so nothing happens
|
// No child, so nothing happens
|
||||||
expect(world.has(rep, Succeeded)).toBe(false);
|
expect(world.has(cyc, Succeeded)).toBe(false);
|
||||||
expect(world.has(rep, Failed)).toBe(false);
|
expect(world.has(cyc, Failed)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("repeat inside sequential advances parent when cancelled", () => {
|
it("cycle inside sequential advances parent when cancelled", () => {
|
||||||
const seq = makeSequential(world);
|
const seq = makeSequential(world);
|
||||||
const rep = makeRepeat(world, seq);
|
const cyc = makeCycle(world, seq);
|
||||||
const leaf = makeLeaf(world, rep);
|
const action = makeWait(world, cyc);
|
||||||
const after = makeLeaf(world, seq);
|
const after = makeWait(world, seq);
|
||||||
|
|
||||||
runner.schedule(seq);
|
runner.schedule(seq);
|
||||||
runner.tick(); // seq schedules rep
|
runner.tick();
|
||||||
runner.tick(); // rep schedules leaf
|
|
||||||
runner.tick(); // leaf runs
|
|
||||||
|
|
||||||
// Cancel the repeat
|
// Cancel the cycle
|
||||||
runner.cancel(rep);
|
runner.cancel(cyc);
|
||||||
runner.tick(); // seq sees rep cancelled, seq becomes cancelled
|
|
||||||
|
|
||||||
expect(world.has(seq, Cancelled)).toBe(true);
|
expect(world.has(seq, Cancelled)).toBe(true);
|
||||||
expect(world.has(after, Scheduled)).toBe(false);
|
expect(world.has(after, Scheduled)).toBe(false);
|
||||||
@@ -577,8 +672,8 @@ describe("Selector tasks", () => {
|
|||||||
|
|
||||||
it("succeeds on first child that succeeds", () => {
|
it("succeeds on first child that succeeds", () => {
|
||||||
const sel = makeSelector(world);
|
const sel = makeSelector(world);
|
||||||
const a = makeLeaf(world, sel);
|
const a = makeWait(world, sel);
|
||||||
const b = makeLeaf(world, sel);
|
const b = makeWait(world, sel);
|
||||||
|
|
||||||
runner.schedule(sel);
|
runner.schedule(sel);
|
||||||
runner.tick(); // schedules a
|
runner.tick(); // schedules a
|
||||||
@@ -595,35 +690,28 @@ describe("Selector tasks", () => {
|
|||||||
|
|
||||||
it("tries next child when previous fails", () => {
|
it("tries next child when previous fails", () => {
|
||||||
const sel = makeSelector(world);
|
const sel = makeSelector(world);
|
||||||
const a = makeLeaf(world, sel);
|
const a = makeWait(world, sel);
|
||||||
const b = makeLeaf(world, sel);
|
const b = makeWait(world, sel);
|
||||||
const c = makeLeaf(world, sel);
|
const c = makeWait(world, sel);
|
||||||
|
|
||||||
runner.schedule(sel);
|
runner.schedule(sel);
|
||||||
runner.tick(); // schedules a
|
runner.tick(); // schedules a
|
||||||
runner.tick(); // runs a
|
runner.tick(); // runs a
|
||||||
runner.fail(a);
|
runner.fail(a);
|
||||||
|
|
||||||
runner.tick(); // selector schedules b
|
expect(world.has(b, Running)).toBe(true);
|
||||||
expect(world.has(b, Scheduled)).toBe(true);
|
|
||||||
|
|
||||||
runner.tick(); // runs b
|
|
||||||
runner.fail(b);
|
runner.fail(b);
|
||||||
|
expect(world.has(c, Running)).toBe(true);
|
||||||
|
|
||||||
runner.tick(); // selector schedules c
|
|
||||||
expect(world.has(c, Scheduled)).toBe(true);
|
|
||||||
|
|
||||||
runner.tick(); // runs c
|
|
||||||
runner.succeed(c);
|
runner.succeed(c);
|
||||||
|
|
||||||
runner.tick(); // selector succeeds
|
|
||||||
expect(world.has(sel, Succeeded)).toBe(true);
|
expect(world.has(sel, Succeeded)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails when all children fail", () => {
|
it("fails when all children fail", () => {
|
||||||
const sel = makeSelector(world);
|
const sel = makeSelector(world);
|
||||||
const a = makeLeaf(world, sel);
|
const a = makeWait(world, sel);
|
||||||
const b = makeLeaf(world, sel);
|
const b = makeWait(world, sel);
|
||||||
|
|
||||||
runner.schedule(sel);
|
runner.schedule(sel);
|
||||||
runner.tick(); // schedules a
|
runner.tick(); // schedules a
|
||||||
@@ -648,16 +736,15 @@ describe("Selector tasks", () => {
|
|||||||
|
|
||||||
it("skips cancelled children and continues", () => {
|
it("skips cancelled children and continues", () => {
|
||||||
const sel = makeSelector(world);
|
const sel = makeSelector(world);
|
||||||
const a = makeLeaf(world, sel);
|
const a = makeWait(world, sel);
|
||||||
const b = makeLeaf(world, sel);
|
const b = makeWait(world, sel);
|
||||||
|
|
||||||
runner.schedule(sel);
|
runner.schedule(sel);
|
||||||
runner.tick(); // schedules a
|
runner.tick(); // schedules a
|
||||||
runner.tick(); // runs a
|
runner.tick(); // runs a
|
||||||
runner.cancel(a);
|
runner.cancel(a);
|
||||||
|
|
||||||
runner.tick(); // selector sees a cancelled, tries b
|
expect(world.has(b, Running)).toBe(true);
|
||||||
expect(world.has(b, Scheduled)).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -674,8 +761,8 @@ describe("Cancel", () => {
|
|||||||
it("cancels all descendants", () => {
|
it("cancels all descendants", () => {
|
||||||
const root = makeSequential(world);
|
const root = makeSequential(world);
|
||||||
const child = makeParallel(world, root);
|
const child = makeParallel(world, root);
|
||||||
const a = makeLeaf(world, child);
|
const a = makeWait(world, child);
|
||||||
const b = makeLeaf(world, child);
|
const b = makeWait(world, child);
|
||||||
|
|
||||||
runner.schedule(root);
|
runner.schedule(root);
|
||||||
runner.tick(); // schedules child
|
runner.tick(); // schedules child
|
||||||
@@ -694,17 +781,17 @@ describe("Cancel", () => {
|
|||||||
it("cancel propagates to parent", () => {
|
it("cancel propagates to parent", () => {
|
||||||
const root = makeSequential(world);
|
const root = makeSequential(world);
|
||||||
const child = makeSequential(world, root);
|
const child = makeSequential(world, root);
|
||||||
const leaf = makeLeaf(world, child);
|
const action = makeWait(world, child);
|
||||||
|
|
||||||
runner.schedule(root);
|
runner.schedule(root);
|
||||||
runner.tick(); // schedules child
|
runner.tick(); // schedules child
|
||||||
runner.tick(); // schedules leaf
|
runner.tick(); // schedules action
|
||||||
runner.tick(); // runs leaf
|
runner.tick(); // runs action
|
||||||
|
|
||||||
runner.cancel(leaf);
|
runner.cancel(action);
|
||||||
|
|
||||||
// leaf cancelled → child re-scheduled → child sees leaf cancelled → child cancelled
|
// action cancelled → child re-scheduled → child sees action cancelled → child cancelled
|
||||||
runner.tick(); // child processes cancelled leaf
|
runner.tick(); // child processes cancelled action
|
||||||
expect(world.has(child, Cancelled)).toBe(true);
|
expect(world.has(child, Cancelled)).toBe(true);
|
||||||
|
|
||||||
// child cancelled → root re-scheduled → root sees child cancelled → root cancelled
|
// child cancelled → root re-scheduled → root sees child cancelled → root cancelled
|
||||||
@@ -713,8 +800,8 @@ describe("Cancel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Multi-frame leaves ──────────────────────────────
|
// ── Multi-frame wait tasks ──────────────────────────────
|
||||||
describe("Multi-frame leaves", () => {
|
describe("Multi-frame wait tasks", () => {
|
||||||
let world: World;
|
let world: World;
|
||||||
let runner: TaskRunner;
|
let runner: TaskRunner;
|
||||||
|
|
||||||
@@ -723,28 +810,28 @@ describe("Multi-frame leaves", () => {
|
|||||||
runner = new TaskRunner(world);
|
runner = new TaskRunner(world);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaf stays Running across ticks until explicitly finished", () => {
|
it("wait task stays Running across ticks until explicitly finished", () => {
|
||||||
const leaf = makeLeaf(world);
|
const action = makeWait(world);
|
||||||
|
|
||||||
runner.schedule(leaf);
|
runner.schedule(action);
|
||||||
runner.tick();
|
runner.tick();
|
||||||
|
|
||||||
expect(world.has(leaf, Running)).toBe(true);
|
expect(world.has(action, Running)).toBe(true);
|
||||||
|
|
||||||
// Tick again — leaf is Running, not Scheduled, so nothing happens
|
// Tick again — action is Running, not Scheduled, so nothing happens
|
||||||
runner.tick();
|
runner.tick();
|
||||||
expect(world.has(leaf, Running)).toBe(true);
|
expect(world.has(action, Running)).toBe(true);
|
||||||
|
|
||||||
// External system finishes it
|
// External system finishes it
|
||||||
runner.succeed(leaf);
|
runner.succeed(action);
|
||||||
expect(world.has(leaf, Succeeded)).toBe(true);
|
expect(world.has(action, Succeeded)).toBe(true);
|
||||||
expect(world.has(leaf, Running)).toBe(false);
|
expect(world.has(action, Running)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sequential waits for multi-frame leaf before advancing", () => {
|
it("sequential waits for multi-frame wait before advancing", () => {
|
||||||
const seq = makeSequential(world);
|
const seq = makeSequential(world);
|
||||||
const a = makeLeaf(world, seq);
|
const a = makeWait(world, seq);
|
||||||
const b = makeLeaf(world, seq);
|
const b = makeWait(world, seq);
|
||||||
|
|
||||||
runner.schedule(seq);
|
runner.schedule(seq);
|
||||||
runner.tick(); // schedules a
|
runner.tick(); // schedules a
|
||||||
@@ -756,10 +843,9 @@ describe("Multi-frame leaves", () => {
|
|||||||
expect(world.has(b, Scheduled)).toBe(false);
|
expect(world.has(b, Scheduled)).toBe(false);
|
||||||
expect(world.has(b, Running)).toBe(false);
|
expect(world.has(b, Running)).toBe(false);
|
||||||
|
|
||||||
// Finish a
|
// Finish a; parent propagation starts b immediately.
|
||||||
runner.succeed(a);
|
runner.succeed(a);
|
||||||
runner.tick(); // seq schedules b
|
expect(world.has(b, Running)).toBe(true);
|
||||||
expect(world.has(b, Scheduled)).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -787,11 +873,11 @@ describe("Edge cases", () => {
|
|||||||
it("tick is a no-op when nothing is scheduled", () => {
|
it("tick is a no-op when nothing is scheduled", () => {
|
||||||
const world = new World();
|
const world = new World();
|
||||||
const runner = new TaskRunner(world);
|
const runner = new TaskRunner(world);
|
||||||
const leaf = makeLeaf(world);
|
const action = makeWait(world);
|
||||||
|
|
||||||
// Leaf exists but is not Scheduled
|
// Wait task exists but is not Scheduled
|
||||||
expect(() => runner.tick()).not.toThrow();
|
expect(() => runner.tick()).not.toThrow();
|
||||||
expect(world.has(leaf, Running)).toBe(false);
|
expect(world.has(action, Running)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deeply nested tree works correctly", () => {
|
it("deeply nested tree works correctly", () => {
|
||||||
@@ -800,29 +886,15 @@ describe("Edge cases", () => {
|
|||||||
|
|
||||||
const root = makeSequential(world);
|
const root = makeSequential(world);
|
||||||
const mid = makeSequential(world, root);
|
const mid = makeSequential(world, root);
|
||||||
const leaf = makeLeaf(world, mid);
|
const action = makeWait(world, mid);
|
||||||
|
|
||||||
runner.schedule(root);
|
runner.schedule(root);
|
||||||
|
|
||||||
// Tick 1: root schedules mid
|
|
||||||
runner.tick();
|
runner.tick();
|
||||||
expect(world.has(mid, Scheduled)).toBe(true);
|
expect(world.has(action, Running)).toBe(true);
|
||||||
|
|
||||||
// Tick 2: mid schedules leaf
|
runner.succeed(action);
|
||||||
runner.tick();
|
|
||||||
expect(world.has(leaf, Scheduled)).toBe(true);
|
|
||||||
|
|
||||||
// Tick 3: leaf runs
|
|
||||||
runner.tick();
|
|
||||||
expect(world.has(leaf, Running)).toBe(true);
|
|
||||||
|
|
||||||
runner.succeed(leaf);
|
|
||||||
// leaf done → mid scheduled
|
|
||||||
|
|
||||||
runner.tick(); // mid sees leaf done → mid succeeds
|
|
||||||
expect(world.has(mid, Succeeded)).toBe(true);
|
expect(world.has(mid, Succeeded)).toBe(true);
|
||||||
|
|
||||||
runner.tick(); // root sees mid done → root succeeds
|
|
||||||
expect(world.has(root, Succeeded)).toBe(true);
|
expect(world.has(root, Succeeded)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behavior tree completes a round after all players select", () => {
|
||||||
|
const { world, players } = setup();
|
||||||
|
const flow = createThreeMonksFlow(world);
|
||||||
|
|
||||||
|
flow.start();
|
||||||
|
flow.runner.tick();
|
||||||
|
|
||||||
|
for (const player of players) {
|
||||||
|
selectAction(world, player, "rest");
|
||||||
|
flow.notifySelectionChanged();
|
||||||
|
flow.runner.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user