Compare commits

..
11 Commits
Author SHA1 Message Date
hyper b038388deb refactor(three-monks): Replace commands with phase wrappers 2026-07-01 16:25:41 +08:00
hyper 6fa1f511bd feat(three-monks): Add tool effect statistics tracking 2026-07-01 16:12:40 +08:00
hyper 572b473089 feat(examples): Add Three Monks game example 2026-07-01 15:44:43 +08:00
hyper 0eb3defc62 feat(three-monks): Add design document 2026-07-01 14:48:22 +08:00
hyper 9bf0e5e049 feat(bt): Add wait and whilst, rename leaf to action 2026-06-28 15:07:34 +08:00
hyper 204b9100e6 feat(bt): Rename repeat to cycle and refactor runner
Rework task execution to be synchronous within a single tick.
Scheduling boundaries are now defined by `cycle` nodes, which
replace the previous `repeat` decorator.  All tasks are executed
immediately when scheduled, and parent/child propagation is
handled inline.
2026-06-28 14:51:52 +08:00
hyper 1253bf82d2 feat(bt)!: Remove legacy tree definition format
The `LegacyTreeDef` type and its normalization path have been removed.
All tree definitions must now use the factory functions (`leaf`,
`sequential`, `parallel`, `selector`, `random`, `repeat`). The
`TreeDef` type is now simply `TaskEntityDef`.
2026-06-28 10:19:40 +08:00
hyper ddde3f7597 feat(bt): Add entity-based task factories
Add `leaf`, `sequential`, `parallel`, `selector`, `random`, and `repeat`
functions that return `EntityDef` trees. Legacy object definitions are
still
accepted for backward compatibility. Non-task child entities are ignored
by
the runner, and `buildTree` now stores the root entity on
`TaskRunner.root`.
2026-06-28 10:15:38 +08:00
hypercross 968672da06 fix(blackjack): update payout function signature 2026-06-02 18:08:01 +08:00
hypercross 365b2c4d13 fix: handle edge object in serialization tests
Add `typecheck` script to package.json, expand tsconfig include
paths to cover tests and examples, and update serialization tests
to correctly resolve player IDs from relationship edges.
2026-06-02 18:05:47 +08:00
hypercross cd6350e0b1 feat: add support for data-carrying relationships
Introduce the ability to attach optional data payloads to
relationships. This includes:

