Compare commits

...
18 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
hypercross a97488e8d6 refactor(examples): reorganized code
Move code to avoid big main.ts and changed the bt to match the pattern
2026-06-02 10:39:27 +08:00
hypercross 5d125167cc refactor: make getRelatedTo return an iterator
Convert `getRelatedTo` from returning an array to returning an
`IterableIterator`. This improves memory efficiency by yielding
entities lazily instead of allocating a new array on every call.
2026-06-02 06:39:07 +08:00
hypercross 2469cdc7cb feat(examples): add blackjack game example 2026-06-02 00:57:22 +08:00
hypercross ef9abf03c6 docs: add singleton components, commands, and behaviour trees 2026-06-02 00:38:26 +08:00
hypercross 4182bc1578 feat(bt): support generator leaves and delta time
Introduce support for generator functions in leaf tasks, allowing
them to yield for multi-frame execution. The `TaskRunner` now accepts
a delta time (`dt`) parameter, which is passed through to leaf
handlers.

Additionally, a `Cancel` symbol is introduced to allow leaf tasks to
explicitly cancel their subtree via a thrown error.
2026-06-02 00:10:18 +08:00
hypercross ec8f668392 feat(bt): add declarative tree builder
Introduce `buildTree` to allow defining behaviour trees using a
declarative `TreeDef` object instead of manual entity spawning and
relationship wiring. This simplifies tree construction and manages
leaf handlers internally.
2026-06-01 23:57:19 +08:00
hypercross ccd0e3afb4 feat(world): add singleton component support
Introduce a mechanism to manage components on a shared, lazily-created
singleton entity. This simplifies access to global state by providing
dedicated methods for adding, removing, getting, and checking for
singleton components.

Refactor the Tetris example to utilize this new singleton pattern for
game state components like Board, Score, and Piece.
2026-06-01 23:52:47 +08:00
35 changed files with 4412 additions and 653 deletions
+166
View File
@@ -10,12 +10,15 @@ Entity-Component-System with an observable-style API for TypeScript. Built for g
- [API](#api)
- [World](#world)
- [Components](#components)
- [Singleton Components](#singleton-components)
- [Queries](#queries)
- [Observable Queries](#observable-queries)
- [Change Tracking](#change-tracking)
- [Relationships](#relationships)
- [Events](#events)
- [Serialization](#serialization)
- [Commands](#commands)
- [Behaviour Trees](#behaviour-trees)
- [TypeScript Inference](#typescript-inference)
## Installation
@@ -132,6 +135,33 @@ Get returns a **live mutable reference** — no defensive copies. Mutations are
---
### Singleton Components
For global state (score, board, config) that doesn't need per-entity tracking. A single internal entity is created lazily and reused for all singleton components.
```ts
const Score = defineComponent("score", { points: 0, level: 1 });
// Add (auto-creates the backing entity on first call)
world.addSingleton(Score);
// Get / set / check — no entity argument needed
world.getSingleton(Score).points += 100;
world.hasSingleton(Score); // true
world.setSingleton(Score, { points: 0, level: 2 });
// Try-get
const s = world.tryGetSingleton(Score); // Score | undefined
// Mark dirty for change tracking
world.markDirtySingleton(Score);
// Remove (destroys the backing entity if it becomes bare)
world.removeSingleton(Score);
```
---
### Queries
Create filters with `query()`. Chain `.without()` to exclude components.
@@ -324,6 +354,142 @@ world2.get(e2, Health); // { current: 75, max: 100 }
---
### Commands
Decouple input from game logic. Define command components, register handlers, spawn command entities from input — the `CommandQueue` drains and dispatches them each frame.
```ts
import { CommandQueue } from "ecs-observable/commands";
const MoveLeft = defineComponent("moveLeft", {});
const MoveRight = defineComponent("moveRight", {});
const queue = new CommandQueue(world);
// Register handlers
queue.handle(MoveLeft, () => {
player.x -= 1;
});
queue.handle(MoveRight, () => {
player.x += 1;
});
// Input → spawn command entities
onKey("ArrowLeft", () => {
const cmd = world.spawn();
world.add(cmd, MoveLeft);
});
// Each frame — drain and dispatch
queue.execute();
```
Command entities are automatically destroyed after processing if they become bare.
---
### Behaviour Trees
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 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
import { defineComponent, entity } from "ecs-observable";
import { buildTree, Cancel, action, wait, parallel, cycle, whilst, sequential } from "ecs-observable/bt";
```
#### Task patterns
**Action** — runs immediately. Normal return = success.
```ts
action(() => { doWork(); })
```
**Fail** — throw any error.
```ts
action(() => { throw new Error("bad"); })
```
**Cancel** — throw the `Cancel` symbol.
```ts
action(() => { throw Cancel; })
```
**Wait** — starts once and stays running until external code completes it.
```ts
wait((world, entity, task) => {
startAnimation(() => task.succeed());
})
```
**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
```ts
sequential([a, b, c]) // left-to-right, all must succeed
selector([a, b, c]) // left-to-right, first success wins
parallel([a, b, c]) // all at once, all must succeed
random([a, b, c]) // pick one child each activation
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
```ts
const runner = buildTree(
world,
parallel([
whilst(
() => true,
action((_world, _entity, dt) => {
updatePhysics(dt);
}),
),
cycle(
sequential([
action(() => { handleInput(); }),
action(() => { render(); }),
]),
),
]),
);
// Kick off
runner.schedule(runner.root!);
// Game loop
setInterval(() => runner.tick(16), 16);
```
---
## TypeScript Inference
Components infer their type from the defaults object — no separate type declaration needed.
+147
View File
@@ -0,0 +1,147 @@
import { defineComponent } from "../../src/component";
import type { World, Entity } from "../../src/index";
import { query } from "../../src/query";
import type { CommandQueue } from "../../src/commands/index";
import {
Card,
InDeck,
InPlayerHand,
InDealerHand,
HoleHidden,
Score,
Bet,
GamePhase,
} from "./components";
import {
handValue,
isBust,
isBlackjack,
determineOutcome,
payout,
} from "./game";
// ── Command definitions ──────────────────────────────
/** Player takes another card. */
export const Hit = defineComponent("hit", {});
/** Player stands (ends their turn). */
export const Stand = defineComponent("stand", {});
/** Start a new round. */
export const NewRound = defineComponent("newRound", {});
/** Increase the bet. */
export const BetMore = defineComponent("betMore", {});
/** Decrease the bet. */
export const BetLess = defineComponent("betLess", {});
// ── Command handlers ─────────────────────────────────
export function registerCommands(
world: World,
commands: CommandQueue,
helpers: ReturnType<typeof import("./components").createCardHelpers>,
): void {
commands.handle(Hit, () => {
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "playerTurn") return;
const cardEntity = helpers.drawCard();
if (cardEntity) {
helpers.dealTo(cardEntity, InPlayerHand);
}
if (isBust(helpers.getHand(InPlayerHand))) {
world.removeSingleton(HoleHidden);
resolveRound(world, helpers);
}
});
commands.handle(Stand, () => {
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "playerTurn") return;
world.removeSingleton(HoleHidden);
world.setSingleton(GamePhase, {
phase: "dealerTurn",
message: "Dealer's turn...",
});
});
commands.handle(NewRound, () => {
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "roundOver" && phase.phase !== "betting") return;
const score = world.getSingleton(Score);
if (score.chips <= 0) {
world.setSingleton(GamePhase, {
phase: "roundOver",
message: "You're out of chips! Restart the program to play again.",
});
return;
}
helpers.startRound();
});
commands.handle(BetMore, () => {
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "betting" && phase.phase !== "roundOver") return;
const bet = world.getSingleton(Bet);
const score = world.getSingleton(Score);
bet.amount = Math.min(bet.amount + 10, score.chips);
});
commands.handle(BetLess, () => {
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "betting" && phase.phase !== "roundOver") return;
const bet = world.getSingleton(Bet);
bet.amount = Math.max(bet.amount - 10, 10);
});
}
// ── Internal ─────────────────────────────────────────
export function resolveRound(
world: World,
helpers: ReturnType<typeof import("./components").createCardHelpers>,
): void {
const playerCards = helpers.getHand(InPlayerHand);
const dealerCards = helpers.getHand(InDealerHand);
const score = world.getSingleton(Score);
const bet = world.getSingleton(Bet);
const outcome = determineOutcome(playerCards, dealerCards);
const winnings = payout(outcome, bet.amount);
score.chips += bet.amount + winnings;
switch (outcome) {
case "blackjack":
case "win":
score.wins++;
break;
case "lose":
score.losses++;
break;
case "push":
score.pushes++;
break;
}
const messages: Record<string, string> = {
win: "You win!",
lose: "Dealer wins.",
push: "Push — tie!",
blackjack: "Blackjack! You win 3:2!",
};
world.setSingleton(GamePhase, {
phase: "roundOver",
message: `${messages[outcome]} Press N for new round.`,
});
}
+203
View File
@@ -0,0 +1,203 @@
import { defineComponent } from "../../src/component";
import type { World, Entity } from "../../src/index";
import { query } from "../../src/query";
import { SUITS, RANKS, isBlackjack, type CardData } from "./game";
// ── Component definitions ────────────────────────────
/** Each card is its own entity. `order` tracks position within its collection. */
export const Card = defineComponent("card", {
rank: "A",
suit: "♠",
order: 0,
});
// ── Tag components — mark which collection a card belongs to ─
export const InDeck = defineComponent("inDeck", {});
export const InPlayerHand = defineComponent("inPlayerHand", {});
export const InDealerHand = defineComponent("inDealerHand", {});
// ── Dealer state ─────────────────────────────────────
/** When present (as singleton), the dealer's hole card is hidden. */
export const HoleHidden = defineComponent("holeHidden", {});
// ── Score / state ────────────────────────────────────
export const Score = defineComponent("score", {
wins: 0,
losses: 0,
pushes: 0,
chips: 100,
});
export const Bet = defineComponent("bet", {
amount: 10,
});
/** Current phase of the game. */
export type Phase = "betting" | "playerTurn" | "dealerTurn" | "roundOver";
export const GamePhase = defineComponent("gamePhase", {
phase: "betting" as Phase,
message: "",
});
// ── Card helpers ─────────────────────────────────────
export function createCardHelpers(world: World) {
return {
/** Create all 52 card entities with the InDeck tag. */
buildDeck(): void {
let order = 0;
for (const suit of SUITS) {
for (const rank of RANKS) {
const e = world.spawn();
world.add(e, Card, { rank, suit, order });
world.add(e, InDeck);
order++;
}
}
},
/** Shuffle the deck: collect all InDeck entities, shuffle, reassign order. */
shuffleDeck(): void {
const entities = [...world.query(query(Card, InDeck))];
for (let i = entities.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[entities[i], entities[j]] = [entities[j], entities[i]];
}
for (let i = 0; i < entities.length; i++) {
world.get(entities[i], Card).order = i;
}
},
/** Draw the top card from the deck (highest order). Returns entity or null. */
drawCard(): Entity | null {
const cards = [...world.query(query(Card, InDeck))];
if (cards.length === 0) return null;
let top = cards[0];
let topOrder = world.get(top, Card).order;
for (let i = 1; i < cards.length; i++) {
const o = world.get(cards[i], Card).order;
if (o > topOrder) {
top = cards[i];
topOrder = o;
}
}
world.remove(top, InDeck);
return top;
},
/** Move a card entity to a hand tag, assigning the next order. */
dealTo(cardEntity: Entity, tag: typeof InPlayerHand): void {
const existing = [...world.query(query(Card, tag))];
const nextOrder =
existing.length === 0
? 0
: Math.max(...existing.map((e) => world.get(e, Card).order)) + 1;
world.add(cardEntity, tag);
world.get(cardEntity, Card).order = nextOrder;
},
/** Collect CardData from a hand tag, sorted by order. */
getHand(tag: typeof InPlayerHand): CardData[] {
return [...world.query(query(Card, tag))]
.map((e) => {
const c = world.get(e, Card);
return { rank: c.rank, suit: c.suit, order: c.order };
})
.sort((a, b) => a.order - b.order)
.map(({ rank, suit }) => ({ rank, suit }));
},
/** Remove all cards from a hand tag (destroy the card entities). */
clearHand(tag: typeof InPlayerHand): void {
for (const e of world.query(query(Card, tag))) {
world.destroy(e);
}
},
/** Count cards remaining in the deck. */
deckCount(): number {
return [...world.query(query(Card, InDeck))].length;
},
/** Deal initial 4 cards and start a round. */
startRound(): void {
const bet = world.getSingleton(Bet);
const score = world.getSingleton(Score);
score.chips -= bet.amount;
if (this.deckCount() < 15) {
const remaining = [...world.query(query(Card, InDeck))];
for (const e of remaining) {
world.remove(e, InDeck);
}
for (const e of remaining) {
world.add(e, InDeck);
}
this.shuffleDeck();
}
this.clearHand(InPlayerHand);
this.clearHand(InDealerHand);
world.removeSingleton(HoleHidden);
const c1 = this.drawCard();
const c2 = this.drawCard();
const c3 = this.drawCard();
const c4 = this.drawCard();
if (c1) this.dealTo(c1, InPlayerHand);
if (c2) this.dealTo(c2, InDealerHand);
if (c3) this.dealTo(c3, InPlayerHand);
if (c4) this.dealTo(c4, InDealerHand);
const playerCards = this.getHand(InPlayerHand);
const dealerCards = this.getHand(InDealerHand);
if (isBlackjack(playerCards)) {
world.removeSingleton(HoleHidden);
if (isBlackjack(dealerCards)) {
score.chips += bet.amount;
score.pushes++;
world.setSingleton(GamePhase, {
phase: "roundOver",
message: "Both have Blackjack — Push! Press N for new round.",
});
} else {
const winnings = payout("blackjack", bet.amount);
score.chips += bet.amount + winnings;
score.wins++;
world.setSingleton(GamePhase, {
phase: "roundOver",
message: "Blackjack! You win 3:2! Press N for new round.",
});
}
} else {
world.addSingleton(HoleHidden);
world.setSingleton(GamePhase, {
phase: "playerTurn",
message: "Your turn — H to hit, S to stand.",
});
}
},
};
}
// ── Internal ─────────────────────────────────────────
function payout(outcome: string, bet: number): number {
switch (outcome) {
case "blackjack":
return Math.floor(bet * 1.5);
case "win":
return bet;
case "push":
return 0;
case "lose":
return -bet;
default:
return 0;
}
}
+104
View File
@@ -0,0 +1,104 @@
// ── Blackjack game logic (pure functions, no ECS dependency) ──
export const SUITS = ["♠", "♥", "♦", "♣"] as const;
export const RANKS = [
"A",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"J",
"Q",
"K",
] as const;
export interface CardData {
rank: string;
suit: string;
}
// ── Hand evaluation ──────────────────────────────────
/** Numeric value of a single card rank. */
export function rankValue(rank: string): number {
if (rank === "A") return 11;
if (rank === "K" || rank === "Q" || rank === "J") return 10;
return parseInt(rank, 10);
}
/** Best total for a hand (aces count as 1 or 11). */
export function handValue(cards: CardData[]): number {
let total = 0;
let aces = 0;
for (const c of cards) {
total += rankValue(c.rank);
if (c.rank === "A") aces++;
}
while (total > 21 && aces > 0) {
total -= 10;
aces--;
}
return total;
}
export function isBust(cards: CardData[]): boolean {
return handValue(cards) > 21;
}
export function isBlackjack(cards: CardData[]): boolean {
return cards.length === 2 && handValue(cards) === 21;
}
export function isSoft(cards: CardData[]): boolean {
let total = 0;
let aces = 0;
for (const c of cards) {
total += rankValue(c.rank);
if (c.rank === "A") aces++;
}
return aces > 0 && total <= 21;
}
// ── Dealer logic ─────────────────────────────────────
/** Dealer must hit on soft 17 in this variant. */
export function dealerShouldHit(cards: CardData[]): boolean {
const val = handValue(cards);
if (val < 17) return true;
if (val === 17 && isSoft(cards)) return true;
return false;
}
// ── Outcome ──────────────────────────────────────────
export type Outcome = "win" | "lose" | "push" | "blackjack";
export function determineOutcome(
playerCards: CardData[],
dealerCards: CardData[],
): Outcome {
if (isBust(playerCards)) return "lose";
if (isBust(dealerCards)) return "win";
if (isBlackjack(playerCards) && !isBlackjack(dealerCards)) return "blackjack";
const pv = handValue(playerCards);
const dv = handValue(dealerCards);
if (pv > dv) return "win";
if (pv < dv) return "lose";
return "push";
}
/** Payout multiplier. Blackjack pays 3:2, win pays 1:1. */
export function payout(outcome: Outcome, bet: number): number {
switch (outcome) {
case "blackjack":
return Math.floor(bet * 1.5);
case "win":
return bet;
case "push":
return 0;
case "lose":
return -bet;
}
}
+27
View File
@@ -0,0 +1,27 @@
// ── Keyboard input via blessed ────────────────────────
import type blessed from "blessed";
export type Key =
| "h"
| "s"
| "n"
| "up"
| "down"
| "q";
/** Wire blessed screen key events to a callback. */
export function startInput(
screen: blessed.Widgets.Screen,
onKey: (key: Key) => void,
): void {
screen.key(
["h", "s", "n", "up", "down", "q", "C-c"],
(_ch, key) => {
if (key.name === "q" || key.name === "C-c") {
screen.destroy();
process.exit(0);
}
onKey(key.name as Key);
},
);
}
+141
View File
@@ -0,0 +1,141 @@
// ── Blackjack: BT-driven game loop with command-based input ──
//
// Architecture:
// Behaviour Tree (buildTree) — controls game flow:
// parallel
// ├── dealerPlay (action) — generator loop, auto-plays dealer hand
// └── cycle
// └── seq (sequential)
// ├── handleInput (action) — reads queued commands
// └── render (action) — draws via blessed
//
// CommandQueue — processes input:
// Keyboard → spawn command entities → CommandQueue.execute()
// → handlers mutate game state
//
// Cards as entities with tag components:
// Each card is an entity with a Card component ({ rank, suit, order }).
// Tag components (InDeck, InPlayerHand, InDealerHand) mark which
// collection the card belongs to. Queries find cards by tag.
// Order is tracked via the `order` field on Card.
//
// Usage:
// npx tsx examples/blackjack/main.ts
import { World } from "../../src/index";
import {
buildTree,
action,
parallel,
cycle,
sequential,
whilst,
} from "../../src/bt/index";
import { CommandQueue } from "../../src/commands/index";
import {
Score,
Bet,
GamePhase,
InDealerHand,
createCardHelpers,
} from "./components";
import {
registerCommands,
resolveRound,
Hit,
Stand,
NewRound,
BetMore,
BetLess,
} from "./commands";
import { dealerShouldHit } from "./game";
import { createUI, render } from "./render";
import { startInput, type Key } from "./input";
// ── Setup ────────────────────────────────────────────
const world = new World();
world.addSingleton(Score);
world.addSingleton(Bet);
world.addSingleton(GamePhase);
const ui = createUI();
const cards = createCardHelpers(world);
const commands = new CommandQueue(world);
registerCommands(world, commands, cards);
// ── Behaviour Tree ───────────────────────────────────
const runner = buildTree(
world,
parallel([
whilst(
() => true,
action(() => {
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "dealerTurn") return;
if (dealerShouldHit(cards.getHand(InDealerHand))) {
const cardEntity = cards.drawCard();
if (cardEntity) {
cards.dealTo(cardEntity, InDealerHand);
}
} else {
resolveRound(world, cards);
}
}),
),
cycle(
sequential([
action(() => {
commands.execute();
}),
action(() => {
render(world, ui);
}),
]),
),
]),
);
// ── Input → Command mapping ──────────────────────────
const keyToCommand: Partial<Record<Key, typeof Hit>> = {
h: Hit,
s: Stand,
n: NewRound,
up: BetMore,
down: BetLess,
};
startInput(ui.screen, (key) => {
const cmd = keyToCommand[key];
if (cmd) {
const cmdEntity = world.spawn();
world.add(cmdEntity, cmd);
}
});
// ── Game loop ────────────────────────────────────────
world.setSingleton(GamePhase, {
phase: "betting",
message: "Welcome to Blackjack! Press N to start.",
});
cards.buildDeck();
cards.shuffleDeck();
runner.schedule((runner as any).root);
const TICK_MS = 16;
const interval = setInterval(() => {
runner.tick(TICK_MS);
}, TICK_MS);
process.on("SIGINT", () => {
clearInterval(interval);
ui.screen.destroy();
process.exit(0);
});
+173
View File
@@ -0,0 +1,173 @@
// ── Terminal rendering via blessed ────────────────────
import blessed from "blessed";
import type { World } from "../../src/index";
import { query } from "../../src/query";
import {
Card,
InPlayerHand,
InDealerHand,
HoleHidden,
Score,
Bet,
GamePhase,
} from "./components";
import { handValue, isBust, isBlackjack } from "./game";
const SUIT_SYMBOLS: Record<string, string> = {
"♠": "\x1b[37m♠\x1b[0m",
"♥": "\x1b[31m♥\x1b[0m",
"♦": "\x1b[31m♦\x1b[0m",
"♣": "\x1b[37m♣\x1b[0m",
};
export function createUI(): {
screen: blessed.Widgets.Screen;
dealerBox: blessed.Widgets.BoxElement;
playerBox: blessed.Widgets.BoxElement;
infoText: blessed.Widgets.TextElement;
controlsText: blessed.Widgets.TextElement;
} {
const screen = blessed.screen({
smartCSR: true,
title: "Blackjack",
});
const dealerBox = blessed.box({
parent: screen,
top: 2,
left: "center",
width: 50,
height: 8,
border: { type: "line" },
label: " Dealer ",
style: { border: { fg: "white" } },
});
const playerBox = blessed.box({
parent: screen,
top: 12,
left: "center",
width: 50,
height: 8,
border: { type: "line" },
label: " Player ",
style: { border: { fg: "white" } },
});
const infoText = blessed.text({
parent: screen,
top: 22,
left: "center",
width: 50,
height: 5,
style: { fg: "white" },
});
const controlsText = blessed.text({
parent: screen,
bottom: 0,
left: "center",
width: 60,
height: 1,
style: { fg: "gray" },
});
return { screen, dealerBox, playerBox, infoText, controlsText };
}
function formatCard(rank: string, suit: string): string {
return `${rank}${SUIT_SYMBOLS[suit] ?? suit}`;
}
/** Collect cards from a hand tag, sorted by order. */
function collectHand(
world: World,
tag: typeof InPlayerHand,
): { rank: string; suit: string }[] {
return [...world.query(query(Card, tag))]
.map((e) => {
const c = world.get(e, Card);
return { rank: c.rank, suit: c.suit, order: c.order };
})
.sort((a, b) => a.order - b.order)
.map(({ rank, suit }) => ({ rank, suit }));
}
function formatHand(
cards: { rank: string; suit: string }[],
hideHole: boolean,
): string {
if (cards.length === 0) return " (empty)";
const lines: string[] = [];
if (hideHole && cards.length >= 2) {
lines.push(` ${formatCard(cards[0].rank, cards[0].suit)} ██`);
lines.push(` Value: ${handValue([cards[0]])}`);
} else {
const cardStr = cards.map((c) => formatCard(c.rank, c.suit)).join(" ");
lines.push(` ${cardStr}`);
const val = handValue(cards);
const extra = isBlackjack(cards)
? " — BLACKJACK!"
: isBust(cards)
? " — BUST!"
: "";
lines.push(` Value: ${val}${extra}`);
}
return lines.join("\n");
}
/** Render the full game state into the blessed UI. */
export function render(world: World, ui: ReturnType<typeof createUI>): void {
const score = world.tryGetSingleton(Score);
const bet = world.tryGetSingleton(Bet);
const phase = world.tryGetSingleton(GamePhase);
const holeHidden = world.hasSingleton(HoleHidden);
// Dealer
const dealerCards = collectHand(world, InDealerHand);
ui.dealerBox.setContent(formatHand(dealerCards, holeHidden));
// Player
const playerCards = collectHand(world, InPlayerHand);
ui.playerBox.setContent(formatHand(playerCards, false));
// Info
let info = "";
if (score) {
info += `Chips: ${score.chips} Wins: ${score.wins} Losses: ${score.losses} Pushes: ${score.pushes}\n`;
}
if (bet) {
info += `Bet: ${bet.amount}\n`;
}
if (phase) {
info += `\n${phase.message}`;
}
ui.infoText.setContent(info);
// Dynamic controls based on phase
if (phase) {
switch (phase.phase) {
case "betting":
ui.controlsText.setContent(
"N : new round ↑↓ : adjust bet Q : quit",
);
break;
case "playerTurn":
ui.controlsText.setContent("H : hit S : stand Q : quit");
break;
case "dealerTurn":
ui.controlsText.setContent("Dealer is playing...");
break;
case "roundOver":
ui.controlsText.setContent(
"N : new round ↑↓ : adjust bet Q : quit",
);
break;
}
}
ui.screen.render();
}
+102
View File
@@ -1,4 +1,24 @@
import { defineComponent } from "../../src/component";
import type { World } from "../../src/index";
import type { CommandQueue } from "../../src/commands/index";
import {
Board,
Piece,
Score,
GameOver,
Paused,
TickTimer,
createPieceHelpers,
} from "./components";
import {
collides,
lockPiece,
clearLines,
scoreForLines,
tryRotate,
} from "./game";
// ── Command definitions ──────────────────────────────
/** Move the active piece left. */
export const MoveLeft = defineComponent("moveLeft", {});
@@ -20,3 +40,85 @@ export const TogglePause = defineComponent("togglePause", {});
/** Restart after game over. */
export const Restart = defineComponent("restart", {});
// ── Command handlers ─────────────────────────────────
export function registerCommands(
world: World,
commands: CommandQueue,
pieces: ReturnType<typeof createPieceHelpers>,
): void {
const hasActivePiece = () => world.hasSingleton(Piece);
const isBlocked = () =>
world.hasSingleton(GameOver) || world.hasSingleton(Paused);
commands.handle(MoveLeft, () => {
if (!hasActivePiece() || isBlocked()) return;
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) {
piece.x--;
}
});
commands.handle(MoveRight, () => {
if (!hasActivePiece() || isBlocked()) return;
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) {
piece.x++;
}
});
commands.handle(Rotate, () => {
if (!hasActivePiece() || isBlocked()) return;
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
const result = tryRotate(board.grid, piece.shape, piece.x, piece.y);
if (result) {
piece.shape = result.shape;
piece.x = result.x;
}
});
commands.handle(SoftDrop, () => {
if (!hasActivePiece() || isBlocked()) return;
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++;
}
});
commands.handle(HardDrop, () => {
if (!hasActivePiece() || isBlocked()) return;
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++;
}
pieces.lockAndSpawn();
});
commands.handle(TogglePause, () => {
if (world.hasSingleton(GameOver)) return;
if (world.hasSingleton(Paused)) {
world.removeSingleton(Paused);
} else {
world.addSingleton(Paused);
}
});
commands.handle(Restart, () => {
if (!world.hasSingleton(GameOver)) return;
const board = world.getSingleton(Board);
for (let r = 0; r < board.grid.length; r++) {
board.grid[r].fill(0);
}
world.setSingleton(Score, { points: 0, lines: 0, level: 1 });
world.setSingleton(TickTimer, { accumulator: 0, interval: 800 });
world.removeSingleton(GameOver);
if (world.hasSingleton(Piece)) world.removeSingleton(Piece);
pieces.spawnPiece();
});
}
+53 -1
View File
@@ -1,6 +1,16 @@
import { defineComponent } from "../../src/component";
import type { World } from "../../src/index";
import {
randomPiece,
collides,
lockPiece,
clearLines,
scoreForLines,
BOARD_W,
} from "./game";
// ── Component definitions ────────────────────────────
// ── Board ────────────────────────────────────────────
/** The playfield grid (20 rows × 10 cols). 0 = empty, non-zero = color index. */
export const Board = defineComponent("board", {
grid: Array.from({ length: 20 }, () => new Uint8Array(10)) as Uint8Array[],
@@ -29,3 +39,45 @@ export const TickTimer = defineComponent("tickTimer", {
accumulator: 0,
interval: 800, // ms between gravity ticks
});
// ── Piece helpers ────────────────────────────────────
export function createPieceHelpers(world: World) {
return {
spawnPiece(): void {
const p = randomPiece();
world.addSingleton(Piece, {
shape: p.shape,
color: p.color,
x: Math.floor((BOARD_W - p.shape[0].length) / 2),
y: 0,
});
},
lockAndSpawn(): void {
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y);
world.removeSingleton(Piece);
const cleared = clearLines(board.grid);
if (cleared > 0) {
const score = world.getSingleton(Score);
score.lines += cleared;
score.points += scoreForLines(cleared, score.level);
score.level = Math.floor(score.lines / 10) + 1;
const timer = world.getSingleton(TickTimer);
timer.interval = Math.max(100, 800 - (score.level - 1) * 70);
}
this.spawnPiece();
const newPiece = world.getSingleton(Piece);
if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) {
world.removeSingleton(Piece);
world.addSingleton(GameOver);
}
},
};
}
+73 -209
View File
@@ -1,26 +1,47 @@
// ── Tetris: BT-driven game loop with command-based input ──
//
// Architecture:
// Behaviour Tree (TaskRunner) — controls game flow:
// root (sequential, repeat)
// ├── handleInput (leaf) — reads queued commands, mutates state
// ── gravityTick (leaf) — auto-drop piece on timer
// └── render (leaf) — draws via blessed
// Behaviour Tree (buildTree) — controls game flow:
// parallel
// ├── gravityTick (action) — generator loop, auto-drop piece on timer
// ── cycle
// └── seq (sequential)
// ├── handleInput (action) — reads queued commands
// └── render (action) — draws via blessed
//
// CommandQueue — processes input:
// Keyboard → spawn command entities → CommandQueue.execute()
// → handlers mutate game state
//
// Singleton components — global state accessed via world.*Singleton():
// Board, Piece, Score, GameOver, Paused, TickTimer
//
// Usage:
// npx tsx examples/tetris/main.ts
import { World } from "../../src/index";
import { TaskRunner, Task, ChildOf } from "../../src/bt/index";
import {
buildTree,
action,
parallel,
cycle,
sequential,
whilst,
} from "../../src/bt/index";
import { CommandQueue } from "../../src/commands/index";
import { Board, Piece, Score, GameOver, Paused, TickTimer } from "./components";
import {
Board,
Piece,
Score,
GameOver,
Paused,
TickTimer,
createPieceHelpers,
} from "./components";
import {
registerCommands,
MoveLeft,
MoveRight,
Rotate,
@@ -30,15 +51,7 @@ import {
Restart,
} from "./commands";
import {
randomPiece,
collides,
lockPiece,
clearLines,
scoreForLines,
tryRotate,
BOARD_W,
} from "./game";
import { collides } from "./game";
import { createUI, render } from "./render";
import { startInput, type Key } from "./input";
@@ -46,203 +59,55 @@ import { startInput, type Key } from "./input";
// ── Setup ────────────────────────────────────────────
const world = new World();
// Create the singleton game entity
const game = world.spawn();
world.add(game, Board);
world.add(game, Score);
world.add(game, TickTimer);
world.addSingleton(Board);
world.addSingleton(Score);
world.addSingleton(TickTimer);
// Create blessed UI
const ui = createUI();
// Spawn the first piece
spawnPiece();
// ── Command handlers ─────────────────────────────────
const pieces = createPieceHelpers(world);
const commands = new CommandQueue(world);
function spawnPiece(): void {
const p = randomPiece();
world.add(game, Piece, {
shape: p.shape,
color: p.color,
x: Math.floor((BOARD_W - p.shape[0].length) / 2),
y: 0,
});
}
function hasActivePiece(): boolean {
return world.has(game, Piece);
}
// Move left
commands.handle(MoveLeft, () => {
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
return;
const piece = world.get(game, Piece);
const board = world.get(game, Board);
if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) {
piece.x--;
}
});
// Move right
commands.handle(MoveRight, () => {
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
return;
const piece = world.get(game, Piece);
const board = world.get(game, Board);
if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) {
piece.x++;
}
});
// Rotate
commands.handle(Rotate, () => {
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
return;
const piece = world.get(game, Piece);
const board = world.get(game, Board);
const result = tryRotate(board.grid, piece.shape, piece.x, piece.y);
if (result) {
piece.shape = result.shape;
piece.x = result.x;
}
});
// Soft drop
commands.handle(SoftDrop, () => {
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
return;
const piece = world.get(game, Piece);
const board = world.get(game, Board);
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++;
}
});
// Hard drop
commands.handle(HardDrop, () => {
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
return;
const piece = world.get(game, Piece);
const board = world.get(game, Board);
while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++;
}
lockAndSpawn();
});
// Toggle pause
commands.handle(TogglePause, () => {
if (world.has(game, GameOver)) return;
if (world.has(game, Paused)) {
world.remove(game, Paused);
} else {
world.add(game, Paused);
}
});
// Restart
commands.handle(Restart, () => {
if (!world.has(game, GameOver)) return;
const board = world.get(game, Board);
for (let r = 0; r < board.grid.length; r++) {
board.grid[r].fill(0);
}
world.set(game, Score, { points: 0, lines: 0, level: 1 });
world.set(game, TickTimer, { accumulator: 0, interval: 800 });
world.remove(game, GameOver);
if (world.has(game, Piece)) world.remove(game, Piece);
spawnPiece();
});
// ── Lock piece & spawn next ──────────────────────────
function lockAndSpawn(): void {
const piece = world.get(game, Piece);
const board = world.get(game, Board);
lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y);
world.remove(game, Piece);
const cleared = clearLines(board.grid);
if (cleared > 0) {
const score = world.get(game, Score);
score.lines += cleared;
score.points += scoreForLines(cleared, score.level);
score.level = Math.floor(score.lines / 10) + 1;
const timer = world.get(game, TickTimer);
timer.interval = Math.max(100, 800 - (score.level - 1) * 70);
}
spawnPiece();
const newPiece = world.get(game, Piece);
if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) {
world.remove(game, Piece);
world.add(game, GameOver);
}
}
registerCommands(world, commands, pieces);
pieces.spawnPiece();
// ── Behaviour Tree ───────────────────────────────────
const runner = new TaskRunner(world);
// Build the BT structure:
// root (repeat)
// └── seq (sequential)
// ├── handleInput (leaf)
// ├── gravityTick (leaf)
// └── render (leaf)
const root = world.spawn();
world.add(root, Task, { kind: "repeat" });
const seq = world.spawn();
world.add(seq, Task, { kind: "sequential" });
world.relate(seq, ChildOf, root);
const handleInputTask = world.spawn();
world.add(handleInputTask, Task, { kind: "leaf" });
world.relate(handleInputTask, ChildOf, seq);
const gravityTask = world.spawn();
world.add(gravityTask, Task, { kind: "leaf" });
world.relate(gravityTask, ChildOf, seq);
const renderTask = world.spawn();
world.add(renderTask, Task, { kind: "leaf" });
world.relate(renderTask, ChildOf, seq);
// ── Leaf handlers ────────────────────────────────────
runner.onLeaf = (_w, entity) => {
if (entity === handleInputTask) {
commands.execute();
runner.succeed(entity);
} else if (entity === gravityTask) {
if (world.has(game, GameOver) || world.has(game, Paused)) {
runner.succeed(entity);
return;
}
const timer = world.get(game, TickTimer);
timer.accumulator += 16;
if (timer.accumulator >= timer.interval) {
timer.accumulator -= timer.interval;
if (hasActivePiece()) {
const piece = world.get(game, Piece);
const board = world.get(game, Board);
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++;
} else {
lockAndSpawn();
const runner = buildTree(
world,
parallel([
whilst(
() => true,
action((_world, _entity, dt) => {
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
return;
}
}
}
runner.succeed(entity);
} else if (entity === renderTask) {
render(world, game, ui);
runner.succeed(entity);
}
};
const timer = world.getSingleton(TickTimer);
timer.accumulator += dt;
if (timer.accumulator >= timer.interval) {
timer.accumulator -= timer.interval;
if (world.hasSingleton(Piece)) {
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++;
} else {
pieces.lockAndSpawn();
}
}
}
}),
),
cycle(
sequential([
action(() => {
commands.execute();
}),
action(() => {
render(world, ui);
}),
]),
),
]),
);
// ── Input → Command mapping ──────────────────────────
const keyToCommand: Partial<Record<Key, typeof MoveLeft>> = {
@@ -264,14 +129,13 @@ startInput(ui.screen, (key) => {
});
// ── Game loop ────────────────────────────────────────
runner.schedule(root);
runner.schedule((runner as any).root);
const TICK_MS = 16;
const interval = setInterval(() => {
runner.tick();
runner.tick(TICK_MS);
}, TICK_MS);
// Cleanup on exit
process.on("SIGINT", () => {
clearInterval(interval);
ui.screen.destroy();
+7 -11
View File
@@ -1,6 +1,6 @@
// ── Terminal rendering via blessed ────────────────────
import blessed from "blessed";
import type { World, Entity } from "../../src/index";
import type { World } from "../../src/index";
import { Board, Piece, Score, GameOver, Paused } from "./components";
import { BOARD_W, BOARD_H, ghostY } from "./game";
@@ -91,16 +91,12 @@ export function createUI(): {
}
/** Render the full game state into the blessed UI. */
export function render(
world: World,
gameEntity: Entity,
ui: ReturnType<typeof createUI>,
): void {
const board = world.get(gameEntity, Board);
const piece = world.tryGet(gameEntity, Piece);
const score = world.tryGet(gameEntity, Score);
const isOver = world.has(gameEntity, GameOver);
const isPaused = world.has(gameEntity, Paused);
export function render(world: World, ui: ReturnType<typeof createUI>): void {
const board = world.getSingleton(Board);
const piece = world.tryGetSingleton(Piece);
const score = world.tryGetSingleton(Score);
const isOver = world.hasSingleton(GameOver);
const isPaused = world.hasSingleton(Paused);
// Build display grid
const display = board.grid.map((row) => [...row]);
+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": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"prepublishOnly": "npm run build"
},
+28 -1
View File
@@ -11,4 +11,31 @@ export {
export type { TaskKind } from "./task";
export { TaskRunner } from "./runner";
export type { LeafHandler, TerminalHandler } from "./runner";
export type {
ActionHandler,
WaitHandler,
ConditionHandler,
TaskControl,
TerminalHandler,
} from "./runner";
export {
buildTree,
Cancel,
action,
wait,
whilst,
sequential,
parallel,
selector,
random,
cycle,
} from "./tree-def";
export type {
TreeDef,
TaskEntityDef,
TaskMeta,
ActionFn,
WaitFn,
ConditionFn,
} from "./tree-def";
+284 -152
View File
@@ -9,11 +9,34 @@ import {
Cancelled,
TERMINAL_TAGS,
ChildOf,
Cancel,
} from "./task";
// ── Types ─────────────────────────────────────────────
/** Callback invoked for each leaf task that becomes Scheduled. */
export type LeafHandler = (world: World, entity: Entity) => void;
/** Control object passed to wait tasks so they can complete themselves. */
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. */
export type TerminalHandler = (
@@ -52,8 +75,12 @@ function clearSubtree(world: World, entity: Entity): void {
}
}
function childrenOf(world: World, parent: Entity): Entity[] {
return world.getRelatedTo(parent, ChildOf);
function* childrenOf(world: World, parent: Entity): IterableIterator<Entity> {
for (const child of world.getRelatedTo(parent, ChildOf)) {
if (world.has(child, Task)) {
yield child;
}
}
}
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.
*
* Only tasks tagged with `Scheduled` are processed each tick.
* When a task finishes, it notifies its parent, which may schedule
* the next child (sequential), aggregate results (parallel), or
* propagate upward.
* Tasks tagged with `Scheduled` define tick boundaries. Once a scheduled
* task starts, child/parent propagation is immediate until a running wait task
* or an explicit scheduling boundary (`cycle`, `whilst`) yields to a future tick.
*
* Leaves are dispatched to a user-provided `onLeaf` callback.
* Terminal results are dispatched to a user-provided `onTerminal` callback.
*
* @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();
* ```
* Action, wait, and whilst condition callbacks are supplied by `buildTree` or
* assigned directly when using `TaskRunner` manually.
*/
export class TaskRunner {
private _world: World;
private _executing = new Set<Entity>();
private _currentDt = 0;
/** Called when a leaf task becomes Scheduled. */
onLeaf: LeafHandler = () => {};
/** Root task entity, set by `buildTree` for convenience. */
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. */
onTerminal: TerminalHandler = () => {};
@@ -100,34 +125,46 @@ export class TaskRunner {
// ── 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.
*/
tick(): void {
const scheduled = [...this._world.query(query(Task, Scheduled))];
for (const entity of scheduled) {
this._world.remove(entity, Scheduled);
this._execute(entity);
tick(dt: number = 0): void {
const previousDt = this._currentDt;
this._currentDt = 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 {
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 {
this._finish(entity, Failed);
}
/** Cancel a task and all its descendants. */
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 {
if (this._world.has(entity, Task)) {
this._world.add(entity, Scheduled);
@@ -142,84 +179,132 @@ export class TaskRunner {
// ── Internal execution ────────────────────────────
private _execute(entity: Entity): void {
const t = this._world.get(entity, Task);
private _execute(entity: Entity, dt: number): void {
if (!this._world.has(entity, Task) || this._executing.has(entity)) return;
switch (t.kind) {
case "leaf":
this._executeLeaf(entity);
break;
case "sequential":
this._executeSequential(entity);
break;
case "parallel":
this._executeParallel(entity);
break;
case "random":
this._executeRandom(entity);
break;
case "repeat":
this._executeRepeat(entity);
break;
case "selector":
this._executeSelector(entity);
break;
const t = this._world.get(entity, Task);
this._executing.add(entity);
try {
switch (t.kind) {
case "action":
this._executeAction(entity, dt);
break;
case "wait":
this._executeWait(entity, dt);
break;
case "sequential":
this._executeSequential(entity, dt);
break;
case "parallel":
this._executeParallel(entity, dt);
break;
case "random":
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): 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.onLeaf(this._world, entity);
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 {
const children = childrenOf(this._world, entity);
// Find the first non-terminal child
for (const child of children) {
if (isTerminal(this._world, child)) {
const status = terminalStatus(this._world, child)!;
if (status === "failed" || status === "cancelled") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return;
}
// succeeded — continue to next child
continue;
status = terminalStatus(this._world, child);
if (status === "failed" || status === "cancelled") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return;
}
if (status === "succeeded") continue;
// Found a child that hasn't run yet — schedule it
this._world.add(child, Scheduled);
return;
}
// All children succeeded
this._finish(entity, Succeeded);
}
private _executeParallel(entity: Entity): void {
const children = childrenOf(this._world, entity);
private _executeParallel(entity: Entity, dt: number): void {
let allDone = true;
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 — this child is done
continue;
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);
status = terminalStatus(this._world, child);
if (status === "failed" || status === "cancelled") {
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
return;
}
if (status === "succeeded") continue;
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) {
@@ -227,13 +312,12 @@ export class TaskRunner {
}
}
private _executeRandom(entity: Entity): void {
const children = childrenOf(this._world, entity);
private _executeRandom(entity: Entity, dt: number): void {
const eligible: Entity[] = [];
// Check if any child already reached a terminal
for (const child of children) {
if (isTerminal(this._world, child)) {
const status = terminalStatus(this._world, child)!;
for (const child of childrenOf(this._world, entity)) {
const status = terminalStatus(this._world, child);
if (status) {
this._finish(
entity,
status === "succeeded"
@@ -244,72 +328,116 @@ export class TaskRunner {
);
return;
}
if (!this._world.has(child, Running)) {
eligible.push(child);
}
}
// Pick a random child that isn't already running
const eligible = children.filter(
(c) => !this._world.has(c, Running) && !this._world.has(c, Scheduled),
);
if (eligible.length === 0) return;
if (eligible.length > 0) {
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 {
const children = childrenOf(this._world, entity);
private _executeCycle(entity: Entity, dt: number): void {
const child = this._firstTaskChild(entity);
if (!child) return;
// Repeat expects exactly one child
if (children.length === 0) return;
this._executeChild(child, dt);
const child = children[0];
// If child reached a terminal, reset the entire subtree and schedule again
if (isTerminal(this._world, child)) {
clearSubtree(this._world, child);
this._world.add(child, Scheduled);
return;
this._world.add(entity, Scheduled);
}
// 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
// Cycle itself never terminates — it just creates tick-boundary cycles.
}
private _executeSelector(entity: Entity): void {
const children = childrenOf(this._world, entity);
// Find the first non-terminal child
for (const child of children) {
if (isTerminal(this._world, child)) {
const status = terminalStatus(this._world, child)!;
if (status === "succeeded") {
// First success — selector succeeds
this._finish(entity, Succeeded);
return;
}
// failed or cancelled — continue to next child
continue;
private _executeWhilst(entity: Entity, dt: number): void {
let condition: boolean;
try {
condition = this.onCondition(this._world, entity, dt);
} catch (err) {
this._finishFromThrown(entity, err);
return;
}
if (!condition) {
this._finish(entity, Succeeded);
return;
}
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;
}
// All children 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 ────────────────────────
private _finishFromThrown(entity: Entity, err: unknown): void {
if (err === Cancel) {
this.cancel(entity);
} else {
this.fail(entity);
}
}
private _finish(
entity: Entity,
tag: typeof Succeeded | typeof Failed | typeof Cancelled,
@@ -322,30 +450,34 @@ export class TaskRunner {
const status = terminalStatus(this._world, entity)!;
this.onTerminal(this._world, entity, status);
// Notify parent
const parent = parentOf(this._world, entity);
if (parent) {
this._world.add(parent, Scheduled);
if (parent && !this._executing.has(parent)) {
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;
// 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)) {
this._cancelTree(child);
this._cancelTree(child, false);
}
// Cancel this node
clearStatus(this._world, entity);
this._world.add(entity, Cancelled);
this.onTerminal(this._world, entity, "cancelled");
// Notify parent
const parent = parentOf(this._world, entity);
if (parent) {
this._world.add(parent, Scheduled);
if (notifyParent && parent && !this._executing.has(parent)) {
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 { 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 ────────────────────────────────────
/**
* Core component for behaviour-tree tasks.
*
* `kind` determines how the task evaluates its children:
* - `"leaf"` — terminal node; external logic drives it to completion.
* `kind` determines how the task evaluates:
* - `"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.
* 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.
* - `"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.
* - `"repeat"` — runs its single child. When the child finishes, resets it
* and runs again. Never terminates on its own (only via cancel).
* - `"cycle"` — runs its single child. When the child finishes, resets it
* 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
* child that succeeds; fails only if all children fail.
*/
export const Task = defineComponent("task", {
kind: "leaf" as
| "leaf"
kind: "action" as
| "action"
| "wait"
| "sequential"
| "parallel"
| "random"
| "repeat"
| "cycle"
| "whilst"
| "selector",
});
@@ -34,7 +45,7 @@ export type TaskKind = (typeof Task.type)["kind"];
/** A task that should be executed this tick. */
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", {});
/** The task completed successfully. */
+231
View File
@@ -0,0 +1,231 @@
import type { World, Entity } from "../index";
import type { EntityDef, EntityDefChild } from "../entity-tree";
import { Task, ChildOf, Cancel } from "./task";
import { TaskRunner, type TaskControl } from "./runner";
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 });
}
/**
* Create a wait task entity definition.
*
* `wait()` creates a task that simply becomes Running. External systems can
* complete it with `runner.succeed(entity)`, `runner.fail(entity)`, or
* `runner.cancel(entity)`.
*/
export function wait(children?: readonly EntityDefChild[]): TaskEntityDef;
export function wait(
start: WaitFn,
children?: readonly EntityDefChild[],
): TaskEntityDef;
export function wait(
startOrChildren?: WaitFn | readonly EntityDefChild[],
children: readonly EntityDefChild[] = [],
): TaskEntityDef {
const hasChildrenAsFirstArg = Array.isArray(startOrChildren);
const start = hasChildrenAsFirstArg
? undefined
: (startOrChildren as WaitFn | undefined);
return task("wait", hasChildrenAsFirstArg ? startOrChildren : children, {
mode: "wait",
start,
});
}
/** Create a sequential task entity definition. */
export function sequential(
children: readonly EntityDefChild[] = [],
): TaskEntityDef {
return task("sequential", children);
}
/** Create a parallel task entity definition. */
export function parallel(
children: readonly EntityDefChild[] = [],
): TaskEntityDef {
return task("parallel", children);
}
/** Create a selector task entity definition. */
export function selector(
children: readonly EntityDefChild[] = [],
): TaskEntityDef {
return task("selector", children);
}
/** 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 ───────────────────────────────────────────
/**
* Materialize a behaviour-tree definition into ECS entities and return a
* fully-wired `TaskRunner`.
*
* Definitions are `EntityDef` trees produced by the task factories (`action`,
* `wait`, `sequential`, `parallel`, `selector`, `random`, `cycle`, `whilst`)
* and generic single-component entity factories. Non-task child entities are
* materialized into the ECS tree but ignored by `TaskRunner` execution.
*/
export function buildTree(world: World, def: TreeDef): TaskRunner {
const actions = new Map<Entity, ActionFn>();
const waits = new Map<Entity, WaitFn>();
const conditions = new Map<Entity, ConditionFn>();
function build(def: EntityDef, parent?: Entity): Entity {
const entity = world.spawn();
world.add(entity, def.component, def.value);
if (parent !== undefined) {
world.relate(entity, ChildOf, parent);
}
if (def.component === Task) {
const taskData = world.get(entity, Task);
const meta = def.meta as TaskMeta | undefined;
if (taskData.kind === "action") {
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);
}
}
for (const child of def.children) {
build(child, entity);
}
return entity;
}
const root = build(def);
if (!world.has(root, Task)) {
throw new Error("buildTree root must be a task entity");
}
const runner = new TaskRunner(world);
runner.root = root;
runner.onAction = (world, entity, dt) => {
actions.get(entity)?.(world, entity, dt);
};
runner.onWait = (world, entity, control, dt) => {
waits.get(entity)?.(world, entity, control, dt);
};
runner.onCondition = (world, entity, dt) => {
const condition = conditions.get(entity);
return condition ? condition(world, entity, dt) : false;
};
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 type { Entity } 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 type {
WorldEvent,
+36 -5
View File
@@ -1,24 +1,55 @@
// ── Relationship ─────────────────────────────────────
/**
* 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
* ```ts
* 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. */
readonly _key: symbol;
/** Human-readable name, used for serialization. */
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.
*
* 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 {
return { _key: Symbol(), name };
export function defineRelationship(name: string): RelationshipDef<{}>;
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 {
/** Entity stable ID → component map (component name → data). */
entities: Record<string, Record<string, unknown>>;
/** Relationship name → (source ID → target ID). */
relationships: Record<string, Record<string, string>>;
/** Relationship name → (source ID → target ID or edge object). */
relationships: Record<
string,
Record<string, string | { target: string; data: unknown }>
>;
}
+170 -13
View File
@@ -35,6 +35,8 @@ export class World {
private _relReverse = new Map<symbol, Map<number, Set<number>>>();
/** Key → RelationshipDef for event emission. */
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 ───────────────────────────────
private _dirty = new Map<symbol, Set<number>>();
@@ -42,6 +44,9 @@ export class World {
// ── Observable layer ──────────────────────────────
private _observable = new ObservableLayer();
// ── Singleton entity ──────────────────────────────
private _singletonEntity: Entity | null = null;
/** Global event stream. */
get events$(): Observable<WorldEvent> {
return this._observable.events$.asObservable();
@@ -90,6 +95,8 @@ export class World {
if (fwd.has(idx)) {
const target = fwd.get(idx);
const rel = this._relKeyToDef.get(key)!;
// Clean up relationship data if applicable
this._relData.get(key)?.remove(idx);
this._relRemoveEdge(entity, target, rel);
}
@@ -250,14 +257,86 @@ export class World {
}
}
// ── Singleton component access ────────────────────
/**
* Add a singleton component to the world.
*
* A single shared entity is created lazily and reused for all singleton
* components. Returns a mutable reference to the component data.
*/
addSingleton<T extends Record<string, any>>(
def: ComponentDef<T>,
init?: Partial<T>,
): T {
if (this._singletonEntity === null) {
this._singletonEntity = this.spawn();
}
return this.add(this._singletonEntity, def, init);
}
/** Remove a singleton component. Destroys the backing entity if it becomes bare. */
removeSingleton(def: ComponentDef<any>): void {
const e = this._singletonEntity;
if (e === null) return;
this.remove(e, def);
if (!this.hasAnyComponent(e)) {
this.destroy(e);
this._singletonEntity = null;
}
}
/** Get a mutable reference to a singleton component. Throws if missing. */
getSingleton<T extends Record<string, any>>(def: ComponentDef<T>): T {
return this.get(this._singletonEntity!, def);
}
/** Try-get a singleton component. Returns undefined if missing. */
tryGetSingleton<T extends Record<string, any>>(
def: ComponentDef<T>,
): T | undefined {
const e = this._singletonEntity;
if (e === null) return undefined;
return this.tryGet(e, def);
}
/** Check whether a singleton component is present. */
hasSingleton(def: ComponentDef<any>): boolean {
const e = this._singletonEntity;
if (e === null) return false;
return this.has(e, def);
}
/** Bulk-replace a singleton component's data. Marks dirty. */
setSingleton<T extends Record<string, any>>(
def: ComponentDef<T>,
value: T,
): void {
this.set(this._singletonEntity!, def, value);
}
/** Mark a singleton component as dirty for change tracking. */
markDirtySingleton(def: ComponentDef<any>): void {
this.markDirty(this._singletonEntity!, def);
}
// ── Relationships ─────────────────────────────────
/**
* Create a directed relationship from `source` to `target`.
* Each source can only have one target per relationship type.
* 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 ti = entityIndex(target);
this._assertAlive(si, source);
@@ -285,6 +364,16 @@ export class World {
this._relCounts[si]++;
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({
type: "relationshipAdded",
source,
@@ -304,9 +393,48 @@ export class World {
const target = this.getRelated(source, rel);
if (target === undefined) return;
this._relData.get(rel._key)?.remove(si);
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.
*/
@@ -322,17 +450,22 @@ export class World {
/**
* Get all source entities that point to `target` via this relationship.
*/
getRelatedTo(target: Entity, rel: RelationshipDef): Entity[] {
*getRelatedTo(
target: Entity,
rel: RelationshipDef,
): IterableIterator<Entity> {
const ti = entityIndex(target);
if (!this._isAlive(ti, target)) return [];
if (!this._isAlive(ti, target)) return;
const rev = this._relReverse.get(rel._key);
if (!rev) return [];
if (!rev) return;
const sources = rev.get(ti);
if (!sources) return [];
if (!sources) return;
return [...sources].map((si) => makeEntity(si, this._generations[si]));
for (const si of sources) {
yield makeEntity(si, this._generations[si]);
}
}
/**
@@ -434,14 +567,26 @@ export class World {
}
// 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) {
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()) {
const ti = entityIndex(target);
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) {
@@ -501,11 +646,23 @@ export class World {
`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 target = idToEntity.get(tgtId);
if (source && target) {
world.relate(source, rel, target);
if (!source) continue;
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 { World, type Entity } from "../src/index";
import { World, defineComponent, entity, type Entity } from "../src/index";
import {
Task,
Scheduled,
@@ -9,12 +9,19 @@ import {
Cancelled,
ChildOf,
TaskRunner,
buildTree,
Cancel,
action,
wait,
cycle,
whilst,
sequential,
} from "../src/bt/index";
// ── Helpers ─────────────────────────────────────────
function makeLeaf(world: World, parent?: Entity): Entity {
function makeWait(world: World, parent?: Entity): Entity {
const e = world.spawn();
world.add(e, Task, { kind: "leaf" });
world.add(e, Task, { kind: "wait" });
if (parent) world.relate(e, ChildOf, parent);
return e;
}
@@ -40,9 +47,9 @@ function makeRandom(world: World, parent?: Entity): Entity {
return e;
}
function makeRepeat(world: World, parent?: Entity): Entity {
function makeCycle(world: World, parent?: Entity): Entity {
const e = world.spawn();
world.add(e, Task, { kind: "repeat" });
world.add(e, Task, { kind: "cycle" });
if (parent) world.relate(e, ChildOf, parent);
return e;
}
@@ -54,8 +61,178 @@ function makeSelector(world: World, parent?: Entity): Entity {
return e;
}
// ── Leaf tasks ──────────────────────────────────────
describe("Leaf tasks", () => {
// ── Entity task factories ───────────────────────────
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 runner: TaskRunner;
@@ -64,79 +241,79 @@ describe("Leaf tasks", () => {
runner = new TaskRunner(world);
});
it("calls onLeaf when a leaf is scheduled and ticked", () => {
const leaf = makeLeaf(world);
it("calls onWait when a wait task is scheduled and ticked", () => {
const action = makeWait(world);
const calls: Entity[] = [];
runner.onLeaf = (_w, e) => calls.push(e);
runner.schedule(leaf);
runner.onWait = (_w, e) => calls.push(e);
runner.schedule(action);
runner.tick();
expect(calls).toEqual([leaf]);
expect(calls).toEqual([action]);
});
it("marks leaf as Running after tick", () => {
const leaf = makeLeaf(world);
runner.schedule(leaf);
it("marks wait task as Running after tick", () => {
const action = makeWait(world);
runner.schedule(action);
runner.tick();
expect(world.has(leaf, Running)).toBe(true);
expect(world.has(leaf, Scheduled)).toBe(false);
expect(world.has(action, Running)).toBe(true);
expect(world.has(action, Scheduled)).toBe(false);
});
it("succeed() marks leaf as Succeeded", () => {
const leaf = makeLeaf(world);
runner.schedule(leaf);
it("succeed() marks wait task as Succeeded", () => {
const action = makeWait(world);
runner.schedule(action);
runner.tick();
runner.succeed(leaf);
runner.succeed(action);
expect(world.has(leaf, Succeeded)).toBe(true);
expect(world.has(leaf, Running)).toBe(false);
expect(world.has(action, Succeeded)).toBe(true);
expect(world.has(action, Running)).toBe(false);
});
it("fail() marks leaf as Failed", () => {
const leaf = makeLeaf(world);
runner.schedule(leaf);
it("fail() marks wait task as Failed", () => {
const action = makeWait(world);
runner.schedule(action);
runner.tick();
runner.fail(leaf);
runner.fail(action);
expect(world.has(leaf, Failed)).toBe(true);
expect(world.has(leaf, Running)).toBe(false);
expect(world.has(action, Failed)).toBe(true);
expect(world.has(action, Running)).toBe(false);
});
it("cancel() marks leaf as Cancelled", () => {
const leaf = makeLeaf(world);
runner.schedule(leaf);
it("cancel() marks wait task as Cancelled", () => {
const action = makeWait(world);
runner.schedule(action);
runner.tick();
runner.cancel(leaf);
runner.cancel(action);
expect(world.has(leaf, Cancelled)).toBe(true);
expect(world.has(leaf, Running)).toBe(false);
expect(world.has(action, Cancelled)).toBe(true);
expect(world.has(action, Running)).toBe(false);
});
it("onTerminal is called when leaf finishes", () => {
const leaf = makeLeaf(world);
it("onTerminal is called when wait task finishes", () => {
const action = makeWait(world);
const terminals: { entity: Entity; status: string }[] = [];
runner.onTerminal = (_w, e, s) => terminals.push({ entity: e, status: s });
runner.schedule(leaf);
runner.schedule(action);
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", () => {
const leaf = makeLeaf(world);
runner.schedule(leaf);
const action = makeWait(world);
runner.schedule(action);
runner.tick();
runner.succeed(leaf);
runner.succeed(action);
runner.reset(leaf);
runner.reset(action);
expect(world.has(leaf, Succeeded)).toBe(false);
expect(world.has(leaf, Running)).toBe(false);
expect(world.has(leaf, Scheduled)).toBe(false);
expect(world.has(action, Succeeded)).toBe(false);
expect(world.has(action, Running)).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", () => {
const seq = makeSequential(world);
const a = makeLeaf(world, seq);
const b = makeLeaf(world, seq);
const c = makeLeaf(world, seq);
const a = makeWait(world, seq);
const b = makeWait(world, seq);
const c = makeWait(world, seq);
const leafCalls: Entity[] = [];
runner.onLeaf = (_w, e) => leafCalls.push(e);
runner.onWait = (_w, e) => leafCalls.push(e);
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();
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(leafCalls).toEqual([a, b, c]);
});
it("fails immediately when a child fails", () => {
const seq = makeSequential(world);
const a = makeLeaf(world, seq);
const b = makeLeaf(world, seq);
const a = makeWait(world, seq);
const b = makeWait(world, seq);
runner.schedule(seq);
runner.tick(); // schedules a
@@ -206,8 +373,8 @@ describe("Sequential tasks", () => {
it("succeeds when all children succeed", () => {
const seq = makeSequential(world);
const a = makeLeaf(world, seq);
const b = makeLeaf(world, seq);
const a = makeWait(world, seq);
const b = makeWait(world, seq);
runner.schedule(seq);
runner.tick(); // schedules a
@@ -224,20 +391,20 @@ describe("Sequential tasks", () => {
it("propagates terminal to grandparent", () => {
const root = makeSequential(world);
const child = makeSequential(world, root);
const leaf = makeLeaf(world, child);
const action = makeWait(world, child);
const terminals: Entity[] = [];
runner.onTerminal = (_w, e) => terminals.push(e);
runner.schedule(root);
runner.tick(); // schedules child
runner.tick(); // schedules leaf
runner.tick(); // runs leaf
runner.succeed(leaf);
runner.tick(); // schedules action
runner.tick(); // runs action
runner.succeed(action);
runner.tick(); // child succeeds
runner.tick(); // root succeeds
expect(terminals).toEqual([leaf, child, root]);
expect(terminals).toEqual([action, child, root]);
expect(world.has(root, Succeeded)).toBe(true);
});
@@ -261,24 +428,24 @@ describe("Parallel tasks", () => {
runner = new TaskRunner(world);
});
it("schedules all children at once", () => {
it("starts all children at once", () => {
const par = makeParallel(world);
const a = makeLeaf(world, par);
const b = makeLeaf(world, par);
const c = makeLeaf(world, par);
const a = makeWait(world, par);
const b = makeWait(world, par);
const c = makeWait(world, par);
runner.schedule(par);
runner.tick();
expect(world.has(a, Scheduled)).toBe(true);
expect(world.has(b, Scheduled)).toBe(true);
expect(world.has(c, Scheduled)).toBe(true);
expect(world.has(a, Running)).toBe(true);
expect(world.has(b, Running)).toBe(true);
expect(world.has(c, Running)).toBe(true);
});
it("succeeds when all children succeed", () => {
const par = makeParallel(world);
const a = makeLeaf(world, par);
const b = makeLeaf(world, par);
const a = makeWait(world, par);
const b = makeWait(world, par);
runner.schedule(par);
runner.tick(); // schedules both
@@ -307,8 +474,8 @@ describe("Parallel tasks", () => {
it("fails immediately when any child fails", () => {
const par = makeParallel(world);
const a = makeLeaf(world, par);
const b = makeLeaf(world, par);
const a = makeWait(world, par);
const b = makeWait(world, par);
runner.schedule(par);
runner.tick(); // schedules both
@@ -343,27 +510,25 @@ describe("Random tasks", () => {
it("picks one child and succeeds/fails with it", () => {
const rand = makeRandom(world);
const a = makeLeaf(world, rand);
const b = makeLeaf(world, rand);
const a = makeWait(world, rand);
const b = makeWait(world, rand);
runner.schedule(rand);
runner.tick();
// Exactly one child should be scheduled
const scheduled = [world.has(a, Scheduled), world.has(b, Scheduled)];
expect(scheduled.filter(Boolean)).toHaveLength(1);
// Exactly one child should be running
const running = [world.has(a, Running), world.has(b, Running)];
expect(running.filter(Boolean)).toHaveLength(1);
const picked = world.has(a, Scheduled) ? a : b;
runner.tick(); // runs picked leaf
const picked = world.has(a, Running) ? a : b;
runner.succeed(picked);
runner.tick(); // random sees child done → succeeds
expect(world.has(rand, Succeeded)).toBe(true);
});
it("fails when picked child fails", () => {
const rand = makeRandom(world);
const a = makeLeaf(world, rand);
const a = makeWait(world, rand);
runner.schedule(rand);
runner.tick(); // schedules a (only child)
@@ -386,8 +551,8 @@ describe("Random tasks", () => {
});
});
// ── Repeat ──────────────────────────────────────────
describe("Repeat tasks", () => {
// ── Cycle ──────────────────────────────────────────
describe("Cycle tasks", () => {
let world: World;
let runner: TaskRunner;
@@ -397,110 +562,98 @@ describe("Repeat tasks", () => {
});
it("re-runs child after it succeeds", () => {
const rep = makeRepeat(world);
const leaf = makeLeaf(world, rep);
const cyc = makeCycle(world);
const action = makeWait(world, cyc);
let leafCount = 0;
runner.onLeaf = () => leafCount++;
let waitCount = 0;
runner.onWait = () => waitCount++;
runner.schedule(rep);
runner.schedule(cyc);
// First run
runner.tick(); // repeat schedules leaf
runner.tick(); // leaf runs
expect(leafCount).toBe(1);
runner.succeed(leaf);
runner.tick();
expect(waitCount).toBe(1);
runner.succeed(action);
// Repeat re-scheduled
runner.tick(); // repeat sees leaf done, resets and schedules again
runner.tick(); // leaf runs again
expect(leafCount).toBe(2);
runner.succeed(leaf);
// Completion schedules the cycle node at a tick boundary.
expect(world.has(cyc, Scheduled)).toBe(true);
runner.tick();
expect(waitCount).toBe(2);
runner.succeed(action);
// And again
runner.tick(); // repeat resets
runner.tick(); // leaf runs
expect(leafCount).toBe(3);
runner.tick();
expect(waitCount).toBe(3);
});
it("re-runs child after it fails", () => {
const rep = makeRepeat(world);
const leaf = makeLeaf(world, rep);
const cyc = makeCycle(world);
const action = makeWait(world, cyc);
let leafCount = 0;
runner.onLeaf = () => leafCount++;
let waitCount = 0;
runner.onWait = () => waitCount++;
runner.schedule(rep);
runner.tick(); // repeat schedules leaf
runner.tick(); // leaf runs
runner.fail(leaf);
runner.schedule(cyc);
runner.tick();
runner.fail(action);
// Repeat re-scheduled, resets leaf
runner.tick(); // repeat resets leaf
runner.tick(); // leaf runs again
expect(leafCount).toBe(2);
expect(world.has(cyc, Scheduled)).toBe(true);
runner.tick();
expect(waitCount).toBe(2);
});
it("never terminates on its own", () => {
const rep = makeRepeat(world);
const leaf = makeLeaf(world, rep);
const cyc = makeCycle(world);
const action = makeWait(world, cyc);
runner.schedule(rep);
runner.tick(); // schedules leaf
runner.tick(); // runs leaf
runner.succeed(leaf);
runner.schedule(cyc);
runner.tick();
runner.succeed(action);
// After many cycles, repeat is still not terminal
// After many cycles, cycle is still not terminal
for (let i = 0; i < 5; i++) {
runner.tick(); // repeat resets + schedules leaf
runner.tick(); // leaf runs
runner.succeed(leaf);
runner.tick();
runner.succeed(action);
}
expect(world.has(rep, Succeeded)).toBe(false);
expect(world.has(rep, Failed)).toBe(false);
expect(world.has(rep, Cancelled)).toBe(false);
expect(world.has(cyc, Succeeded)).toBe(false);
expect(world.has(cyc, Failed)).toBe(false);
expect(world.has(cyc, Cancelled)).toBe(false);
});
it("can be cancelled", () => {
const rep = makeRepeat(world);
const leaf = makeLeaf(world, rep);
const cyc = makeCycle(world);
const action = makeWait(world, cyc);
runner.schedule(rep);
runner.tick(); // schedules leaf
runner.tick(); // runs leaf
runner.schedule(cyc);
runner.tick();
runner.cancel(rep);
runner.cancel(cyc);
expect(world.has(rep, Cancelled)).toBe(true);
expect(world.has(leaf, Cancelled)).toBe(true);
expect(world.has(cyc, Cancelled)).toBe(true);
expect(world.has(action, Cancelled)).toBe(true);
});
it("empty repeat does nothing", () => {
const rep = makeRepeat(world);
it("empty cycle does nothing", () => {
const cyc = makeCycle(world);
runner.schedule(rep);
runner.schedule(cyc);
runner.tick();
// No child, so nothing happens
expect(world.has(rep, Succeeded)).toBe(false);
expect(world.has(rep, Failed)).toBe(false);
expect(world.has(cyc, Succeeded)).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 rep = makeRepeat(world, seq);
const leaf = makeLeaf(world, rep);
const after = makeLeaf(world, seq);
const cyc = makeCycle(world, seq);
const action = makeWait(world, cyc);
const after = makeWait(world, seq);
runner.schedule(seq);
runner.tick(); // seq schedules rep
runner.tick(); // rep schedules leaf
runner.tick(); // leaf runs
runner.tick();
// Cancel the repeat
runner.cancel(rep);
runner.tick(); // seq sees rep cancelled, seq becomes cancelled
// Cancel the cycle
runner.cancel(cyc);
expect(world.has(seq, Cancelled)).toBe(true);
expect(world.has(after, Scheduled)).toBe(false);
@@ -519,8 +672,8 @@ describe("Selector tasks", () => {
it("succeeds on first child that succeeds", () => {
const sel = makeSelector(world);
const a = makeLeaf(world, sel);
const b = makeLeaf(world, sel);
const a = makeWait(world, sel);
const b = makeWait(world, sel);
runner.schedule(sel);
runner.tick(); // schedules a
@@ -537,35 +690,28 @@ describe("Selector tasks", () => {
it("tries next child when previous fails", () => {
const sel = makeSelector(world);
const a = makeLeaf(world, sel);
const b = makeLeaf(world, sel);
const c = makeLeaf(world, sel);
const a = makeWait(world, sel);
const b = makeWait(world, sel);
const c = makeWait(world, sel);
runner.schedule(sel);
runner.tick(); // schedules a
runner.tick(); // runs a
runner.fail(a);
runner.tick(); // selector schedules b
expect(world.has(b, Scheduled)).toBe(true);
expect(world.has(b, Running)).toBe(true);
runner.tick(); // runs 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.tick(); // selector succeeds
expect(world.has(sel, Succeeded)).toBe(true);
});
it("fails when all children fail", () => {
const sel = makeSelector(world);
const a = makeLeaf(world, sel);
const b = makeLeaf(world, sel);
const a = makeWait(world, sel);
const b = makeWait(world, sel);
runner.schedule(sel);
runner.tick(); // schedules a
@@ -590,16 +736,15 @@ describe("Selector tasks", () => {
it("skips cancelled children and continues", () => {
const sel = makeSelector(world);
const a = makeLeaf(world, sel);
const b = makeLeaf(world, sel);
const a = makeWait(world, sel);
const b = makeWait(world, sel);
runner.schedule(sel);
runner.tick(); // schedules a
runner.tick(); // runs a
runner.cancel(a);
runner.tick(); // selector sees a cancelled, tries b
expect(world.has(b, Scheduled)).toBe(true);
expect(world.has(b, Running)).toBe(true);
});
});
@@ -616,8 +761,8 @@ describe("Cancel", () => {
it("cancels all descendants", () => {
const root = makeSequential(world);
const child = makeParallel(world, root);
const a = makeLeaf(world, child);
const b = makeLeaf(world, child);
const a = makeWait(world, child);
const b = makeWait(world, child);
runner.schedule(root);
runner.tick(); // schedules child
@@ -636,17 +781,17 @@ describe("Cancel", () => {
it("cancel propagates to parent", () => {
const root = makeSequential(world);
const child = makeSequential(world, root);
const leaf = makeLeaf(world, child);
const action = makeWait(world, child);
runner.schedule(root);
runner.tick(); // schedules child
runner.tick(); // schedules leaf
runner.tick(); // runs leaf
runner.tick(); // schedules action
runner.tick(); // runs action
runner.cancel(leaf);
runner.cancel(action);
// leaf cancelled → child re-scheduled → child sees leaf cancelled → child cancelled
runner.tick(); // child processes cancelled leaf
// action cancelled → child re-scheduled → child sees action cancelled → child cancelled
runner.tick(); // child processes cancelled action
expect(world.has(child, Cancelled)).toBe(true);
// child cancelled → root re-scheduled → root sees child cancelled → root cancelled
@@ -655,8 +800,8 @@ describe("Cancel", () => {
});
});
// ── Multi-frame leaves ──────────────────────────────
describe("Multi-frame leaves", () => {
// ── Multi-frame wait tasks ──────────────────────────────
describe("Multi-frame wait tasks", () => {
let world: World;
let runner: TaskRunner;
@@ -665,28 +810,28 @@ describe("Multi-frame leaves", () => {
runner = new TaskRunner(world);
});
it("leaf stays Running across ticks until explicitly finished", () => {
const leaf = makeLeaf(world);
it("wait task stays Running across ticks until explicitly finished", () => {
const action = makeWait(world);
runner.schedule(leaf);
runner.schedule(action);
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();
expect(world.has(leaf, Running)).toBe(true);
expect(world.has(action, Running)).toBe(true);
// External system finishes it
runner.succeed(leaf);
expect(world.has(leaf, Succeeded)).toBe(true);
expect(world.has(leaf, Running)).toBe(false);
runner.succeed(action);
expect(world.has(action, Succeeded)).toBe(true);
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 a = makeLeaf(world, seq);
const b = makeLeaf(world, seq);
const a = makeWait(world, seq);
const b = makeWait(world, seq);
runner.schedule(seq);
runner.tick(); // schedules a
@@ -698,10 +843,9 @@ describe("Multi-frame leaves", () => {
expect(world.has(b, Scheduled)).toBe(false);
expect(world.has(b, Running)).toBe(false);
// Finish a
// Finish a; parent propagation starts b immediately.
runner.succeed(a);
runner.tick(); // seq schedules b
expect(world.has(b, Scheduled)).toBe(true);
expect(world.has(b, Running)).toBe(true);
});
});
@@ -729,11 +873,11 @@ describe("Edge cases", () => {
it("tick is a no-op when nothing is scheduled", () => {
const world = new 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(world.has(leaf, Running)).toBe(false);
expect(world.has(action, Running)).toBe(false);
});
it("deeply nested tree works correctly", () => {
@@ -742,29 +886,15 @@ describe("Edge cases", () => {
const root = makeSequential(world);
const mid = makeSequential(world, root);
const leaf = makeLeaf(world, mid);
const action = makeWait(world, mid);
runner.schedule(root);
// Tick 1: root schedules mid
runner.tick();
expect(world.has(mid, Scheduled)).toBe(true);
expect(world.has(action, Running)).toBe(true);
// Tick 2: mid schedules leaf
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
runner.succeed(action);
expect(world.has(mid, Succeeded)).toBe(true);
runner.tick(); // root sees mid done → root succeeds
expect(world.has(root, Succeeded)).toBe(true);
});
});
+162 -9
View File
@@ -55,7 +55,7 @@ describe("Relationships", () => {
world.relate(a, ChildOf, parent);
world.relate(b, ChildOf, parent);
const children = world.getRelatedTo(parent, ChildOf);
const children = [...world.getRelatedTo(parent, ChildOf)];
expect(children).toHaveLength(2);
expect(children).toContain(a);
expect(children).toContain(b);
@@ -63,7 +63,7 @@ describe("Relationships", () => {
it("getRelatedTo returns empty when no edges", () => {
const e = world.spawn();
expect(world.getRelatedTo(e, ChildOf)).toEqual([]);
expect([...world.getRelatedTo(e, ChildOf)]).toEqual([]);
});
it("unrelate removes the relationship", () => {
@@ -74,7 +74,7 @@ describe("Relationships", () => {
world.unrelate(child, ChildOf);
expect(world.getRelated(child, ChildOf)).toBeUndefined();
expect(world.getRelatedTo(parent, ChildOf)).toEqual([]);
expect([...world.getRelatedTo(parent, ChildOf)]).toEqual([]);
});
it("unrelate is idempotent", () => {
@@ -89,13 +89,13 @@ describe("Relationships", () => {
world.relate(a, ChildOf, b);
expect(world.getRelated(a, ChildOf)).toBe(b);
expect(world.getRelatedTo(b, ChildOf)).toContain(a);
expect([...world.getRelatedTo(b, ChildOf)]).toContain(a);
world.relate(a, ChildOf, c);
expect(world.getRelated(a, ChildOf)).toBe(c);
// a should no longer point to b
expect(world.getRelatedTo(b, ChildOf)).toEqual([]);
expect(world.getRelatedTo(c, ChildOf)).toContain(a);
expect([...world.getRelatedTo(b, ChildOf)]).toEqual([]);
expect([...world.getRelatedTo(c, ChildOf)]).toContain(a);
});
});
@@ -243,7 +243,7 @@ describe("Destroy cleanup", () => {
world.destroy(child);
expect(world.getRelated(child, ChildOf)).toBeUndefined();
expect(world.getRelatedTo(parent, ChildOf)).toEqual([]);
expect([...world.getRelatedTo(parent, ChildOf)]).toEqual([]);
});
it("removes edges when target is destroyed", () => {
@@ -252,7 +252,7 @@ describe("Destroy cleanup", () => {
world.relate(child, ChildOf, parent);
world.destroy(parent);
expect(world.getRelatedTo(parent, ChildOf)).toEqual([]);
expect([...world.getRelatedTo(parent, ChildOf)]).toEqual([]);
expect(world.getRelated(child, ChildOf)).toBeUndefined();
});
@@ -376,6 +376,159 @@ describe("Dead entity safety", () => {
it("getRelatedTo returns empty for dead entity", () => {
const e = world.spawn();
world.destroy(e);
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];
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
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,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
"include": ["src", "test", "examples"],
"exclude": ["node_modules", "dist"],
}