- Updating `defineRelationship` to accept default values.
- Adding `getRelData` and `setRelData` to the `World` class.
- Allowing `relate` to accept an optional data override.
- Updating serialization to include relationship data in snapshots.
- Implementing lazy storage for relationship data using `SparseSet`.
2026-06-02 17:56:12 +08:00
28 changed files with 3231 additions and 671 deletions
+60 -46
View File
@@ -393,82 +393,96 @@ Command entities are automatically destroyed after processing if they become bar
Behaviour trees control game flow by composing tasks into a tree. Each node in the tree is an ECS entity with a `Task` component. Parent-child relationships are `ChildOf` edges. This means you can query, observe, and serialize the tree just like any other ECS data. Behaviour trees control game flow by composing tasks into a tree. Each node in the tree is an ECS entity with a `Task` component. Parent-child relationships are `ChildOf` edges. This means you can query, observe, and serialize the tree just like any other ECS data.
`buildTree()` takes a declarative definition and materializes it into entities, returning a fully-wired `TaskRunner`. `buildTree()` takes a task entity definition and materializes it into entities, returning a fully-wired `TaskRunner`. Task definitions are created with factories, and non-task child entities can be mixed in as metadata; the runner ignores those non-task children during execution.
```ts ```ts
import { buildTree, Cancel } from "ecs-observable/bt"; import { defineComponent, entity } from "ecs-observable";
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
{ kind: "leaf", run: () => { doWork(); } } action(() => { doWork(); })
``` ```
**Fail** — throw any error. **Fail** — throw any error.
```ts ```ts
{ kind: "leaf", run: () => { throw new Error("bad"); } } action(() => { throw new Error("bad"); })
``` ```
**Cancel** — throw the `Cancel` symbol. **Cancel** — throw the `Cancel` symbol.
```ts ```ts
{ kind: "leaf", run: () => { 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
{ kind: "leaf", *run() { wait((world, entity, task) => {
while (true) { startAnimation(() => task.succeed());
const dt: number = yield; // delta time from runner.tick(dt) })
timer.accumulator += dt; ```
if (timer.accumulator >= timer.interval) {
// ... act ... **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;
if (timer.accumulator >= timer.interval) {
// ... act ...
} }
} } }),
)
``` ```
#### Composite nodes #### Composite nodes
```ts ```ts
{ kind: "sequential", children: [a, b, c] } // left-to-right, all must succeed sequential([a, b, c]) // left-to-right, all must succeed
{ kind: "selector", children: [a, b, c] } // left-to-right, first success wins selector([a, b, c]) // left-to-right, first success wins
{ kind: "parallel", children: [a, b, c] } // all at once, all must succeed parallel([a, b, c]) // all at once, all must succeed
{ kind: "random", children: [a, b, c] } // pick one child each activation random([a, b, c]) // pick one child each activation
{ kind: "repeat", child: 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:
```ts
const Label = defineComponent("label", { value: "" });
sequential([
entity(Label, { value: "main sequence" }),
action(handleInput),
action(render),
])
``` ```
#### Full example #### Full example
```ts ```ts
const runner = buildTree(world, { const runner = buildTree(
kind: "parallel", world,
children: [ parallel([
{ whilst(
kind: "leaf", () => true,
*run() { action((_world, _entity, dt) => {
while (true) { updatePhysics(dt);
const dt: number = yield; }),
updatePhysics(dt); ),
} cycle(
}, sequential([
}, action(() => { handleInput(); }),
{ action(() => { render(); }),
kind: "repeat", ]),
child: { ),
kind: "sequential", ]),
children: [ );
{ kind: "leaf", run: () => { handleInput(); } },
{ kind: "leaf", run: () => { render(); } },
],
},
},
],
});
// Kick off // Kick off
runner.schedule((runner as any).root); runner.schedule(runner.root!);
// Game loop // Game loop
setInterval(() => runner.tick(16), 16); setInterval(() => runner.tick(16), 16);
+1 -1
View File
@@ -166,7 +166,7 @@ export function createCardHelpers(world: World) {
message: "Both have Blackjack — Push! Press N for new round.", message: "Both have Blackjack — Push! Press N for new round.",
}); });
} else { } else {
const winnings = payout(0, "blackjack", bet.amount); const winnings = payout("blackjack", bet.amount);
score.chips += bet.amount + winnings; score.chips += bet.amount + winnings;
score.wins++; score.wins++;
world.setSingleton(GamePhase, { world.setSingleton(GamePhase, {
+40 -46
View File
@@ -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()
@@ -23,7 +23,14 @@
// npx tsx examples/blackjack/main.ts // npx tsx examples/blackjack/main.ts
import { World } from "../../src/index"; import { World } from "../../src/index";
import { buildTree } from "../../src/bt/index"; import {
buildTree,
action,
parallel,
cycle,
sequential,
whilst,
} from "../../src/bt/index";
import { CommandQueue } from "../../src/commands/index"; import { CommandQueue } from "../../src/commands/index";
import { import {
@@ -63,50 +70,37 @@ const commands = new CommandQueue(world);
registerCommands(world, commands, cards); registerCommands(world, commands, cards);
// ── Behaviour Tree ─────────────────────────────────── // ── Behaviour Tree ───────────────────────────────────
const runner = buildTree(world, { const runner = buildTree(
kind: "parallel", world,
children: [ parallel([
{ whilst(
kind: "leaf", () => true,
*run() { action(() => {
while (true) { const phase = world.getSingleton(GamePhase);
const dt: number = yield; if (phase.phase !== "dealerTurn") return;
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "dealerTurn") continue;
if (dealerShouldHit(cards.getHand(InDealerHand))) { if (dealerShouldHit(cards.getHand(InDealerHand))) {
const cardEntity = cards.drawCard(); const cardEntity = cards.drawCard();
if (cardEntity) { if (cardEntity) {
cards.dealTo(cardEntity, InDealerHand); cards.dealTo(cardEntity, InDealerHand);
}
} else {
resolveRound(world, cards);
} }
} else {
resolveRound(world, cards);
} }
}, }),
}, ),
{ cycle(
kind: "repeat", sequential([
child: { action(() => {
kind: "sequential", commands.execute();
children: [ }),
{ action(() => {
kind: "leaf", render(world, ui);
run: () => { }),
commands.execute(); ]),
}, ),
}, ]),
{ );
kind: "leaf",
run: () => {
render(world, ui);
},
},
],
},
},
],
});
// ── Input → Command mapping ────────────────────────── // ── Input → Command mapping ──────────────────────────
const keyToCommand: Partial<Record<Key, typeof Hit>> = { const keyToCommand: Partial<Record<Key, typeof Hit>> = {
+46 -52
View File
@@ -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()
@@ -20,7 +20,14 @@
// npx tsx examples/tetris/main.ts // npx tsx examples/tetris/main.ts
import { World } from "../../src/index"; import { World } from "../../src/index";
import { buildTree } from "../../src/bt/index"; import {
buildTree,
action,
parallel,
cycle,
sequential,
whilst,
} from "../../src/bt/index";
import { CommandQueue } from "../../src/commands/index"; import { CommandQueue } from "../../src/commands/index";
import { import {
@@ -64,56 +71,43 @@ registerCommands(world, commands, pieces);
pieces.spawnPiece(); pieces.spawnPiece();
// ── Behaviour Tree ─────────────────────────────────── // ── Behaviour Tree ───────────────────────────────────
const runner = buildTree(world, { const runner = buildTree(
kind: "parallel", world,
children: [ parallel([
{ whilst(
kind: "leaf", () => true,
*run() { action((_world, _entity, dt) => {
while (true) { if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
const dt: number = yield; return;
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) { }
continue; const timer = world.getSingleton(TickTimer);
} timer.accumulator += dt;
const timer = world.getSingleton(TickTimer); if (timer.accumulator >= timer.interval) {
timer.accumulator += dt; timer.accumulator -= timer.interval;
if (timer.accumulator >= timer.interval) { if (world.hasSingleton(Piece)) {
timer.accumulator -= timer.interval; const piece = world.getSingleton(Piece);
if (world.hasSingleton(Piece)) { const board = world.getSingleton(Board);
const piece = world.getSingleton(Piece); if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
const board = world.getSingleton(Board); piece.y++;
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) { } else {
piece.y++; pieces.lockAndSpawn();
} else {
pieces.lockAndSpawn();
}
} }
} }
} }
}, }),
}, ),
{ cycle(
kind: "repeat", sequential([
child: { action(() => {
kind: "sequential", commands.execute();
children: [ }),
{ action(() => {
kind: "leaf", render(world, ui);
run: () => { }),
commands.execute(); ]),
}, ),
}, ]),
{ );
kind: "leaf",
run: () => {
render(world, ui);
},
},
],
},
},
],
});
// ── Input → Command mapping ────────────────────────── // ── Input → Command mapping ──────────────────────────
const keyToCommand: Partial<Record<Key, typeof MoveLeft>> = { const keyToCommand: Partial<Record<Key, typeof MoveLeft>> = {
+219
View File
@@ -0,0 +1,219 @@
import { defineComponent } from "../../src/component";
import type { Entity, World } from "../../src/index";
import { query } from "../../src/query";
export type ActionKind = "fetchWater" | "exchange" | "drink" | "chant" | "rest";
export type ActionZone = "hand" | "selected" | "cooldown";
export type ToolKind =
| "woodenFish"
| "bucket"
| "bottle"
| "woodenBucket"
| "bigBowl"
| "bigBucket"
| "mouse"
| "shoulderPole"
| "waterJar"
| "ladle";
export type Phase = "setup" | "selecting" | "resolving" | "gameOver";
export const ACTION_KINDS: readonly ActionKind[] = [
"fetchWater",
"exchange",
"drink",
"chant",
"rest",
] as const;
export const TOOL_KINDS: readonly ToolKind[] = [
"woodenFish",
"bucket",
"bottle",
"woodenBucket",
"bigBowl",
"bigBucket",
"mouse",
"shoulderPole",
"waterJar",
"ladle",
] as const;
export const CARRYING_TOOLS = new Set<ToolKind>([
"bucket",
"woodenBucket",
"bigBucket",
"shoulderPole",
"ladle",
]);
export const Player = defineComponent("threeMonks.player", {
seat: 0,
name: "",
water: 0,
});
export const ActionCard = defineComponent("threeMonks.actionCard", {
owner: 0 as Entity,
kind: "rest" as ActionKind,
zone: "hand" as ActionZone,
});
export const Tool = defineComponent("threeMonks.tool", {
owner: 0 as Entity,
kind: "bucket" as ToolKind,
water: 0,
});
export const Table = defineComponent("threeMonks.table", {
centralWater: 0,
round: 1,
});
export const WoodenFishMarker = defineComponent("threeMonks.woodenFishMarker", {
holder: 0 as Entity,
});
export const GameState = defineComponent("threeMonks.gameState", {
phase: "setup" as Phase,
winner: 0 as Entity | 0,
message: "",
});
export type RandomFn = () => number;
export function setupGame(
world: World,
playerNames: readonly string[],
random: RandomFn = Math.random,
): Entity[] {
if (playerNames.length < 3 || playerNames.length > 8) {
throw new Error("Three Monks requires 3-8 players");
}
const players: Entity[] = [];
for (let seat = 0; seat < playerNames.length; seat++) {
const player = world.spawn();
world.add(player, Player, { seat, name: playerNames[seat], water: 2 });
players.push(player);
for (const kind of ACTION_KINDS) {
const card = world.spawn();
world.add(card, ActionCard, { owner: player, kind, zone: "hand" });
}
}
const tools = dealBalancedTools(players.length, random);
for (let i = 0; i < players.length; i++) {
const tool = world.spawn();
world.add(tool, Tool, { owner: players[i], kind: tools[i], water: 0 });
}
const holder = players[Math.floor(random() * players.length)];
world.addSingleton(Table, { centralWater: 0, round: 1 });
world.addSingleton(WoodenFishMarker, { holder });
world.addSingleton(GameState, {
phase: "selecting",
winner: 0,
message: "Choose an action card.",
});
return players;
}
export function getPlayersInSeatOrder(world: World): Entity[] {
return [...world.query(query(Player))].sort(
(a, b) => world.get(a, Player).seat - world.get(b, Player).seat,
);
}
export function getPlayersFromMarker(world: World): Entity[] {
const players = getPlayersInSeatOrder(world);
if (players.length === 0) return [];
const holder = world.getSingleton(WoodenFishMarker).holder;
const index = Math.max(0, players.indexOf(holder));
return [...players.slice(index), ...players.slice(0, index)];
}
export function getLeftPlayer(world: World, player: Entity): Entity {
const players = getPlayersInSeatOrder(world);
const index = players.indexOf(player);
if (index < 0) throw new Error("Player is not seated");
return players[(index + 1) % players.length];
}
export function getRightPlayer(world: World, player: Entity): Entity {
const players = getPlayersInSeatOrder(world);
const index = players.indexOf(player);
if (index < 0) throw new Error("Player is not seated");
return players[(index - 1 + players.length) % players.length];
}
export function getToolOf(world: World, player: Entity): Entity {
for (const tool of world.query(query(Tool))) {
if (world.get(tool, Tool).owner === player) return tool;
}
throw new Error("Player does not have a tool");
}
export function getActionCard(
world: World,
player: Entity,
kind: ActionKind,
): Entity | null {
for (const card of world.query(query(ActionCard))) {
const data = world.get(card, ActionCard);
if (data.owner === player && data.kind === kind) return card;
}
return null;
}
export function getSelectedAction(
world: World,
player: Entity,
): ActionKind | null {
for (const card of world.query(query(ActionCard))) {
const data = world.get(card, ActionCard);
if (data.owner === player && data.zone === "selected") return data.kind;
}
return null;
}
export function allPlayersSelected(world: World): boolean {
return getPlayersInSeatOrder(world).every(
(player) => getSelectedAction(world, player) !== null,
);
}
export function hasWinner(world: World): boolean {
return world.getSingleton(GameState).winner !== 0;
}
function dealBalancedTools(playerCount: number, random: RandomFn): ToolKind[] {
const carrying = shuffle(
TOOL_KINDS.filter((kind) => CARRYING_TOOLS.has(kind)),
random,
);
const nonCarrying = shuffle(
TOOL_KINDS.filter((kind) => !CARRYING_TOOLS.has(kind)),
random,
);
const carryingCount = Math.ceil(playerCount / 2);
const tools = [
...carrying.slice(0, carryingCount),
...nonCarrying.slice(0, playerCount - carryingCount),
];
return shuffle(tools, random);
}
function shuffle<T>(items: T[], random: RandomFn): T[] {
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
return items;
}
+78
View File
@@ -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`: 单局详细回放
+71
View File
@@ -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();
},
};
}
+208
View File
@@ -0,0 +1,208 @@
import { query, type Entity, type World } from "../../src/index";
import {
ActionCard,
GameState,
Player,
Table,
Tool,
WoodenFishMarker,
getPlayersInSeatOrder,
getSelectedAction,
getToolOf,
type ActionKind,
type ToolKind,
} from "./components";
import type { RoundProposals } from "./rules";
export const ACTION_LABELS: Record<ActionKind, string> = {
fetchWater: "挑水",
exchange: "交换",
drink: "喝水",
chant: "念经",
rest: "休息",
};
export const TOOL_LABELS: Record<ToolKind, string> = {
woodenFish: "木鱼",
bucket: "水桶",
bottle: "净瓶",
woodenBucket: "木桶",
bigBowl: "大碗",
bigBucket: "大桶",
mouse: "老鼠",
shoulderPole: "扁担",
waterJar: "水缸",
ladle: "水瓢",
};
export interface PlayerSnapshot {
entity: Entity;
name: string;
seat: number;
water: number;
action: ActionKind | null;
tool: ToolKind;
toolWater: number;
hasMarker: boolean;
}
export interface GameSnapshot {
round: number;
centralWater: number;
phase: string;
winner: Entity | 0;
players: PlayerSnapshot[];
}
export class GameLog {
private readonly lines: string[] = [];
add(line = ""): void {
this.lines.push(line);
}
section(title: string): void {
if (this.lines.length > 0) this.lines.push("");
this.lines.push(title);
}
entries(): readonly string[] {
return this.lines;
}
toString(): string {
return this.lines.join("\n");
}
}
export function snapshotGame(world: World): GameSnapshot {
const table = world.getSingleton(Table);
const marker = world.getSingleton(WoodenFishMarker).holder;
const state = world.getSingleton(GameState);
return {
round: table.round,
centralWater: table.centralWater,
phase: state.phase,
winner: state.winner,
players: getPlayersInSeatOrder(world).map((player) => {
const playerData = world.get(player, Player);
const toolData = world.get(getToolOf(world, player), Tool);
return {
entity: player,
name: playerData.name,
seat: playerData.seat,
water: playerData.water,
action: getSelectedAction(world, player),
tool: toolData.kind,
toolWater: toolData.water,
hasMarker: player === marker,
};
}),
};
}
export function formatSnapshot(snapshot: GameSnapshot): string[] {
return [
`${snapshot.round} 轮 | 中央水=${snapshot.centralWater} | 阶段=${formatPhase(snapshot.phase)}`,
...snapshot.players.map(formatPlayerSnapshot),
];
}
export function formatPlayerSnapshot(player: PlayerSnapshot): string {
const marker = player.hasMarker ? " 🪵" : "";
const action = player.action ? ` | 行动=${ACTION_LABELS[player.action]}` : "";
return `${player.name}${marker}: 已喝=${player.water}, 道具=${TOOL_LABELS[player.tool]}(${player.toolWater})${action}`;
}
export function formatProposals(proposals: RoundProposals): string {
const phases: string[] = [];
if (proposals.chant) phases.push("念经");
if (proposals.fetchWater) phases.push("挑水");
if (proposals.exchange) phases.push("交换");
if (proposals.drink) phases.push("喝水");
return phases.length > 0 ? phases.join(" → ") : "无行动阶段";
}
export function formatAvailableActions(world: World, player: Entity): string {
const actions: ActionKind[] = [];
for (const card of world.query(query(ActionCard))) {
const data = world.get(card, ActionCard);
if (data.owner === player && data.zone === "hand") actions.push(data.kind);
}
return actions.map((action) => ACTION_LABELS[action]).join("、");
}
export function logSnapshot(log: GameLog, world: World): void {
for (const line of formatSnapshot(snapshotGame(world))) {
log.add(line);
}
}
export function logPhaseDelta(
log: GameLog,
label: string,
before: GameSnapshot,
after: GameSnapshot,
): void {
const changes: string[] = [];
if (before.centralWater !== after.centralWater) {
changes.push(`中央水 ${before.centralWater}${after.centralWater}`);
}
for (const beforePlayer of before.players) {
const afterPlayer = after.players.find(
(p) => p.entity === beforePlayer.entity,
)!;
const playerChanges: string[] = [];
if (beforePlayer.water !== afterPlayer.water) {
playerChanges.push(`已喝 ${beforePlayer.water}${afterPlayer.water}`);
}
if (
beforePlayer.tool !== afterPlayer.tool ||
beforePlayer.toolWater !== afterPlayer.toolWater
) {
playerChanges.push(
`道具 ${TOOL_LABELS[beforePlayer.tool]}(${beforePlayer.toolWater})→${TOOL_LABELS[afterPlayer.tool]}(${afterPlayer.toolWater})`,
);
}
if (beforePlayer.hasMarker !== afterPlayer.hasMarker) {
playerChanges.push(
afterPlayer.hasMarker ? "获得木鱼标记" : "失去木鱼标记",
);
}
if (playerChanges.length > 0) {
changes.push(`${afterPlayer.name}: ${playerChanges.join("")}`);
}
}
if (changes.length === 0) {
log.add(` ${label}: 无变化`);
} else {
log.add(` ${label}: ${changes.join("")}`);
}
}
export function formatPhase(phase: string): string {
switch (phase) {
case "setup":
return "设置";
case "selecting":
return "出牌";
case "resolving":
return "结算";
case "gameOver":
return "游戏结束";
default:
return phase;
}
}
export function winnerName(world: World): string | null {
const winner = world.getSingleton(GameState).winner;
if (winner === 0) return null;
return world.get(winner, Player).name;
}
+235
View File
@@ -0,0 +1,235 @@
三个和尚随机对局(种子=20260701)
玩家:慧空、明心、了尘、净远
初始状态:
第 1 轮 | 中央水=0 | 阶段=出牌
慧空: 已喝=2, 道具=老鼠(0)
明心: 已喝=2, 道具=水瓢(0)
了尘: 已喝=2, 道具=木桶(0)
净远 🪵: 已喝=2, 道具=水缸(0)
第 1 轮
慧空 选择 挑水(可选:挑水、交换、喝水、念经、休息)
明心 选择 休息(可选:挑水、交换、喝水、念经、休息)
了尘 选择 交换(可选:挑水、交换、喝水、念经、休息)
净远 选择 喝水(可选:挑水、交换、喝水、念经、休息)
本轮行动阶段:挑水 → 交换 → 喝水
挑水: 中央水 0→3;了尘: 已喝 2→0,道具 木桶(0)→木桶(2)
交换: 慧空: 道具 老鼠(0)→水缸(0);明心: 道具 水瓢(0)→老鼠(0);了尘: 道具 木桶(2)→水瓢(0);净远: 道具 水缸(0)→木桶(2)
喝水: 中央水 3→0;明心: 已喝 2→3;了尘: 已喝 0→1;净远: 已喝 2→5,道具 木桶(2)→木桶(0)
轮末道具: 无变化
轮末状态:
第 2 轮 | 中央水=0 | 阶段=出牌
慧空: 已喝=2, 道具=水缸(0)
明心: 已喝=3, 道具=老鼠(0)
了尘: 已喝=1, 道具=水瓢(0)
净远 🪵: 已喝=5, 道具=木桶(0)
第 2 轮
慧空 选择 念经(可选:交换、喝水、念经、休息)
明心 选择 交换(可选:挑水、交换、喝水、念经)
了尘 选择 休息(可选:挑水、喝水、念经、休息)
净远 选择 交换(可选:挑水、交换、念经、休息)
本轮行动阶段:念经 → 交换
念经: 慧空: 获得木鱼标记;净远: 失去木鱼标记
交换: 明心: 道具 老鼠(0)→木桶(0);了尘: 道具 水瓢(0)→老鼠(0);净远: 道具 木桶(0)→水瓢(0)
轮末道具: 无变化
轮末状态:
第 3 轮 | 中央水=0 | 阶段=出牌
慧空 🪵: 已喝=2, 道具=水缸(0)
明心: 已喝=3, 道具=木桶(0)
了尘: 已喝=1, 道具=老鼠(0)
净远: 已喝=5, 道具=水瓢(0)
第 3 轮
慧空 选择 交换(可选:挑水、交换、喝水、休息)
明心 选择 喝水(可选:挑水、喝水、念经、休息)
了尘 选择 交换(可选:挑水、交换、喝水、念经)
净远 选择 挑水(可选:挑水、喝水、念经、休息)
本轮行动阶段:挑水 → 交换 → 喝水
挑水: 中央水 0→1;净远: 已喝 5→3,道具 水瓢(0)→水瓢(3)
交换: 慧空: 道具 水缸(0)→水瓢(3);明心: 道具 木桶(0)→水缸(0);了尘: 道具 老鼠(0)→木桶(0);净远: 道具 水瓢(3)→老鼠(0)
喝水: 中央水 1→0;慧空: 已喝 2→5,道具 水瓢(3)→水瓢(1);明心: 已喝 3→4
轮末道具: 无变化
轮末状态:
第 4 轮 | 中央水=0 | 阶段=出牌
慧空 🪵: 已喝=5, 道具=水瓢(1)
明心: 已喝=4, 道具=水缸(0)
了尘: 已喝=1, 道具=木桶(0)
净远: 已喝=3, 道具=老鼠(0)
第 4 轮
慧空 选择 念经(可选:挑水、喝水、念经、休息)
明心 选择 挑水(可选:挑水、交换、念经、休息)
了尘 选择 休息(可选:挑水、喝水、念经、休息)
净远 选择 休息(可选:交换、喝水、念经、休息)
本轮行动阶段:念经 → 挑水
念经: 无变化
挑水: 中央水 0→2;慧空: 已喝 5→3,道具 水瓢(1)→水瓢(4)
轮末道具: 无变化
轮末状态:
第 5 轮 | 中央水=2 | 阶段=出牌
慧空 🪵: 已喝=3, 道具=水瓢(4)
明心: 已喝=4, 道具=水缸(0)
了尘: 已喝=1, 道具=木桶(0)
净远: 已喝=3, 道具=老鼠(0)
第 5 轮
慧空 选择 休息(可选:挑水、交换、喝水、休息)
明心 选择 喝水(可选:交换、喝水、念经、休息)
了尘 选择 挑水(可选:挑水、交换、喝水、念经)
净远 选择 念经(可选:挑水、交换、喝水、念经)
本轮行动阶段:念经 → 挑水 → 喝水
念经: 慧空: 道具 水瓢(4)→水瓢(3),失去木鱼标记;净远: 道具 老鼠(0)→老鼠(1),获得木鱼标记
挑水: 中央水 2→5
喝水: 中央水 5→1;慧空: 已喝 3→6,道具 水瓢(3)→水瓢(1);明心: 已喝 4→6;了尘: 已喝 1→2;净远: 已喝 3→5,道具 老鼠(1)→老鼠(0)
轮末道具: 无变化
轮末状态:
第 6 轮 | 中央水=1 | 阶段=出牌
慧空: 已喝=6, 道具=水瓢(1)
明心: 已喝=6, 道具=水缸(0)
了尘: 已喝=2, 道具=木桶(0)
净远 🪵: 已喝=5, 道具=老鼠(0)
第 6 轮
慧空 选择 念经(可选:挑水、交换、喝水、念经)
明心 选择 挑水(可选:挑水、交换、念经、休息)
了尘 选择 交换(可选:交换、喝水、念经、休息)
净远 选择 挑水(可选:挑水、交换、喝水、休息)
本轮行动阶段:念经 → 挑水 → 交换
念经: 慧空: 获得木鱼标记;净远: 失去木鱼标记
挑水: 中央水 1→2;慧空: 已喝 6→4,道具 水瓢(1)→水瓢(4)
交换: 明心: 道具 水缸(0)→老鼠(0);了尘: 道具 木桶(0)→水缸(0);净远: 道具 老鼠(0)→木桶(0)
轮末道具: 无变化
轮末状态:
第 7 轮 | 中央水=2 | 阶段=出牌
慧空 🪵: 已喝=4, 道具=水瓢(4)
明心: 已喝=6, 道具=老鼠(0)
了尘: 已喝=2, 道具=水缸(0)
净远: 已喝=5, 道具=木桶(0)
第 7 轮
慧空 选择 喝水(可选:挑水、交换、喝水、休息)
明心 选择 休息(可选:交换、喝水、念经、休息)
了尘 选择 休息(可选:挑水、喝水、念经、休息)
净远 选择 休息(可选:交换、喝水、念经、休息)
本轮行动阶段:喝水
喝水: 中央水 2→0;慧空: 已喝 4→7,道具 水瓢(4)→水瓢(2);明心: 已喝 6→7
轮末道具: 无变化
轮末状态:
第 8 轮 | 中央水=0 | 阶段=出牌
慧空 🪵: 已喝=7, 道具=水瓢(2)
明心: 已喝=7, 道具=老鼠(0)
了尘: 已喝=2, 道具=水缸(0)
净远: 已喝=5, 道具=木桶(0)
第 8 轮
慧空 选择 休息(可选:挑水、交换、念经、休息)
明心 选择 挑水(可选:挑水、交换、喝水、念经)
了尘 选择 挑水(可选:挑水、交换、喝水、念经)
净远 选择 挑水(可选:挑水、交换、喝水、念经)
本轮行动阶段:挑水
挑水: 中央水 0→3;净远: 已喝 5→3,道具 木桶(0)→木桶(2)
轮末道具: 无变化
轮末状态:
第 9 轮 | 中央水=3 | 阶段=出牌
慧空 🪵: 已喝=7, 道具=水瓢(2)
明心: 已喝=7, 道具=老鼠(0)
了尘: 已喝=2, 道具=水缸(0)
净远: 已喝=3, 道具=木桶(2)
第 9 轮
慧空 选择 交换(可选:挑水、交换、喝水、念经)
明心 选择 休息(可选:交换、喝水、念经、休息)
了尘 选择 交换(可选:交换、喝水、念经、休息)
净远 选择 交换(可选:交换、喝水、念经、休息)
本轮行动阶段:交换
交换: 慧空: 道具 水瓢(2)→木桶(2);明心: 道具 老鼠(0)→水瓢(2);了尘: 道具 水缸(0)→老鼠(0);净远: 道具 木桶(2)→水缸(0)
轮末道具: 无变化
轮末状态:
第 10 轮 | 中央水=3 | 阶段=出牌
慧空 🪵: 已喝=7, 道具=木桶(2)
明心: 已喝=7, 道具=水瓢(2)
了尘: 已喝=2, 道具=老鼠(0)
净远: 已喝=3, 道具=水缸(0)
第 10 轮
慧空 选择 喝水(可选:挑水、喝水、念经、休息)
明心 选择 交换(可选:挑水、交换、喝水、念经)
了尘 选择 念经(可选:挑水、喝水、念经、休息)
净远 选择 挑水(可选:挑水、喝水、念经、休息)
本轮行动阶段:念经 → 挑水 → 交换 → 喝水
念经: 慧空: 失去木鱼标记;明心: 道具 水瓢(2)→水瓢(1);了尘: 道具 老鼠(0)→老鼠(1),获得木鱼标记
挑水: 中央水 3→4;明心: 已喝 7→5,道具 水瓢(1)→水瓢(4)
交换: 慧空: 道具 木桶(2)→水缸(0);明心: 道具 水瓢(4)→木桶(2);净远: 道具 水缸(0)→水瓢(4)
喝水: 中央水 4→0;慧空: 已喝 7→9;明心: 已喝 5→8,道具 木桶(2)→木桶(0);了尘: 已喝 2→4,道具 老鼠(1)→老鼠(0);净远: 已喝 3→6,道具 水瓢(4)→水瓢(2)
轮末道具: 无变化
轮末状态:
第 11 轮 | 中央水=0 | 阶段=出牌
慧空: 已喝=9, 道具=水缸(0)
明心: 已喝=8, 道具=木桶(0)
了尘 🪵: 已喝=4, 道具=老鼠(0)
净远: 已喝=6, 道具=水瓢(2)
第 11 轮
慧空 选择 挑水(可选:挑水、交换、念经、休息)
明心 选择 喝水(可选:挑水、喝水、念经、休息)
了尘 选择 休息(可选:挑水、交换、喝水、休息)
净远 选择 休息(可选:交换、喝水、念经、休息)
本轮行动阶段:挑水 → 喝水
挑水: 中央水 0→3;明心: 已喝 8→6,道具 木桶(0)→木桶(2)
喝水: 中央水 3→0;明心: 已喝 6→9,道具 木桶(2)→木桶(0);了尘: 已喝 4→5;净远: 已喝 6→9,道具 水瓢(2)→水瓢(0)
轮末道具: 无变化
轮末状态:
第 12 轮 | 中央水=0 | 阶段=出牌
慧空: 已喝=9, 道具=水缸(0)
明心: 已喝=9, 道具=木桶(0)
了尘 🪵: 已喝=5, 道具=老鼠(0)
净远: 已喝=9, 道具=水瓢(0)
第 12 轮
慧空 选择 休息(可选:交换、喝水、念经、休息)
明心 选择 挑水(可选:挑水、交换、念经、休息)
了尘 选择 挑水(可选:挑水、交换、喝水、念经)
净远 选择 念经(可选:挑水、交换、喝水、念经)
本轮行动阶段:念经 → 挑水
念经: 了尘: 失去木鱼标记;净远: 获得木鱼标记
挑水: 中央水 0→1;净远: 已喝 9→7,道具 水瓢(0)→水瓢(3)
轮末道具: 无变化
轮末状态:
第 13 轮 | 中央水=1 | 阶段=出牌
慧空: 已喝=9, 道具=水缸(0)
明心: 已喝=9, 道具=木桶(0)
了尘: 已喝=5, 道具=老鼠(0)
净远 🪵: 已喝=7, 道具=水瓢(3)
第 13 轮
慧空 选择 交换(可选:挑水、交换、喝水、念经)
明心 选择 交换(可选:交换、喝水、念经、休息)
了尘 选择 念经(可选:交换、喝水、念经、休息)
净远 选择 交换(可选:挑水、交换、喝水、休息)
本轮行动阶段:念经 → 交换
念经: 了尘: 道具 老鼠(0)→老鼠(1),获得木鱼标记;净远: 道具 水瓢(3)→水瓢(2),失去木鱼标记
交换: 慧空: 道具 水缸(0)→水瓢(2);明心: 道具 木桶(0)→水缸(0);净远: 道具 水瓢(2)→木桶(0)
轮末道具: 无变化
轮末状态:
第 14 轮 | 中央水=1 | 阶段=出牌
慧空: 已喝=9, 道具=水瓢(2)
明心: 已喝=9, 道具=水缸(0)
了尘 🪵: 已喝=5, 道具=老鼠(1)
净远: 已喝=7, 道具=木桶(0)
第 14 轮
慧空 选择 喝水(可选:挑水、喝水、念经、休息)
明心 选择 喝水(可选:挑水、喝水、念经、休息)
了尘 选择 休息(可选:挑水、交换、喝水、休息)
净远 选择 休息(可选:挑水、喝水、念经、休息)
本轮行动阶段:喝水
喝水: 中央水 1→0;慧空: 已喝 9→11,道具 水瓢(2)→水瓢(0);了尘: 已喝 5→7,道具 老鼠(1)→老鼠(0)
游戏结束
胜者:慧空
第 14 轮 | 中央水=0 | 阶段=游戏结束
慧空: 已喝=11, 道具=水瓢(0) | 行动=喝水
明心: 已喝=9, 道具=水缸(0) | 行动=喝水
了尘 🪵: 已喝=7, 道具=老鼠(0) | 行动=休息
净远: 已喝=7, 道具=木桶(0) | 行动=休息
+151
View File
@@ -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);
}
+372
View File
@@ -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!`;
}
+129
View File
@@ -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();
}
+94
View File
@@ -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(" | "),
),
];
}
+1
View File
@@ -29,6 +29,7 @@
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"dev": "tsup --watch", "dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
+27 -3
View File
@@ -11,7 +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 { buildTree, Cancel } from "./tree-def"; export {
export type { TreeDef, LeafFn } from "./tree-def"; buildTree,
Cancel,
action,
wait,
whilst,
sequential,
parallel,
selector,
random,
cycle,
} from "./tree-def";
export type {
TreeDef,
TaskEntityDef,
TaskMeta,
ActionFn,
WaitFn,
ConditionFn,
} from "./tree-def";
+277 -147
View File
@@ -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 = (
@@ -53,7 +76,11 @@ function clearSubtree(world: World, entity: Entity): void {
} }
function* childrenOf(world: World, parent: Entity): IterableIterator<Entity> { function* childrenOf(world: World, parent: Entity): IterableIterator<Entity> {
yield* world.getRelatedTo(parent, ChildOf); for (const child of world.getRelatedTo(parent, ChildOf)) {
if (world.has(child, Task)) {
yield child;
}
}
} }
function parentOf(world: World, child: Entity): Entity | null { function parentOf(world: World, child: Entity): Entity | null {
@@ -64,31 +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;
/** Called when a leaf task becomes Scheduled. */ /** Root task entity, set by `buildTree` for convenience. */
onLeaf: LeafHandler = () => {}; root?: Entity;
/** Called when an action task starts executing. */
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 = () => {};
@@ -100,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 scheduled = [...this._world.query(query(Task, Scheduled))]; const previousDt = this._currentDt;
for (const entity of scheduled) { this._currentDt = dt;
this._world.remove(entity, Scheduled);
this._execute(entity, dt); try {
const scheduled = [...this._world.query(query(Task, Scheduled))];
for (const entity of scheduled) {
if (!this._world.has(entity, Scheduled)) continue;
this._world.remove(entity, Scheduled);
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);
@@ -145,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;
switch (t.kind) { const t = this._world.get(entity, Task);
case "leaf": this._executing.add(entity);
this._executeLeaf(entity, dt);
break; try {
case "sequential": switch (t.kind) {
this._executeSequential(entity); case "action":
break; this._executeAction(entity, dt);
case "parallel": break;
this._executeParallel(entity); case "wait":
break; this._executeWait(entity, dt);
case "random": break;
this._executeRandom(entity); case "sequential":
break; this._executeSequential(entity, dt);
case "repeat": break;
this._executeRepeat(entity); case "parallel":
break; this._executeParallel(entity, dt);
case "selector": break;
this._executeSelector(entity); case "random":
break; this._executeRandom(entity, dt);
break;
case "cycle":
this._executeCycle(entity, dt);
break;
case "whilst":
this._executeWhilst(entity, dt);
break;
case "selector":
this._executeSelector(entity, dt);
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);
try {
this.onAction(this._world, entity, dt);
if (!isTerminal(this._world, entity)) {
this.succeed(entity);
}
} catch (err) {
this._finishFromThrown(entity, err);
}
}
private _executeWait(entity: Entity, dt: number): void {
this._world.add(entity, Running); this._world.add(entity, Running);
this.onLeaf(this._world, entity, dt); const control: TaskControl = {
succeed: () => this.succeed(entity),
fail: () => this.fail(entity),
cancel: () => this.cancel(entity),
};
try {
this.onWait(this._world, entity, control, dt);
} catch (err) {
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") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return;
}
if (status === "succeeded") continue;
this._executeChild(child, dt);
private _executeSequential(entity: Entity): void { status = terminalStatus(this._world, child);
const children = childrenOf(this._world, entity); if (status === "failed" || status === "cancelled") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
// Find the first non-terminal child return;
for (const child of children) {
if (isTerminal(this._world, child)) {
const status = terminalStatus(this._world, child)!;
if (status === "failed" || status === "cancelled") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return;
}
// succeeded — continue to next child
continue;
} }
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
continue;
} }
if (status === "succeeded") 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) {
@@ -229,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"
@@ -245,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)];
this._world.add(pick, Scheduled); const pick = eligible[Math.floor(Math.random() * eligible.length)];
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);
return;
} }
// Cycle itself never terminates — it just creates tick-boundary cycles.
// Schedule child if not already running or scheduled
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 { private _executeWhilst(entity: Entity, dt: number): void {
const children = childrenOf(this._world, entity); let condition: boolean;
try {
// Find the first non-terminal child condition = this.onCondition(this._world, entity, dt);
for (const child of children) { } catch (err) {
if (isTerminal(this._world, child)) { this._finishFromThrown(entity, err);
const status = terminalStatus(this._world, child)!; return;
if (status === "succeeded") { }
// First success — selector succeeds
this._finish(entity, Succeeded); if (!condition) {
return; this._finish(entity, Succeeded);
} return;
// failed or cancelled — continue to next child }
continue;
const child = this._firstTaskChild(entity);
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;
// Found a child that hasn't run yet — schedule it
this._world.add(child, Scheduled);
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,
@@ -324,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
View File
@@ -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. */
+192 -111
View File
@@ -1,87 +1,205 @@
import type { World, Entity } from "../index"; import type { World, Entity } from "../index";
import { Task, ChildOf } from "./task"; import type { EntityDef, EntityDefChild } from "../entity-tree";
import { TaskRunner } from "./runner"; import { Task, ChildOf, Cancel } from "./task";
import { TaskRunner, type TaskControl } from "./runner";
// ── Cancel ──────────────────────────────────────────── export { Cancel };
// ── Task callback types ───────────────────────────────
/** Runs immediately. Return = success, throw = failure, throw Cancel = cancel. */
export type ActionFn = (world: World, entity: Entity, dt: number) => void;
/** Starts a task that remains Running until completed by the supplied control. */
export type WaitFn = (
world: World,
entity: Entity,
control: TaskControl,
dt: number,
) => void;
/** Controls a `whilst` task. False means the loop has completed successfully. */
export type ConditionFn = (world: World, entity: Entity, dt: number) => boolean;
export type TaskMeta =
| { readonly mode: "action"; readonly run: ActionFn }
| { 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`. */
export type TreeDef = TaskEntityDef;
// ── Entity task factories ──────────────────────────────
function compactChildren(children: readonly EntityDefChild[]): EntityDef[] {
return children.filter(Boolean) as EntityDef[];
}
function task(
kind: typeof Task.type.kind,
children: readonly EntityDefChild[] = [],
meta?: TaskMeta,
): TaskEntityDef {
return {
kind: "entity",
component: Task,
value: { kind },
children: compactChildren(children),
meta,
};
}
/** Create an action task entity definition. */
export function action(
run: ActionFn,
children: readonly EntityDefChild[] = [],
): TaskEntityDef {
return task("action", children, { mode: "action", run });
}
/** /**
* Throw this inside a leaf `run` function to cancel the leaf and its subtree. * Create a wait task entity definition.
* *
* @example * `wait()` creates a task that simply becomes Running. External systems can
* ```ts * complete it with `runner.succeed(entity)`, `runner.fail(entity)`, or
* { kind: "leaf", run: () => { throw Cancel; } } * `runner.cancel(entity)`.
* ```
*/ */
export const Cancel: unique symbol = Symbol("leaf.cancel"); 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,
});
}
// ── Tree definition ─────────────────────────────────── /** Create a sequential task entity definition. */
export function sequential(
children: readonly EntityDefChild[] = [],
): TaskEntityDef {
return task("sequential", children);
}
/** A leaf function — plain or generator. */ /** Create a parallel task entity definition. */
export type LeafFn = export function parallel(
| ((world: World, dt: number) => void) children: readonly EntityDefChild[] = [],
| (() => Generator<number | void, void, number>); ): TaskEntityDef {
return task("parallel", children);
}
/** Declarative behaviour-tree definition. */ /** Create a selector task entity definition. */
export type TreeDef = export function selector(
| { kind: "leaf"; run: LeafFn } children: readonly EntityDefChild[] = [],
| { kind: "sequential"; children: TreeDef[] } ): TaskEntityDef {
| { kind: "parallel"; children: TreeDef[] } return task("selector", children);
| { kind: "selector"; children: TreeDef[] } }
| { kind: "random"; children: TreeDef[] }
| { kind: "repeat"; child: TreeDef }; /** Create a random task entity definition. */
export function random(
children: readonly EntityDefChild[] = [],
): TaskEntityDef {
return task("random", children);
}
/**
* Create a cycle task entity definition.
*
* `cycle(child)` and `cycle([child, metadataEntity])` are both supported.
* The runner operates only on child entities that have the `Task` component.
*/
export function cycle(child: EntityDef): TaskEntityDef;
export function cycle(children?: readonly EntityDefChild[]): TaskEntityDef;
export function cycle(
childOrChildren: EntityDef | readonly EntityDefChild[] = [],
): TaskEntityDef {
return task(
"cycle",
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 ───────────────────────────────────────────
/** /**
* Recursively materialize a `TreeDef` into ECS entities and return a * Materialize a behaviour-tree definition into ECS entities and return a
* fully-wired `TaskRunner`. * fully-wired `TaskRunner`.
* *
* Leaf `run` functions: * Definitions are `EntityDef` trees produced by the task factories (`action`,
* - **Plain function** — runs once per tick. `return` = success. `throw` = fail. * `wait`, `sequential`, `parallel`, `selector`, `random`, `cycle`, `whilst`)
* `throw Cancel` = cancel. * and generic single-component entity factories. Non-task child entities are
* - **Generator function** — each `yield` suspends until next tick. The value * materialized into the ECS tree but ignored by `TaskRunner` execution.
* yielded is the desired delay in ms (or `undefined` for next frame).
* Generator completion = success. `throw` = fail. `throw Cancel` = cancel.
*
* @example
* ```ts
* const runner = buildTree(world, {
* kind: "repeat",
* child: {
* kind: "sequential",
* children: [
* { kind: "leaf", run: () => { doWork(); } },
* { kind: "leaf", *run() { yield 1000; doLater(); } },
* ],
* },
* });
* runner.schedule(runner.root);
* setInterval(() => runner.tick(16), 16);
* ```
*/ */
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: TreeDef, parent?: Entity): Entity { function build(def: EntityDef, parent?: Entity): Entity {
const entity = world.spawn(); const entity = world.spawn();
world.add(entity, def.component, def.value);
if (def.kind === "leaf") { if (parent !== undefined) {
world.add(entity, Task, { kind: "leaf" }); world.relate(entity, ChildOf, parent);
leafHandlers.set(entity, def.run); }
} else if (def.kind === "repeat") {
world.add(entity, Task, { kind: "repeat" }); if (def.component === Task) {
build(def.child, entity); const taskData = world.get(entity, Task);
} else { const meta = def.meta as TaskMeta | undefined;
world.add(entity, Task, { kind: def.kind });
for (const child of def.children) { if (taskData.kind === "action") {
build(child, entity); if (meta?.mode !== "action") {
throw new Error("Action task entity is missing an action function");
}
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);
} }
} }
if (parent) { for (const child of def.children) {
world.relate(entity, ChildOf, parent); build(child, entity);
} }
return entity; return entity;
@@ -89,62 +207,25 @@ export function buildTree(world: World, def: TreeDef): TaskRunner {
const root = build(def); const root = build(def);
if (!world.has(root, Task)) {
throw new Error("buildTree root must be a task entity");
}
const runner = new TaskRunner(world); const runner = new TaskRunner(world);
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);
}; };
// Stash the root entity on the runner for convenience runner.onCondition = (world, entity, dt) => {
(runner as any).root = root; const condition = conditions.get(entity);
return condition ? condition(world, entity, dt) : false;
};
return runner; return runner;
} }
+80
View File
@@ -0,0 +1,80 @@
import type { ComponentDef } from "./component";
import type { Entity } from "./entity";
import type { RelationshipDef } from "./relationship";
import type { World } from "./world";
/**
* Declarative definition for one entity with one primary component.
*
* Children are materialized as entities and related to their parent by the
* relationship passed to `buildEntityTree`.
*/
export interface EntityDef<T extends Record<string, any> = any, M = unknown> {
readonly kind: "entity";
readonly component: ComponentDef<T>;
readonly value?: Partial<T>;
readonly children: readonly EntityDef[];
readonly meta?: M;
}
export type EntityDefChild = EntityDef | null | undefined | false;
function compactChildren(children: readonly EntityDefChild[]): EntityDef[] {
return children.filter(Boolean) as EntityDef[];
}
/** Create a single-component entity definition. */
export function entity<T extends Record<string, any>>(
component: ComponentDef<T>,
value?: Partial<T>,
children?: readonly EntityDefChild[],
): EntityDef<T>;
export function entity<T extends Record<string, any>>(
component: ComponentDef<T>,
children?: readonly EntityDefChild[],
): EntityDef<T>;
export function entity<T extends Record<string, any>>(
component: ComponentDef<T>,
valueOrChildren?: Partial<T> | readonly EntityDefChild[],
children: readonly EntityDefChild[] = [],
): EntityDef<T> {
const hasChildrenAsSecondArg = Array.isArray(valueOrChildren);
const value = hasChildrenAsSecondArg
? undefined
: (valueOrChildren as Partial<T> | undefined);
const childDefs = hasChildrenAsSecondArg ? valueOrChildren : children;
return {
kind: "entity",
component,
value,
children: compactChildren(childDefs),
};
}
/**
* Materialize an `EntityDef` tree into ECS entities.
*
* The supplied relationship connects child entities to their parent, which lets
* callers choose the semantic meaning of the tree edge.
*/
export function buildEntityTree(
world: World,
def: EntityDef,
childRelationship: RelationshipDef,
parent?: Entity,
): Entity {
const entity = world.spawn();
world.add(entity, def.component, def.value);
if (parent !== undefined) {
world.relate(entity, childRelationship, parent);
}
for (const child of def.children) {
buildEntityTree(world, child, childRelationship, entity);
}
return entity;
}
+2
View File
@@ -8,6 +8,8 @@ export { query } from "./query";
export { Query } from "./query"; export { Query } from "./query";
export type { Entity } from "./entity"; export type { Entity } from "./entity";
export { makeEntity, entityIndex, entityGeneration } from "./entity"; export { makeEntity, entityIndex, entityGeneration } from "./entity";
export { entity, buildEntityTree } from "./entity-tree";
export type { EntityDef, EntityDefChild } from "./entity-tree";
export { SparseSet } from "./storage/sparse-set"; export { SparseSet } from "./storage/sparse-set";
export type { export type {
WorldEvent, WorldEvent,
+36 -5
View File
@@ -1,24 +1,55 @@
// ── Relationship ───────────────────────────────────── // ── Relationship ─────────────────────────────────────
/** /**
* A relationship definition — like a component, but represents a directed * A relationship definition — like a component, but represents a directed
* link between two entities. * link between two entities. Every relationship carries an optional data
* payload (defaults to `{}` for bare edges).
* *
* @example * @example
* ```ts * ```ts
* const ChildOf = defineRelationship('childOf'); * const ChildOf = defineRelationship('childOf');
* world.relate(child, ChildOf, parent); * const Health = defineRelationship('health', { hp: 100 });
* ``` * ```
*/ */
export interface RelationshipDef { export interface RelationshipDef<T extends Record<string, any> = {}> {
/** Unique symbol used as the storage key. */ /** Unique symbol used as the storage key. */
readonly _key: symbol; readonly _key: symbol;
/** Human-readable name, used for serialization. */ /** Human-readable name, used for serialization. */
readonly name: string; readonly name: string;
/** Default values used when no data override is provided. */
readonly defaults: T;
/** Phantom type for inference. */
readonly type: T;
} }
/** /**
* Define a named relationship between entities. * Define a named relationship between entities.
*
* When `defaults` is omitted the relationship is a bare edge (no data).
* When `defaults` is provided the relationship carries data accessible
* via `world.getRelData()` / `world.setRelData()`.
*
* @example
* ```ts
* // Bare edge
* const ChildOf = defineRelationship('childOf');
*
* // With data
* const Health = defineRelationship('health', { hp: 100 });
* ```
*/ */
export function defineRelationship(name: string): RelationshipDef { export function defineRelationship(name: string): RelationshipDef<{}>;
return { _key: Symbol(), name }; export function defineRelationship<T extends Record<string, any>>(
name: string,
defaults: T,
): RelationshipDef<T>;
export function defineRelationship<T extends Record<string, any>>(
name: string,
defaults?: T,
): RelationshipDef<{}> | RelationshipDef<T> {
return {
_key: Symbol(),
name,
defaults: (defaults ?? {}) as any,
type: undefined as unknown as any,
};
} }
+5 -2
View File
@@ -5,6 +5,9 @@
export interface WorldSnapshot { export interface WorldSnapshot {
/** Entity stable ID → component map (component name → data). */ /** Entity stable ID → component map (component name → data). */
entities: Record<string, Record<string, unknown>>; entities: Record<string, Record<string, unknown>>;
/** Relationship name → (source ID → target ID). */ /** Relationship name → (source ID → target ID or edge object). */
relationships: Record<string, Record<string, string>>; relationships: Record<
string,
Record<string, string | { target: string; data: unknown }>
>;
} }
+94 -8
View File
@@ -35,6 +35,8 @@ export class World {
private _relReverse = new Map<symbol, Map<number, Set<number>>>(); private _relReverse = new Map<symbol, Map<number, Set<number>>>();
/** Key → RelationshipDef for event emission. */ /** Key → RelationshipDef for event emission. */
private _relKeyToDef = new Map<symbol, RelationshipDef>(); private _relKeyToDef = new Map<symbol, RelationshipDef>();
/** Relationship data: relationship._key → SparseSet<data> (keyed by source index). */
private _relData = new Map<symbol, SparseSet<any>>();
// ── Change tracking ─────────────────────────────── // ── Change tracking ───────────────────────────────
private _dirty = new Map<symbol, Set<number>>(); private _dirty = new Map<symbol, Set<number>>();
@@ -93,6 +95,8 @@ export class World {
if (fwd.has(idx)) { if (fwd.has(idx)) {
const target = fwd.get(idx); const target = fwd.get(idx);
const rel = this._relKeyToDef.get(key)!; const rel = this._relKeyToDef.get(key)!;
// Clean up relationship data if applicable
this._relData.get(key)?.remove(idx);
this._relRemoveEdge(entity, target, rel); this._relRemoveEdge(entity, target, rel);
} }
@@ -322,8 +326,17 @@ export class World {
* Create a directed relationship from `source` to `target`. * Create a directed relationship from `source` to `target`.
* Each source can only have one target per relationship type. * Each source can only have one target per relationship type.
* If a relationship already exists, it is replaced. * If a relationship already exists, it is replaced.
*
* An optional `data` payload can be provided to store data along
* with the edge (accessible via `getRelData` / `setRelData`).
* Data is stored lazily — bare edges without data use no storage.
*/ */
relate(source: Entity, rel: RelationshipDef, target: Entity): void { relate<T extends Record<string, any> = {}>(
source: Entity,
rel: RelationshipDef<T>,
target: Entity,
data?: Partial<T>,
): void {
const si = entityIndex(source); const si = entityIndex(source);
const ti = entityIndex(target); const ti = entityIndex(target);
this._assertAlive(si, source); this._assertAlive(si, source);
@@ -351,6 +364,16 @@ export class World {
this._relCounts[si]++; this._relCounts[si]++;
this._relCounts[ti]++; this._relCounts[ti]++;
// Lazy data storage — only allocate when data is provided
if (data !== undefined) {
let dataStore = this._relData.get(rel._key);
if (!dataStore) {
dataStore = new SparseSet<any>();
this._relData.set(rel._key, dataStore);
}
dataStore.set(si, { ...rel.defaults, ...data });
}
this._emit({ this._emit({
type: "relationshipAdded", type: "relationshipAdded",
source, source,
@@ -370,9 +393,48 @@ export class World {
const target = this.getRelated(source, rel); const target = this.getRelated(source, rel);
if (target === undefined) return; if (target === undefined) return;
this._relData.get(rel._key)?.remove(si);
this._relRemoveEdge(source, target, rel); this._relRemoveEdge(source, target, rel);
} }
/**
* Get the data stored alongside a relationship.
* Returns the relationship's defaults if no data was explicitly set.
*/
getRelData<T extends Record<string, any> = {}>(
source: Entity,
rel: RelationshipDef<T>,
): T {
const si = entityIndex(source);
this._assertAlive(si, source);
const store = this._relData.get(rel._key);
if (!store || !store.has(si)) {
return { ...rel.defaults };
}
return store.get(si);
}
/**
* Set the data for a relationship edge.
* Creates storage lazily if this is the first data set on this relationship type.
*/
setRelData<T extends Record<string, any> = {}>(
source: Entity,
rel: RelationshipDef<T>,
data: T,
): void {
const si = entityIndex(source);
this._assertAlive(si, source);
let store = this._relData.get(rel._key);
if (!store) {
store = new SparseSet<any>();
this._relData.set(rel._key, store);
}
store.set(si, data);
}
/** /**
* Get the target entity for a relationship, or undefined. * Get the target entity for a relationship, or undefined.
*/ */
@@ -505,14 +567,26 @@ export class World {
} }
// Relationships // Relationships
const relationships: Record<string, Record<string, string>> = {}; const relationships: Record<
string,
Record<string, string | { target: string; data: unknown }>
> = {};
for (const [key, fwd] of this._relForward) { for (const [key, fwd] of this._relForward) {
const rel = this._relKeyToDef.get(key)!; const rel = this._relKeyToDef.get(key)!;
const edges: Record<string, string> = {}; const edges: Record<string, string | { target: string; data: unknown }> =
{};
const dataStore = this._relData.get(key);
for (const [si, target] of fwd.entries()) { for (const [si, target] of fwd.entries()) {
const ti = entityIndex(target); const ti = entityIndex(target);
if (ids[si] !== undefined && ids[ti] !== undefined) { if (ids[si] !== undefined && ids[ti] !== undefined) {
edges[ids[si]] = ids[ti]; if (dataStore?.has(si)) {
edges[ids[si]] = {
target: ids[ti],
data: dataStore.get(si),
};
} else {
edges[ids[si]] = ids[ti];
}
} }
} }
if (Object.keys(edges).length > 0) { if (Object.keys(edges).length > 0) {
@@ -572,11 +646,23 @@ export class World {
`Pass it in the relationships array.`, `Pass it in the relationships array.`,
); );
} }
for (const [srcId, tgtId] of Object.entries(edges)) { for (const [srcId, value] of Object.entries(edges)) {
const source = idToEntity.get(srcId); const source = idToEntity.get(srcId);
const target = idToEntity.get(tgtId); if (!source) continue;
if (source && target) {
world.relate(source, rel, target); if (typeof value === "string") {
// Pure edge (no data)
const target = idToEntity.get(value);
if (target) {
world.relate(source, rel, target);
}
} else if (typeof value === "object" && value !== null) {
// Edge with data
const edge = value as { target: string; data?: unknown };
const target = idToEntity.get(edge.target);
if (target) {
world.relate(source, rel, target, edge.data as any);
}
} }
} }
} }
+366 -236
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, beforeEach } from "vitest";
import { World, type Entity } from "../src/index"; import { World, defineComponent, entity, type Entity } from "../src/index";
import { import {
Task, Task,
Scheduled, Scheduled,
@@ -9,12 +9,19 @@ import {
Cancelled, Cancelled,
ChildOf, ChildOf,
TaskRunner, TaskRunner,
buildTree,
Cancel,
action,
wait,
cycle,
whilst,
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;
} }
@@ -40,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;
} }
@@ -54,8 +61,178 @@ function makeSelector(world: World, parent?: Entity): Entity {
return e; return e;
} }
// ── Leaf tasks ────────────────────────────────────── // ── Entity task factories ───────────────────────────
describe("Leaf tasks", () => { describe("Entity task factories", () => {
it("materializes non-task child entities and ignores them during execution", () => {
const Label = defineComponent("testLabel", { value: "" });
const world = new World();
const calls: string[] = [];
const runner = buildTree(
world,
sequential([
entity(Label, { value: "sequence metadata" }),
action(() => calls.push("a")),
entity(Label, { value: "between leaves" }),
action(() => calls.push("b")),
]),
);
const root = runner.root!;
const children = [...world.getRelatedTo(root, ChildOf)];
const labels = children
.filter((child) => world.has(child, Label))
.map((child) => world.get(child, Label).value);
expect(labels).toEqual(["sequence metadata", "between leaves"]);
runner.schedule(root);
for (let i = 0; i < 5; i++) runner.tick();
expect(calls).toEqual(["a", "b"]);
expect(world.has(root, Succeeded)).toBe(true);
});
it("action succeeds immediately when it returns", () => {
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();
let calls = 0;
const runner = buildTree(
world,
cycle([
entity(Label, { value: "cycle metadata" }),
action(() => {
calls++;
}),
]),
);
runner.schedule(runner.root!);
for (let i = 0; i < 5; i++) runner.tick();
expect(calls).toBeGreaterThan(1);
});
});
// ── Wait tasks ──────────────────────────────────────
describe("Wait tasks", () => {
let world: World; let world: World;
let runner: TaskRunner; let runner: TaskRunner;
@@ -64,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);
}); });
}); });
@@ -152,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
@@ -206,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
@@ -224,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);
}); });
@@ -261,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
@@ -307,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
@@ -343,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)
@@ -386,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;
@@ -397,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);
@@ -519,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
@@ -537,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
@@ -590,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);
}); });
}); });
@@ -616,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
@@ -636,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
@@ -655,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;
@@ -665,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
@@ -698,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);
}); });
}); });
@@ -729,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", () => {
@@ -742,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);
}); });
}); });
+153
View File
@@ -379,3 +379,156 @@ describe("Dead entity safety", () => {
expect([...world.getRelatedTo(e, ChildOf)]).toEqual([]); expect([...world.getRelatedTo(e, ChildOf)]).toEqual([]);
}); });
}); });
// ── Data-carrying relationships ───────────────────────
describe("Data-carrying relationships", () => {
let world: World;
beforeEach(() => {
world = new World();
});
it("defines a data-carrying relationship", () => {
const Health = defineRelationship("health", { hp: 100, maxHp: 100 });
expect(Health.name).toBe("health");
expect(Health.defaults).toEqual({ hp: 100, maxHp: 100 });
});
it("relate stores defaults as data", () => {
const Health = defineRelationship("health", { hp: 100 });
const player = world.spawn();
const game = world.spawn();
world.relate(player, Health, game);
const data = world.getRelData(player, Health);
expect(data).toEqual({ hp: 100 });
});
it("relate accepts data override", () => {
const Health = defineRelationship("health", { hp: 100 });
const player = world.spawn();
const game = world.spawn();
world.relate(player, Health, game, { hp: 50 });
const data = world.getRelData(player, Health);
expect(data).toEqual({ hp: 50 });
});
it("setRelData updates relationship data", () => {
const Health = defineRelationship("health", { hp: 100 });
const player = world.spawn();
const game = world.spawn();
world.relate(player, Health, game);
world.setRelData(player, Health, { hp: 75 });
expect(world.getRelData(player, Health)).toEqual({ hp: 75 });
});
it("getRelData returns defaults when no data was set", () => {
const Health = defineRelationship("health", { hp: 100 });
const player = world.spawn();
// Even without an edge, getRelData returns a copy of defaults
expect(world.getRelData(player, Health)).toEqual({ hp: 100 });
});
it("setRelData works even without prior relate", () => {
const Health = defineRelationship("health", { hp: 100 });
const player = world.spawn();
world.setRelData(player, Health, { hp: 50 });
expect(world.getRelData(player, Health)).toEqual({ hp: 50 });
});
it("data survives unrelate and re-relate", () => {
const Health = defineRelationship("health", { hp: 100 });
const player = world.spawn();
const game = world.spawn();
world.relate(player, Health, game, { hp: 50 });
world.unrelate(player, Health);
// After unrelate, stored data is gone, returns defaults
expect(world.getRelData(player, Health)).toEqual({ hp: 100 });
world.relate(player, Health, game, { hp: 80 });
// Note: this is a *new* relate with data, so stored data is { hp: 80 }
expect(world.getRelData(player, Health)).toEqual({ hp: 80 });
});
it("data is cleaned up on destroy", () => {
const Health = defineRelationship("health", { hp: 100 });
const player = world.spawn();
const game = world.spawn();
world.relate(player, Health, game, { hp: 50 });
world.destroy(player);
// After destroy the entity is dead — assertAlive throws, not the data lookup
expect(() => world.getRelData(player, Health)).toThrow("not alive");
});
it("setRelData with no prior edge stores data that getsRelated does not see", () => {
const Health = defineRelationship("health", { hp: 100 });
const player = world.spawn();
world.setRelData(player, Health, { hp: 50 });
// No edge exists yet
expect(world.getRelated(player, Health)).toBeUndefined();
// But data is stored (decoupled storage)
expect(world.getRelData(player, Health)).toEqual({ hp: 50 });
});
it("data-carrying relationships serialize and deserialize", () => {
const Health = defineRelationship("health", { hp: 100, maxHp: 100 });
const player = world.spawn();
const game = world.spawn();
world.relate(player, Health, game, { hp: 50, maxHp: 100 });
const snapshot = world.toJSON();
// Verify the snapshot has data in the relationship structure
const relSection = snapshot.relationships["health"];
expect(relSection).toBeDefined();
// Find the player source ID — it's the entity without components
const playerId = Object.keys(snapshot.entities).find(
(id) => Object.keys(snapshot.entities[id]).length === 0,
)!;
const edgeValue = relSection[playerId];
expect(typeof edgeValue).toBe("object");
expect((edgeValue as any).target).toBeDefined();
expect((edgeValue as any).data).toEqual({ hp: 50, maxHp: 100 });
// Check JSON round-trip preserves data
const parsed = JSON.parse(JSON.stringify(snapshot));
const world2 = World.fromJSON(parsed, [], [Health]);
const snap2 = world2.toJSON();
const playerId2 = Object.keys(snap2.entities).find(
(id) => Object.keys(snap2.entities[id]).length === 0,
)!;
expect(snap2.relationships["health"]).toBeDefined();
const edgeValue2 = snap2.relationships["health"][playerId2];
expect((edgeValue2 as any).data).toEqual({ hp: 50, maxHp: 100 });
});
it("pure edge-defined relationships still work alongside data relationships", () => {
const ChildOf2 = defineRelationship("childOf2");
const Score = defineRelationship("score", { points: 0 });
const parent = world.spawn();
const child = world.spawn();
const game = world.spawn();
world.relate(child, ChildOf2, parent);
world.relate(child, Score, game, { points: 42 });
// Pure edge still works
expect(world.getRelated(child, ChildOf2)).toBe(parent);
// Data edge still works
expect(world.getRelData(child, Score)).toEqual({ points: 42 });
});
});
+3 -1
View File
@@ -277,7 +277,9 @@ describe("Serialization — complex state", () => {
const comps = snap.entities[id]; const comps = snap.entities[id];
return comps.position && comps.velocity && !comps.health; return comps.position && comps.velocity && !comps.health;
})!; })!;
const playerId = snap.relationships.ownedBy[bulletId]; const playerEdge = snap.relationships.ownedBy[bulletId];
const playerId =
typeof playerEdge === "string" ? playerEdge : playerEdge.target;
// Player should have a name // Player should have a name
const playerComps = snap.entities[playerId]; const playerComps = snap.entities[playerId];
+268
View File
@@ -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]);
});
});
+2 -3
View File
@@ -11,8 +11,7 @@
"declarationMap": true, "declarationMap": true,
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src"
}, },
"include": ["src"], "include": ["src", "test", "examples"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"],
} }