Compare commits
35
Commits
4ede2d7f3b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b038388deb | ||
|
|
6fa1f511bd | ||
|
|
572b473089 | ||
|
|
0eb3defc62 | ||
|
|
9bf0e5e049 | ||
|
|
204b9100e6 | ||
|
|
1253bf82d2 | ||
|
|
ddde3f7597 | ||
|
|
968672da06 | ||
|
|
365b2c4d13 | ||
|
|
cd6350e0b1 | ||
|
|
a97488e8d6 | ||
|
|
5d125167cc | ||
|
|
2469cdc7cb | ||
|
|
ef9abf03c6 | ||
|
|
4182bc1578 | ||
|
|
ec8f668392 | ||
|
|
ccd0e3afb4 | ||
|
|
efa92be5ab | ||
|
|
c3c24d2350 | ||
|
|
3620e80807 | ||
|
|
4e37e03d3f | ||
|
|
2fe9203be9 | ||
|
|
9e788b135b | ||
|
|
fd78e9ce6d | ||
|
|
46da8abbe1 | ||
|
|
87c01858e7 | ||
|
|
81efb6cb0a | ||
|
|
05674a349f | ||
|
|
9953c7c556 | ||
|
|
24616a0855 | ||
|
|
1c55485f9f | ||
|
|
d0bb119911 | ||
|
|
32f8f29912 | ||
|
|
ba4a688f57 |
@@ -0,0 +1,510 @@
|
|||||||
|
# ecs-observable
|
||||||
|
|
||||||
|
Entity-Component-System with an observable-style API for TypeScript. Built for games and simulations.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [Concepts](#concepts)
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install ecs-observable
|
||||||
|
```
|
||||||
|
|
||||||
|
## Concepts
|
||||||
|
|
||||||
|
| Concept | Purpose |
|
||||||
|
|-------------|---------|
|
||||||
|
| **Entity** | Opaque `number` handle. Has no data of its own. |
|
||||||
|
| **Component** | Plain object attached to an entity. Defined via `defineComponent()`. |
|
||||||
|
| **Query** | Filter that finds entities matching a component signature. |
|
||||||
|
| **Relationship** | Directed edge between two entities (e.g. `ChildOf`, `Targeting`). |
|
||||||
|
| **World** | Central container that stores entities, components, and relationships. |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { World, defineComponent, query } from "ecs-observable";
|
||||||
|
|
||||||
|
// 1. Define components
|
||||||
|
const Position = defineComponent("position", { x: 0, y: 0 });
|
||||||
|
const Velocity = defineComponent("velocity", { vx: 0, vy: 0 });
|
||||||
|
|
||||||
|
// 2. Create world
|
||||||
|
const world = new World();
|
||||||
|
|
||||||
|
// 3. Spawn entities and add components
|
||||||
|
const player = world.spawn();
|
||||||
|
world.add(player, Position, { x: 100, y: 200 });
|
||||||
|
world.add(player, Velocity, { vx: 2, vy: 0 });
|
||||||
|
|
||||||
|
const npc = world.spawn();
|
||||||
|
world.add(npc, Position, { x: 300, y: 150 });
|
||||||
|
|
||||||
|
// 4. Iterate with queries
|
||||||
|
for (const e of world.query(query(Position, Velocity))) {
|
||||||
|
const pos = world.get(e, Position);
|
||||||
|
const vel = world.get(e, Velocity);
|
||||||
|
pos.x += vel.vx;
|
||||||
|
pos.y += vel.vy;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### World
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const world = new World();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Entity Lifecycle
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Create
|
||||||
|
const e = world.spawn();
|
||||||
|
|
||||||
|
// Check
|
||||||
|
world.isAlive(e); // true
|
||||||
|
|
||||||
|
// Destroy (removes all components and relationships)
|
||||||
|
world.destroy(e);
|
||||||
|
world.isAlive(e); // false
|
||||||
|
|
||||||
|
// Count
|
||||||
|
world.entityCount; // number of live entities
|
||||||
|
```
|
||||||
|
|
||||||
|
Destroyed slots are recycled (with a generation bump) so stale entity handles do not match new ones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
Define a component with a name and defaults. The defaults provide the TypeScript shape and initial values.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const Health = defineComponent("health", { current: 100, max: 100 });
|
||||||
|
type Health = typeof Health.type; // → { current: number; max: number }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CRUD
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const e = world.spawn();
|
||||||
|
|
||||||
|
// Add — returns a mutable reference initialized from defaults
|
||||||
|
const h = world.add(e, Health, { current: 50 }); // override defaults
|
||||||
|
h.current; // 50
|
||||||
|
|
||||||
|
// Get — returns the same mutable reference (throws if missing)
|
||||||
|
world.get(e, Health).current = 75;
|
||||||
|
|
||||||
|
// Try-get — safe access
|
||||||
|
const val = world.tryGet(e, Health); // Health | undefined
|
||||||
|
|
||||||
|
// Has — check presence
|
||||||
|
world.has(e, Health); // true
|
||||||
|
|
||||||
|
// Set — bulk replace (marks dirty automatically)
|
||||||
|
world.set(e, Health, { current: 90, max: 90 });
|
||||||
|
|
||||||
|
// Remove
|
||||||
|
world.remove(e, Health);
|
||||||
|
```
|
||||||
|
|
||||||
|
Get returns a **live mutable reference** — no defensive copies. Mutations are visible immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const Position = defineComponent("position", { x: 0, y: 0 });
|
||||||
|
const Velocity = defineComponent("velocity", { vx: 0, vy: 0 });
|
||||||
|
const Dead = defineComponent("dead", { timestamp: 0 });
|
||||||
|
|
||||||
|
// Entities with Position AND Velocity
|
||||||
|
query(Position, Velocity)
|
||||||
|
|
||||||
|
// Entities with Position but NOT Dead
|
||||||
|
query(Position).without(Dead)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Synchronous iteration
|
||||||
|
|
||||||
|
```ts
|
||||||
|
for (const e of world.query(query(Position, Velocity))) {
|
||||||
|
const pos = world.get(e, Position);
|
||||||
|
const vel = world.get(e, Velocity);
|
||||||
|
// update pos from vel...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Efficient: iterates the smallest component store and cross-checks the others.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Observable Queries
|
||||||
|
|
||||||
|
Subscribe to get live diffs when the result set changes.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
world.observe(query(Position, Velocity)).subscribe(update => {
|
||||||
|
// update.added — Entity[]
|
||||||
|
// update.removed — Entity[]
|
||||||
|
// update.changed — Entity[] (only after flush, see below)
|
||||||
|
|
||||||
|
for (const e of update.added) { /* e now matches */ }
|
||||||
|
for (const e of update.removed) { /* e no longer matches */ }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Common pattern — maintain a rendering list:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const movers = new Set<number>();
|
||||||
|
|
||||||
|
world.observe(query(Position, Velocity)).subscribe(update => {
|
||||||
|
for (const e of update.added) movers.add(e);
|
||||||
|
for (const e of update.removed) movers.delete(e);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Subscriptions are **seeded** on creation: existing matches are tracked without emitting spurious added events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Change Tracking
|
||||||
|
|
||||||
|
For observable queries to emit `changed`, you must explicitly mark dirty and flush.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const log: QueryUpdate[] = [];
|
||||||
|
world.observe(query(Position)).subscribe(u => log.push(u));
|
||||||
|
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Position);
|
||||||
|
|
||||||
|
// Mutate and flush
|
||||||
|
world.get(e, Position).x = 99;
|
||||||
|
world.markDirty(e, Position);
|
||||||
|
world.flush();
|
||||||
|
// → log contains { added: [], removed: [], changed: [e] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**`world.set()` marks dirty for you**, so `markDirty` is only needed after direct mutation via `world.get()`.
|
||||||
|
|
||||||
|
Flush is batched — call it once per frame after all systems have run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
|
||||||
|
Directed edges between entities. A source can target at most one entity per relationship type.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const ChildOf = defineRelationship("childOf");
|
||||||
|
const Parent = defineRelationship("parent");
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const parent = world.spawn();
|
||||||
|
const child = world.spawn();
|
||||||
|
|
||||||
|
// Create
|
||||||
|
world.relate(child, ChildOf, parent);
|
||||||
|
|
||||||
|
// Read
|
||||||
|
world.getRelated(child, ChildOf); // → parent
|
||||||
|
world.getRelatedTo(parent, ChildOf); // → [child]
|
||||||
|
|
||||||
|
// Replace
|
||||||
|
world.relate(child, ChildOf, otherParent); // old edge removed automatically
|
||||||
|
|
||||||
|
// Remove
|
||||||
|
world.unrelate(child, ChildOf);
|
||||||
|
```
|
||||||
|
|
||||||
|
Destroying an entity cleans up all its edges bidirectionally.
|
||||||
|
|
||||||
|
#### Observable relationships
|
||||||
|
|
||||||
|
```ts
|
||||||
|
world.observeRelated(ChildOf).subscribe(update => {
|
||||||
|
// update.added — { source: Entity; target: Entity }[]
|
||||||
|
// update.removed — { source: Entity; target: Entity }[]
|
||||||
|
for (const { source, target } of update.added) {
|
||||||
|
console.log("new child relationship");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Like query observers, these are seeded with current edges on subscription.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
The global event stream gives full visibility into everything happening in the world.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
world.events$.subscribe(ev => {
|
||||||
|
switch (ev.type) {
|
||||||
|
case "spawned":
|
||||||
|
case "destroyed":
|
||||||
|
// ev.entity
|
||||||
|
break;
|
||||||
|
case "componentAdded":
|
||||||
|
case "componentRemoved":
|
||||||
|
case "componentChanged":
|
||||||
|
// ev.entity, ev.component
|
||||||
|
break;
|
||||||
|
case "relationshipAdded":
|
||||||
|
case "relationshipRemoved":
|
||||||
|
// ev.source, ev.target, ev.relationship
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Events fire **immediately** on mutation (synchronous), before `flush()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Serialization
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Export
|
||||||
|
const snapshot = world.toJSON();
|
||||||
|
const json = JSON.stringify(snapshot);
|
||||||
|
|
||||||
|
// Import — must supply the known component/relationship definitions
|
||||||
|
const components: ComponentDef<any>[] = [Position, Velocity, Health];
|
||||||
|
const relationships: RelationshipDef[] = [ChildOf];
|
||||||
|
|
||||||
|
const loaded = World.fromJSON(JSON.parse(json), components, relationships);
|
||||||
|
```
|
||||||
|
|
||||||
|
Snapshots use stable string IDs (`"e0"`, `"e1"`, …). Bare entities (no components) are preserved. Holes from destroyed entities are collapsed.
|
||||||
|
|
||||||
|
**Round-trip example:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const world = new World();
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Position, { x: 42, y: 99 });
|
||||||
|
world.add(e, Health, { current: 75, max: 100 });
|
||||||
|
|
||||||
|
const snap = world.toJSON();
|
||||||
|
const world2 = World.fromJSON(snap, [Position, Health]);
|
||||||
|
|
||||||
|
world2.entityCount; // 1
|
||||||
|
const e2 = [...world2.query(query(Position))][0];
|
||||||
|
world2.get(e2, Position); // { x: 42, y: 99 }
|
||||||
|
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.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const Inventory = defineComponent("inventory", {
|
||||||
|
items: [] as string[],
|
||||||
|
gold: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// world.add returns the typed component
|
||||||
|
const inv = world.add(e, Inventory, { gold: 42 });
|
||||||
|
inv.items.push("sword"); // ✅ string[]
|
||||||
|
inv.gold = 100; // ✅ number
|
||||||
|
|
||||||
|
// world.get also returns typed
|
||||||
|
world.get(e, Inventory).items; // string[]
|
||||||
|
```
|
||||||
@@ -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.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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", {});
|
||||||
|
|
||||||
|
/** Move the active piece right. */
|
||||||
|
export const MoveRight = defineComponent("moveRight", {});
|
||||||
|
|
||||||
|
/** Rotate the active piece clockwise. */
|
||||||
|
export const Rotate = defineComponent("rotate", {});
|
||||||
|
|
||||||
|
/** Soft drop — move piece down one row immediately. */
|
||||||
|
export const SoftDrop = defineComponent("softDrop", {});
|
||||||
|
|
||||||
|
/** Hard drop — slam piece to the bottom instantly. */
|
||||||
|
export const HardDrop = defineComponent("hardDrop", {});
|
||||||
|
|
||||||
|
/** Pause / unpause the game. */
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { defineComponent } from "../../src/component";
|
||||||
|
import type { World } from "../../src/index";
|
||||||
|
import {
|
||||||
|
randomPiece,
|
||||||
|
collides,
|
||||||
|
lockPiece,
|
||||||
|
clearLines,
|
||||||
|
scoreForLines,
|
||||||
|
BOARD_W,
|
||||||
|
} from "./game";
|
||||||
|
|
||||||
|
// ── Component definitions ────────────────────────────
|
||||||
|
|
||||||
|
/** 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[],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Active piece ─────────────────────────────────────
|
||||||
|
export const Piece = defineComponent("piece", {
|
||||||
|
shape: [] as number[][],
|
||||||
|
color: 1,
|
||||||
|
x: 3,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Score / state ────────────────────────────────────
|
||||||
|
export const Score = defineComponent("score", {
|
||||||
|
points: 0,
|
||||||
|
lines: 0,
|
||||||
|
level: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GameOver = defineComponent("gameOver", {});
|
||||||
|
export const Paused = defineComponent("paused", {});
|
||||||
|
|
||||||
|
// ── Timing ───────────────────────────────────────────
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// ── Tetris game logic (pure functions, no ECS dependency) ──
|
||||||
|
|
||||||
|
export const BOARD_W = 10;
|
||||||
|
export const BOARD_H = 20;
|
||||||
|
|
||||||
|
// ── Tetrominoes ──────────────────────────────────────
|
||||||
|
export const PIECES: { shape: number[][]; color: number }[] = [
|
||||||
|
{ shape: [[1, 1, 1, 1]], color: 1 }, // I
|
||||||
|
{ shape: [[1, 1], [1, 1]], color: 2 }, // O
|
||||||
|
{ shape: [[0, 1, 0], [1, 1, 1]], color: 3 }, // T
|
||||||
|
{ shape: [[1, 0, 0], [1, 1, 1]], color: 4 }, // J
|
||||||
|
{ shape: [[0, 0, 1], [1, 1, 1]], color: 5 }, // L
|
||||||
|
{ shape: [[0, 1, 1], [1, 1, 0]], color: 6 }, // S
|
||||||
|
{ shape: [[1, 1, 0], [0, 1, 1]], color: 7 }, // Z
|
||||||
|
];
|
||||||
|
|
||||||
|
export function randomPiece(): { shape: number[][]; color: number } {
|
||||||
|
const p = PIECES[Math.floor(Math.random() * PIECES.length)];
|
||||||
|
return { shape: p.shape.map((r) => [...r]), color: p.color };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collision ────────────────────────────────────────
|
||||||
|
export function collides(
|
||||||
|
grid: Uint8Array[],
|
||||||
|
shape: number[][],
|
||||||
|
px: number,
|
||||||
|
py: number,
|
||||||
|
): boolean {
|
||||||
|
for (let r = 0; r < shape.length; r++) {
|
||||||
|
for (let c = 0; c < shape[r].length; c++) {
|
||||||
|
if (!shape[r][c]) continue;
|
||||||
|
const bx = px + c;
|
||||||
|
const by = py + r;
|
||||||
|
if (bx < 0 || bx >= BOARD_W || by >= BOARD_H) return true;
|
||||||
|
if (by < 0) continue; // above the board is ok
|
||||||
|
if (grid[by][bx]) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lock piece onto the grid ─────────────────────────
|
||||||
|
export function lockPiece(
|
||||||
|
grid: Uint8Array[],
|
||||||
|
shape: number[][],
|
||||||
|
color: number,
|
||||||
|
px: number,
|
||||||
|
py: number,
|
||||||
|
): void {
|
||||||
|
for (let r = 0; r < shape.length; r++) {
|
||||||
|
for (let c = 0; c < shape[r].length; c++) {
|
||||||
|
if (!shape[r][c]) continue;
|
||||||
|
const bx = px + c;
|
||||||
|
const by = py + r;
|
||||||
|
if (by >= 0 && by < BOARD_H && bx >= 0 && bx < BOARD_W) {
|
||||||
|
grid[by][bx] = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Line clearing ────────────────────────────────────
|
||||||
|
export function clearLines(grid: Uint8Array[]): number {
|
||||||
|
let cleared = 0;
|
||||||
|
for (let r = BOARD_H - 1; r >= 0; r--) {
|
||||||
|
if (grid[r].every((v) => v !== 0)) {
|
||||||
|
// Shift everything above down
|
||||||
|
for (let rr = r; rr > 0; rr--) {
|
||||||
|
grid[rr] = grid[rr - 1];
|
||||||
|
}
|
||||||
|
grid[0] = new Uint8Array(BOARD_W);
|
||||||
|
cleared++;
|
||||||
|
r++; // re-check this row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleared;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scoring ──────────────────────────────────────────
|
||||||
|
const LINE_SCORES = [0, 100, 300, 500, 800];
|
||||||
|
|
||||||
|
export function scoreForLines(lines: number, level: number): number {
|
||||||
|
return (LINE_SCORES[lines] ?? lines * 200) * level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ghost piece (hard-drop preview) ──────────────────
|
||||||
|
export function ghostY(
|
||||||
|
grid: Uint8Array[],
|
||||||
|
shape: number[][],
|
||||||
|
px: number,
|
||||||
|
py: number,
|
||||||
|
): number {
|
||||||
|
let gy = py;
|
||||||
|
while (!collides(grid, shape, px, gy + 1)) {
|
||||||
|
gy++;
|
||||||
|
}
|
||||||
|
return gy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rotation (clockwise) ─────────────────────────────
|
||||||
|
export function rotateCW(shape: number[][]): number[][] {
|
||||||
|
const rows = shape.length;
|
||||||
|
const cols = shape[0].length;
|
||||||
|
const rotated: number[][] = [];
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const row: number[] = [];
|
||||||
|
for (let r = rows - 1; r >= 0; r--) {
|
||||||
|
row.push(shape[r][c]);
|
||||||
|
}
|
||||||
|
rotated.push(row);
|
||||||
|
}
|
||||||
|
return rotated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wall kick ────────────────────────────────────────
|
||||||
|
/** Try to rotate with basic wall kicks. Returns the rotated shape and x offset, or null. */
|
||||||
|
export function tryRotate(
|
||||||
|
grid: Uint8Array[],
|
||||||
|
shape: number[][],
|
||||||
|
px: number,
|
||||||
|
py: number,
|
||||||
|
): { shape: number[][]; x: number } | null {
|
||||||
|
const rotated = rotateCW(shape);
|
||||||
|
// Try offsets: 0, -1, +1, -2, +2
|
||||||
|
for (const dx of [0, -1, 1, -2, 2]) {
|
||||||
|
if (!collides(grid, rotated, px + dx, py)) {
|
||||||
|
return { shape: rotated, x: px + dx };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// ── Keyboard input via blessed ────────────────────────
|
||||||
|
import type blessed from "blessed";
|
||||||
|
|
||||||
|
export type Key = "left" | "right" | "up" | "down" | "space" | "p" | "r" | "q";
|
||||||
|
|
||||||
|
/** Wire blessed screen key events to a callback. */
|
||||||
|
export function startInput(
|
||||||
|
screen: blessed.Widgets.Screen,
|
||||||
|
onKey: (key: Key) => void,
|
||||||
|
): void {
|
||||||
|
screen.key(
|
||||||
|
["left", "right", "up", "down", "space", "p", "r", "q", "C-c"],
|
||||||
|
(_ch, key) => {
|
||||||
|
if (key.name === "q") {
|
||||||
|
screen.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (key.name === "C-c") {
|
||||||
|
screen.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
onKey(key.name as Key);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// ── Tetris: BT-driven game loop with command-based input ──
|
||||||
|
//
|
||||||
|
// Architecture:
|
||||||
|
// 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 {
|
||||||
|
buildTree,
|
||||||
|
action,
|
||||||
|
parallel,
|
||||||
|
cycle,
|
||||||
|
sequential,
|
||||||
|
whilst,
|
||||||
|
} from "../../src/bt/index";
|
||||||
|
import { CommandQueue } from "../../src/commands/index";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Board,
|
||||||
|
Piece,
|
||||||
|
Score,
|
||||||
|
GameOver,
|
||||||
|
Paused,
|
||||||
|
TickTimer,
|
||||||
|
createPieceHelpers,
|
||||||
|
} from "./components";
|
||||||
|
|
||||||
|
import {
|
||||||
|
registerCommands,
|
||||||
|
MoveLeft,
|
||||||
|
MoveRight,
|
||||||
|
Rotate,
|
||||||
|
SoftDrop,
|
||||||
|
HardDrop,
|
||||||
|
TogglePause,
|
||||||
|
Restart,
|
||||||
|
} from "./commands";
|
||||||
|
|
||||||
|
import { collides } from "./game";
|
||||||
|
|
||||||
|
import { createUI, render } from "./render";
|
||||||
|
import { startInput, type Key } from "./input";
|
||||||
|
|
||||||
|
// ── Setup ────────────────────────────────────────────
|
||||||
|
const world = new World();
|
||||||
|
|
||||||
|
world.addSingleton(Board);
|
||||||
|
world.addSingleton(Score);
|
||||||
|
world.addSingleton(TickTimer);
|
||||||
|
|
||||||
|
const ui = createUI();
|
||||||
|
const pieces = createPieceHelpers(world);
|
||||||
|
const commands = new CommandQueue(world);
|
||||||
|
|
||||||
|
registerCommands(world, commands, pieces);
|
||||||
|
pieces.spawnPiece();
|
||||||
|
|
||||||
|
// ── Behaviour Tree ───────────────────────────────────
|
||||||
|
const runner = buildTree(
|
||||||
|
world,
|
||||||
|
parallel([
|
||||||
|
whilst(
|
||||||
|
() => true,
|
||||||
|
action((_world, _entity, dt) => {
|
||||||
|
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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>> = {
|
||||||
|
left: MoveLeft,
|
||||||
|
right: MoveRight,
|
||||||
|
up: Rotate,
|
||||||
|
down: SoftDrop,
|
||||||
|
space: HardDrop,
|
||||||
|
p: TogglePause,
|
||||||
|
r: Restart,
|
||||||
|
};
|
||||||
|
|
||||||
|
startInput(ui.screen, (key) => {
|
||||||
|
const cmd = keyToCommand[key];
|
||||||
|
if (cmd) {
|
||||||
|
const cmdEntity = world.spawn();
|
||||||
|
world.add(cmdEntity, cmd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Game loop ────────────────────────────────────────
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// ── Terminal rendering via blessed ────────────────────
|
||||||
|
import blessed from "blessed";
|
||||||
|
import type { World } from "../../src/index";
|
||||||
|
import { Board, Piece, Score, GameOver, Paused } from "./components";
|
||||||
|
import { BOARD_W, BOARD_H, ghostY } from "./game";
|
||||||
|
|
||||||
|
// ANSI color codes for the 7 piece colors
|
||||||
|
const COLORS: Record<number, string> = {
|
||||||
|
0: "\x1b[40m", // black (empty)
|
||||||
|
1: "\x1b[46m", // cyan (I)
|
||||||
|
2: "\x1b[43m", // yellow (O)
|
||||||
|
3: "\x1b[45m", // magenta (T)
|
||||||
|
4: "\x1b[44m", // blue (J)
|
||||||
|
5: "\x1b[47m\x1b[30m", // white on black (L)
|
||||||
|
6: "\x1b[42m", // green (S)
|
||||||
|
7: "\x1b[41m", // red (Z)
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESET = "\x1b[0m";
|
||||||
|
const GHOST_CHAR = "░";
|
||||||
|
|
||||||
|
export function createUI(): {
|
||||||
|
screen: blessed.Widgets.Screen;
|
||||||
|
boardBox: blessed.Widgets.BoxElement;
|
||||||
|
scoreText: blessed.Widgets.TextElement;
|
||||||
|
statusBox: blessed.Widgets.BoxElement;
|
||||||
|
statusText: blessed.Widgets.TextElement;
|
||||||
|
controlsText: blessed.Widgets.TextElement;
|
||||||
|
} {
|
||||||
|
const screen = blessed.screen({
|
||||||
|
smartCSR: true,
|
||||||
|
title: "Tetris",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Board display
|
||||||
|
const boardBox = blessed.box({
|
||||||
|
parent: screen,
|
||||||
|
top: 2,
|
||||||
|
left: "center",
|
||||||
|
width: BOARD_W * 2 + 2,
|
||||||
|
height: BOARD_H + 2,
|
||||||
|
border: { type: "line" },
|
||||||
|
style: { border: { fg: "white" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Score
|
||||||
|
const scoreText = blessed.text({
|
||||||
|
parent: screen,
|
||||||
|
top: 2,
|
||||||
|
left: 2,
|
||||||
|
width: 30,
|
||||||
|
height: 3,
|
||||||
|
content: "Score: 0 Lines: 0 Level: 1",
|
||||||
|
style: { fg: "white" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status overlay (game over / paused)
|
||||||
|
const statusBox = blessed.box({
|
||||||
|
parent: screen,
|
||||||
|
top: "center",
|
||||||
|
left: "center",
|
||||||
|
width: 22,
|
||||||
|
height: 5,
|
||||||
|
border: { type: "line" },
|
||||||
|
style: { border: { fg: "yellow" }, fg: "yellow" },
|
||||||
|
hidden: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusText = blessed.text({
|
||||||
|
parent: statusBox,
|
||||||
|
top: 1,
|
||||||
|
left: "center",
|
||||||
|
width: 20,
|
||||||
|
align: "center",
|
||||||
|
style: { fg: "yellow" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Controls hint
|
||||||
|
const controlsText = blessed.text({
|
||||||
|
parent: screen,
|
||||||
|
bottom: 0,
|
||||||
|
left: "center",
|
||||||
|
width: 60,
|
||||||
|
height: 1,
|
||||||
|
content:
|
||||||
|
"← → : move ↑ : rotate ↓ : soft drop Space : hard drop P : pause Q : quit",
|
||||||
|
style: { fg: "gray" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { screen, boardBox, scoreText, statusBox, statusText, controlsText };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the full game state into the blessed UI. */
|
||||||
|
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]);
|
||||||
|
|
||||||
|
if (piece) {
|
||||||
|
const gy = ghostY(board.grid, piece.shape, piece.x, piece.y);
|
||||||
|
|
||||||
|
// Ghost
|
||||||
|
for (let r = 0; r < piece.shape.length; r++) {
|
||||||
|
for (let c = 0; c < piece.shape[r].length; c++) {
|
||||||
|
if (!piece.shape[r][c]) continue;
|
||||||
|
const by = gy + r;
|
||||||
|
const bx = piece.x + c;
|
||||||
|
if (
|
||||||
|
by >= 0 &&
|
||||||
|
by < BOARD_H &&
|
||||||
|
bx >= 0 &&
|
||||||
|
bx < BOARD_W &&
|
||||||
|
!display[by][bx]
|
||||||
|
) {
|
||||||
|
display[by][bx] = -piece.color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active piece
|
||||||
|
for (let r = 0; r < piece.shape.length; r++) {
|
||||||
|
for (let c = 0; c < piece.shape[r].length; c++) {
|
||||||
|
if (!piece.shape[r][c]) continue;
|
||||||
|
const by = piece.y + r;
|
||||||
|
const bx = piece.x + c;
|
||||||
|
if (by >= 0 && by < BOARD_H && bx >= 0 && bx < BOARD_W) {
|
||||||
|
display[by][bx] = piece.color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build board string
|
||||||
|
let boardStr = "";
|
||||||
|
for (let r = 0; r < BOARD_H; r++) {
|
||||||
|
for (let c = 0; c < BOARD_W; c++) {
|
||||||
|
const v = display[r][c];
|
||||||
|
if (v === 0) {
|
||||||
|
boardStr += " ·";
|
||||||
|
} else if (v < 0) {
|
||||||
|
boardStr += COLORS[-v] + GHOST_CHAR + GHOST_CHAR + RESET;
|
||||||
|
} else {
|
||||||
|
boardStr += COLORS[v] + " " + RESET;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (r < BOARD_H - 1) boardStr += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.boardBox.setContent(boardStr);
|
||||||
|
|
||||||
|
// Score
|
||||||
|
if (score) {
|
||||||
|
ui.scoreText.setContent(
|
||||||
|
`Score: ${score.points} Lines: ${score.lines} Level: ${score.level}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status
|
||||||
|
if (isOver) {
|
||||||
|
ui.statusBox.show();
|
||||||
|
ui.statusText.setContent("GAME OVER\nPress R to restart");
|
||||||
|
} else if (isPaused) {
|
||||||
|
ui.statusBox.show();
|
||||||
|
ui.statusText.setContent("PAUSED");
|
||||||
|
} else {
|
||||||
|
ui.statusBox.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.screen.render();
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { defineComponent } from "../../src/component";
|
||||||
|
import type { Entity, World } from "../../src/index";
|
||||||
|
import { query } from "../../src/query";
|
||||||
|
|
||||||
|
export type ActionKind = "fetchWater" | "exchange" | "drink" | "chant" | "rest";
|
||||||
|
export type ActionZone = "hand" | "selected" | "cooldown";
|
||||||
|
|
||||||
|
export type ToolKind =
|
||||||
|
| "woodenFish"
|
||||||
|
| "bucket"
|
||||||
|
| "bottle"
|
||||||
|
| "woodenBucket"
|
||||||
|
| "bigBowl"
|
||||||
|
| "bigBucket"
|
||||||
|
| "mouse"
|
||||||
|
| "shoulderPole"
|
||||||
|
| "waterJar"
|
||||||
|
| "ladle";
|
||||||
|
|
||||||
|
export type Phase = "setup" | "selecting" | "resolving" | "gameOver";
|
||||||
|
|
||||||
|
export const ACTION_KINDS: readonly ActionKind[] = [
|
||||||
|
"fetchWater",
|
||||||
|
"exchange",
|
||||||
|
"drink",
|
||||||
|
"chant",
|
||||||
|
"rest",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const TOOL_KINDS: readonly ToolKind[] = [
|
||||||
|
"woodenFish",
|
||||||
|
"bucket",
|
||||||
|
"bottle",
|
||||||
|
"woodenBucket",
|
||||||
|
"bigBowl",
|
||||||
|
"bigBucket",
|
||||||
|
"mouse",
|
||||||
|
"shoulderPole",
|
||||||
|
"waterJar",
|
||||||
|
"ladle",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const CARRYING_TOOLS = new Set<ToolKind>([
|
||||||
|
"bucket",
|
||||||
|
"woodenBucket",
|
||||||
|
"bigBucket",
|
||||||
|
"shoulderPole",
|
||||||
|
"ladle",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const Player = defineComponent("threeMonks.player", {
|
||||||
|
seat: 0,
|
||||||
|
name: "",
|
||||||
|
water: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ActionCard = defineComponent("threeMonks.actionCard", {
|
||||||
|
owner: 0 as Entity,
|
||||||
|
kind: "rest" as ActionKind,
|
||||||
|
zone: "hand" as ActionZone,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Tool = defineComponent("threeMonks.tool", {
|
||||||
|
owner: 0 as Entity,
|
||||||
|
kind: "bucket" as ToolKind,
|
||||||
|
water: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Table = defineComponent("threeMonks.table", {
|
||||||
|
centralWater: 0,
|
||||||
|
round: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WoodenFishMarker = defineComponent("threeMonks.woodenFishMarker", {
|
||||||
|
holder: 0 as Entity,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GameState = defineComponent("threeMonks.gameState", {
|
||||||
|
phase: "setup" as Phase,
|
||||||
|
winner: 0 as Entity | 0,
|
||||||
|
message: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RandomFn = () => number;
|
||||||
|
|
||||||
|
export function setupGame(
|
||||||
|
world: World,
|
||||||
|
playerNames: readonly string[],
|
||||||
|
random: RandomFn = Math.random,
|
||||||
|
): Entity[] {
|
||||||
|
if (playerNames.length < 3 || playerNames.length > 8) {
|
||||||
|
throw new Error("Three Monks requires 3-8 players");
|
||||||
|
}
|
||||||
|
|
||||||
|
const players: Entity[] = [];
|
||||||
|
for (let seat = 0; seat < playerNames.length; seat++) {
|
||||||
|
const player = world.spawn();
|
||||||
|
world.add(player, Player, { seat, name: playerNames[seat], water: 2 });
|
||||||
|
players.push(player);
|
||||||
|
|
||||||
|
for (const kind of ACTION_KINDS) {
|
||||||
|
const card = world.spawn();
|
||||||
|
world.add(card, ActionCard, { owner: player, kind, zone: "hand" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = dealBalancedTools(players.length, random);
|
||||||
|
for (let i = 0; i < players.length; i++) {
|
||||||
|
const tool = world.spawn();
|
||||||
|
world.add(tool, Tool, { owner: players[i], kind: tools[i], water: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const holder = players[Math.floor(random() * players.length)];
|
||||||
|
world.addSingleton(Table, { centralWater: 0, round: 1 });
|
||||||
|
world.addSingleton(WoodenFishMarker, { holder });
|
||||||
|
world.addSingleton(GameState, {
|
||||||
|
phase: "selecting",
|
||||||
|
winner: 0,
|
||||||
|
message: "Choose an action card.",
|
||||||
|
});
|
||||||
|
|
||||||
|
return players;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlayersInSeatOrder(world: World): Entity[] {
|
||||||
|
return [...world.query(query(Player))].sort(
|
||||||
|
(a, b) => world.get(a, Player).seat - world.get(b, Player).seat,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlayersFromMarker(world: World): Entity[] {
|
||||||
|
const players = getPlayersInSeatOrder(world);
|
||||||
|
if (players.length === 0) return [];
|
||||||
|
|
||||||
|
const holder = world.getSingleton(WoodenFishMarker).holder;
|
||||||
|
const index = Math.max(0, players.indexOf(holder));
|
||||||
|
return [...players.slice(index), ...players.slice(0, index)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLeftPlayer(world: World, player: Entity): Entity {
|
||||||
|
const players = getPlayersInSeatOrder(world);
|
||||||
|
const index = players.indexOf(player);
|
||||||
|
if (index < 0) throw new Error("Player is not seated");
|
||||||
|
return players[(index + 1) % players.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRightPlayer(world: World, player: Entity): Entity {
|
||||||
|
const players = getPlayersInSeatOrder(world);
|
||||||
|
const index = players.indexOf(player);
|
||||||
|
if (index < 0) throw new Error("Player is not seated");
|
||||||
|
return players[(index - 1 + players.length) % players.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToolOf(world: World, player: Entity): Entity {
|
||||||
|
for (const tool of world.query(query(Tool))) {
|
||||||
|
if (world.get(tool, Tool).owner === player) return tool;
|
||||||
|
}
|
||||||
|
throw new Error("Player does not have a tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActionCard(
|
||||||
|
world: World,
|
||||||
|
player: Entity,
|
||||||
|
kind: ActionKind,
|
||||||
|
): Entity | null {
|
||||||
|
for (const card of world.query(query(ActionCard))) {
|
||||||
|
const data = world.get(card, ActionCard);
|
||||||
|
if (data.owner === player && data.kind === kind) return card;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectedAction(
|
||||||
|
world: World,
|
||||||
|
player: Entity,
|
||||||
|
): ActionKind | null {
|
||||||
|
for (const card of world.query(query(ActionCard))) {
|
||||||
|
const data = world.get(card, ActionCard);
|
||||||
|
if (data.owner === player && data.zone === "selected") return data.kind;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allPlayersSelected(world: World): boolean {
|
||||||
|
return getPlayersInSeatOrder(world).every(
|
||||||
|
(player) => getSelectedAction(world, player) !== null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasWinner(world: World): boolean {
|
||||||
|
return world.getSingleton(GameState).winner !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dealBalancedTools(playerCount: number, random: RandomFn): ToolKind[] {
|
||||||
|
const carrying = shuffle(
|
||||||
|
TOOL_KINDS.filter((kind) => CARRYING_TOOLS.has(kind)),
|
||||||
|
random,
|
||||||
|
);
|
||||||
|
const nonCarrying = shuffle(
|
||||||
|
TOOL_KINDS.filter((kind) => !CARRYING_TOOLS.has(kind)),
|
||||||
|
random,
|
||||||
|
);
|
||||||
|
|
||||||
|
const carryingCount = Math.ceil(playerCount / 2);
|
||||||
|
const tools = [
|
||||||
|
...carrying.slice(0, carryingCount),
|
||||||
|
...nonCarrying.slice(0, playerCount - carryingCount),
|
||||||
|
];
|
||||||
|
|
||||||
|
return shuffle(tools, random);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffle<T>(items: T[], random: RandomFn): T[] {
|
||||||
|
for (let i = items.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(random() * (i + 1));
|
||||||
|
[items[i], items[j]] = [items[j], items[i]];
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 三个和尚
|
||||||
|
|
||||||
|
三个和尚是 3-8 人进行的卡牌游戏。
|
||||||
|
|
||||||
|
玩家在游戏中扮演挑水生活的和尚。但是挑水的人越多,挑到的水却越少。
|
||||||
|
|
||||||
|
首先喝到 10 口水的玩家赢得游戏胜利。
|
||||||
|
|
||||||
|
## 游戏流程
|
||||||
|
|
||||||
|
游戏按轮进行。
|
||||||
|
|
||||||
|
每轮游戏进行以下阶段:
|
||||||
|
- 出牌阶段:每名玩家各选择一张手牌扣下,同时翻开。将上一轮打出的牌收回手牌。
|
||||||
|
- 行动阶段:依次进行有玩家提议进行的行动阶段。
|
||||||
|
- 念经阶段:
|
||||||
|
- 参与玩家:提议念经的玩家。
|
||||||
|
- 将木鱼标记交给最后一名念经的玩家。
|
||||||
|
- 挑水阶段:
|
||||||
|
- 参与玩家:持有挑水道具,且没有休息的玩家。
|
||||||
|
- 每名参与玩家将 2 个自己的水标记放在挑水道具上;若水不足,则不放。
|
||||||
|
- 从供应堆拿取玩家人数减去参与玩家数的水标记,放入桌面中央。
|
||||||
|
- 交换阶段:
|
||||||
|
- 参与玩家:没有提议念经的玩家。
|
||||||
|
- 每名参与玩家将自己的道具交换给左侧参与玩家。
|
||||||
|
- 道具上的水标记一同交换。
|
||||||
|
- 喝水阶段:
|
||||||
|
- 参与玩家:所有玩家。
|
||||||
|
- 每名参与玩家依次从桌面中央获得 1 个水标记。
|
||||||
|
- 喝水时,可以从自己面前的道具上获得最多 2 个水标记。
|
||||||
|
|
||||||
|
游戏开始时,每名玩家获得 2 个水标记,然后随机挑选一名玩家获得木鱼标记。
|
||||||
|
|
||||||
|
所有的结算从持有木鱼标记的玩家开始顺时针依次进行。
|
||||||
|
|
||||||
|
## 规则细化
|
||||||
|
|
||||||
|
- 行动阶段按固定顺序结算:念经 → 挑水 → 交换 → 喝水。只要任意玩家提议某阶段,该阶段本轮会结算一次。
|
||||||
|
- 出牌时,上一轮打出的行动牌本轮不可选择;所有玩家完成本轮选择后,上一轮行动牌回到手牌,本轮行动牌成为下一轮不可选择的牌。
|
||||||
|
- 游戏开始时洗混 10 张道具牌,每名玩家随机获得 1 张;未使用的道具牌不进入本局。发给玩家的道具牌中,挑水道具始终占一半;玩家人数为奇数时,挑水道具数量向上取整。
|
||||||
|
- 喝水阶段中,玩家按木鱼标记开始的顺时针顺序逐个结算。玩家喝水后若达到 10 口水,立即获胜;若多人同阶段可能达到 10,结算顺序靠前者先胜利。
|
||||||
|
- 木鱼道具在一轮结束时将木鱼标记交给其拥有者,会覆盖本轮念经阶段得到的木鱼标记。
|
||||||
|
- 挑水时,水从参与玩家已喝到的水移动到其挑水道具上;若玩家的水不足以支付需要放置的数量,则本次不移动水,因此该玩家和其道具上的水总量不变。
|
||||||
|
- 净瓶的水来自供应堆。
|
||||||
|
- 老鼠在念经时总共移动 1 个水标记:从左右相邻玩家任一有水的道具上移动 1 个水到老鼠上。
|
||||||
|
- 大碗在其拥有者喝水时,若本次没有从大碗上喝到水,则可从桌面中央将 1 个水标记放到大碗上。
|
||||||
|
- 水缸拥有者只有自己提议喝水时才参与喝水阶段;参与时从供应堆额外获得 1 口水。
|
||||||
|
|
||||||
|
## 游戏配件
|
||||||
|
|
||||||
|
玩家配件(8 组)
|
||||||
|
- 5x 行动牌:
|
||||||
|
- 提议挑水/交换/喝水/念经:若有任何玩家打出,本轮会进行此阶段。
|
||||||
|
- 休息:不参与挑水阶段。
|
||||||
|
|
||||||
|
公共配件:
|
||||||
|
- 若干水标记物
|
||||||
|
- 木鱼标记
|
||||||
|
- 10x 道具牌
|
||||||
|
- 木鱼:一轮结束时,获得木鱼标记。
|
||||||
|
- 水桶:挑水道具。挑水时只需将 1 个自己的水放在桶里。
|
||||||
|
- 净瓶:念经时,在净瓶上放一个水标记。
|
||||||
|
- 木桶:挑水道具。若有其他玩家参与挑水,无需将水放在桶里。
|
||||||
|
- 大碗:喝水时若未从大碗里喝到水,可从桌面中央将 1 个水标记放在大碗上。
|
||||||
|
- 大桶:挑水道具。喝水时可从桶里喝任意口水。
|
||||||
|
- 老鼠:念经时,可从两侧玩家的道具上将一个水移动到老鼠上。
|
||||||
|
- 扁担:挑水道具。被交换时,将扁担上的水放回桌面中央。
|
||||||
|
- 水缸:喝水时从供应堆额外获得一口水。必须自己提议喝水才能参与喝水。
|
||||||
|
- 水瓢:挑水道具。挑水后,从中央将一个水放在瓢里。
|
||||||
|
|
||||||
|
## 设计实现
|
||||||
|
|
||||||
|
- `components.ts`: 游戏数据与状态
|
||||||
|
- `gameflow.ts`: 规则流程行为树
|
||||||
|
- `rules.ts`: 规则引擎(阶段包装器 + 核心结算函数)
|
||||||
|
- `logging.ts`: 日志输出
|
||||||
|
- `simulate.ts`: 对局模拟与统计
|
||||||
|
- `random-playthrough.ts`: 单局详细回放
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { World } from "../../src/index";
|
||||||
|
import {
|
||||||
|
action,
|
||||||
|
buildTree,
|
||||||
|
sequential,
|
||||||
|
wait,
|
||||||
|
whilst,
|
||||||
|
type TaskControl,
|
||||||
|
type TaskRunner,
|
||||||
|
} from "../../src/bt/index";
|
||||||
|
import { allPlayersSelected, hasWinner } from "./components";
|
||||||
|
import {
|
||||||
|
beginSelectionPhase,
|
||||||
|
chantPhase,
|
||||||
|
exchangePhase,
|
||||||
|
fetchWaterPhase,
|
||||||
|
drinkPhase,
|
||||||
|
endOfRoundPhase,
|
||||||
|
prepareNextRound,
|
||||||
|
} from "./rules";
|
||||||
|
|
||||||
|
export interface ThreeMonksFlow {
|
||||||
|
runner: TaskRunner;
|
||||||
|
start(): void;
|
||||||
|
notifySelectionChanged(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createThreeMonksFlow(world: World): ThreeMonksFlow {
|
||||||
|
let selectionControl: TaskControl | null = null;
|
||||||
|
|
||||||
|
const completeSelectionIfReady = () => {
|
||||||
|
if (selectionControl && allPlayersSelected(world)) {
|
||||||
|
const control = selectionControl;
|
||||||
|
selectionControl = null;
|
||||||
|
control.succeed();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runner = buildTree(
|
||||||
|
world,
|
||||||
|
whilst(
|
||||||
|
(world) => !hasWinner(world),
|
||||||
|
sequential([
|
||||||
|
action((world) => beginSelectionPhase(world)),
|
||||||
|
wait((world, _entity, control) => {
|
||||||
|
selectionControl = control;
|
||||||
|
if (allPlayersSelected(world)) {
|
||||||
|
selectionControl = null;
|
||||||
|
control.succeed();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
action((world) => chantPhase(world)),
|
||||||
|
action((world) => fetchWaterPhase(world)),
|
||||||
|
action((world) => exchangePhase(world)),
|
||||||
|
action((world) => drinkPhase(world)),
|
||||||
|
action((world) => endOfRoundPhase(world)),
|
||||||
|
action((world) => prepareNextRound(world)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
runner,
|
||||||
|
start() {
|
||||||
|
runner.schedule(runner.root!);
|
||||||
|
},
|
||||||
|
notifySelectionChanged() {
|
||||||
|
completeSelectionIfReady();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { query, type Entity, type World } from "../../src/index";
|
||||||
|
import {
|
||||||
|
ActionCard,
|
||||||
|
GameState,
|
||||||
|
Player,
|
||||||
|
Table,
|
||||||
|
Tool,
|
||||||
|
WoodenFishMarker,
|
||||||
|
getPlayersInSeatOrder,
|
||||||
|
getSelectedAction,
|
||||||
|
getToolOf,
|
||||||
|
type ActionKind,
|
||||||
|
type ToolKind,
|
||||||
|
} from "./components";
|
||||||
|
import type { RoundProposals } from "./rules";
|
||||||
|
|
||||||
|
export const ACTION_LABELS: Record<ActionKind, string> = {
|
||||||
|
fetchWater: "挑水",
|
||||||
|
exchange: "交换",
|
||||||
|
drink: "喝水",
|
||||||
|
chant: "念经",
|
||||||
|
rest: "休息",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TOOL_LABELS: Record<ToolKind, string> = {
|
||||||
|
woodenFish: "木鱼",
|
||||||
|
bucket: "水桶",
|
||||||
|
bottle: "净瓶",
|
||||||
|
woodenBucket: "木桶",
|
||||||
|
bigBowl: "大碗",
|
||||||
|
bigBucket: "大桶",
|
||||||
|
mouse: "老鼠",
|
||||||
|
shoulderPole: "扁担",
|
||||||
|
waterJar: "水缸",
|
||||||
|
ladle: "水瓢",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PlayerSnapshot {
|
||||||
|
entity: Entity;
|
||||||
|
name: string;
|
||||||
|
seat: number;
|
||||||
|
water: number;
|
||||||
|
action: ActionKind | null;
|
||||||
|
tool: ToolKind;
|
||||||
|
toolWater: number;
|
||||||
|
hasMarker: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameSnapshot {
|
||||||
|
round: number;
|
||||||
|
centralWater: number;
|
||||||
|
phase: string;
|
||||||
|
winner: Entity | 0;
|
||||||
|
players: PlayerSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GameLog {
|
||||||
|
private readonly lines: string[] = [];
|
||||||
|
|
||||||
|
add(line = ""): void {
|
||||||
|
this.lines.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
section(title: string): void {
|
||||||
|
if (this.lines.length > 0) this.lines.push("");
|
||||||
|
this.lines.push(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
entries(): readonly string[] {
|
||||||
|
return this.lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.lines.join("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotGame(world: World): GameSnapshot {
|
||||||
|
const table = world.getSingleton(Table);
|
||||||
|
const marker = world.getSingleton(WoodenFishMarker).holder;
|
||||||
|
const state = world.getSingleton(GameState);
|
||||||
|
|
||||||
|
return {
|
||||||
|
round: table.round,
|
||||||
|
centralWater: table.centralWater,
|
||||||
|
phase: state.phase,
|
||||||
|
winner: state.winner,
|
||||||
|
players: getPlayersInSeatOrder(world).map((player) => {
|
||||||
|
const playerData = world.get(player, Player);
|
||||||
|
const toolData = world.get(getToolOf(world, player), Tool);
|
||||||
|
return {
|
||||||
|
entity: player,
|
||||||
|
name: playerData.name,
|
||||||
|
seat: playerData.seat,
|
||||||
|
water: playerData.water,
|
||||||
|
action: getSelectedAction(world, player),
|
||||||
|
tool: toolData.kind,
|
||||||
|
toolWater: toolData.water,
|
||||||
|
hasMarker: player === marker,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSnapshot(snapshot: GameSnapshot): string[] {
|
||||||
|
return [
|
||||||
|
`第 ${snapshot.round} 轮 | 中央水=${snapshot.centralWater} | 阶段=${formatPhase(snapshot.phase)}`,
|
||||||
|
...snapshot.players.map(formatPlayerSnapshot),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPlayerSnapshot(player: PlayerSnapshot): string {
|
||||||
|
const marker = player.hasMarker ? " 🪵" : "";
|
||||||
|
const action = player.action ? ` | 行动=${ACTION_LABELS[player.action]}` : "";
|
||||||
|
return `${player.name}${marker}: 已喝=${player.water}, 道具=${TOOL_LABELS[player.tool]}(${player.toolWater})${action}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatProposals(proposals: RoundProposals): string {
|
||||||
|
const phases: string[] = [];
|
||||||
|
if (proposals.chant) phases.push("念经");
|
||||||
|
if (proposals.fetchWater) phases.push("挑水");
|
||||||
|
if (proposals.exchange) phases.push("交换");
|
||||||
|
if (proposals.drink) phases.push("喝水");
|
||||||
|
return phases.length > 0 ? phases.join(" → ") : "无行动阶段";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAvailableActions(world: World, player: Entity): string {
|
||||||
|
const actions: ActionKind[] = [];
|
||||||
|
for (const card of world.query(query(ActionCard))) {
|
||||||
|
const data = world.get(card, ActionCard);
|
||||||
|
if (data.owner === player && data.zone === "hand") actions.push(data.kind);
|
||||||
|
}
|
||||||
|
return actions.map((action) => ACTION_LABELS[action]).join("、");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logSnapshot(log: GameLog, world: World): void {
|
||||||
|
for (const line of formatSnapshot(snapshotGame(world))) {
|
||||||
|
log.add(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logPhaseDelta(
|
||||||
|
log: GameLog,
|
||||||
|
label: string,
|
||||||
|
before: GameSnapshot,
|
||||||
|
after: GameSnapshot,
|
||||||
|
): void {
|
||||||
|
const changes: string[] = [];
|
||||||
|
|
||||||
|
if (before.centralWater !== after.centralWater) {
|
||||||
|
changes.push(`中央水 ${before.centralWater}→${after.centralWater}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const beforePlayer of before.players) {
|
||||||
|
const afterPlayer = after.players.find(
|
||||||
|
(p) => p.entity === beforePlayer.entity,
|
||||||
|
)!;
|
||||||
|
const playerChanges: string[] = [];
|
||||||
|
|
||||||
|
if (beforePlayer.water !== afterPlayer.water) {
|
||||||
|
playerChanges.push(`已喝 ${beforePlayer.water}→${afterPlayer.water}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
beforePlayer.tool !== afterPlayer.tool ||
|
||||||
|
beforePlayer.toolWater !== afterPlayer.toolWater
|
||||||
|
) {
|
||||||
|
playerChanges.push(
|
||||||
|
`道具 ${TOOL_LABELS[beforePlayer.tool]}(${beforePlayer.toolWater})→${TOOL_LABELS[afterPlayer.tool]}(${afterPlayer.toolWater})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (beforePlayer.hasMarker !== afterPlayer.hasMarker) {
|
||||||
|
playerChanges.push(
|
||||||
|
afterPlayer.hasMarker ? "获得木鱼标记" : "失去木鱼标记",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerChanges.length > 0) {
|
||||||
|
changes.push(`${afterPlayer.name}: ${playerChanges.join(",")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.length === 0) {
|
||||||
|
log.add(` ${label}: 无变化`);
|
||||||
|
} else {
|
||||||
|
log.add(` ${label}: ${changes.join(";")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPhase(phase: string): string {
|
||||||
|
switch (phase) {
|
||||||
|
case "setup":
|
||||||
|
return "设置";
|
||||||
|
case "selecting":
|
||||||
|
return "出牌";
|
||||||
|
case "resolving":
|
||||||
|
return "结算";
|
||||||
|
case "gameOver":
|
||||||
|
return "游戏结束";
|
||||||
|
default:
|
||||||
|
return phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function winnerName(world: World): string | null {
|
||||||
|
const winner = world.getSingleton(GameState).winner;
|
||||||
|
if (winner === 0) return null;
|
||||||
|
return world.get(winner, Player).name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
三个和尚随机对局(种子=20260701)
|
||||||
|
玩家:慧空、明心、了尘、净远
|
||||||
|
初始状态:
|
||||||
|
第 1 轮 | 中央水=0 | 阶段=出牌
|
||||||
|
慧空: 已喝=2, 道具=老鼠(0)
|
||||||
|
明心: 已喝=2, 道具=水瓢(0)
|
||||||
|
了尘: 已喝=2, 道具=木桶(0)
|
||||||
|
净远 🪵: 已喝=2, 道具=水缸(0)
|
||||||
|
|
||||||
|
第 1 轮
|
||||||
|
慧空 选择 挑水(可选:挑水、交换、喝水、念经、休息)
|
||||||
|
明心 选择 休息(可选:挑水、交换、喝水、念经、休息)
|
||||||
|
了尘 选择 交换(可选:挑水、交换、喝水、念经、休息)
|
||||||
|
净远 选择 喝水(可选:挑水、交换、喝水、念经、休息)
|
||||||
|
本轮行动阶段:挑水 → 交换 → 喝水
|
||||||
|
挑水: 中央水 0→3;了尘: 已喝 2→0,道具 木桶(0)→木桶(2)
|
||||||
|
交换: 慧空: 道具 老鼠(0)→水缸(0);明心: 道具 水瓢(0)→老鼠(0);了尘: 道具 木桶(2)→水瓢(0);净远: 道具 水缸(0)→木桶(2)
|
||||||
|
喝水: 中央水 3→0;明心: 已喝 2→3;了尘: 已喝 0→1;净远: 已喝 2→5,道具 木桶(2)→木桶(0)
|
||||||
|
轮末道具: 无变化
|
||||||
|
轮末状态:
|
||||||
|
第 2 轮 | 中央水=0 | 阶段=出牌
|
||||||
|
慧空: 已喝=2, 道具=水缸(0)
|
||||||
|
明心: 已喝=3, 道具=老鼠(0)
|
||||||
|
了尘: 已喝=1, 道具=水瓢(0)
|
||||||
|
净远 🪵: 已喝=5, 道具=木桶(0)
|
||||||
|
|
||||||
|
第 2 轮
|
||||||
|
慧空 选择 念经(可选:交换、喝水、念经、休息)
|
||||||
|
明心 选择 交换(可选:挑水、交换、喝水、念经)
|
||||||
|
了尘 选择 休息(可选:挑水、喝水、念经、休息)
|
||||||
|
净远 选择 交换(可选:挑水、交换、念经、休息)
|
||||||
|
本轮行动阶段:念经 → 交换
|
||||||
|
念经: 慧空: 获得木鱼标记;净远: 失去木鱼标记
|
||||||
|
交换: 明心: 道具 老鼠(0)→木桶(0);了尘: 道具 水瓢(0)→老鼠(0);净远: 道具 木桶(0)→水瓢(0)
|
||||||
|
轮末道具: 无变化
|
||||||
|
轮末状态:
|
||||||
|
第 3 轮 | 中央水=0 | 阶段=出牌
|
||||||
|
慧空 🪵: 已喝=2, 道具=水缸(0)
|
||||||
|
明心: 已喝=3, 道具=木桶(0)
|
||||||
|
了尘: 已喝=1, 道具=老鼠(0)
|
||||||
|
净远: 已喝=5, 道具=水瓢(0)
|
||||||
|
|
||||||
|
第 3 轮
|
||||||
|
慧空 选择 交换(可选:挑水、交换、喝水、休息)
|
||||||
|
明心 选择 喝水(可选:挑水、喝水、念经、休息)
|
||||||
|
了尘 选择 交换(可选:挑水、交换、喝水、念经)
|
||||||
|
净远 选择 挑水(可选:挑水、喝水、念经、休息)
|
||||||
|
本轮行动阶段:挑水 → 交换 → 喝水
|
||||||
|
挑水: 中央水 0→1;净远: 已喝 5→3,道具 水瓢(0)→水瓢(3)
|
||||||
|
交换: 慧空: 道具 水缸(0)→水瓢(3);明心: 道具 木桶(0)→水缸(0);了尘: 道具 老鼠(0)→木桶(0);净远: 道具 水瓢(3)→老鼠(0)
|
||||||
|
喝水: 中央水 1→0;慧空: 已喝 2→5,道具 水瓢(3)→水瓢(1);明心: 已喝 3→4
|
||||||
|
轮末道具: 无变化
|
||||||
|
轮末状态:
|
||||||
|
第 4 轮 | 中央水=0 | 阶段=出牌
|
||||||
|
慧空 🪵: 已喝=5, 道具=水瓢(1)
|
||||||
|
明心: 已喝=4, 道具=水缸(0)
|
||||||
|
了尘: 已喝=1, 道具=木桶(0)
|
||||||
|
净远: 已喝=3, 道具=老鼠(0)
|
||||||
|
|
||||||
|
第 4 轮
|
||||||
|
慧空 选择 念经(可选:挑水、喝水、念经、休息)
|
||||||
|
明心 选择 挑水(可选:挑水、交换、念经、休息)
|
||||||
|
了尘 选择 休息(可选:挑水、喝水、念经、休息)
|
||||||
|
净远 选择 休息(可选:交换、喝水、念经、休息)
|
||||||
|
本轮行动阶段:念经 → 挑水
|
||||||
|
念经: 无变化
|
||||||
|
挑水: 中央水 0→2;慧空: 已喝 5→3,道具 水瓢(1)→水瓢(4)
|
||||||
|
轮末道具: 无变化
|
||||||
|
轮末状态:
|
||||||
|
第 5 轮 | 中央水=2 | 阶段=出牌
|
||||||
|
慧空 🪵: 已喝=3, 道具=水瓢(4)
|
||||||
|
明心: 已喝=4, 道具=水缸(0)
|
||||||
|
了尘: 已喝=1, 道具=木桶(0)
|
||||||
|
净远: 已喝=3, 道具=老鼠(0)
|
||||||
|
|
||||||
|
第 5 轮
|
||||||
|
慧空 选择 休息(可选:挑水、交换、喝水、休息)
|
||||||
|
明心 选择 喝水(可选:交换、喝水、念经、休息)
|
||||||
|
了尘 选择 挑水(可选:挑水、交换、喝水、念经)
|
||||||
|
净远 选择 念经(可选:挑水、交换、喝水、念经)
|
||||||
|
本轮行动阶段:念经 → 挑水 → 喝水
|
||||||
|
念经: 慧空: 道具 水瓢(4)→水瓢(3),失去木鱼标记;净远: 道具 老鼠(0)→老鼠(1),获得木鱼标记
|
||||||
|
挑水: 中央水 2→5
|
||||||
|
喝水: 中央水 5→1;慧空: 已喝 3→6,道具 水瓢(3)→水瓢(1);明心: 已喝 4→6;了尘: 已喝 1→2;净远: 已喝 3→5,道具 老鼠(1)→老鼠(0)
|
||||||
|
轮末道具: 无变化
|
||||||
|
轮末状态:
|
||||||
|
第 6 轮 | 中央水=1 | 阶段=出牌
|
||||||
|
慧空: 已喝=6, 道具=水瓢(1)
|
||||||
|
明心: 已喝=6, 道具=水缸(0)
|
||||||
|
了尘: 已喝=2, 道具=木桶(0)
|
||||||
|
净远 🪵: 已喝=5, 道具=老鼠(0)
|
||||||
|
|
||||||
|
第 6 轮
|
||||||
|
慧空 选择 念经(可选:挑水、交换、喝水、念经)
|
||||||
|
明心 选择 挑水(可选:挑水、交换、念经、休息)
|
||||||
|
了尘 选择 交换(可选:交换、喝水、念经、休息)
|
||||||
|
净远 选择 挑水(可选:挑水、交换、喝水、休息)
|
||||||
|
本轮行动阶段:念经 → 挑水 → 交换
|
||||||
|
念经: 慧空: 获得木鱼标记;净远: 失去木鱼标记
|
||||||
|
挑水: 中央水 1→2;慧空: 已喝 6→4,道具 水瓢(1)→水瓢(4)
|
||||||
|
交换: 明心: 道具 水缸(0)→老鼠(0);了尘: 道具 木桶(0)→水缸(0);净远: 道具 老鼠(0)→木桶(0)
|
||||||
|
轮末道具: 无变化
|
||||||
|
轮末状态:
|
||||||
|
第 7 轮 | 中央水=2 | 阶段=出牌
|
||||||
|
慧空 🪵: 已喝=4, 道具=水瓢(4)
|
||||||
|
明心: 已喝=6, 道具=老鼠(0)
|
||||||
|
了尘: 已喝=2, 道具=水缸(0)
|
||||||
|
净远: 已喝=5, 道具=木桶(0)
|
||||||
|
|
||||||
|
第 7 轮
|
||||||
|
慧空 选择 喝水(可选:挑水、交换、喝水、休息)
|
||||||
|
明心 选择 休息(可选:交换、喝水、念经、休息)
|
||||||
|
了尘 选择 休息(可选:挑水、喝水、念经、休息)
|
||||||
|
净远 选择 休息(可选:交换、喝水、念经、休息)
|
||||||
|
本轮行动阶段:喝水
|
||||||
|
喝水: 中央水 2→0;慧空: 已喝 4→7,道具 水瓢(4)→水瓢(2);明心: 已喝 6→7
|
||||||
|
轮末道具: 无变化
|
||||||
|
轮末状态:
|
||||||
|
第 8 轮 | 中央水=0 | 阶段=出牌
|
||||||
|
慧空 🪵: 已喝=7, 道具=水瓢(2)
|
||||||
|
明心: 已喝=7, 道具=老鼠(0)
|
||||||
|
了尘: 已喝=2, 道具=水缸(0)
|
||||||
|
净远: 已喝=5, 道具=木桶(0)
|
||||||
|
|
||||||
|
第 8 轮
|
||||||
|
慧空 选择 休息(可选:挑水、交换、念经、休息)
|
||||||
|
明心 选择 挑水(可选:挑水、交换、喝水、念经)
|
||||||
|
了尘 选择 挑水(可选:挑水、交换、喝水、念经)
|
||||||
|
净远 选择 挑水(可选:挑水、交换、喝水、念经)
|
||||||
|
本轮行动阶段:挑水
|
||||||
|
挑水: 中央水 0→3;净远: 已喝 5→3,道具 木桶(0)→木桶(2)
|
||||||
|
轮末道具: 无变化
|
||||||
|
轮末状态:
|
||||||
|
第 9 轮 | 中央水=3 | 阶段=出牌
|
||||||
|
慧空 🪵: 已喝=7, 道具=水瓢(2)
|
||||||
|
明心: 已喝=7, 道具=老鼠(0)
|
||||||
|
了尘: 已喝=2, 道具=水缸(0)
|
||||||
|
净远: 已喝=3, 道具=木桶(2)
|
||||||
|
|
||||||
|
第 9 轮
|
||||||
|
慧空 选择 交换(可选:挑水、交换、喝水、念经)
|
||||||
|
明心 选择 休息(可选:交换、喝水、念经、休息)
|
||||||
|
了尘 选择 交换(可选:交换、喝水、念经、休息)
|
||||||
|
净远 选择 交换(可选:交换、喝水、念经、休息)
|
||||||
|
本轮行动阶段:交换
|
||||||
|
交换: 慧空: 道具 水瓢(2)→木桶(2);明心: 道具 老鼠(0)→水瓢(2);了尘: 道具 水缸(0)→老鼠(0);净远: 道具 木桶(2)→水缸(0)
|
||||||
|
轮末道具: 无变化
|
||||||
|
轮末状态:
|
||||||
|
第 10 轮 | 中央水=3 | 阶段=出牌
|
||||||
|
慧空 🪵: 已喝=7, 道具=木桶(2)
|
||||||
|
明心: 已喝=7, 道具=水瓢(2)
|
||||||
|
了尘: 已喝=2, 道具=老鼠(0)
|
||||||
|
净远: 已喝=3, 道具=水缸(0)
|
||||||
|
|
||||||
|
第 10 轮
|
||||||
|
慧空 选择 喝水(可选:挑水、喝水、念经、休息)
|
||||||
|
明心 选择 交换(可选:挑水、交换、喝水、念经)
|
||||||
|
了尘 选择 念经(可选:挑水、喝水、念经、休息)
|
||||||
|
净远 选择 挑水(可选:挑水、喝水、念经、休息)
|
||||||
|
本轮行动阶段:念经 → 挑水 → 交换 → 喝水
|
||||||
|
念经: 慧空: 失去木鱼标记;明心: 道具 水瓢(2)→水瓢(1);了尘: 道具 老鼠(0)→老鼠(1),获得木鱼标记
|
||||||
|
挑水: 中央水 3→4;明心: 已喝 7→5,道具 水瓢(1)→水瓢(4)
|
||||||
|
交换: 慧空: 道具 木桶(2)→水缸(0);明心: 道具 水瓢(4)→木桶(2);净远: 道具 水缸(0)→水瓢(4)
|
||||||
|
喝水: 中央水 4→0;慧空: 已喝 7→9;明心: 已喝 5→8,道具 木桶(2)→木桶(0);了尘: 已喝 2→4,道具 老鼠(1)→老鼠(0);净远: 已喝 3→6,道具 水瓢(4)→水瓢(2)
|
||||||
|
轮末道具: 无变化
|
||||||
|
轮末状态:
|
||||||
|
第 11 轮 | 中央水=0 | 阶段=出牌
|
||||||
|
慧空: 已喝=9, 道具=水缸(0)
|
||||||
|
明心: 已喝=8, 道具=木桶(0)
|
||||||
|
了尘 🪵: 已喝=4, 道具=老鼠(0)
|
||||||
|
净远: 已喝=6, 道具=水瓢(2)
|
||||||
|
|
||||||
|
第 11 轮
|
||||||
|
慧空 选择 挑水(可选:挑水、交换、念经、休息)
|
||||||
|
明心 选择 喝水(可选:挑水、喝水、念经、休息)
|
||||||
|
了尘 选择 休息(可选:挑水、交换、喝水、休息)
|
||||||
|
净远 选择 休息(可选:交换、喝水、念经、休息)
|
||||||
|
本轮行动阶段:挑水 → 喝水
|
||||||
|
挑水: 中央水 0→3;明心: 已喝 8→6,道具 木桶(0)→木桶(2)
|
||||||
|
喝水: 中央水 3→0;明心: 已喝 6→9,道具 木桶(2)→木桶(0);了尘: 已喝 4→5;净远: 已喝 6→9,道具 水瓢(2)→水瓢(0)
|
||||||
|
轮末道具: 无变化
|
||||||
|
轮末状态:
|
||||||
|
第 12 轮 | 中央水=0 | 阶段=出牌
|
||||||
|
慧空: 已喝=9, 道具=水缸(0)
|
||||||
|
明心: 已喝=9, 道具=木桶(0)
|
||||||
|
了尘 🪵: 已喝=5, 道具=老鼠(0)
|
||||||
|
净远: 已喝=9, 道具=水瓢(0)
|
||||||
|
|
||||||
|
第 12 轮
|
||||||
|
慧空 选择 休息(可选:交换、喝水、念经、休息)
|
||||||
|
明心 选择 挑水(可选:挑水、交换、念经、休息)
|
||||||
|
了尘 选择 挑水(可选:挑水、交换、喝水、念经)
|
||||||
|
净远 选择 念经(可选:挑水、交换、喝水、念经)
|
||||||
|
本轮行动阶段:念经 → 挑水
|
||||||
|
念经: 了尘: 失去木鱼标记;净远: 获得木鱼标记
|
||||||
|
挑水: 中央水 0→1;净远: 已喝 9→7,道具 水瓢(0)→水瓢(3)
|
||||||
|
轮末道具: 无变化
|
||||||
|
轮末状态:
|
||||||
|
第 13 轮 | 中央水=1 | 阶段=出牌
|
||||||
|
慧空: 已喝=9, 道具=水缸(0)
|
||||||
|
明心: 已喝=9, 道具=木桶(0)
|
||||||
|
了尘: 已喝=5, 道具=老鼠(0)
|
||||||
|
净远 🪵: 已喝=7, 道具=水瓢(3)
|
||||||
|
|
||||||
|
第 13 轮
|
||||||
|
慧空 选择 交换(可选:挑水、交换、喝水、念经)
|
||||||
|
明心 选择 交换(可选:交换、喝水、念经、休息)
|
||||||
|
了尘 选择 念经(可选:交换、喝水、念经、休息)
|
||||||
|
净远 选择 交换(可选:挑水、交换、喝水、休息)
|
||||||
|
本轮行动阶段:念经 → 交换
|
||||||
|
念经: 了尘: 道具 老鼠(0)→老鼠(1),获得木鱼标记;净远: 道具 水瓢(3)→水瓢(2),失去木鱼标记
|
||||||
|
交换: 慧空: 道具 水缸(0)→水瓢(2);明心: 道具 木桶(0)→水缸(0);净远: 道具 水瓢(2)→木桶(0)
|
||||||
|
轮末道具: 无变化
|
||||||
|
轮末状态:
|
||||||
|
第 14 轮 | 中央水=1 | 阶段=出牌
|
||||||
|
慧空: 已喝=9, 道具=水瓢(2)
|
||||||
|
明心: 已喝=9, 道具=水缸(0)
|
||||||
|
了尘 🪵: 已喝=5, 道具=老鼠(1)
|
||||||
|
净远: 已喝=7, 道具=木桶(0)
|
||||||
|
|
||||||
|
第 14 轮
|
||||||
|
慧空 选择 喝水(可选:挑水、喝水、念经、休息)
|
||||||
|
明心 选择 喝水(可选:挑水、喝水、念经、休息)
|
||||||
|
了尘 选择 休息(可选:挑水、交换、喝水、休息)
|
||||||
|
净远 选择 休息(可选:挑水、喝水、念经、休息)
|
||||||
|
本轮行动阶段:喝水
|
||||||
|
喝水: 中央水 1→0;慧空: 已喝 9→11,道具 水瓢(2)→水瓢(0);了尘: 已喝 5→7,道具 老鼠(1)→老鼠(0)
|
||||||
|
|
||||||
|
游戏结束
|
||||||
|
胜者:慧空
|
||||||
|
第 14 轮 | 中央水=0 | 阶段=游戏结束
|
||||||
|
慧空: 已喝=11, 道具=水瓢(0) | 行动=喝水
|
||||||
|
明心: 已喝=9, 道具=水缸(0) | 行动=喝水
|
||||||
|
了尘 🪵: 已喝=7, 道具=老鼠(0) | 行动=休息
|
||||||
|
净远: 已喝=7, 道具=木桶(0) | 行动=休息
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { World, query, type Entity } from "../../src/index";
|
||||||
|
import {
|
||||||
|
ActionCard,
|
||||||
|
GameState,
|
||||||
|
Player,
|
||||||
|
Table,
|
||||||
|
setupGame,
|
||||||
|
type ActionKind,
|
||||||
|
} from "./components";
|
||||||
|
import {
|
||||||
|
beginSelectionPhase,
|
||||||
|
collectProposals,
|
||||||
|
prepareNextRound,
|
||||||
|
selectAction,
|
||||||
|
chantPhase,
|
||||||
|
fetchWaterPhase,
|
||||||
|
exchangePhase,
|
||||||
|
drinkPhase,
|
||||||
|
endOfRoundPhase,
|
||||||
|
} from "./rules";
|
||||||
|
import {
|
||||||
|
ACTION_LABELS,
|
||||||
|
GameLog,
|
||||||
|
formatAvailableActions,
|
||||||
|
formatProposals,
|
||||||
|
logPhaseDelta,
|
||||||
|
logSnapshot,
|
||||||
|
snapshotGame,
|
||||||
|
winnerName,
|
||||||
|
} from "./logging";
|
||||||
|
|
||||||
|
export interface RandomPlaythroughOptions {
|
||||||
|
seed?: number;
|
||||||
|
playerNames?: readonly string[];
|
||||||
|
maxRounds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RandomPlaythroughResult {
|
||||||
|
world: World;
|
||||||
|
log: string;
|
||||||
|
roundsPlayed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRandomPlayLog(
|
||||||
|
options: RandomPlaythroughOptions = {},
|
||||||
|
): RandomPlaythroughResult {
|
||||||
|
const seed = options.seed ?? 20260701;
|
||||||
|
const random = mulberry32(seed);
|
||||||
|
const playerNames = options.playerNames ?? ["慧空", "明心", "了尘", "净远"];
|
||||||
|
const maxRounds = options.maxRounds ?? 200;
|
||||||
|
|
||||||
|
const world = new World();
|
||||||
|
const players = setupGame(world, playerNames, random);
|
||||||
|
const log = new GameLog();
|
||||||
|
|
||||||
|
log.section(`三个和尚随机对局(种子=${seed})`);
|
||||||
|
log.add(`玩家:${playerNames.join("、")}`);
|
||||||
|
log.add("初始状态:");
|
||||||
|
logSnapshot(log, world);
|
||||||
|
|
||||||
|
let roundsPlayed = 0;
|
||||||
|
|
||||||
|
while (world.getSingleton(GameState).phase !== "gameOver") {
|
||||||
|
if (roundsPlayed >= maxRounds) {
|
||||||
|
throw new Error(`随机对局未能在 ${maxRounds} 轮内结束`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const round = world.getSingleton(Table).round;
|
||||||
|
log.section(`第 ${round} 轮`);
|
||||||
|
beginSelectionPhase(world);
|
||||||
|
|
||||||
|
for (const player of players) {
|
||||||
|
const action = pickRandom(availableActions(world, player), random);
|
||||||
|
const playerName = world.get(player, Player).name;
|
||||||
|
log.add(
|
||||||
|
` ${playerName} 选择 ${ACTION_LABELS[action]}(可选:${formatAvailableActions(world, player)})`,
|
||||||
|
);
|
||||||
|
selectAction(world, player, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposals = collectProposals(world);
|
||||||
|
log.add(` 本轮行动阶段:${formatProposals(proposals)}`);
|
||||||
|
|
||||||
|
applyLoggedPhase(log, world, "念经", () => chantPhase(world));
|
||||||
|
applyLoggedPhase(log, world, "挑水", () => fetchWaterPhase(world));
|
||||||
|
applyLoggedPhase(log, world, "交换", () => exchangePhase(world));
|
||||||
|
applyLoggedPhase(log, world, "喝水", () => drinkPhase(world));
|
||||||
|
if (world.getSingleton(GameState).phase === "gameOver") break;
|
||||||
|
|
||||||
|
applyLoggedPhase(log, world, "轮末道具", () => endOfRoundPhase(world));
|
||||||
|
if (world.getSingleton(GameState).phase === "gameOver") break;
|
||||||
|
|
||||||
|
prepareNextRound(world);
|
||||||
|
log.add(" 轮末状态:");
|
||||||
|
logSnapshot(log, world);
|
||||||
|
roundsPlayed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
roundsPlayed++;
|
||||||
|
log.section("游戏结束");
|
||||||
|
log.add(`胜者:${winnerName(world)}`);
|
||||||
|
logSnapshot(log, world);
|
||||||
|
|
||||||
|
return { world, log: log.toString(), roundsPlayed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLoggedPhase(
|
||||||
|
log: GameLog,
|
||||||
|
world: World,
|
||||||
|
label: string,
|
||||||
|
phase: () => void,
|
||||||
|
): void {
|
||||||
|
const before = snapshotGame(world);
|
||||||
|
phase();
|
||||||
|
const after = snapshotGame(world);
|
||||||
|
logPhaseDelta(log, label, before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableActions(world: World, player: Entity): ActionKind[] {
|
||||||
|
const actions: ActionKind[] = [];
|
||||||
|
for (const card of world.query(query(ActionCard))) {
|
||||||
|
const data = world.get(card, ActionCard);
|
||||||
|
if (data.owner === player && data.zone === "hand") {
|
||||||
|
actions.push(data.kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (actions.length === 0) {
|
||||||
|
throw new Error(`${world.get(player, Player).name} 没有可选行动牌`);
|
||||||
|
}
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRandom<T>(items: readonly T[], random: () => number): T {
|
||||||
|
return items[Math.floor(random() * items.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let state = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
state += 0x6d2b79f5;
|
||||||
|
let t = state;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1]?.endsWith("random-playthrough.ts")) {
|
||||||
|
const result = generateRandomPlayLog();
|
||||||
|
console.log(result.log);
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import type { Entity, World } from "../../src/index";
|
||||||
|
import { query } from "../../src/query";
|
||||||
|
import type { ToolEffectRecorder } from "./stats";
|
||||||
|
import {
|
||||||
|
ActionCard,
|
||||||
|
CARRYING_TOOLS,
|
||||||
|
GameState,
|
||||||
|
Player,
|
||||||
|
Table,
|
||||||
|
Tool,
|
||||||
|
WoodenFishMarker,
|
||||||
|
allPlayersSelected,
|
||||||
|
getActionCard,
|
||||||
|
getLeftPlayer,
|
||||||
|
getPlayersFromMarker,
|
||||||
|
getPlayersInSeatOrder,
|
||||||
|
getRightPlayer,
|
||||||
|
getSelectedAction,
|
||||||
|
getToolOf,
|
||||||
|
type ActionKind,
|
||||||
|
} from "./components";
|
||||||
|
|
||||||
|
export interface RoundProposals {
|
||||||
|
fetchWater: boolean;
|
||||||
|
exchange: boolean;
|
||||||
|
drink: boolean;
|
||||||
|
chant: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beginSelectionPhase(world: World): void {
|
||||||
|
const state = world.getSingleton(GameState);
|
||||||
|
if (state.phase !== "gameOver") {
|
||||||
|
state.phase = "selecting";
|
||||||
|
state.message = `Round ${world.getSingleton(Table).round}: choose an action card.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectAction(
|
||||||
|
world: World,
|
||||||
|
player: Entity,
|
||||||
|
kind: ActionKind,
|
||||||
|
): void {
|
||||||
|
const state = world.getSingleton(GameState);
|
||||||
|
if (state.phase !== "selecting") return;
|
||||||
|
if (!world.has(player, Player)) throw new Error("Invalid player");
|
||||||
|
|
||||||
|
const card = getActionCard(world, player, kind);
|
||||||
|
if (!card) throw new Error("Player does not have that action card");
|
||||||
|
|
||||||
|
const cardData = world.get(card, ActionCard);
|
||||||
|
if (cardData.zone === "cooldown") {
|
||||||
|
throw new Error("Cannot select the action card played last round");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const other of world.query(query(ActionCard))) {
|
||||||
|
const data = world.get(other, ActionCard);
|
||||||
|
if (data.owner === player && data.zone === "selected") {
|
||||||
|
data.zone = "hand";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cardData.zone = "selected";
|
||||||
|
|
||||||
|
if (allPlayersSelected(world)) {
|
||||||
|
state.message = "All players selected. Resolving round.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectProposals(world: World): RoundProposals {
|
||||||
|
const proposals: RoundProposals = {
|
||||||
|
fetchWater: false,
|
||||||
|
exchange: false,
|
||||||
|
drink: false,
|
||||||
|
chant: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const player of getPlayersInSeatOrder(world)) {
|
||||||
|
const action = getSelectedAction(world, player);
|
||||||
|
if (!action || action === "rest") continue;
|
||||||
|
proposals[action] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proposals;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProposed(world: World, kind: ActionKind): boolean {
|
||||||
|
for (const player of getPlayersInSeatOrder(world)) {
|
||||||
|
if (getSelectedAction(world, player) === kind) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGameOver(world: World): boolean {
|
||||||
|
return world.getSingleton(GameState).phase === "gameOver";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase wrappers (each checks proposal + gameOver, then delegates) ──
|
||||||
|
|
||||||
|
export function chantPhase(world: World, stats?: ToolEffectRecorder): void {
|
||||||
|
if (isGameOver(world) || !isProposed(world, "chant")) return;
|
||||||
|
resolveChant(world, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchWaterPhase(
|
||||||
|
world: World,
|
||||||
|
stats?: ToolEffectRecorder,
|
||||||
|
): void {
|
||||||
|
if (isGameOver(world) || !isProposed(world, "fetchWater")) return;
|
||||||
|
resolveFetchWater(world, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exchangePhase(world: World, stats?: ToolEffectRecorder): void {
|
||||||
|
if (isGameOver(world) || !isProposed(world, "exchange")) return;
|
||||||
|
resolveExchange(world, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drinkPhase(world: World, stats?: ToolEffectRecorder): void {
|
||||||
|
if (isGameOver(world) || !isProposed(world, "drink")) return;
|
||||||
|
resolveDrink(world, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endOfRoundPhase(
|
||||||
|
world: World,
|
||||||
|
stats?: ToolEffectRecorder,
|
||||||
|
): void {
|
||||||
|
if (isGameOver(world)) return;
|
||||||
|
resolveEndOfRoundTools(world, stats);
|
||||||
|
checkWinner(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareNextRound(world: World): void {
|
||||||
|
const state = world.getSingleton(GameState);
|
||||||
|
if (state.phase === "gameOver") return;
|
||||||
|
|
||||||
|
for (const card of world.query(query(ActionCard))) {
|
||||||
|
const data = world.get(card, ActionCard);
|
||||||
|
if (data.zone === "cooldown") data.zone = "hand";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const card of world.query(query(ActionCard))) {
|
||||||
|
const data = world.get(card, ActionCard);
|
||||||
|
if (data.zone === "selected") data.zone = "cooldown";
|
||||||
|
}
|
||||||
|
|
||||||
|
world.getSingleton(Table).round++;
|
||||||
|
state.phase = "selecting";
|
||||||
|
state.message = `Round ${world.getSingleton(Table).round}: choose an action card.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFetchWater(
|
||||||
|
world: World,
|
||||||
|
stats?: ToolEffectRecorder,
|
||||||
|
): void {
|
||||||
|
const table = world.getSingleton(Table);
|
||||||
|
const players = getPlayersInSeatOrder(world);
|
||||||
|
const participants = players.filter((player) => {
|
||||||
|
const action = getSelectedAction(world, player);
|
||||||
|
const tool = world.get(getToolOf(world, player), Tool);
|
||||||
|
return (
|
||||||
|
action !== null && action !== "rest" && CARRYING_TOOLS.has(tool.kind)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const player of participants) {
|
||||||
|
const toolEntity = getToolOf(world, player);
|
||||||
|
const tool = world.get(toolEntity, Tool);
|
||||||
|
let placed = 2;
|
||||||
|
|
||||||
|
if (tool.kind === "bucket") placed = 1;
|
||||||
|
if (tool.kind === "woodenBucket" && participants.length > 1) placed = 0;
|
||||||
|
|
||||||
|
const playerData = world.get(player, Player);
|
||||||
|
if (playerData.water >= placed) {
|
||||||
|
playerData.water -= placed;
|
||||||
|
tool.water += placed;
|
||||||
|
stats?.record(tool.kind, "挑水", {
|
||||||
|
playerWaterDelta: -placed,
|
||||||
|
ownToolWaterDelta: placed,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
stats?.record(tool.kind, "挑水", {}, "水不足,未放水");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table.centralWater += players.length - participants.length;
|
||||||
|
|
||||||
|
for (const player of participants) {
|
||||||
|
const tool = world.get(getToolOf(world, player), Tool);
|
||||||
|
if (tool.kind === "ladle" && table.centralWater > 0) {
|
||||||
|
table.centralWater--;
|
||||||
|
tool.water++;
|
||||||
|
stats?.record(tool.kind, "挑水", {
|
||||||
|
ownToolWaterDelta: 1,
|
||||||
|
centralWaterDelta: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveExchange(
|
||||||
|
world: World,
|
||||||
|
stats?: ToolEffectRecorder,
|
||||||
|
): void {
|
||||||
|
const participants = getPlayersInSeatOrder(world).filter(
|
||||||
|
(player) => getSelectedAction(world, player) !== "chant",
|
||||||
|
);
|
||||||
|
if (participants.length <= 1) return;
|
||||||
|
|
||||||
|
const table = world.getSingleton(Table);
|
||||||
|
const tools = participants.map((player) => getToolOf(world, player));
|
||||||
|
|
||||||
|
for (const toolEntity of tools) {
|
||||||
|
const tool = world.get(toolEntity, Tool);
|
||||||
|
if (tool.kind === "shoulderPole" && tool.water > 0) {
|
||||||
|
const dumped = tool.water;
|
||||||
|
table.centralWater += dumped;
|
||||||
|
tool.water = 0;
|
||||||
|
stats?.record(tool.kind, "交换", {
|
||||||
|
ownToolWaterDelta: -dumped,
|
||||||
|
centralWaterDelta: dumped,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < participants.length; i++) {
|
||||||
|
const giverTool = tools[i];
|
||||||
|
const receiver = participants[(i + 1) % participants.length];
|
||||||
|
world.get(giverTool, Tool).owner = receiver;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDrink(world: World, stats?: ToolEffectRecorder): void {
|
||||||
|
for (const player of getPlayersFromMarker(world)) {
|
||||||
|
if (!canParticipateInDrink(world, player)) continue;
|
||||||
|
|
||||||
|
const table = world.getSingleton(Table);
|
||||||
|
const playerData = world.get(player, Player);
|
||||||
|
const tool = world.get(getToolOf(world, player), Tool);
|
||||||
|
let drankFromTool = 0;
|
||||||
|
|
||||||
|
if (table.centralWater > 0) {
|
||||||
|
table.centralWater--;
|
||||||
|
playerData.water++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxFromTool =
|
||||||
|
tool.kind === "bigBucket" ? tool.water : Math.min(2, tool.water);
|
||||||
|
if (maxFromTool > 0) {
|
||||||
|
tool.water -= maxFromTool;
|
||||||
|
playerData.water += maxFromTool;
|
||||||
|
drankFromTool = maxFromTool;
|
||||||
|
stats?.record(tool.kind, "喝水", {
|
||||||
|
playerWaterDelta: maxFromTool,
|
||||||
|
ownToolWaterDelta: -maxFromTool,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.kind === "waterJar") {
|
||||||
|
playerData.water++;
|
||||||
|
stats?.record(tool.kind, "喝水", {
|
||||||
|
playerWaterDelta: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
tool.kind === "bigBowl" &&
|
||||||
|
drankFromTool === 0 &&
|
||||||
|
table.centralWater > 0
|
||||||
|
) {
|
||||||
|
table.centralWater--;
|
||||||
|
tool.water++;
|
||||||
|
stats?.record(tool.kind, "喝水", {
|
||||||
|
ownToolWaterDelta: 1,
|
||||||
|
centralWaterDelta: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerData.water >= 10) {
|
||||||
|
setWinner(world, player);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveChant(world: World, stats?: ToolEffectRecorder): void {
|
||||||
|
const chanters = getPlayersFromMarker(world).filter(
|
||||||
|
(player) => getSelectedAction(world, player) === "chant",
|
||||||
|
);
|
||||||
|
if (chanters.length === 0) return;
|
||||||
|
|
||||||
|
world.getSingleton(WoodenFishMarker).holder = chanters[chanters.length - 1];
|
||||||
|
|
||||||
|
for (const player of chanters) {
|
||||||
|
const toolEntity = getToolOf(world, player);
|
||||||
|
const tool = world.get(toolEntity, Tool);
|
||||||
|
|
||||||
|
if (tool.kind === "bottle") {
|
||||||
|
tool.water++;
|
||||||
|
stats?.record(tool.kind, "念经", {
|
||||||
|
ownToolWaterDelta: 1,
|
||||||
|
});
|
||||||
|
} else if (tool.kind === "mouse") {
|
||||||
|
const moved = moveOneNeighborWaterTo(world, player, toolEntity);
|
||||||
|
if (moved) {
|
||||||
|
stats?.record(tool.kind, "念经", {
|
||||||
|
ownToolWaterDelta: 1,
|
||||||
|
otherToolWaterDelta: -1,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
stats?.record(tool.kind, "念经", {}, "相邻道具无水可偷");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEndOfRoundTools(
|
||||||
|
world: World,
|
||||||
|
stats?: ToolEffectRecorder,
|
||||||
|
): void {
|
||||||
|
for (const toolEntity of world.query(query(Tool))) {
|
||||||
|
const tool = world.get(toolEntity, Tool);
|
||||||
|
if (tool.kind === "woodenFish") {
|
||||||
|
world.getSingleton(WoodenFishMarker).holder = tool.owner;
|
||||||
|
stats?.record(tool.kind, "轮末", {}, "获得木鱼标记");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkWinner(world: World): Entity | null {
|
||||||
|
for (const player of getPlayersFromMarker(world)) {
|
||||||
|
if (world.get(player, Player).water >= 10) {
|
||||||
|
setWinner(world, player);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canParticipateInDrink(world: World, player: Entity): boolean {
|
||||||
|
const tool = world.get(getToolOf(world, player), Tool);
|
||||||
|
if (tool.kind !== "waterJar") return true;
|
||||||
|
return getSelectedAction(world, player) === "drink";
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveOneNeighborWaterTo(
|
||||||
|
world: World,
|
||||||
|
player: Entity,
|
||||||
|
destinationTool: Entity,
|
||||||
|
): boolean {
|
||||||
|
const neighbors = [
|
||||||
|
getLeftPlayer(world, player),
|
||||||
|
getRightPlayer(world, player),
|
||||||
|
];
|
||||||
|
for (const neighbor of neighbors) {
|
||||||
|
const neighborToolEntity = getToolOf(world, neighbor);
|
||||||
|
const neighborTool = world.get(neighborToolEntity, Tool);
|
||||||
|
if (neighborTool.water > 0) {
|
||||||
|
neighborTool.water--;
|
||||||
|
world.get(destinationTool, Tool).water++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWinner(world: World, player: Entity): void {
|
||||||
|
const state = world.getSingleton(GameState);
|
||||||
|
state.phase = "gameOver";
|
||||||
|
state.winner = player;
|
||||||
|
state.message = `${world.get(player, Player).name} wins!`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { World, query, type Entity } from "../../src/index";
|
||||||
|
import {
|
||||||
|
ActionCard,
|
||||||
|
GameState,
|
||||||
|
Player,
|
||||||
|
Table,
|
||||||
|
setupGame,
|
||||||
|
type ActionKind,
|
||||||
|
} from "./components";
|
||||||
|
import { ToolEffectStats, formatToolEffectSummaries } from "./stats";
|
||||||
|
import {
|
||||||
|
beginSelectionPhase,
|
||||||
|
prepareNextRound,
|
||||||
|
selectAction,
|
||||||
|
chantPhase,
|
||||||
|
fetchWaterPhase,
|
||||||
|
exchangePhase,
|
||||||
|
drinkPhase,
|
||||||
|
endOfRoundPhase,
|
||||||
|
} from "./rules";
|
||||||
|
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let state = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
state += 0x6d2b79f5;
|
||||||
|
let t = state;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableActions(world: World, player: Entity): ActionKind[] {
|
||||||
|
const actions: ActionKind[] = [];
|
||||||
|
for (const card of world.query(query(ActionCard))) {
|
||||||
|
const data = world.get(card, ActionCard);
|
||||||
|
if (data.owner === player && data.zone === "hand") {
|
||||||
|
actions.push(data.kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRandom<T>(items: readonly T[], random: () => number): T {
|
||||||
|
return items[Math.floor(random() * items.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameStats {
|
||||||
|
rounds: number;
|
||||||
|
waterDiff: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSingleGame(seed: number, stats?: ToolEffectStats): GameStats {
|
||||||
|
const random = mulberry32(seed);
|
||||||
|
const world = new World();
|
||||||
|
const playerNames = ["慧空", "明心", "了尘", "净远"];
|
||||||
|
const players = setupGame(world, playerNames, random);
|
||||||
|
|
||||||
|
let roundsPlayed = 0;
|
||||||
|
const maxRounds = 1000;
|
||||||
|
|
||||||
|
while (world.getSingleton(GameState).phase !== "gameOver") {
|
||||||
|
if (roundsPlayed >= maxRounds) {
|
||||||
|
throw new Error(`Game did not finish within ${maxRounds} rounds`);
|
||||||
|
}
|
||||||
|
|
||||||
|
beginSelectionPhase(world);
|
||||||
|
|
||||||
|
for (const player of players) {
|
||||||
|
const actions = availableActions(world, player);
|
||||||
|
const action = pickRandom(actions, random);
|
||||||
|
selectAction(world, player, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
chantPhase(world, stats);
|
||||||
|
fetchWaterPhase(world, stats);
|
||||||
|
exchangePhase(world, stats);
|
||||||
|
drinkPhase(world, stats);
|
||||||
|
if (world.getSingleton(GameState).phase === "gameOver") break;
|
||||||
|
|
||||||
|
endOfRoundPhase(world, stats);
|
||||||
|
if (world.getSingleton(GameState).phase === "gameOver") break;
|
||||||
|
|
||||||
|
prepareNextRound(world);
|
||||||
|
roundsPlayed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalRound = world.getSingleton(Table).round;
|
||||||
|
const waterCounts = players.map((p) => world.get(p, Player).water);
|
||||||
|
const maxWater = Math.max(...waterCounts);
|
||||||
|
const minWater = Math.min(...waterCounts);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rounds: finalRound,
|
||||||
|
waterDiff: maxWater - minWater,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runSimulation(gameCount = 100, startSeed = 20260701): void {
|
||||||
|
let totalRounds = 0;
|
||||||
|
let totalWaterDiff = 0;
|
||||||
|
const toolStats = new ToolEffectStats();
|
||||||
|
|
||||||
|
console.log(`开始模拟 ${gameCount} 局游戏...`);
|
||||||
|
|
||||||
|
for (let i = 0; i < gameCount; i++) {
|
||||||
|
const seed = startSeed + i;
|
||||||
|
const stats = runSingleGame(seed, toolStats);
|
||||||
|
totalRounds += stats.rounds;
|
||||||
|
totalWaterDiff += stats.waterDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgRounds = totalRounds / gameCount;
|
||||||
|
const avgWaterDiff = totalWaterDiff / gameCount;
|
||||||
|
|
||||||
|
console.log("\n================ 统计结果 ================");
|
||||||
|
console.log(`模拟总局数: ${gameCount}`);
|
||||||
|
console.log(`结束时平均轮数: ${avgRounds.toFixed(2)} 轮`);
|
||||||
|
console.log(`结束时首尾玩家平均水差距: ${avgWaterDiff.toFixed(2)} 口水`);
|
||||||
|
console.log("==========================================");
|
||||||
|
console.log("\n道具效果统计(总变化量):");
|
||||||
|
for (const line of formatToolEffectSummaries(toolStats)) {
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1]?.endsWith("simulate.ts")) {
|
||||||
|
runSimulation();
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { ToolKind } from "./components";
|
||||||
|
import { TOOL_LABELS } from "./logging";
|
||||||
|
|
||||||
|
export interface ToolEffectDelta {
|
||||||
|
playerWaterDelta?: number;
|
||||||
|
ownToolWaterDelta?: number;
|
||||||
|
otherToolWaterDelta?: number;
|
||||||
|
centralWaterDelta?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolEffectRecord extends Required<ToolEffectDelta> {
|
||||||
|
tool: ToolKind;
|
||||||
|
phase: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolEffectSummary extends Required<ToolEffectDelta> {
|
||||||
|
tool: ToolKind;
|
||||||
|
activations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolEffectRecorder {
|
||||||
|
record(tool: ToolKind, phase: string, delta?: ToolEffectDelta, description?: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ToolEffectStats implements ToolEffectRecorder {
|
||||||
|
private readonly summaries = new Map<ToolKind, ToolEffectSummary>();
|
||||||
|
private readonly records: ToolEffectRecord[] = [];
|
||||||
|
|
||||||
|
record(
|
||||||
|
tool: ToolKind,
|
||||||
|
phase: string,
|
||||||
|
delta: ToolEffectDelta = {},
|
||||||
|
description?: string,
|
||||||
|
): void {
|
||||||
|
const record: ToolEffectRecord = {
|
||||||
|
tool,
|
||||||
|
phase,
|
||||||
|
description,
|
||||||
|
playerWaterDelta: delta.playerWaterDelta ?? 0,
|
||||||
|
ownToolWaterDelta: delta.ownToolWaterDelta ?? 0,
|
||||||
|
otherToolWaterDelta: delta.otherToolWaterDelta ?? 0,
|
||||||
|
centralWaterDelta: delta.centralWaterDelta ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.records.push(record);
|
||||||
|
|
||||||
|
const summary = this.summaries.get(tool) ?? {
|
||||||
|
tool,
|
||||||
|
activations: 0,
|
||||||
|
playerWaterDelta: 0,
|
||||||
|
ownToolWaterDelta: 0,
|
||||||
|
otherToolWaterDelta: 0,
|
||||||
|
centralWaterDelta: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
summary.activations++;
|
||||||
|
summary.playerWaterDelta += record.playerWaterDelta;
|
||||||
|
summary.ownToolWaterDelta += record.ownToolWaterDelta;
|
||||||
|
summary.otherToolWaterDelta += record.otherToolWaterDelta;
|
||||||
|
summary.centralWaterDelta += record.centralWaterDelta;
|
||||||
|
this.summaries.set(tool, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecords(): readonly ToolEffectRecord[] {
|
||||||
|
return this.records;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSummaries(): ToolEffectSummary[] {
|
||||||
|
return [...this.summaries.values()].sort((a, b) =>
|
||||||
|
TOOL_LABELS[a.tool].localeCompare(TOOL_LABELS[b.tool], "zh-Hans-CN"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatToolEffectSummaries(stats: ToolEffectStats): string[] {
|
||||||
|
const summaries = stats.getSummaries();
|
||||||
|
if (summaries.length === 0) return ["无道具效果记录"];
|
||||||
|
|
||||||
|
return [
|
||||||
|
"道具 | 触发次数 | 玩家水变化 | 自身道具水变化 | 其他道具水变化 | 中央水变化",
|
||||||
|
"--- | ---: | ---: | ---: | ---: | ---:",
|
||||||
|
...summaries.map((summary) =>
|
||||||
|
[
|
||||||
|
TOOL_LABELS[summary.tool],
|
||||||
|
summary.activations,
|
||||||
|
summary.playerWaterDelta,
|
||||||
|
summary.ownToolWaterDelta,
|
||||||
|
summary.otherToolWaterDelta,
|
||||||
|
summary.centralWaterDelta,
|
||||||
|
].join(" | "),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
Generated
+1212
-14
File diff suppressed because it is too large
Load Diff
+17
-6
@@ -11,6 +11,16 @@
|
|||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
"require": "./dist/index.cjs"
|
"require": "./dist/index.cjs"
|
||||||
|
},
|
||||||
|
"./commands": {
|
||||||
|
"types": "./dist/commands/index.d.ts",
|
||||||
|
"import": "./dist/commands/index.js",
|
||||||
|
"require": "./dist/commands/index.cjs"
|
||||||
|
},
|
||||||
|
"./bt": {
|
||||||
|
"types": "./dist/bt/index.d.ts",
|
||||||
|
"import": "./dist/bt/index.js",
|
||||||
|
"require": "./dist/bt/index.cjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@@ -19,20 +29,21 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"dev": "tsup --watch",
|
"dev": "tsup --watch",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
|
||||||
"rxjs": "^7.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"rxjs": "^7.8.1",
|
"@types/blessed": "^0.1.27",
|
||||||
|
"@types/node": "^25.9.1",
|
||||||
|
"blessed": "^0.1.81",
|
||||||
"tsup": "^8.3.5",
|
"tsup": "^8.3.5",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0",
|
||||||
|
"vitest": "^4.1.7"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ecs",
|
"ecs",
|
||||||
"entity-component-system",
|
"entity-component-system",
|
||||||
"rxjs",
|
|
||||||
"observable",
|
"observable",
|
||||||
"game"
|
"game"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export {
|
||||||
|
Task,
|
||||||
|
Scheduled,
|
||||||
|
Running,
|
||||||
|
Succeeded,
|
||||||
|
Failed,
|
||||||
|
Cancelled,
|
||||||
|
TERMINAL_TAGS,
|
||||||
|
ChildOf,
|
||||||
|
} from "./task";
|
||||||
|
export type { TaskKind } from "./task";
|
||||||
|
|
||||||
|
export { TaskRunner } 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";
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
import type { World, Entity } from "../index";
|
||||||
|
import { query } from "../query";
|
||||||
|
import {
|
||||||
|
Task,
|
||||||
|
Scheduled,
|
||||||
|
Running,
|
||||||
|
Succeeded,
|
||||||
|
Failed,
|
||||||
|
Cancelled,
|
||||||
|
TERMINAL_TAGS,
|
||||||
|
ChildOf,
|
||||||
|
Cancel,
|
||||||
|
} from "./task";
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────
|
||||||
|
/** 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 = (
|
||||||
|
world: World,
|
||||||
|
entity: Entity,
|
||||||
|
status: "succeeded" | "failed" | "cancelled",
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────
|
||||||
|
function terminalStatus(
|
||||||
|
world: World,
|
||||||
|
entity: Entity,
|
||||||
|
): "succeeded" | "failed" | "cancelled" | null {
|
||||||
|
if (world.has(entity, Succeeded)) return "succeeded";
|
||||||
|
if (world.has(entity, Failed)) return "failed";
|
||||||
|
if (world.has(entity, Cancelled)) return "cancelled";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminal(world: World, entity: Entity): boolean {
|
||||||
|
return terminalStatus(world, entity) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStatus(world: World, entity: Entity): void {
|
||||||
|
for (const tag of TERMINAL_TAGS) {
|
||||||
|
if (world.has(entity, tag)) world.remove(entity, tag);
|
||||||
|
}
|
||||||
|
if (world.has(entity, Running)) world.remove(entity, Running);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively clear status from an entity and all its descendants. */
|
||||||
|
function clearSubtree(world: World, entity: Entity): void {
|
||||||
|
clearStatus(world, entity);
|
||||||
|
for (const child of childrenOf(world, entity)) {
|
||||||
|
clearSubtree(world, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return world.getRelated(child, ChildOf) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskRunner ────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Push-based behaviour-tree runner.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/** 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 = () => {};
|
||||||
|
|
||||||
|
constructor(world: World) {
|
||||||
|
this._world = world;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process tasks scheduled for this tick.
|
||||||
|
*
|
||||||
|
* 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(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 task as succeeded and propagate upward. */
|
||||||
|
succeed(entity: Entity): void {
|
||||||
|
this._finish(entity, Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset a task to idle (removes all status tags). */
|
||||||
|
reset(entity: Entity): void {
|
||||||
|
clearStatus(this._world, entity);
|
||||||
|
this._world.remove(entity, Scheduled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal execution ────────────────────────────
|
||||||
|
|
||||||
|
private _execute(entity: Entity, dt: number): void {
|
||||||
|
if (!this._world.has(entity, Task) || this._executing.has(entity)) return;
|
||||||
|
|
||||||
|
const t = this._world.get(entity, Task);
|
||||||
|
this._executing.add(entity);
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (t.kind) {
|
||||||
|
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 _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);
|
||||||
|
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);
|
||||||
|
|
||||||
|
status = terminalStatus(this._world, child);
|
||||||
|
if (status === "failed" || status === "cancelled") {
|
||||||
|
this._finish(entity, status === "cancelled" ? Cancelled : Failed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === "succeeded") continue;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._finish(entity, Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _executeParallel(entity: Entity, dt: number): void {
|
||||||
|
let allDone = true;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allDone) {
|
||||||
|
this._finish(entity, Succeeded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _executeRandom(entity: Entity, dt: number): void {
|
||||||
|
const eligible: Entity[] = [];
|
||||||
|
|
||||||
|
for (const child of childrenOf(this._world, entity)) {
|
||||||
|
const status = terminalStatus(this._world, child);
|
||||||
|
if (status) {
|
||||||
|
this._finish(
|
||||||
|
entity,
|
||||||
|
status === "succeeded"
|
||||||
|
? Succeeded
|
||||||
|
: status === "cancelled"
|
||||||
|
? Cancelled
|
||||||
|
: Failed,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this._world.has(child, Running)) {
|
||||||
|
eligible.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eligible.length === 0) return;
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _executeCycle(entity: Entity, dt: number): void {
|
||||||
|
const child = this._firstTaskChild(entity);
|
||||||
|
if (!child) return;
|
||||||
|
|
||||||
|
this._executeChild(child, dt);
|
||||||
|
|
||||||
|
if (isTerminal(this._world, child)) {
|
||||||
|
clearSubtree(this._world, child);
|
||||||
|
this._world.add(entity, Scheduled);
|
||||||
|
}
|
||||||
|
// Cycle itself never terminates — it just creates tick-boundary cycles.
|
||||||
|
}
|
||||||
|
|
||||||
|
private _executeWhilst(entity: Entity, dt: number): void {
|
||||||
|
let condition: boolean;
|
||||||
|
try {
|
||||||
|
condition = this.onCondition(this._world, entity, dt);
|
||||||
|
} catch (err) {
|
||||||
|
this._finishFromThrown(entity, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
): void {
|
||||||
|
if (!this._world.has(entity, Task)) return;
|
||||||
|
|
||||||
|
clearStatus(this._world, entity);
|
||||||
|
this._world.add(entity, tag);
|
||||||
|
|
||||||
|
const status = terminalStatus(this._world, entity)!;
|
||||||
|
this.onTerminal(this._world, entity, status);
|
||||||
|
|
||||||
|
const parent = parentOf(this._world, entity);
|
||||||
|
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, notifyParent: boolean): void {
|
||||||
|
if (!this._world.has(entity, Task)) return;
|
||||||
|
|
||||||
|
// 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, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearStatus(this._world, entity);
|
||||||
|
this._world.add(entity, Cancelled);
|
||||||
|
this.onTerminal(this._world, entity, "cancelled");
|
||||||
|
|
||||||
|
const parent = parentOf(this._world, entity);
|
||||||
|
if (notifyParent && parent && !this._executing.has(parent)) {
|
||||||
|
if (this._world.has(parent, Scheduled)) {
|
||||||
|
this._world.remove(parent, Scheduled);
|
||||||
|
}
|
||||||
|
this._execute(parent, this._currentDt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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:
|
||||||
|
* - `"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"` — starts all children at once.
|
||||||
|
* Succeeds when all children succeed; fails when any child fails.
|
||||||
|
* - `"random"` — picks one child at random each time it runs.
|
||||||
|
* Succeeds/fails with that child's result.
|
||||||
|
* - `"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: "action" as
|
||||||
|
| "action"
|
||||||
|
| "wait"
|
||||||
|
| "sequential"
|
||||||
|
| "parallel"
|
||||||
|
| "random"
|
||||||
|
| "cycle"
|
||||||
|
| "whilst"
|
||||||
|
| "selector",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TaskKind = (typeof Task.type)["kind"];
|
||||||
|
|
||||||
|
// ── Status tags (zero-size — presence is the signal) ──
|
||||||
|
/** A task that should be executed this tick. */
|
||||||
|
export const Scheduled = defineComponent("scheduled", {});
|
||||||
|
|
||||||
|
/** A task that is currently executing or waiting for completion. */
|
||||||
|
export const Running = defineComponent("running", {});
|
||||||
|
|
||||||
|
/** The task completed successfully. */
|
||||||
|
export const Succeeded = defineComponent("succeeded", {});
|
||||||
|
|
||||||
|
/** The task failed. */
|
||||||
|
export const Failed = defineComponent("failed", {});
|
||||||
|
|
||||||
|
/** The task was cancelled externally. */
|
||||||
|
export const Cancelled = defineComponent("cancelled", {});
|
||||||
|
|
||||||
|
/** All terminal status tags. */
|
||||||
|
export const TERMINAL_TAGS = [Succeeded, Failed, Cancelled] as const;
|
||||||
|
|
||||||
|
// ── Relationship ──────────────────────────────────────
|
||||||
|
/** Parent → child edge in the task tree. */
|
||||||
|
export const ChildOf = defineRelationship("taskChild");
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { query as makeQuery } from "../query";
|
||||||
|
import type { World, Entity, ComponentDef, Query } from "../index";
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────
|
||||||
|
/** A handler that processes a command extracted from an entity. */
|
||||||
|
export type CommandHandler<T extends Record<string, any>> = (
|
||||||
|
command: T,
|
||||||
|
entity?: Entity,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/** Pending work: entity, its command data, and the handler to invoke. */
|
||||||
|
interface Pending<T extends Record<string, any> = any> {
|
||||||
|
entity: Entity;
|
||||||
|
handler: CommandHandler<T>;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registered handler bookkeeping. */
|
||||||
|
interface Registration<T extends Record<string, any> = any> {
|
||||||
|
def: ComponentDef<T>;
|
||||||
|
query: Query;
|
||||||
|
handler: CommandHandler<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CommandQueue ─────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Pull-based command system.
|
||||||
|
*
|
||||||
|
* Register handlers for command component types, then call `execute()` each
|
||||||
|
* frame. It scans the world for entities carrying command components, removes
|
||||||
|
* them, dispatches to handlers, and destroys entities that become empty.
|
||||||
|
*
|
||||||
|
* Interruptions pause processing — while any tracked promise is unresolved,
|
||||||
|
* `execute()` is a no-op.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const Damage = defineComponent('damage', { amount: 0 });
|
||||||
|
*
|
||||||
|
* const queue = new CommandQueue(world);
|
||||||
|
* queue.handle(Damage, (cmd, entity) => {
|
||||||
|
* const hp = world.get(entity!, Health);
|
||||||
|
* hp.current -= cmd.amount;
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // each frame:
|
||||||
|
* queue.execute();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class CommandQueue {
|
||||||
|
private _world: World;
|
||||||
|
private _registrations: Registration[] = [];
|
||||||
|
private _pendingPromises = new Set<Promise<any>>();
|
||||||
|
private _interrupted = false;
|
||||||
|
|
||||||
|
constructor(world: World) {
|
||||||
|
this._world = world;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Registration ─────────────────────────────────
|
||||||
|
|
||||||
|
/** Register a handler for `def`. Each handler is called once per entity per frame. */
|
||||||
|
handle<T extends Record<string, any>>(
|
||||||
|
def: ComponentDef<T>,
|
||||||
|
handler: CommandHandler<T>,
|
||||||
|
): this {
|
||||||
|
this._registrations.push({ def, query: makeQuery(def), handler });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Interruption ─────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a promise. While any tracked promise is unresolved,
|
||||||
|
* `execute()` skips command processing.
|
||||||
|
*
|
||||||
|
* Once all tracked promises have settled, processing resumes.
|
||||||
|
*/
|
||||||
|
interrupt(promise: Promise<any>): void {
|
||||||
|
this._pendingPromises.add(promise);
|
||||||
|
this._interrupted = true;
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
this._pendingPromises.delete(promise);
|
||||||
|
if (this._pendingPromises.size === 0) {
|
||||||
|
this._interrupted = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
promise.then(remove, remove);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while at least one interruption promise is pending. */
|
||||||
|
get isInterrupted(): boolean {
|
||||||
|
return this._interrupted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Execution ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain all command components from the world and dispatch to handlers.
|
||||||
|
*
|
||||||
|
* For each registered component type, every matching entity has the
|
||||||
|
* component removed. The handler receives the entity and the command
|
||||||
|
* data. If the entity has no components left after removal, it is
|
||||||
|
* destroyed.
|
||||||
|
*
|
||||||
|
* If `isInterrupted` is true, this method is a no-op.
|
||||||
|
*/
|
||||||
|
execute(): void {
|
||||||
|
if (this._interrupted) return;
|
||||||
|
|
||||||
|
const pending: Pending[] = [];
|
||||||
|
|
||||||
|
// 1. Snapshot + remove command components
|
||||||
|
for (const reg of this._registrations) {
|
||||||
|
// Snapshot into array; sparse-set iteration is not mutation-safe
|
||||||
|
const entities = [...this._world.query(reg.query)];
|
||||||
|
for (const entity of entities) {
|
||||||
|
const data = this._world.tryGet(entity, reg.def);
|
||||||
|
if (data !== undefined) {
|
||||||
|
this._world.remove(entity, reg.def);
|
||||||
|
pending.push({ entity, handler: reg.handler, data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Destroy entities that became empty after command removal
|
||||||
|
for (const p of pending) {
|
||||||
|
if (
|
||||||
|
this._world.isAlive(p.entity) &&
|
||||||
|
!this._world.hasAnyComponent(p.entity)
|
||||||
|
) {
|
||||||
|
this._world.destroy(p.entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Dispatch handlers (after cleanup so handlers see consistent state)
|
||||||
|
for (const p of pending) {
|
||||||
|
p.handler(p.data, p.entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { CommandQueue } from "./command-queue";
|
||||||
|
export type { CommandHandler } from "./command-queue";
|
||||||
+9
-5
@@ -5,13 +5,15 @@
|
|||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* const Position = defineComponent({ x: 0, y: 0 });
|
* const Position = defineComponent('position', { x: 0, y: 0 });
|
||||||
* type Position = typeof Position.type;
|
* type Position = typeof Position.type;
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export interface ComponentDef<T extends Record<string, any>> {
|
export interface ComponentDef<T extends Record<string, any>> {
|
||||||
/** Unique symbol used as the storage key. */
|
/** Unique symbol used as the storage key. */
|
||||||
readonly _key: symbol;
|
readonly _key: symbol;
|
||||||
|
/** Human-readable name, used for serialization. */
|
||||||
|
readonly name: string;
|
||||||
/** Default values applied when a component is first added. */
|
/** Default values applied when a component is first added. */
|
||||||
readonly defaults: T;
|
readonly defaults: T;
|
||||||
/** Phantom type for inference. */
|
/** Phantom type for inference. */
|
||||||
@@ -19,15 +21,17 @@ export interface ComponentDef<T extends Record<string, any>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a component type. The argument provides both default values and the
|
* Define a component type. The name is used for serialization.
|
||||||
* TypeScript shape.
|
* The defaults object provides both the TypeScript shape and initial values.
|
||||||
*/
|
*/
|
||||||
export function defineComponent<T extends Record<string, any>>(
|
export function defineComponent<T extends Record<string, any>>(
|
||||||
defaults: T
|
name: string,
|
||||||
|
defaults: T,
|
||||||
): ComponentDef<T> {
|
): ComponentDef<T> {
|
||||||
return {
|
return {
|
||||||
_key: Symbol(),
|
_key: Symbol(),
|
||||||
|
name,
|
||||||
defaults: { ...defaults },
|
defaults: { ...defaults },
|
||||||
type: undefined as unknown as T, // phantom; never read at runtime
|
type: undefined as unknown as T,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+21
-9
@@ -1,10 +1,22 @@
|
|||||||
// ── Public API ─────────────────────────────────────────
|
// ── Public API ─────────────────────────────────────────
|
||||||
export { World } from './world';
|
export { World } from "./world";
|
||||||
export { defineComponent } from './component';
|
export { defineComponent } from "./component";
|
||||||
export type { ComponentDef } from './component';
|
export type { ComponentDef } from "./component";
|
||||||
export { query } from './query';
|
export { defineRelationship } from "./relationship";
|
||||||
export { Query } from './query';
|
export type { RelationshipDef } from "./relationship";
|
||||||
export type { Entity } from './entity';
|
export { query } from "./query";
|
||||||
export { makeEntity, entityIndex, entityGeneration } from './entity';
|
export { Query } from "./query";
|
||||||
export { SparseSet } from './storage/sparse-set';
|
export type { Entity } from "./entity";
|
||||||
export type { WorldEvent, QueryUpdate } from './observable/events';
|
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,
|
||||||
|
EntityEvent,
|
||||||
|
RelEvent,
|
||||||
|
QueryUpdate,
|
||||||
|
RelationshipUpdate,
|
||||||
|
} from "./observable/events";
|
||||||
|
export type { WorldSnapshot } from "./serialization";
|
||||||
|
export type { Observable, Subscription } from "./observable/subject";
|
||||||
|
|||||||
+45
-10
@@ -1,46 +1,71 @@
|
|||||||
import type { ComponentDef } from '../component';
|
import type { ComponentDef } from "../component";
|
||||||
import type { Entity } from '../entity';
|
import type { RelationshipDef } from "../relationship";
|
||||||
|
import type { Entity } from "../entity";
|
||||||
|
|
||||||
// ── World Events ──────────────────────────────────────
|
// ── World Events ──────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Discriminated union of all world-level events.
|
* Events that carry an `entity` field — component and lifecycle events.
|
||||||
* Emitted via `world.events$`.
|
|
||||||
*/
|
*/
|
||||||
export type WorldEvent =
|
export type EntityEvent =
|
||||||
| SpawnedEvent
|
| SpawnedEvent
|
||||||
| DestroyedEvent
|
| DestroyedEvent
|
||||||
| ComponentAddedEvent
|
| ComponentAddedEvent
|
||||||
| ComponentRemovedEvent
|
| ComponentRemovedEvent
|
||||||
| ComponentChangedEvent;
|
| ComponentChangedEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events that carry `source`/`target` fields — relationship events.
|
||||||
|
*/
|
||||||
|
export type RelEvent = RelAddedEvent | RelRemovedEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminated union of all world-level events.
|
||||||
|
* Emitted via `world.events$`.
|
||||||
|
*/
|
||||||
|
export type WorldEvent = EntityEvent | RelEvent;
|
||||||
|
|
||||||
export interface SpawnedEvent {
|
export interface SpawnedEvent {
|
||||||
type: 'spawned';
|
type: "spawned";
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DestroyedEvent {
|
export interface DestroyedEvent {
|
||||||
type: 'destroyed';
|
type: "destroyed";
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComponentAddedEvent {
|
export interface ComponentAddedEvent {
|
||||||
type: 'componentAdded';
|
type: "componentAdded";
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
component: ComponentDef<any>;
|
component: ComponentDef<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComponentRemovedEvent {
|
export interface ComponentRemovedEvent {
|
||||||
type: 'componentRemoved';
|
type: "componentRemoved";
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
component: ComponentDef<any>;
|
component: ComponentDef<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComponentChangedEvent {
|
export interface ComponentChangedEvent {
|
||||||
type: 'componentChanged';
|
type: "componentChanged";
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
component: ComponentDef<any>;
|
component: ComponentDef<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RelAddedEvent {
|
||||||
|
type: "relationshipAdded";
|
||||||
|
source: Entity;
|
||||||
|
target: Entity;
|
||||||
|
relationship: RelationshipDef;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelRemovedEvent {
|
||||||
|
type: "relationshipRemoved";
|
||||||
|
source: Entity;
|
||||||
|
target: Entity;
|
||||||
|
relationship: RelationshipDef;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Query Observables ────────────────────────────────
|
// ── Query Observables ────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Emitted by `world.observe(query)` when the result set changes.
|
* Emitted by `world.observe(query)` when the result set changes.
|
||||||
@@ -53,3 +78,13 @@ export interface QueryUpdate {
|
|||||||
/** Entities still matching that had a `markDirty` this frame. */
|
/** Entities still matching that had a `markDirty` this frame. */
|
||||||
changed: Entity[];
|
changed: Entity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emitted by `world.observeRelated(rel)` when relationships change.
|
||||||
|
*/
|
||||||
|
export interface RelationshipUpdate {
|
||||||
|
/** Newly established relationships. */
|
||||||
|
added: { source: Entity; target: Entity }[];
|
||||||
|
/** Broken relationships. */
|
||||||
|
removed: { source: Entity; target: Entity }[];
|
||||||
|
}
|
||||||
|
|||||||
+215
-45
@@ -1,33 +1,40 @@
|
|||||||
import { Subject } from "rxjs";
|
import { Subject } from "./subject";
|
||||||
import type { Query } from "../query";
|
import type { Query } from "../query";
|
||||||
import type { Entity } from "../entity";
|
import type { Entity } from "../entity";
|
||||||
import type { WorldEvent, QueryUpdate } from "./events";
|
import type {
|
||||||
|
WorldEvent,
|
||||||
|
EntityEvent,
|
||||||
|
QueryUpdate,
|
||||||
|
RelationshipUpdate,
|
||||||
|
} from "./events";
|
||||||
|
import type { RelationshipDef } from "../relationship";
|
||||||
|
import type { ComponentDef } from "../component";
|
||||||
|
|
||||||
// ── Internal observer state per query ────────────────
|
// ── Internal state ───────────────────────────────────
|
||||||
interface QueryObserverState {
|
interface QueryObserverState {
|
||||||
query: Query;
|
query: Query;
|
||||||
/** Cached set of entities currently matching the query. */
|
|
||||||
matched: Set<Entity>;
|
matched: Set<Entity>;
|
||||||
subject: Subject<QueryUpdate>;
|
subject: Subject<QueryUpdate>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RelationshipObserverState {
|
||||||
|
rel: RelationshipDef;
|
||||||
|
edges: Set<string>;
|
||||||
|
subject: Subject<RelationshipUpdate>;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Observable layer ─────────────────────────────────
|
// ── Observable layer ─────────────────────────────────
|
||||||
/**
|
|
||||||
* Manages observable subscriptions for a World.
|
|
||||||
* Kept separate from the World class for clarity.
|
|
||||||
*/
|
|
||||||
export class ObservableLayer {
|
export class ObservableLayer {
|
||||||
/** Raw event stream. */
|
|
||||||
readonly events$ = new Subject<WorldEvent>();
|
readonly events$ = new Subject<WorldEvent>();
|
||||||
|
|
||||||
/** Active query observers. */
|
|
||||||
private _observers: QueryObserverState[] = [];
|
private _observers: QueryObserverState[] = [];
|
||||||
|
private _relObservers: RelationshipObserverState[] = [];
|
||||||
|
|
||||||
|
// ── Observer index: component key → observers that care ──
|
||||||
|
private _compIndex = new Map<symbol, Set<QueryObserverState>>();
|
||||||
|
|
||||||
|
// ── Query observers ─────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create a Subject for a query.
|
|
||||||
* If this is the first subscription, seed the matched set using
|
|
||||||
* the provided queryMatches callback.
|
|
||||||
*/
|
|
||||||
observe(query: Query): Subject<QueryUpdate> {
|
observe(query: Query): Subject<QueryUpdate> {
|
||||||
const existing = this._observers.find(
|
const existing = this._observers.find(
|
||||||
(o) => o.query === query || queriesEqual(o.query, query),
|
(o) => o.query === query || queriesEqual(o.query, query),
|
||||||
@@ -39,48 +46,146 @@ export class ObservableLayer {
|
|||||||
matched: new Set(),
|
matched: new Set(),
|
||||||
subject: new Subject<QueryUpdate>(),
|
subject: new Subject<QueryUpdate>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Seeding is handled by World (we don't have entity iteration here).
|
|
||||||
// The World's observe() method seeds via a separate path.
|
|
||||||
this._observers.push(state);
|
this._observers.push(state);
|
||||||
|
this._indexObserver(state);
|
||||||
return state.subject;
|
return state.subject;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Seed the initial matched set for an observer.
|
|
||||||
* Called once by World.observe() with all currently-matching entities.
|
|
||||||
*/
|
|
||||||
seed(query: Query, entities: Entity[]): void {
|
seed(query: Query, entities: Entity[]): void {
|
||||||
const obs = this._observers.find(
|
const obs = this._observers.find(
|
||||||
(o) => o.query === query || queriesEqual(o.query, query),
|
(o) => o.query === query || queriesEqual(o.query, query),
|
||||||
);
|
);
|
||||||
if (!obs) return;
|
if (!obs) return;
|
||||||
|
for (const e of entities) obs.matched.add(e);
|
||||||
|
}
|
||||||
|
|
||||||
for (const e of entities) {
|
// ── Relationship observers ───────────────────────
|
||||||
obs.matched.add(e);
|
|
||||||
|
observeRelated(rel: RelationshipDef): Subject<RelationshipUpdate> {
|
||||||
|
const existing = this._relObservers.find((o) => o.rel._key === rel._key);
|
||||||
|
if (existing) return existing.subject;
|
||||||
|
|
||||||
|
const state: RelationshipObserverState = {
|
||||||
|
rel,
|
||||||
|
edges: new Set(),
|
||||||
|
subject: new Subject<RelationshipUpdate>(),
|
||||||
|
};
|
||||||
|
this._relObservers.push(state);
|
||||||
|
return state.subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
seedRelated(
|
||||||
|
rel: RelationshipDef,
|
||||||
|
edges: { source: Entity; target: Entity }[],
|
||||||
|
): void {
|
||||||
|
const obs = this._relObservers.find((o) => o.rel._key === rel._key);
|
||||||
|
if (!obs) return;
|
||||||
|
for (const { source, target } of edges) {
|
||||||
|
obs.edges.add(edgeKey(source, target));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Event dispatch ───────────────────────────────
|
||||||
* Feed an event into the observable system.
|
|
||||||
* Called by the World after state mutation.
|
|
||||||
*/
|
|
||||||
onEvent(
|
onEvent(
|
||||||
event: WorldEvent,
|
event: WorldEvent,
|
||||||
queryMatches: (query: Query, e: Entity) => boolean,
|
queryMatches: (query: Query, e: Entity) => boolean,
|
||||||
): void {
|
): void {
|
||||||
// Forward to the global stream
|
|
||||||
this.events$.next(event);
|
this.events$.next(event);
|
||||||
|
|
||||||
// Update each observer
|
this._dispatchToObservers(event, queryMatches);
|
||||||
for (const observer of this._observers) {
|
|
||||||
this._updateObserver(observer, event, queryMatches);
|
for (const o of this._relObservers) {
|
||||||
|
this._updateRelObserver(o, event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Private: observer indexing ───────────────────
|
||||||
|
|
||||||
|
/** Add an observer to the component index. */
|
||||||
|
private _indexObserver(state: QueryObserverState): void {
|
||||||
|
for (const def of state.query.with) {
|
||||||
|
this._addToIndex(def._key, state);
|
||||||
|
}
|
||||||
|
for (const def of state.query.not) {
|
||||||
|
this._addToIndex(def._key, state);
|
||||||
|
}
|
||||||
|
// Also index under a well-known symbol for spawn/destroy events
|
||||||
|
// (those always fan out to all observers).
|
||||||
|
this._addToIndex(ANY_KEY, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove an observer from the component index. */
|
||||||
|
private _unindexObserver(state: QueryObserverState): void {
|
||||||
|
for (const def of state.query.with) {
|
||||||
|
this._remFromIndex(def._key, state);
|
||||||
|
}
|
||||||
|
for (const def of state.query.not) {
|
||||||
|
this._remFromIndex(def._key, state);
|
||||||
|
}
|
||||||
|
this._remFromIndex(ANY_KEY, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _addToIndex(key: symbol, state: QueryObserverState): void {
|
||||||
|
let set = this._compIndex.get(key);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
this._compIndex.set(key, set);
|
||||||
|
}
|
||||||
|
set.add(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _remFromIndex(key: symbol, state: QueryObserverState): void {
|
||||||
|
const set = this._compIndex.get(key);
|
||||||
|
if (!set) return;
|
||||||
|
set.delete(state);
|
||||||
|
if (set.size === 0) this._compIndex.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch to only the relevant observers. */
|
||||||
|
private _dispatchToObservers(
|
||||||
|
event: WorldEvent,
|
||||||
|
queryMatches: (query: Query, e: Entity) => boolean,
|
||||||
|
): void {
|
||||||
|
if (!("entity" in event)) return;
|
||||||
|
const entityEvent = event as EntityEvent;
|
||||||
|
|
||||||
|
// Determine which component keys are relevant
|
||||||
|
let keys: symbol[] = [];
|
||||||
|
|
||||||
|
switch (entityEvent.type) {
|
||||||
|
case "spawned":
|
||||||
|
case "destroyed":
|
||||||
|
keys = [ANY_KEY];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "componentAdded":
|
||||||
|
case "componentRemoved":
|
||||||
|
case "componentChanged":
|
||||||
|
keys = [entityEvent.component._key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect unique observers to update (deduplicate across keys)
|
||||||
|
const seen = new Set<QueryObserverState>();
|
||||||
|
for (const key of keys) {
|
||||||
|
const set = this._compIndex.get(key);
|
||||||
|
if (!set) continue;
|
||||||
|
for (const o of set) {
|
||||||
|
if (!seen.has(o)) {
|
||||||
|
seen.add(o);
|
||||||
|
// event is entity-bearing after the in-guard above
|
||||||
|
this._updateObserver(o, entityEvent, queryMatches);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private: observer update logic ───────────────
|
||||||
|
|
||||||
private _updateObserver(
|
private _updateObserver(
|
||||||
obs: QueryObserverState,
|
obs: QueryObserverState,
|
||||||
event: WorldEvent,
|
event: EntityEvent,
|
||||||
queryMatches: (query: Query, e: Entity) => boolean,
|
queryMatches: (query: Query, e: Entity) => boolean,
|
||||||
): void {
|
): void {
|
||||||
const e = event.entity;
|
const e = event.entity;
|
||||||
@@ -89,7 +194,6 @@ export class ObservableLayer {
|
|||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "spawned":
|
case "spawned":
|
||||||
// Entity is bare; won't match unless components added later
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "destroyed":
|
case "destroyed":
|
||||||
@@ -120,25 +224,87 @@ export class ObservableLayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset all observer state (useful for tests). */
|
private _updateRelObserver(
|
||||||
reset(): void {
|
obs: RelationshipObserverState,
|
||||||
for (const obs of this._observers) {
|
event: WorldEvent,
|
||||||
obs.subject.complete();
|
): void {
|
||||||
obs.matched.clear();
|
switch (event.type) {
|
||||||
|
case "relationshipAdded": {
|
||||||
|
if (event.relationship._key !== obs.rel._key) break;
|
||||||
|
const key = edgeKey(event.source, event.target);
|
||||||
|
if (obs.edges.has(key)) break;
|
||||||
|
obs.edges.add(key);
|
||||||
|
obs.subject.next({
|
||||||
|
added: [{ source: event.source, target: event.target }],
|
||||||
|
removed: [],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "relationshipRemoved": {
|
||||||
|
if (event.relationship._key !== obs.rel._key) break;
|
||||||
|
const key = edgeKey(event.source, event.target);
|
||||||
|
if (!obs.edges.has(key)) break;
|
||||||
|
obs.edges.delete(key);
|
||||||
|
obs.subject.next({
|
||||||
|
added: [],
|
||||||
|
removed: [{ source: event.source, target: event.target }],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "destroyed": {
|
||||||
|
const removed: { source: Entity; target: Entity }[] = [];
|
||||||
|
for (const key of obs.edges) {
|
||||||
|
const [si, ti] = key.split(":").map(Number);
|
||||||
|
const idx = event.entity & 0xfffff;
|
||||||
|
if (si === idx || ti === idx) {
|
||||||
|
removed.push({ source: si as Entity, target: ti as Entity });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of removed) {
|
||||||
|
obs.edges.delete(edgeKey(r.source, r.target));
|
||||||
|
}
|
||||||
|
if (removed.length > 0) {
|
||||||
|
obs.subject.next({ added: [], removed });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this._observers = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Complete all streams. */
|
// ── Teardown ─────────────────────────────────────
|
||||||
complete(): void {
|
|
||||||
this.events$.complete();
|
reset(): void {
|
||||||
for (const obs of this._observers) {
|
for (const o of this._observers) {
|
||||||
obs.subject.complete();
|
o.subject.complete();
|
||||||
|
o.matched.clear();
|
||||||
}
|
}
|
||||||
this._observers = [];
|
this._observers = [];
|
||||||
|
this._compIndex.clear();
|
||||||
|
|
||||||
|
for (const o of this._relObservers) {
|
||||||
|
o.subject.complete();
|
||||||
|
o.edges.clear();
|
||||||
|
}
|
||||||
|
this._relObservers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
complete(): void {
|
||||||
|
this.events$.complete();
|
||||||
|
for (const o of this._observers) o.subject.complete();
|
||||||
|
this._observers = [];
|
||||||
|
this._compIndex.clear();
|
||||||
|
for (const o of this._relObservers) o.subject.complete();
|
||||||
|
this._relObservers = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────
|
||||||
|
|
||||||
|
/** Sentinel key for observers that must be notified on spawn/destroy. */
|
||||||
|
const ANY_KEY = Symbol("any");
|
||||||
|
|
||||||
function queriesEqual(a: Query, b: Query): boolean {
|
function queriesEqual(a: Query, b: Query): boolean {
|
||||||
if (a.with.length !== b.with.length) return false;
|
if (a.with.length !== b.with.length) return false;
|
||||||
if (a.not.length !== b.not.length) return false;
|
if (a.not.length !== b.not.length) return false;
|
||||||
@@ -147,3 +313,7 @@ function queriesEqual(a: Query, b: Query): boolean {
|
|||||||
a.not.every((c, i) => c === b.not[i])
|
a.not.every((c, i) => c === b.not[i])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function edgeKey(source: Entity, target: Entity): string {
|
||||||
|
return `${source & 0xfffff}:${target & 0xfffff}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// ── Internal Observable / Subject ─────────────────────
|
||||||
|
/** Minimal subscription handle returned by `.subscribe()`. */
|
||||||
|
export interface Subscription {
|
||||||
|
unsubscribe(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal observable interface — only supports single-callback subscribe. */
|
||||||
|
export interface Observable<T> {
|
||||||
|
subscribe(observer: (value: T) => void): Subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lightweight multicast subject, replacing the RxJS dependency. */
|
||||||
|
export class Subject<T> implements Observable<T> {
|
||||||
|
private _subs = new Set<(value: T) => void>();
|
||||||
|
private _done = false;
|
||||||
|
|
||||||
|
/** Push a value to all current subscribers. No-op after complete. */
|
||||||
|
next(value: T): void {
|
||||||
|
if (this._done) return;
|
||||||
|
for (const fn of this._subs) {
|
||||||
|
fn(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a subscriber. Returns a handle to unsubscribe. */
|
||||||
|
subscribe(observer: (value: T) => void): Subscription {
|
||||||
|
const fn = observer;
|
||||||
|
this._subs.add(fn);
|
||||||
|
return {
|
||||||
|
unsubscribe: () => {
|
||||||
|
this._subs.delete(fn);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete this subject — clears all subscribers and silences future calls. */
|
||||||
|
complete(): void {
|
||||||
|
this._done = true;
|
||||||
|
this._subs.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a read-only Observable facade (hides `next` / `complete`). */
|
||||||
|
asObservable(): Observable<T> {
|
||||||
|
return {
|
||||||
|
subscribe: (observer: (value: T) => void): Subscription => {
|
||||||
|
return this.subscribe(observer);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// ── Relationship ─────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* A relationship definition — like a component, but represents a directed
|
||||||
|
* link between two entities. Every relationship carries an optional data
|
||||||
|
* payload (defaults to `{}` for bare edges).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const ChildOf = defineRelationship('childOf');
|
||||||
|
* const Health = defineRelationship('health', { hp: 100 });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
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<{}>;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Plain JSON-compatible representation of a World.
|
||||||
|
* Returned by `world.toJSON()`, consumed by `World.fromJSON()`.
|
||||||
|
*/
|
||||||
|
export interface WorldSnapshot {
|
||||||
|
/** Entity stable ID → component map (component name → data). */
|
||||||
|
entities: Record<string, Record<string, unknown>>;
|
||||||
|
/** Relationship name → (source ID → target ID or edge object). */
|
||||||
|
relationships: Record<
|
||||||
|
string,
|
||||||
|
Record<string, string | { target: string; data: unknown }>
|
||||||
|
>;
|
||||||
|
}
|
||||||
+495
-19
@@ -4,33 +4,51 @@ import { makeEntity, entityIndex, entityGeneration } from "./entity";
|
|||||||
import type { Query } from "./query";
|
import type { Query } from "./query";
|
||||||
import { SparseSet } from "./storage/sparse-set";
|
import { SparseSet } from "./storage/sparse-set";
|
||||||
import { ObservableLayer } from "./observable/observe";
|
import { ObservableLayer } from "./observable/observe";
|
||||||
import type { QueryUpdate } from "./observable/events";
|
import type { QueryUpdate, RelationshipUpdate } from "./observable/events";
|
||||||
import { Observable } from "rxjs";
|
import type { RelationshipDef } from "./relationship";
|
||||||
|
import type { Observable } from "./observable/subject";
|
||||||
|
import type { WorldEvent } from "./observable/events";
|
||||||
|
import type { WorldSnapshot } from "./serialization";
|
||||||
|
|
||||||
// ── World ─────────────────────────────────────────────
|
// ── World ─────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* The central ECS container.
|
* The central ECS container.
|
||||||
*
|
*
|
||||||
* Manages entities, components, queries, and change tracking.
|
* Manages entities, components, relationships, queries, and change tracking.
|
||||||
* Call `flush()` once per frame to emit batched observable events.
|
* Call `flush()` once per frame to emit batched observable events.
|
||||||
*/
|
*/
|
||||||
export class World {
|
export class World {
|
||||||
// ── Entity pools ──────────────────────────────────
|
// ── Entity pools ──────────────────────────────────
|
||||||
private _generations: number[] = [];
|
private _generations: number[] = [];
|
||||||
private _free: number[] = [];
|
private _free: number[] = [];
|
||||||
|
private _componentCounts: number[] = [];
|
||||||
|
private _relCounts: number[] = [];
|
||||||
|
|
||||||
// ── Component storage ─────────────────────────────
|
// ── Component storage ─────────────────────────────
|
||||||
private _components = new Map<symbol, SparseSet<any>>();
|
private _components = new Map<symbol, SparseSet<any>>();
|
||||||
private _keyToDef = new Map<symbol, ComponentDef<any>>();
|
private _keyToDef = new Map<symbol, ComponentDef<any>>();
|
||||||
|
|
||||||
|
// ── Relationship storage ──────────────────────────
|
||||||
|
/** Forward: relationship._key → SparseSet<target entity> (keyed by source index). */
|
||||||
|
private _relForward = new Map<symbol, SparseSet<Entity>>();
|
||||||
|
/** Reverse: relationship._key → Map<target index, Set<source index>>. */
|
||||||
|
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 ───────────────────────────────
|
// ── Change tracking ───────────────────────────────
|
||||||
private _dirty = new Map<symbol, Set<number>>();
|
private _dirty = new Map<symbol, Set<number>>();
|
||||||
|
|
||||||
// ── Observable layer ──────────────────────────────
|
// ── Observable layer ──────────────────────────────
|
||||||
private _observable = new ObservableLayer();
|
private _observable = new ObservableLayer();
|
||||||
|
|
||||||
|
// ── Singleton entity ──────────────────────────────
|
||||||
|
private _singletonEntity: Entity | null = null;
|
||||||
|
|
||||||
/** Global event stream. */
|
/** Global event stream. */
|
||||||
get events$(): Observable<any> {
|
get events$(): Observable<WorldEvent> {
|
||||||
return this._observable.events$.asObservable();
|
return this._observable.events$.asObservable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +59,8 @@ export class World {
|
|||||||
if (this._free.length > 0) {
|
if (this._free.length > 0) {
|
||||||
const idx = this._free.pop()!;
|
const idx = this._free.pop()!;
|
||||||
const gen = this._generations[idx];
|
const gen = this._generations[idx];
|
||||||
|
this._componentCounts[idx] = 0;
|
||||||
|
this._relCounts[idx] = 0;
|
||||||
const e = makeEntity(idx, gen);
|
const e = makeEntity(idx, gen);
|
||||||
this._emit({ type: "spawned", entity: e });
|
this._emit({ type: "spawned", entity: e });
|
||||||
return e;
|
return e;
|
||||||
@@ -48,16 +68,51 @@ export class World {
|
|||||||
|
|
||||||
const idx = this._generations.length;
|
const idx = this._generations.length;
|
||||||
this._generations.push(1);
|
this._generations.push(1);
|
||||||
|
this._componentCounts.push(0);
|
||||||
|
this._relCounts.push(0);
|
||||||
const e = makeEntity(idx, 1);
|
const e = makeEntity(idx, 1);
|
||||||
this._emit({ type: "spawned", entity: e });
|
this._emit({ type: "spawned", entity: e });
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Destroy an entity, removing all its components. */
|
/** Destroy an entity, removing all its components and relationships. */
|
||||||
destroy(entity: Entity): void {
|
destroy(entity: Entity): void {
|
||||||
const idx = entityIndex(entity);
|
const idx = entityIndex(entity);
|
||||||
if (!this._isAlive(idx, entity)) return;
|
if (!this._isAlive(idx, entity)) return;
|
||||||
|
|
||||||
|
// Short-circuit: truly bare entities have nothing to clean up
|
||||||
|
if (this._componentCounts[idx] === 0 && this._relCounts[idx] === 0) {
|
||||||
|
this._generations[idx]++;
|
||||||
|
this._free.push(idx);
|
||||||
|
this._emit({ type: "destroyed", entity });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean relationships before components
|
||||||
|
for (const [key] of this._relForward) {
|
||||||
|
// Entity as source
|
||||||
|
const fwd = this._relForward.get(key)!;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity as target
|
||||||
|
const rev = this._relReverse.get(key)!;
|
||||||
|
const sources = rev.get(idx);
|
||||||
|
if (sources) {
|
||||||
|
for (const si of [...sources]) {
|
||||||
|
const source = makeEntity(si, this._generations[si]);
|
||||||
|
const rel = this._relKeyToDef.get(key)!;
|
||||||
|
this._relRemoveEdge(source, entity, rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove components
|
||||||
for (const [, store] of this._components) {
|
for (const [, store] of this._components) {
|
||||||
store.remove(idx);
|
store.remove(idx);
|
||||||
}
|
}
|
||||||
@@ -65,6 +120,8 @@ export class World {
|
|||||||
dirty.delete(idx);
|
dirty.delete(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._componentCounts[idx] = 0;
|
||||||
|
this._relCounts[idx] = 0;
|
||||||
this._generations[idx]++;
|
this._generations[idx]++;
|
||||||
this._free.push(idx);
|
this._free.push(idx);
|
||||||
|
|
||||||
@@ -76,9 +133,15 @@ export class World {
|
|||||||
return this._isAlive(entityIndex(entity), entity);
|
return this._isAlive(entityIndex(entity), entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns true if the entity holds at least one component of any type. */
|
||||||
|
hasAnyComponent(entity: Entity): boolean {
|
||||||
|
const idx = entityIndex(entity);
|
||||||
|
if (!this._isAlive(idx, entity)) return false;
|
||||||
|
return this._componentCounts[idx] > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Component operations ──────────────────────────
|
// ── Component operations ──────────────────────────
|
||||||
|
|
||||||
/** Add a component to an entity. Returns the live value. */
|
|
||||||
add<T extends Record<string, any>>(
|
add<T extends Record<string, any>>(
|
||||||
entity: Entity,
|
entity: Entity,
|
||||||
def: ComponentDef<T>,
|
def: ComponentDef<T>,
|
||||||
@@ -88,14 +151,16 @@ export class World {
|
|||||||
this._assertAlive(idx, entity);
|
this._assertAlive(idx, entity);
|
||||||
|
|
||||||
const store = this._getOrCreateStore(def);
|
const store = this._getOrCreateStore(def);
|
||||||
|
const existed = store.has(idx);
|
||||||
const value = { ...def.defaults, ...init };
|
const value = { ...def.defaults, ...init };
|
||||||
store.set(idx, value);
|
store.set(idx, value);
|
||||||
|
|
||||||
|
if (!existed) this._componentCounts[idx]++;
|
||||||
|
|
||||||
this._emit({ type: "componentAdded", entity, component: def });
|
this._emit({ type: "componentAdded", entity, component: def });
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a component from an entity. */
|
|
||||||
remove(entity: Entity, def: ComponentDef<any>): void {
|
remove(entity: Entity, def: ComponentDef<any>): void {
|
||||||
const idx = entityIndex(entity);
|
const idx = entityIndex(entity);
|
||||||
this._assertAlive(idx, entity);
|
this._assertAlive(idx, entity);
|
||||||
@@ -107,11 +172,11 @@ export class World {
|
|||||||
this._dirty.get(def._key)?.delete(idx);
|
this._dirty.get(def._key)?.delete(idx);
|
||||||
|
|
||||||
if (removed) {
|
if (removed) {
|
||||||
|
this._componentCounts[idx]--;
|
||||||
this._emit({ type: "componentRemoved", entity, component: def });
|
this._emit({ type: "componentRemoved", entity, component: def });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a mutable reference to a component. Throws if absent. */
|
|
||||||
get<T extends Record<string, any>>(entity: Entity, def: ComponentDef<T>): T {
|
get<T extends Record<string, any>>(entity: Entity, def: ComponentDef<T>): T {
|
||||||
const idx = entityIndex(entity);
|
const idx = entityIndex(entity);
|
||||||
this._assertAlive(idx, entity);
|
this._assertAlive(idx, entity);
|
||||||
@@ -125,7 +190,6 @@ export class World {
|
|||||||
return store.get(idx);
|
return store.get(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a mutable reference, or undefined if absent. */
|
|
||||||
tryGet<T extends Record<string, any>>(
|
tryGet<T extends Record<string, any>>(
|
||||||
entity: Entity,
|
entity: Entity,
|
||||||
def: ComponentDef<T>,
|
def: ComponentDef<T>,
|
||||||
@@ -135,7 +199,6 @@ export class World {
|
|||||||
return this._components.get(def._key)?.tryGet(idx);
|
return this._components.get(def._key)?.tryGet(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if an entity has a component. */
|
|
||||||
has(entity: Entity, def: ComponentDef<any>): boolean {
|
has(entity: Entity, def: ComponentDef<any>): boolean {
|
||||||
const idx = entityIndex(entity);
|
const idx = entityIndex(entity);
|
||||||
if (!this._isAlive(idx, entity)) return false;
|
if (!this._isAlive(idx, entity)) return false;
|
||||||
@@ -143,7 +206,6 @@ export class World {
|
|||||||
return store?.has(idx) ?? false;
|
return store?.has(idx) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace a component value. Sets the value and marks dirty. */
|
|
||||||
set<T extends Record<string, any>>(
|
set<T extends Record<string, any>>(
|
||||||
entity: Entity,
|
entity: Entity,
|
||||||
def: ComponentDef<T>,
|
def: ComponentDef<T>,
|
||||||
@@ -165,7 +227,6 @@ export class World {
|
|||||||
|
|
||||||
// ── Change tracking ───────────────────────────────
|
// ── Change tracking ───────────────────────────────
|
||||||
|
|
||||||
/** Mark entity's component as dirty. Not emitted until `flush()`. */
|
|
||||||
markDirty(entity: Entity, def: ComponentDef<any>): void {
|
markDirty(entity: Entity, def: ComponentDef<any>): void {
|
||||||
const idx = entityIndex(entity);
|
const idx = entityIndex(entity);
|
||||||
this._assertAlive(idx, entity);
|
this._assertAlive(idx, entity);
|
||||||
@@ -178,7 +239,6 @@ export class World {
|
|||||||
dirty.add(idx);
|
dirty.add(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Emit all pending change events. Call once per frame. */
|
|
||||||
flush(): void {
|
flush(): void {
|
||||||
for (const [key, dirtySet] of this._dirty) {
|
for (const [key, dirtySet] of this._dirty) {
|
||||||
if (dirtySet.size === 0) continue;
|
if (dirtySet.size === 0) continue;
|
||||||
@@ -197,9 +257,245 @@ 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<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);
|
||||||
|
this._assertAlive(ti, target);
|
||||||
|
|
||||||
|
// If source already has this relationship, remove it first
|
||||||
|
const existing = this.getRelated(source, rel);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
this._relRemoveEdge(source, existing, rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._relEnsureMaps(rel);
|
||||||
|
|
||||||
|
// Forward
|
||||||
|
this._relForward.get(rel._key)!.set(si, target);
|
||||||
|
|
||||||
|
// Reverse
|
||||||
|
let rev = this._relReverse.get(rel._key)!.get(ti);
|
||||||
|
if (!rev) {
|
||||||
|
rev = new Set();
|
||||||
|
this._relReverse.get(rel._key)!.set(ti, rev);
|
||||||
|
}
|
||||||
|
rev.add(si);
|
||||||
|
|
||||||
|
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,
|
||||||
|
target,
|
||||||
|
relationship: rel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the relationship from `source`.
|
||||||
|
* No-op if no such relationship exists.
|
||||||
|
*/
|
||||||
|
unrelate(source: Entity, rel: RelationshipDef): void {
|
||||||
|
const si = entityIndex(source);
|
||||||
|
if (!this._isAlive(si, source)) return;
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
getRelated(source: Entity, rel: RelationshipDef): Entity | undefined {
|
||||||
|
const si = entityIndex(source);
|
||||||
|
if (!this._isAlive(si, source)) return undefined;
|
||||||
|
|
||||||
|
const fwd = this._relForward.get(rel._key);
|
||||||
|
if (!fwd) return undefined;
|
||||||
|
return fwd.tryGet(si);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all source entities that point to `target` via this relationship.
|
||||||
|
*/
|
||||||
|
*getRelatedTo(
|
||||||
|
target: Entity,
|
||||||
|
rel: RelationshipDef,
|
||||||
|
): IterableIterator<Entity> {
|
||||||
|
const ti = entityIndex(target);
|
||||||
|
if (!this._isAlive(ti, target)) return;
|
||||||
|
|
||||||
|
const rev = this._relReverse.get(rel._key);
|
||||||
|
if (!rev) return;
|
||||||
|
|
||||||
|
const sources = rev.get(ti);
|
||||||
|
if (!sources) return;
|
||||||
|
|
||||||
|
for (const si of sources) {
|
||||||
|
yield makeEntity(si, this._generations[si]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe relationship changes for a given type.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* world.observeRelated(ChildOf).subscribe(update => {
|
||||||
|
* // update.added, update.removed
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
observeRelated(rel: RelationshipDef): Observable<RelationshipUpdate> {
|
||||||
|
const subject = this._observable.observeRelated(rel);
|
||||||
|
|
||||||
|
// Seed with current edges
|
||||||
|
const edges: { source: Entity; target: Entity }[] = [];
|
||||||
|
const fwd = this._relForward.get(rel._key);
|
||||||
|
if (fwd) {
|
||||||
|
for (const [si, target] of fwd.entries()) {
|
||||||
|
const source = makeEntity(si, this._generations[si]);
|
||||||
|
edges.push({ source, target });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._observable.seedRelated(rel, edges);
|
||||||
|
|
||||||
|
return subject.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Queries ───────────────────────────────────────
|
// ── Queries ───────────────────────────────────────
|
||||||
|
|
||||||
/** Iterate all entities matching a query synchronously. */
|
|
||||||
*query(q: Query): IterableIterator<Entity> {
|
*query(q: Query): IterableIterator<Entity> {
|
||||||
const withStores = q.with.map((d) => this._components.get(d._key));
|
const withStores = q.with.map((d) => this._components.get(d._key));
|
||||||
const withoutStores = q.not.map((d) => this._components.get(d._key));
|
const withoutStores = q.not.map((d) => this._components.get(d._key));
|
||||||
@@ -216,18 +512,13 @@ export class World {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Observe changes to a query's result set. */
|
|
||||||
observe(q: Query): Observable<QueryUpdate> {
|
observe(q: Query): Observable<QueryUpdate> {
|
||||||
const subject = this._observable.observe(q);
|
const subject = this._observable.observe(q);
|
||||||
|
|
||||||
// Seed with currently-matching entities
|
|
||||||
const existing = [...this.query(q)];
|
const existing = [...this.query(q)];
|
||||||
this._observable.seed(q, existing);
|
this._observable.seed(q, existing);
|
||||||
|
|
||||||
return subject.asObservable();
|
return subject.asObservable();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Total number of *alive* entities. */
|
|
||||||
get entityCount(): number {
|
get entityCount(): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (let i = 0; i < this._generations.length; i++) {
|
for (let i = 0; i < this._generations.length; i++) {
|
||||||
@@ -238,6 +529,147 @@ export class World {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Serialization ────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the entire world to a plain JSON-compatible object.
|
||||||
|
*
|
||||||
|
* Each entity gets a stable string ID ("e0", "e1", …).
|
||||||
|
* Components are keyed by their `name`. Relationships are keyed
|
||||||
|
* by their `name` with entity references using the same stable IDs.
|
||||||
|
*/
|
||||||
|
toJSON(): WorldSnapshot {
|
||||||
|
// Build entity index → string id mapping
|
||||||
|
const ids: string[] = [];
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
const entities: Record<string, Record<string, unknown>> = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < this._generations.length; i++) {
|
||||||
|
if (this._generations[i] === 0 || this._free.includes(i)) continue;
|
||||||
|
|
||||||
|
const strId = `e${nextId++}`;
|
||||||
|
ids[i] = strId;
|
||||||
|
|
||||||
|
const comps: Record<string, unknown> = {};
|
||||||
|
for (const [key, store] of this._components) {
|
||||||
|
if (store.has(i)) {
|
||||||
|
const def = this._keyToDef.get(key)!;
|
||||||
|
comps[def.name] = store.get(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(comps).length > 0) {
|
||||||
|
entities[strId] = comps;
|
||||||
|
} else {
|
||||||
|
// Still record bare entities
|
||||||
|
entities[strId] = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
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 | { 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) {
|
||||||
|
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) {
|
||||||
|
relationships[rel.name] = edges;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entities, relationships };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize a world from a snapshot.
|
||||||
|
*
|
||||||
|
* @param data The output of `world.toJSON()`.
|
||||||
|
* @param components All ComponentDefs that may appear in the snapshot.
|
||||||
|
* @param relationships All RelationshipDefs that may appear in the snapshot.
|
||||||
|
*/
|
||||||
|
static fromJSON(
|
||||||
|
data: WorldSnapshot,
|
||||||
|
components: ComponentDef<any>[],
|
||||||
|
relationships?: RelationshipDef[],
|
||||||
|
): World {
|
||||||
|
const world = new World();
|
||||||
|
|
||||||
|
const compByName = new Map(components.map((c) => [c.name, c])) as Map<
|
||||||
|
string,
|
||||||
|
ComponentDef<Record<string, unknown>>
|
||||||
|
>;
|
||||||
|
const relByName = new Map((relationships ?? []).map((r) => [r.name, r]));
|
||||||
|
|
||||||
|
// Map string ids → real Entity handles
|
||||||
|
const idToEntity = new Map<string, Entity>();
|
||||||
|
|
||||||
|
for (const [strId, comps] of Object.entries(data.entities)) {
|
||||||
|
const entity = world.spawn();
|
||||||
|
idToEntity.set(strId, entity);
|
||||||
|
|
||||||
|
for (const [compName, value] of Object.entries(comps)) {
|
||||||
|
const def = compByName.get(compName);
|
||||||
|
if (!def) {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown component "${compName}" in snapshot. ` +
|
||||||
|
`Pass it in the components array.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Unknown at deserialization boundary; shape matches ComponentDef.defaults
|
||||||
|
world.add(entity, def, value as Partial<Record<string, unknown>>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore relationships
|
||||||
|
for (const [relName, edges] of Object.entries(data.relationships)) {
|
||||||
|
const rel = relByName.get(relName);
|
||||||
|
if (!rel) {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown relationship "${relName}" in snapshot. ` +
|
||||||
|
`Pass it in the relationships array.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [srcId, value] of Object.entries(edges)) {
|
||||||
|
const source = idToEntity.get(srcId);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internals ─────────────────────────────────────
|
// ── Internals ─────────────────────────────────────
|
||||||
|
|
||||||
private _emit(event: import("./observable/events").WorldEvent): void {
|
private _emit(event: import("./observable/events").WorldEvent): void {
|
||||||
@@ -258,6 +690,8 @@ export class World {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Component storage helpers ────────────────────
|
||||||
|
|
||||||
private _getOrCreateStore<T extends Record<string, any>>(
|
private _getOrCreateStore<T extends Record<string, any>>(
|
||||||
def: ComponentDef<T>,
|
def: ComponentDef<T>,
|
||||||
): SparseSet<T> {
|
): SparseSet<T> {
|
||||||
@@ -281,4 +715,46 @@ export class World {
|
|||||||
query.not.every((d) => !(this._components.get(d._key)?.has(idx) ?? false))
|
query.not.every((d) => !(this._components.get(d._key)?.has(idx) ?? false))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Relationship helpers ─────────────────────────
|
||||||
|
|
||||||
|
private _relEnsureMaps(rel: RelationshipDef): void {
|
||||||
|
if (!this._relForward.has(rel._key)) {
|
||||||
|
this._relForward.set(rel._key, new SparseSet<Entity>());
|
||||||
|
this._relReverse.set(rel._key, new Map());
|
||||||
|
this._relKeyToDef.set(rel._key, rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove an edge internally and emit the event. */
|
||||||
|
private _relRemoveEdge(
|
||||||
|
source: Entity,
|
||||||
|
target: Entity,
|
||||||
|
rel: RelationshipDef,
|
||||||
|
): void {
|
||||||
|
const si = entityIndex(source);
|
||||||
|
const ti = entityIndex(target);
|
||||||
|
|
||||||
|
const fwd = this._relForward.get(rel._key);
|
||||||
|
if (fwd) fwd.remove(si);
|
||||||
|
|
||||||
|
const rev = this._relReverse.get(rel._key);
|
||||||
|
if (rev) {
|
||||||
|
const sources = rev.get(ti);
|
||||||
|
if (sources) {
|
||||||
|
sources.delete(si);
|
||||||
|
if (sources.size === 0) rev.delete(ti);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._relCounts[si]--;
|
||||||
|
this._relCounts[ti]--;
|
||||||
|
|
||||||
|
this._emit({
|
||||||
|
type: "relationshipRemoved",
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
relationship: rel,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+900
@@ -0,0 +1,900 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { World, defineComponent, entity, type Entity } from "../src/index";
|
||||||
|
import {
|
||||||
|
Task,
|
||||||
|
Scheduled,
|
||||||
|
Running,
|
||||||
|
Succeeded,
|
||||||
|
Failed,
|
||||||
|
Cancelled,
|
||||||
|
ChildOf,
|
||||||
|
TaskRunner,
|
||||||
|
buildTree,
|
||||||
|
Cancel,
|
||||||
|
action,
|
||||||
|
wait,
|
||||||
|
cycle,
|
||||||
|
whilst,
|
||||||
|
sequential,
|
||||||
|
} from "../src/bt/index";
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────
|
||||||
|
function makeWait(world: World, parent?: Entity): Entity {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Task, { kind: "wait" });
|
||||||
|
if (parent) world.relate(e, ChildOf, parent);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSequential(world: World, parent?: Entity): Entity {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Task, { kind: "sequential" });
|
||||||
|
if (parent) world.relate(e, ChildOf, parent);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeParallel(world: World, parent?: Entity): Entity {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Task, { kind: "parallel" });
|
||||||
|
if (parent) world.relate(e, ChildOf, parent);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRandom(world: World, parent?: Entity): Entity {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Task, { kind: "random" });
|
||||||
|
if (parent) world.relate(e, ChildOf, parent);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCycle(world: World, parent?: Entity): Entity {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Task, { kind: "cycle" });
|
||||||
|
if (parent) world.relate(e, ChildOf, parent);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSelector(world: World, parent?: Entity): Entity {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Task, { kind: "selector" });
|
||||||
|
if (parent) world.relate(e, ChildOf, parent);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
runner = new TaskRunner(world);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onWait when a wait task is scheduled and ticked", () => {
|
||||||
|
const action = makeWait(world);
|
||||||
|
const calls: Entity[] = [];
|
||||||
|
|
||||||
|
runner.onWait = (_w, e) => calls.push(e);
|
||||||
|
runner.schedule(action);
|
||||||
|
runner.tick();
|
||||||
|
|
||||||
|
expect(calls).toEqual([action]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks wait task as Running after tick", () => {
|
||||||
|
const action = makeWait(world);
|
||||||
|
runner.schedule(action);
|
||||||
|
runner.tick();
|
||||||
|
|
||||||
|
expect(world.has(action, Running)).toBe(true);
|
||||||
|
expect(world.has(action, Scheduled)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("succeed() marks wait task as Succeeded", () => {
|
||||||
|
const action = makeWait(world);
|
||||||
|
runner.schedule(action);
|
||||||
|
runner.tick();
|
||||||
|
runner.succeed(action);
|
||||||
|
|
||||||
|
expect(world.has(action, Succeeded)).toBe(true);
|
||||||
|
expect(world.has(action, Running)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fail() marks wait task as Failed", () => {
|
||||||
|
const action = makeWait(world);
|
||||||
|
runner.schedule(action);
|
||||||
|
runner.tick();
|
||||||
|
runner.fail(action);
|
||||||
|
|
||||||
|
expect(world.has(action, Failed)).toBe(true);
|
||||||
|
expect(world.has(action, Running)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel() marks wait task as Cancelled", () => {
|
||||||
|
const action = makeWait(world);
|
||||||
|
runner.schedule(action);
|
||||||
|
runner.tick();
|
||||||
|
runner.cancel(action);
|
||||||
|
|
||||||
|
expect(world.has(action, Cancelled)).toBe(true);
|
||||||
|
expect(world.has(action, Running)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(action);
|
||||||
|
runner.tick();
|
||||||
|
runner.succeed(action);
|
||||||
|
|
||||||
|
expect(terminals).toEqual([{ entity: action, status: "succeeded" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reset() clears all status tags", () => {
|
||||||
|
const action = makeWait(world);
|
||||||
|
runner.schedule(action);
|
||||||
|
runner.tick();
|
||||||
|
runner.succeed(action);
|
||||||
|
|
||||||
|
runner.reset(action);
|
||||||
|
|
||||||
|
expect(world.has(action, Succeeded)).toBe(false);
|
||||||
|
expect(world.has(action, Running)).toBe(false);
|
||||||
|
expect(world.has(action, Scheduled)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sequential ──────────────────────────────────────
|
||||||
|
describe("Sequential tasks", () => {
|
||||||
|
let world: World;
|
||||||
|
let runner: TaskRunner;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
runner = new TaskRunner(world);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs children one at a time in order", () => {
|
||||||
|
const seq = makeSequential(world);
|
||||||
|
const a = makeWait(world, seq);
|
||||||
|
const b = makeWait(world, seq);
|
||||||
|
const c = makeWait(world, seq);
|
||||||
|
|
||||||
|
const leafCalls: Entity[] = [];
|
||||||
|
runner.onWait = (_w, e) => leafCalls.push(e);
|
||||||
|
|
||||||
|
runner.schedule(seq);
|
||||||
|
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 = makeWait(world, seq);
|
||||||
|
const b = makeWait(world, seq);
|
||||||
|
|
||||||
|
runner.schedule(seq);
|
||||||
|
runner.tick(); // schedules a
|
||||||
|
runner.tick(); // runs a
|
||||||
|
runner.fail(a);
|
||||||
|
|
||||||
|
// parent re-scheduled
|
||||||
|
runner.tick(); // sees a failed → seq fails
|
||||||
|
expect(world.has(seq, Failed)).toBe(true);
|
||||||
|
// b was never touched
|
||||||
|
expect(world.has(b, Scheduled)).toBe(false);
|
||||||
|
expect(world.has(b, Running)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("succeeds when all children succeed", () => {
|
||||||
|
const seq = makeSequential(world);
|
||||||
|
const a = makeWait(world, seq);
|
||||||
|
const b = makeWait(world, seq);
|
||||||
|
|
||||||
|
runner.schedule(seq);
|
||||||
|
runner.tick(); // schedules a
|
||||||
|
runner.tick(); // runs a
|
||||||
|
runner.succeed(a);
|
||||||
|
runner.tick(); // schedules b
|
||||||
|
runner.tick(); // runs b
|
||||||
|
runner.succeed(b);
|
||||||
|
runner.tick(); // seq succeeds
|
||||||
|
|
||||||
|
expect(world.has(seq, Succeeded)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates terminal to grandparent", () => {
|
||||||
|
const root = makeSequential(world);
|
||||||
|
const child = makeSequential(world, root);
|
||||||
|
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 action
|
||||||
|
runner.tick(); // runs action
|
||||||
|
runner.succeed(action);
|
||||||
|
runner.tick(); // child succeeds
|
||||||
|
runner.tick(); // root succeeds
|
||||||
|
|
||||||
|
expect(terminals).toEqual([action, child, root]);
|
||||||
|
expect(world.has(root, Succeeded)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty sequential succeeds immediately", () => {
|
||||||
|
const seq = makeSequential(world);
|
||||||
|
|
||||||
|
runner.schedule(seq);
|
||||||
|
runner.tick();
|
||||||
|
|
||||||
|
expect(world.has(seq, Succeeded)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Parallel ────────────────────────────────────────
|
||||||
|
describe("Parallel tasks", () => {
|
||||||
|
let world: World;
|
||||||
|
let runner: TaskRunner;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
runner = new TaskRunner(world);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts all children at once", () => {
|
||||||
|
const par = makeParallel(world);
|
||||||
|
const a = makeWait(world, par);
|
||||||
|
const b = makeWait(world, par);
|
||||||
|
const c = makeWait(world, par);
|
||||||
|
|
||||||
|
runner.schedule(par);
|
||||||
|
runner.tick();
|
||||||
|
|
||||||
|
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 = makeWait(world, par);
|
||||||
|
const b = makeWait(world, par);
|
||||||
|
|
||||||
|
runner.schedule(par);
|
||||||
|
runner.tick(); // schedules both
|
||||||
|
|
||||||
|
runner.tick(); // runs a
|
||||||
|
runner.succeed(a);
|
||||||
|
|
||||||
|
// par is re-scheduled, but b is still running
|
||||||
|
runner.tick(); // par sees a done, b still needs scheduling
|
||||||
|
// b should be scheduled (it was removed from Scheduled when ticked)
|
||||||
|
// Actually: b was scheduled in first tick, then ticked in second tick
|
||||||
|
// Let me trace more carefully...
|
||||||
|
|
||||||
|
// After first tick: a=Scheduled, b=Scheduled, par=no status
|
||||||
|
// Second tick processes Scheduled: a and b both get ticked
|
||||||
|
// a runs, b runs. Both are Running.
|
||||||
|
// We succeed a → par gets Scheduled
|
||||||
|
// Third tick: par sees a=Succeeded, b=Running → waits
|
||||||
|
// We need to succeed b too
|
||||||
|
runner.succeed(b);
|
||||||
|
// par gets Scheduled again
|
||||||
|
runner.tick(); // par sees both done → succeeds
|
||||||
|
|
||||||
|
expect(world.has(par, Succeeded)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails immediately when any child fails", () => {
|
||||||
|
const par = makeParallel(world);
|
||||||
|
const a = makeWait(world, par);
|
||||||
|
const b = makeWait(world, par);
|
||||||
|
|
||||||
|
runner.schedule(par);
|
||||||
|
runner.tick(); // schedules both
|
||||||
|
runner.tick(); // runs a
|
||||||
|
runner.tick(); // runs b
|
||||||
|
runner.fail(a);
|
||||||
|
|
||||||
|
// par re-scheduled
|
||||||
|
runner.tick(); // sees a failed → par fails
|
||||||
|
expect(world.has(par, Failed)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty parallel succeeds immediately", () => {
|
||||||
|
const par = makeParallel(world);
|
||||||
|
|
||||||
|
runner.schedule(par);
|
||||||
|
runner.tick();
|
||||||
|
|
||||||
|
expect(world.has(par, Succeeded)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Random ──────────────────────────────────────────
|
||||||
|
describe("Random tasks", () => {
|
||||||
|
let world: World;
|
||||||
|
let runner: TaskRunner;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
runner = new TaskRunner(world);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks one child and succeeds/fails with it", () => {
|
||||||
|
const rand = makeRandom(world);
|
||||||
|
const a = makeWait(world, rand);
|
||||||
|
const b = makeWait(world, rand);
|
||||||
|
|
||||||
|
runner.schedule(rand);
|
||||||
|
runner.tick();
|
||||||
|
|
||||||
|
// 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, Running) ? a : b;
|
||||||
|
runner.succeed(picked);
|
||||||
|
|
||||||
|
expect(world.has(rand, Succeeded)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when picked child fails", () => {
|
||||||
|
const rand = makeRandom(world);
|
||||||
|
const a = makeWait(world, rand);
|
||||||
|
|
||||||
|
runner.schedule(rand);
|
||||||
|
runner.tick(); // schedules a (only child)
|
||||||
|
runner.tick(); // runs a
|
||||||
|
runner.fail(a);
|
||||||
|
runner.tick(); // random fails
|
||||||
|
|
||||||
|
expect(world.has(rand, Failed)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty random does nothing (no children to pick)", () => {
|
||||||
|
const rand = makeRandom(world);
|
||||||
|
|
||||||
|
runner.schedule(rand);
|
||||||
|
runner.tick();
|
||||||
|
|
||||||
|
// No children, so no status change
|
||||||
|
expect(world.has(rand, Succeeded)).toBe(false);
|
||||||
|
expect(world.has(rand, Failed)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Cycle ──────────────────────────────────────────
|
||||||
|
describe("Cycle tasks", () => {
|
||||||
|
let world: World;
|
||||||
|
let runner: TaskRunner;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
runner = new TaskRunner(world);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-runs child after it succeeds", () => {
|
||||||
|
const cyc = makeCycle(world);
|
||||||
|
const action = makeWait(world, cyc);
|
||||||
|
|
||||||
|
let waitCount = 0;
|
||||||
|
runner.onWait = () => waitCount++;
|
||||||
|
|
||||||
|
runner.schedule(cyc);
|
||||||
|
|
||||||
|
runner.tick();
|
||||||
|
expect(waitCount).toBe(1);
|
||||||
|
runner.succeed(action);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
runner.tick();
|
||||||
|
expect(waitCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-runs child after it fails", () => {
|
||||||
|
const cyc = makeCycle(world);
|
||||||
|
const action = makeWait(world, cyc);
|
||||||
|
|
||||||
|
let waitCount = 0;
|
||||||
|
runner.onWait = () => waitCount++;
|
||||||
|
|
||||||
|
runner.schedule(cyc);
|
||||||
|
runner.tick();
|
||||||
|
runner.fail(action);
|
||||||
|
|
||||||
|
expect(world.has(cyc, Scheduled)).toBe(true);
|
||||||
|
runner.tick();
|
||||||
|
expect(waitCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never terminates on its own", () => {
|
||||||
|
const cyc = makeCycle(world);
|
||||||
|
const action = makeWait(world, cyc);
|
||||||
|
|
||||||
|
runner.schedule(cyc);
|
||||||
|
runner.tick();
|
||||||
|
runner.succeed(action);
|
||||||
|
|
||||||
|
// After many cycles, cycle is still not terminal
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
runner.tick();
|
||||||
|
runner.succeed(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 cyc = makeCycle(world);
|
||||||
|
const action = makeWait(world, cyc);
|
||||||
|
|
||||||
|
runner.schedule(cyc);
|
||||||
|
runner.tick();
|
||||||
|
|
||||||
|
runner.cancel(cyc);
|
||||||
|
|
||||||
|
expect(world.has(cyc, Cancelled)).toBe(true);
|
||||||
|
expect(world.has(action, Cancelled)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty cycle does nothing", () => {
|
||||||
|
const cyc = makeCycle(world);
|
||||||
|
|
||||||
|
runner.schedule(cyc);
|
||||||
|
runner.tick();
|
||||||
|
|
||||||
|
// No child, so nothing happens
|
||||||
|
expect(world.has(cyc, Succeeded)).toBe(false);
|
||||||
|
expect(world.has(cyc, Failed)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cycle inside sequential advances parent when cancelled", () => {
|
||||||
|
const seq = makeSequential(world);
|
||||||
|
const cyc = makeCycle(world, seq);
|
||||||
|
const action = makeWait(world, cyc);
|
||||||
|
const after = makeWait(world, seq);
|
||||||
|
|
||||||
|
runner.schedule(seq);
|
||||||
|
runner.tick();
|
||||||
|
|
||||||
|
// Cancel the cycle
|
||||||
|
runner.cancel(cyc);
|
||||||
|
|
||||||
|
expect(world.has(seq, Cancelled)).toBe(true);
|
||||||
|
expect(world.has(after, Scheduled)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Selector ────────────────────────────────────────
|
||||||
|
describe("Selector tasks", () => {
|
||||||
|
let world: World;
|
||||||
|
let runner: TaskRunner;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
runner = new TaskRunner(world);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("succeeds on first child that succeeds", () => {
|
||||||
|
const sel = makeSelector(world);
|
||||||
|
const a = makeWait(world, sel);
|
||||||
|
const b = makeWait(world, sel);
|
||||||
|
|
||||||
|
runner.schedule(sel);
|
||||||
|
runner.tick(); // schedules a
|
||||||
|
runner.tick(); // runs a
|
||||||
|
runner.succeed(a);
|
||||||
|
|
||||||
|
// Selector re-scheduled, sees a succeeded
|
||||||
|
runner.tick();
|
||||||
|
expect(world.has(sel, Succeeded)).toBe(true);
|
||||||
|
// b was never touched
|
||||||
|
expect(world.has(b, Scheduled)).toBe(false);
|
||||||
|
expect(world.has(b, Running)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tries next child when previous fails", () => {
|
||||||
|
const sel = makeSelector(world);
|
||||||
|
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);
|
||||||
|
|
||||||
|
expect(world.has(b, Running)).toBe(true);
|
||||||
|
|
||||||
|
runner.fail(b);
|
||||||
|
expect(world.has(c, Running)).toBe(true);
|
||||||
|
|
||||||
|
runner.succeed(c);
|
||||||
|
expect(world.has(sel, Succeeded)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when all children fail", () => {
|
||||||
|
const sel = makeSelector(world);
|
||||||
|
const a = makeWait(world, sel);
|
||||||
|
const b = makeWait(world, sel);
|
||||||
|
|
||||||
|
runner.schedule(sel);
|
||||||
|
runner.tick(); // schedules a
|
||||||
|
runner.tick(); // runs a
|
||||||
|
runner.fail(a);
|
||||||
|
runner.tick(); // schedules b
|
||||||
|
runner.tick(); // runs b
|
||||||
|
runner.fail(b);
|
||||||
|
runner.tick(); // all failed
|
||||||
|
|
||||||
|
expect(world.has(sel, Failed)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty selector fails immediately", () => {
|
||||||
|
const sel = makeSelector(world);
|
||||||
|
|
||||||
|
runner.schedule(sel);
|
||||||
|
runner.tick();
|
||||||
|
|
||||||
|
expect(world.has(sel, Failed)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips cancelled children and continues", () => {
|
||||||
|
const sel = makeSelector(world);
|
||||||
|
const a = makeWait(world, sel);
|
||||||
|
const b = makeWait(world, sel);
|
||||||
|
|
||||||
|
runner.schedule(sel);
|
||||||
|
runner.tick(); // schedules a
|
||||||
|
runner.tick(); // runs a
|
||||||
|
runner.cancel(a);
|
||||||
|
|
||||||
|
expect(world.has(b, Running)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Cancel ──────────────────────────────────────────
|
||||||
|
describe("Cancel", () => {
|
||||||
|
let world: World;
|
||||||
|
let runner: TaskRunner;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
runner = new TaskRunner(world);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels all descendants", () => {
|
||||||
|
const root = makeSequential(world);
|
||||||
|
const child = makeParallel(world, root);
|
||||||
|
const a = makeWait(world, child);
|
||||||
|
const b = makeWait(world, child);
|
||||||
|
|
||||||
|
runner.schedule(root);
|
||||||
|
runner.tick(); // schedules child
|
||||||
|
runner.tick(); // schedules a, b
|
||||||
|
runner.tick(); // runs a
|
||||||
|
runner.tick(); // runs b
|
||||||
|
|
||||||
|
runner.cancel(root);
|
||||||
|
|
||||||
|
expect(world.has(root, Cancelled)).toBe(true);
|
||||||
|
expect(world.has(child, Cancelled)).toBe(true);
|
||||||
|
expect(world.has(a, Cancelled)).toBe(true);
|
||||||
|
expect(world.has(b, Cancelled)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel propagates to parent", () => {
|
||||||
|
const root = makeSequential(world);
|
||||||
|
const child = makeSequential(world, root);
|
||||||
|
const action = makeWait(world, child);
|
||||||
|
|
||||||
|
runner.schedule(root);
|
||||||
|
runner.tick(); // schedules child
|
||||||
|
runner.tick(); // schedules action
|
||||||
|
runner.tick(); // runs action
|
||||||
|
|
||||||
|
runner.cancel(action);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
runner.tick();
|
||||||
|
expect(world.has(root, Cancelled)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Multi-frame wait tasks ──────────────────────────────
|
||||||
|
describe("Multi-frame wait tasks", () => {
|
||||||
|
let world: World;
|
||||||
|
let runner: TaskRunner;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
runner = new TaskRunner(world);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wait task stays Running across ticks until explicitly finished", () => {
|
||||||
|
const action = makeWait(world);
|
||||||
|
|
||||||
|
runner.schedule(action);
|
||||||
|
runner.tick();
|
||||||
|
|
||||||
|
expect(world.has(action, Running)).toBe(true);
|
||||||
|
|
||||||
|
// Tick again — action is Running, not Scheduled, so nothing happens
|
||||||
|
runner.tick();
|
||||||
|
expect(world.has(action, Running)).toBe(true);
|
||||||
|
|
||||||
|
// External system finishes it
|
||||||
|
runner.succeed(action);
|
||||||
|
expect(world.has(action, Succeeded)).toBe(true);
|
||||||
|
expect(world.has(action, Running)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sequential waits for multi-frame wait before advancing", () => {
|
||||||
|
const seq = makeSequential(world);
|
||||||
|
const a = makeWait(world, seq);
|
||||||
|
const b = makeWait(world, seq);
|
||||||
|
|
||||||
|
runner.schedule(seq);
|
||||||
|
runner.tick(); // schedules a
|
||||||
|
runner.tick(); // runs a → Running
|
||||||
|
|
||||||
|
// Tick several times — seq should not advance
|
||||||
|
runner.tick();
|
||||||
|
runner.tick();
|
||||||
|
expect(world.has(b, Scheduled)).toBe(false);
|
||||||
|
expect(world.has(b, Running)).toBe(false);
|
||||||
|
|
||||||
|
// Finish a; parent propagation starts b immediately.
|
||||||
|
runner.succeed(a);
|
||||||
|
expect(world.has(b, Running)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Edge cases ──────────────────────────────────────
|
||||||
|
describe("Edge cases", () => {
|
||||||
|
it("schedule() is a no-op on non-task entities", () => {
|
||||||
|
const world = new World();
|
||||||
|
const runner = new TaskRunner(world);
|
||||||
|
const e = world.spawn();
|
||||||
|
|
||||||
|
runner.schedule(e);
|
||||||
|
expect(world.has(e, Scheduled)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("succeed/fail/cancel are no-ops on non-task entities", () => {
|
||||||
|
const world = new World();
|
||||||
|
const runner = new TaskRunner(world);
|
||||||
|
const e = world.spawn();
|
||||||
|
|
||||||
|
expect(() => runner.succeed(e)).not.toThrow();
|
||||||
|
expect(() => runner.fail(e)).not.toThrow();
|
||||||
|
expect(() => runner.cancel(e)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tick is a no-op when nothing is scheduled", () => {
|
||||||
|
const world = new World();
|
||||||
|
const runner = new TaskRunner(world);
|
||||||
|
const action = makeWait(world);
|
||||||
|
|
||||||
|
// Wait task exists but is not Scheduled
|
||||||
|
expect(() => runner.tick()).not.toThrow();
|
||||||
|
expect(world.has(action, Running)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deeply nested tree works correctly", () => {
|
||||||
|
const world = new World();
|
||||||
|
const runner = new TaskRunner(world);
|
||||||
|
|
||||||
|
const root = makeSequential(world);
|
||||||
|
const mid = makeSequential(world, root);
|
||||||
|
const action = makeWait(world, mid);
|
||||||
|
|
||||||
|
runner.schedule(root);
|
||||||
|
|
||||||
|
runner.tick();
|
||||||
|
expect(world.has(action, Running)).toBe(true);
|
||||||
|
|
||||||
|
runner.succeed(action);
|
||||||
|
expect(world.has(mid, Succeeded)).toBe(true);
|
||||||
|
expect(world.has(root, Succeeded)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { World, defineComponent, type Entity } from "../src/index";
|
||||||
|
import { CommandQueue } from "../src/commands/command-queue";
|
||||||
|
|
||||||
|
// ── Components ──────────────────────────────────────
|
||||||
|
const Health = defineComponent("health", { current: 100, max: 100 });
|
||||||
|
const DamageCmd = defineComponent("damageCmd", { amount: 0 });
|
||||||
|
const HealCmd = defineComponent("healCmd", { amount: 0 });
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────
|
||||||
|
async function settled() {
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Basic command dispatch ──────────────────────────
|
||||||
|
describe("CommandQueue", () => {
|
||||||
|
let world: World;
|
||||||
|
let queue: CommandQueue;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
queue = new CommandQueue(world);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches commands to registered handlers", () => {
|
||||||
|
const results: { entity: Entity; amount: number }[] = [];
|
||||||
|
|
||||||
|
queue.handle(DamageCmd, (cmd, entity) => {
|
||||||
|
results.push({ entity: entity!, amount: cmd.amount });
|
||||||
|
});
|
||||||
|
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, DamageCmd, { amount: 25 });
|
||||||
|
|
||||||
|
queue.execute();
|
||||||
|
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].amount).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the command component after dispatch", () => {
|
||||||
|
queue.handle(DamageCmd, () => {});
|
||||||
|
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, DamageCmd, { amount: 10 });
|
||||||
|
|
||||||
|
expect(world.has(e, DamageCmd)).toBe(true);
|
||||||
|
|
||||||
|
queue.execute();
|
||||||
|
|
||||||
|
expect(world.has(e, DamageCmd)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("destroys an entity when it becomes empty after command removal", () => {
|
||||||
|
queue.handle(DamageCmd, () => {});
|
||||||
|
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, DamageCmd, { amount: 10 });
|
||||||
|
|
||||||
|
queue.execute();
|
||||||
|
|
||||||
|
expect(world.isAlive(e)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT destroy entities that still have other components", () => {
|
||||||
|
queue.handle(DamageCmd, () => {});
|
||||||
|
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Health);
|
||||||
|
world.add(e, DamageCmd, { amount: 10 });
|
||||||
|
|
||||||
|
queue.execute();
|
||||||
|
|
||||||
|
expect(world.isAlive(e)).toBe(true);
|
||||||
|
expect(world.has(e, Health)).toBe(true);
|
||||||
|
expect(world.has(e, DamageCmd)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple entities with the same command type", () => {
|
||||||
|
const hits: Entity[] = [];
|
||||||
|
queue.handle(DamageCmd, (_, entity) => {
|
||||||
|
hits.push(entity!);
|
||||||
|
});
|
||||||
|
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
const c = world.spawn();
|
||||||
|
world.add(a, DamageCmd, { amount: 5 });
|
||||||
|
world.add(b, DamageCmd, { amount: 10 });
|
||||||
|
world.add(c, DamageCmd, { amount: 15 });
|
||||||
|
|
||||||
|
queue.execute();
|
||||||
|
|
||||||
|
expect(hits).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple registered command types", () => {
|
||||||
|
const damages: number[] = [];
|
||||||
|
const heals: number[] = [];
|
||||||
|
|
||||||
|
queue
|
||||||
|
.handle(DamageCmd, (cmd) => damages.push(cmd.amount))
|
||||||
|
.handle(HealCmd, (cmd) => heals.push(cmd.amount));
|
||||||
|
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.add(a, DamageCmd, { amount: 5 });
|
||||||
|
world.add(b, HealCmd, { amount: 20 });
|
||||||
|
|
||||||
|
queue.execute();
|
||||||
|
|
||||||
|
expect(damages).toEqual([5]);
|
||||||
|
expect(heals).toEqual([20]);
|
||||||
|
expect(world.has(a, DamageCmd)).toBe(false);
|
||||||
|
expect(world.has(b, HealCmd)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when no command entities exist", () => {
|
||||||
|
queue.handle(DamageCmd, () => {
|
||||||
|
throw new Error("should not be called");
|
||||||
|
});
|
||||||
|
|
||||||
|
// No entities with DamageCmd
|
||||||
|
|
||||||
|
expect(() => queue.execute()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when no handlers are registered", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, DamageCmd, { amount: 10 });
|
||||||
|
|
||||||
|
expect(() => queue.execute()).not.toThrow();
|
||||||
|
expect(world.has(e, DamageCmd)).toBe(true); // not consumed
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Interruption ────────────────────────────────────
|
||||||
|
describe("CommandQueue interruption", () => {
|
||||||
|
let world: World;
|
||||||
|
let queue: CommandQueue;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
queue = new CommandQueue(world);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips processing when interrupted", () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
queue.handle(DamageCmd, handler);
|
||||||
|
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, DamageCmd, { amount: 10 });
|
||||||
|
|
||||||
|
// Interrupt with a never-resolving promise
|
||||||
|
queue.interrupt(new Promise(() => {}));
|
||||||
|
|
||||||
|
expect(queue.isInterrupted).toBe(true);
|
||||||
|
|
||||||
|
queue.execute();
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
expect(world.has(e, DamageCmd)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes processing after interruption promise resolves", async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
queue.handle(DamageCmd, handler);
|
||||||
|
|
||||||
|
let resolve!: () => void;
|
||||||
|
const promise = new Promise<void>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.interrupt(promise);
|
||||||
|
expect(queue.isInterrupted).toBe(true);
|
||||||
|
|
||||||
|
queue.execute();
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
await settled();
|
||||||
|
|
||||||
|
expect(queue.isInterrupted).toBe(false);
|
||||||
|
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, DamageCmd, { amount: 10 });
|
||||||
|
|
||||||
|
queue.execute();
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes after all interruption promises settle (including rejections)", async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
queue.handle(DamageCmd, handler);
|
||||||
|
|
||||||
|
queue.interrupt(Promise.reject(new Error("fail")));
|
||||||
|
await settled();
|
||||||
|
expect(queue.isInterrupted).toBe(false);
|
||||||
|
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, DamageCmd, { amount: 10 });
|
||||||
|
queue.execute();
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays interrupted while at least one of several promises is pending", async () => {
|
||||||
|
let resolveA!: () => void;
|
||||||
|
const a = new Promise<void>((r) => {
|
||||||
|
resolveA = r;
|
||||||
|
});
|
||||||
|
const b = Promise.resolve();
|
||||||
|
|
||||||
|
queue.interrupt(a);
|
||||||
|
queue.interrupt(b);
|
||||||
|
|
||||||
|
await settled(); // b resolves
|
||||||
|
expect(queue.isInterrupted).toBe(true); // a still pending
|
||||||
|
|
||||||
|
resolveA();
|
||||||
|
await settled();
|
||||||
|
expect(queue.isInterrupted).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Edge cases ──────────────────────────────────────
|
||||||
|
describe("CommandQueue edge cases", () => {
|
||||||
|
it("handler can safely add components to surviving entity", () => {
|
||||||
|
const world = new World();
|
||||||
|
const queue = new CommandQueue(world);
|
||||||
|
const Flag = defineComponent("flag", { set: false });
|
||||||
|
|
||||||
|
queue.handle(DamageCmd, (_cmd, entity) => {
|
||||||
|
world.add(entity!, Flag, { set: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Health);
|
||||||
|
world.add(e, DamageCmd, { amount: 10 });
|
||||||
|
|
||||||
|
queue.execute();
|
||||||
|
|
||||||
|
expect(world.has(e, Flag)).toBe(true);
|
||||||
|
expect(world.isAlive(e)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handler can destroy a different entity", () => {
|
||||||
|
const world = new World();
|
||||||
|
const queue = new CommandQueue(world);
|
||||||
|
|
||||||
|
const other = world.spawn();
|
||||||
|
queue.handle(DamageCmd, () => {
|
||||||
|
world.destroy(other);
|
||||||
|
});
|
||||||
|
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, DamageCmd, { amount: 10 });
|
||||||
|
|
||||||
|
queue.execute();
|
||||||
|
|
||||||
|
expect(world.isAlive(other)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("chainable .handle() calls", () => {
|
||||||
|
const world = new World();
|
||||||
|
const queue = new CommandQueue(world);
|
||||||
|
|
||||||
|
const a = vi.fn();
|
||||||
|
const b = vi.fn();
|
||||||
|
|
||||||
|
queue
|
||||||
|
.handle(DamageCmd, (_cmd, _entity) => a())
|
||||||
|
.handle(HealCmd, (_cmd, _entity) => b());
|
||||||
|
|
||||||
|
const e1 = world.spawn();
|
||||||
|
world.add(e1, DamageCmd, { amount: 5 });
|
||||||
|
const e2 = world.spawn();
|
||||||
|
world.add(e2, HealCmd, { amount: 10 });
|
||||||
|
|
||||||
|
queue.execute();
|
||||||
|
|
||||||
|
expect(a).toHaveBeenCalled();
|
||||||
|
expect(b).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
World,
|
||||||
|
defineRelationship,
|
||||||
|
type RelationshipUpdate,
|
||||||
|
type WorldEvent,
|
||||||
|
} from "../src/index";
|
||||||
|
|
||||||
|
// ── Relationships ─────────────────────────────────────
|
||||||
|
const ChildOf = defineRelationship("childOf");
|
||||||
|
const Targeting = defineRelationship("targeting");
|
||||||
|
const Inside = defineRelationship("inside");
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────
|
||||||
|
function collectEvents(world: World): WorldEvent[] {
|
||||||
|
const log: WorldEvent[] = [];
|
||||||
|
world.events$.subscribe((e: WorldEvent) => log.push(e));
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRelUpdates(obs$: {
|
||||||
|
subscribe: Function;
|
||||||
|
}): RelationshipUpdate[] {
|
||||||
|
const log: RelationshipUpdate[] = [];
|
||||||
|
obs$.subscribe((u: RelationshipUpdate) => log.push(u));
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Basic relate / unrelate ───────────────────────────
|
||||||
|
describe("Relationships", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relates two entities", () => {
|
||||||
|
const parent = world.spawn();
|
||||||
|
const child = world.spawn();
|
||||||
|
|
||||||
|
world.relate(child, ChildOf, parent);
|
||||||
|
expect(world.getRelated(child, ChildOf)).toBe(parent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRelated returns undefined when no relationship", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
expect(world.getRelated(e, ChildOf)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRelatedTo returns reverse lookup", () => {
|
||||||
|
const parent = world.spawn();
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
|
||||||
|
world.relate(a, ChildOf, parent);
|
||||||
|
world.relate(b, ChildOf, parent);
|
||||||
|
|
||||||
|
const children = [...world.getRelatedTo(parent, ChildOf)];
|
||||||
|
expect(children).toHaveLength(2);
|
||||||
|
expect(children).toContain(a);
|
||||||
|
expect(children).toContain(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRelatedTo returns empty when no edges", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
expect([...world.getRelatedTo(e, ChildOf)]).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unrelate removes the relationship", () => {
|
||||||
|
const parent = world.spawn();
|
||||||
|
const child = world.spawn();
|
||||||
|
|
||||||
|
world.relate(child, ChildOf, parent);
|
||||||
|
world.unrelate(child, ChildOf);
|
||||||
|
|
||||||
|
expect(world.getRelated(child, ChildOf)).toBeUndefined();
|
||||||
|
expect([...world.getRelatedTo(parent, ChildOf)]).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unrelate is idempotent", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
expect(() => world.unrelate(e, ChildOf)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relate replaces existing relationship", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
const c = world.spawn();
|
||||||
|
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
expect(world.getRelated(a, ChildOf)).toBe(b);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Events ────────────────────────────────────────────
|
||||||
|
describe("Relationship events", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits relationshipAdded event", () => {
|
||||||
|
const events = collectEvents(world);
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
const ev = events.find((e) => e.type === "relationshipAdded")!;
|
||||||
|
expect(ev).toMatchObject({
|
||||||
|
type: "relationshipAdded",
|
||||||
|
source: a,
|
||||||
|
target: b,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits relationshipRemoved on unrelate", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
const events = collectEvents(world);
|
||||||
|
world.unrelate(a, ChildOf);
|
||||||
|
|
||||||
|
const ev = events.find((e) => e.type === "relationshipRemoved")!;
|
||||||
|
expect(ev).toMatchObject({
|
||||||
|
type: "relationshipRemoved",
|
||||||
|
source: a,
|
||||||
|
target: b,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits relationshipRemoved when replacing an edge", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
const c = world.spawn();
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
const events = collectEvents(world);
|
||||||
|
world.relate(a, ChildOf, c);
|
||||||
|
|
||||||
|
const removed = events.filter((e) => e.type === "relationshipRemoved");
|
||||||
|
const added = events.filter((e) => e.type === "relationshipAdded");
|
||||||
|
|
||||||
|
expect(removed).toHaveLength(1);
|
||||||
|
expect(removed[0]).toMatchObject({ source: a, target: b });
|
||||||
|
expect(added).toHaveLength(1);
|
||||||
|
expect(added[0]).toMatchObject({ source: a, target: c });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Observable relationships ──────────────────────────
|
||||||
|
describe("Observable relationships", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits added on relate", () => {
|
||||||
|
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].added).toEqual([{ source: a, target: b }]);
|
||||||
|
expect(log[0].removed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits removed on unrelate", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
||||||
|
world.unrelate(a, ChildOf);
|
||||||
|
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].removed).toEqual([{ source: a, target: b }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits removed+added on replacement", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
const c = world.spawn();
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
||||||
|
world.relate(a, ChildOf, c);
|
||||||
|
|
||||||
|
// Should have two updates: one removed, one added
|
||||||
|
expect(log).toHaveLength(2);
|
||||||
|
expect(log[0].removed).toEqual([{ source: a, target: b }]);
|
||||||
|
expect(log[1].added).toEqual([{ source: a, target: c }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds with existing relationships", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
||||||
|
|
||||||
|
// Unrelate should trigger removed — proving seed worked
|
||||||
|
world.unrelate(a, ChildOf);
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].removed).toEqual([{ source: a, target: b }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("observers are scoped to relationship type", () => {
|
||||||
|
const childLog = collectRelUpdates(world.observeRelated(ChildOf));
|
||||||
|
const targetLog = collectRelUpdates(world.observeRelated(Targeting));
|
||||||
|
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
expect(childLog).toHaveLength(1);
|
||||||
|
expect(targetLog).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Destroy cleanup ───────────────────────────────────
|
||||||
|
describe("Destroy cleanup", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes edges when source is destroyed", () => {
|
||||||
|
const parent = world.spawn();
|
||||||
|
const child = world.spawn();
|
||||||
|
world.relate(child, ChildOf, parent);
|
||||||
|
|
||||||
|
world.destroy(child);
|
||||||
|
expect(world.getRelated(child, ChildOf)).toBeUndefined();
|
||||||
|
expect([...world.getRelatedTo(parent, ChildOf)]).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes edges when target is destroyed", () => {
|
||||||
|
const parent = world.spawn();
|
||||||
|
const child = world.spawn();
|
||||||
|
world.relate(child, ChildOf, parent);
|
||||||
|
|
||||||
|
world.destroy(parent);
|
||||||
|
expect([...world.getRelatedTo(parent, ChildOf)]).toEqual([]);
|
||||||
|
expect(world.getRelated(child, ChildOf)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits relationshipRemoved events on destroy", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
const c = world.spawn();
|
||||||
|
|
||||||
|
world.relate(a, Targeting, b);
|
||||||
|
world.relate(a, ChildOf, c);
|
||||||
|
|
||||||
|
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
||||||
|
const tLog = collectRelUpdates(world.observeRelated(Targeting));
|
||||||
|
|
||||||
|
world.destroy(a);
|
||||||
|
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].removed).toEqual([{ source: a, target: c }]);
|
||||||
|
|
||||||
|
expect(tLog).toHaveLength(1);
|
||||||
|
expect(tLog[0].removed).toEqual([{ source: a, target: b }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects cross-relationship observers when destroying target", () => {
|
||||||
|
const parent = world.spawn();
|
||||||
|
const child = world.spawn();
|
||||||
|
world.relate(child, ChildOf, parent);
|
||||||
|
|
||||||
|
const log = collectRelUpdates(world.observeRelated(ChildOf));
|
||||||
|
world.destroy(parent);
|
||||||
|
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].removed).toEqual([{ source: child, target: parent }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles destroy when entity is source for multiple relationships", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
const c = world.spawn();
|
||||||
|
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
world.relate(a, Targeting, c);
|
||||||
|
|
||||||
|
const childLog = collectRelUpdates(world.observeRelated(ChildOf));
|
||||||
|
const targetLog = collectRelUpdates(world.observeRelated(Targeting));
|
||||||
|
|
||||||
|
world.destroy(a);
|
||||||
|
|
||||||
|
expect(childLog).toHaveLength(1);
|
||||||
|
expect(childLog[0].removed).toEqual([{ source: a, target: b }]);
|
||||||
|
|
||||||
|
expect(targetLog).toHaveLength(1);
|
||||||
|
expect(targetLog[0].removed).toEqual([{ source: a, target: c }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Multiple relationship types ──────────────────────
|
||||||
|
describe("Multiple relationships", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an entity can have different relationship types simultaneously", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
|
||||||
|
world.relate(e, ChildOf, a);
|
||||||
|
world.relate(e, Targeting, b);
|
||||||
|
|
||||||
|
expect(world.getRelated(e, ChildOf)).toBe(a);
|
||||||
|
expect(world.getRelated(e, Targeting)).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relationships of different types don't interfere", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
const c = world.spawn();
|
||||||
|
|
||||||
|
world.relate(a, ChildOf, b);
|
||||||
|
world.relate(a, Targeting, c);
|
||||||
|
|
||||||
|
world.unrelate(a, ChildOf);
|
||||||
|
|
||||||
|
expect(world.getRelated(a, ChildOf)).toBeUndefined();
|
||||||
|
// Targeting should still be intact
|
||||||
|
expect(world.getRelated(a, Targeting)).toBe(c);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Dead entities ─────────────────────────────────────
|
||||||
|
describe("Dead entity safety", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relate throws on dead source", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.destroy(a);
|
||||||
|
expect(() => world.relate(a, ChildOf, b)).toThrow("not alive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relate throws on dead target", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.destroy(b);
|
||||||
|
expect(() => world.relate(a, ChildOf, b)).toThrow("not alive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRelated returns undefined for dead entity", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.destroy(e);
|
||||||
|
expect(world.getRelated(e, ChildOf)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRelatedTo returns empty for dead entity", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.destroy(e);
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
World,
|
||||||
|
defineComponent,
|
||||||
|
defineRelationship,
|
||||||
|
type WorldSnapshot,
|
||||||
|
query,
|
||||||
|
type QueryUpdate,
|
||||||
|
type RelationshipUpdate,
|
||||||
|
type WorldEvent,
|
||||||
|
type ComponentDef,
|
||||||
|
type RelationshipDef,
|
||||||
|
} from "../src/index";
|
||||||
|
|
||||||
|
// ── Definitions ─────────────────────────────────────
|
||||||
|
const Position = defineComponent("position", { x: 0, y: 0 });
|
||||||
|
const Velocity = defineComponent("velocity", { vx: 0, vy: 0 });
|
||||||
|
const Health = defineComponent("health", { current: 100, max: 100 });
|
||||||
|
const Shield = defineComponent("shield", { armor: 5, broken: false });
|
||||||
|
const Name = defineComponent("name", { value: "" });
|
||||||
|
const Team = defineComponent("team", { id: 0, color: "#fff" });
|
||||||
|
|
||||||
|
const ChildOf = defineRelationship("childOf");
|
||||||
|
const Targeting = defineRelationship("targeting");
|
||||||
|
const OwnedBy = defineRelationship("ownedBy");
|
||||||
|
|
||||||
|
// ── Serialization helpers ────────────────────────────
|
||||||
|
function roundTrip(
|
||||||
|
world: World,
|
||||||
|
components: ComponentDef<any>[] = [
|
||||||
|
Position,
|
||||||
|
Velocity,
|
||||||
|
Health,
|
||||||
|
Shield,
|
||||||
|
Name,
|
||||||
|
Team,
|
||||||
|
],
|
||||||
|
rels: RelationshipDef[] = [ChildOf, Targeting, OwnedBy],
|
||||||
|
): World {
|
||||||
|
const json = JSON.stringify(world.toJSON());
|
||||||
|
return World.fromJSON(JSON.parse(json), components, rels);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedIds(snap: WorldSnapshot): string[] {
|
||||||
|
return Object.keys(snap.entities).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────
|
||||||
|
describe("Serialization", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes components by name", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Position, { x: 10, y: 20 });
|
||||||
|
world.add(e, Velocity, { vx: 1, vy: 0 });
|
||||||
|
|
||||||
|
const snap = world.toJSON();
|
||||||
|
expect(snap.entities).toHaveProperty("e0");
|
||||||
|
expect(snap.entities.e0.position).toEqual({ x: 10, y: 20 });
|
||||||
|
expect(snap.entities.e0.velocity).toEqual({ vx: 1, vy: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips components through JSON", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Position, { x: 42, y: 99 });
|
||||||
|
world.add(e, Velocity, { vx: 2, vy: -1 });
|
||||||
|
|
||||||
|
const loaded = roundTrip(world);
|
||||||
|
|
||||||
|
const loadedEnts = [...loaded.query(query(Position, Velocity))];
|
||||||
|
expect(loadedEnts).toHaveLength(1);
|
||||||
|
|
||||||
|
const loadedE = loadedEnts[0];
|
||||||
|
expect(loaded.get(loadedE, Position)).toEqual({ x: 42, y: 99 });
|
||||||
|
expect(loaded.get(loadedE, Velocity)).toEqual({ vx: 2, vy: -1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple entities", () => {
|
||||||
|
world.spawn();
|
||||||
|
world.spawn();
|
||||||
|
|
||||||
|
const snap = world.toJSON();
|
||||||
|
const entries = Object.entries(snap.entities);
|
||||||
|
expect(entries).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes relationships", () => {
|
||||||
|
const parent = world.spawn();
|
||||||
|
const child = world.spawn();
|
||||||
|
world.relate(child, ChildOf, parent);
|
||||||
|
|
||||||
|
const snap = world.toJSON();
|
||||||
|
expect(snap.relationships.childOf).toHaveProperty("e1");
|
||||||
|
expect(snap.relationships.childOf.e1).toBe("e0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips relationships", () => {
|
||||||
|
const parent = world.spawn();
|
||||||
|
const child = world.spawn();
|
||||||
|
world.relate(child, ChildOf, parent);
|
||||||
|
|
||||||
|
const loaded = roundTrip(world);
|
||||||
|
|
||||||
|
const reSnap = loaded.toJSON();
|
||||||
|
expect(Object.keys(reSnap.relationships.childOf)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on unknown component in snapshot", () => {
|
||||||
|
const snap: WorldSnapshot = {
|
||||||
|
entities: { e0: { unknownComp: { x: 1 } } },
|
||||||
|
relationships: {},
|
||||||
|
};
|
||||||
|
expect(() => World.fromJSON(snap, [Position])).toThrow(
|
||||||
|
'Unknown component "unknownComp"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on unknown relationship in snapshot", () => {
|
||||||
|
const snap: WorldSnapshot = {
|
||||||
|
entities: { e0: { position: { x: 0, y: 0 } } },
|
||||||
|
relationships: { unknownRel: { e0: "e1" } },
|
||||||
|
};
|
||||||
|
expect(() => World.fromJSON(snap, [Position], [ChildOf])).toThrow(
|
||||||
|
'Unknown relationship "unknownRel"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves entities with no components", () => {
|
||||||
|
world.spawn();
|
||||||
|
const snap = world.toJSON();
|
||||||
|
expect(snap.entities).toHaveProperty("e0");
|
||||||
|
expect(snap.entities.e0).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves bare entities on round-trip", () => {
|
||||||
|
world.spawn();
|
||||||
|
const loaded = roundTrip(world);
|
||||||
|
expect(loaded.entityCount).toBe(1);
|
||||||
|
|
||||||
|
const withPos = [...loaded.query(query(Position))];
|
||||||
|
expect(withPos).toHaveLength(0);
|
||||||
|
expect(loaded.entityCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entity IDs are re-sequential (hole collapsed)", () => {
|
||||||
|
world.spawn(); // e0
|
||||||
|
const b = world.spawn();
|
||||||
|
world.spawn(); // e2
|
||||||
|
world.destroy(b); // hole at e1
|
||||||
|
|
||||||
|
const snap = world.toJSON();
|
||||||
|
// Holes are collapsed during serialization
|
||||||
|
expect(sortedIds(snap)).toEqual(["e0", "e1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips JSON stringify and parse", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
world.add(a, Position, { x: 10, y: 20 });
|
||||||
|
world.add(a, Health, { current: 75, max: 100 });
|
||||||
|
|
||||||
|
const loaded = roundTrip(world);
|
||||||
|
const reSnap = loaded.toJSON();
|
||||||
|
|
||||||
|
expect(reSnap.entities.e0.position).toEqual({ x: 10, y: 20 });
|
||||||
|
expect(reSnap.entities.e0.health).toEqual({ current: 75, max: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty world serializes to empty snapshot", () => {
|
||||||
|
const snap = world.toJSON();
|
||||||
|
expect(snap.entities).toEqual({});
|
||||||
|
expect(snap.relationships).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Extended: rich mixed state ───────────────────────
|
||||||
|
describe("Serialization — complex state", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
function setupRichWorld() {
|
||||||
|
const w = new World();
|
||||||
|
|
||||||
|
// Player with many components
|
||||||
|
const player = w.spawn();
|
||||||
|
w.add(player, Position, { x: 100, y: 200 });
|
||||||
|
w.add(player, Velocity, { vx: 0, vy: 0 });
|
||||||
|
w.add(player, Health, { current: 85, max: 100 });
|
||||||
|
w.add(player, Shield, { armor: 20, broken: false });
|
||||||
|
w.add(player, Name, { value: "Hero" });
|
||||||
|
w.add(player, Team, { id: 1, color: "#ff0000" });
|
||||||
|
|
||||||
|
// Enemy
|
||||||
|
const enemy = w.spawn();
|
||||||
|
w.add(enemy, Position, { x: 500, y: 300 });
|
||||||
|
w.add(enemy, Health, { current: 50, max: 50 });
|
||||||
|
w.add(enemy, Team, { id: 2, color: "#0000ff" });
|
||||||
|
|
||||||
|
// Bullet (few components)
|
||||||
|
const bullet = w.spawn();
|
||||||
|
w.add(bullet, Position, { x: 100, y: 200 });
|
||||||
|
w.add(bullet, Velocity, { vx: 10, vy: 0 });
|
||||||
|
|
||||||
|
// Bare entity
|
||||||
|
w.spawn();
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
w.relate(bullet, OwnedBy, player);
|
||||||
|
w.relate(enemy, Targeting, player);
|
||||||
|
|
||||||
|
return { w, player, enemy, bullet };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("round-trips a rich world", () => {
|
||||||
|
const { w, player, enemy, bullet } = setupRichWorld();
|
||||||
|
const loaded = roundTrip(w);
|
||||||
|
|
||||||
|
expect(loaded.entityCount).toBe(4);
|
||||||
|
|
||||||
|
// Find player by Name component
|
||||||
|
const players = [...loaded.query(query(Name))];
|
||||||
|
expect(players).toHaveLength(1);
|
||||||
|
const p = players[0];
|
||||||
|
expect(loaded.get(p, Position)).toEqual({ x: 100, y: 200 });
|
||||||
|
expect(loaded.get(p, Velocity)).toEqual({ vx: 0, vy: 0 });
|
||||||
|
expect(loaded.get(p, Health)).toEqual({ current: 85, max: 100 });
|
||||||
|
expect(loaded.get(p, Shield)).toEqual({ armor: 20, broken: false });
|
||||||
|
expect(loaded.get(p, Name)).toEqual({ value: "Hero" });
|
||||||
|
expect(loaded.get(p, Team)).toEqual({ id: 1, color: "#ff0000" });
|
||||||
|
|
||||||
|
// Find enemy
|
||||||
|
const enemies = [...loaded.query(query(Health, Team))].filter(
|
||||||
|
(e) => !loaded.has(e, Name),
|
||||||
|
);
|
||||||
|
expect(enemies).toHaveLength(1);
|
||||||
|
const en = enemies[0];
|
||||||
|
expect(loaded.get(en, Health)).toEqual({ current: 50, max: 50 });
|
||||||
|
|
||||||
|
// Bullets
|
||||||
|
const bullets = [...loaded.query(query(Position, Velocity))].filter(
|
||||||
|
(e) =>
|
||||||
|
!loaded.has(e, Health) &&
|
||||||
|
!loaded.has(e, Name) &&
|
||||||
|
!loaded.has(e, Shield),
|
||||||
|
);
|
||||||
|
expect(bullets).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves relationships in rich world", () => {
|
||||||
|
const { w } = setupRichWorld();
|
||||||
|
const loaded = roundTrip(w);
|
||||||
|
|
||||||
|
const snap = loaded.toJSON();
|
||||||
|
|
||||||
|
// Verify relationship structure exists
|
||||||
|
expect(snap.relationships).toHaveProperty("ownedBy");
|
||||||
|
expect(snap.relationships).toHaveProperty("targeting");
|
||||||
|
|
||||||
|
const ownedByEdges = snap.relationships.ownedBy;
|
||||||
|
const targetingEdges = snap.relationships.targeting;
|
||||||
|
|
||||||
|
// OwnedBy: exactly one edge
|
||||||
|
expect(Object.keys(ownedByEdges)).toHaveLength(1);
|
||||||
|
// Targeting: exactly one edge
|
||||||
|
expect(Object.keys(targetingEdges)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relationships reference correct entities after round-trip", () => {
|
||||||
|
const { w } = setupRichWorld();
|
||||||
|
const snap = w.toJSON();
|
||||||
|
|
||||||
|
// Let's trace: bullet has Position+Velocity, is OwnedBy
|
||||||
|
// Find bullet's string id
|
||||||
|
const bulletId = Object.keys(snap.entities).find((id) => {
|
||||||
|
const comps = snap.entities[id];
|
||||||
|
return comps.position && comps.velocity && !comps.health;
|
||||||
|
})!;
|
||||||
|
const playerEdge = snap.relationships.ownedBy[bulletId];
|
||||||
|
const playerId =
|
||||||
|
typeof playerEdge === "string" ? playerEdge : playerEdge.target;
|
||||||
|
|
||||||
|
// Player should have a name
|
||||||
|
const playerComps = snap.entities[playerId];
|
||||||
|
expect(playerComps).toHaveProperty("name");
|
||||||
|
expect((playerComps.name as { value: string }).value).toBe("Hero");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Extended: multiple relationship types ────────────
|
||||||
|
describe("Serialization — multiple relationships", () => {
|
||||||
|
it("round-trips multiple relationship types on same entity", () => {
|
||||||
|
const w = new World();
|
||||||
|
const a = w.spawn();
|
||||||
|
const b = w.spawn();
|
||||||
|
const c = w.spawn();
|
||||||
|
|
||||||
|
w.relate(a, ChildOf, b);
|
||||||
|
w.relate(a, Targeting, c);
|
||||||
|
|
||||||
|
const loaded = roundTrip(w);
|
||||||
|
const snap = loaded.toJSON();
|
||||||
|
|
||||||
|
expect(Object.keys(snap.relationships.childOf)).toHaveLength(1);
|
||||||
|
expect(Object.keys(snap.relationships.targeting)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips many-to-one relationships", () => {
|
||||||
|
const w = new World();
|
||||||
|
const parent = w.spawn();
|
||||||
|
const c1 = w.spawn();
|
||||||
|
const c2 = w.spawn();
|
||||||
|
const c3 = w.spawn();
|
||||||
|
|
||||||
|
w.relate(c1, ChildOf, parent);
|
||||||
|
w.relate(c2, ChildOf, parent);
|
||||||
|
w.relate(c3, ChildOf, parent);
|
||||||
|
|
||||||
|
const loaded = roundTrip(w);
|
||||||
|
const snap = loaded.toJSON();
|
||||||
|
|
||||||
|
const edges = snap.relationships.childOf;
|
||||||
|
const children = Object.keys(edges);
|
||||||
|
expect(children).toHaveLength(3);
|
||||||
|
|
||||||
|
// All three should point to the same parent
|
||||||
|
const parentId = edges[children[0]];
|
||||||
|
expect(edges[children[1]]).toBe(parentId);
|
||||||
|
expect(edges[children[2]]).toBe(parentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips replaced relationships correctly", () => {
|
||||||
|
const w = new World();
|
||||||
|
const a = w.spawn();
|
||||||
|
const b = w.spawn();
|
||||||
|
const c = w.spawn();
|
||||||
|
|
||||||
|
w.relate(a, ChildOf, b);
|
||||||
|
w.relate(a, ChildOf, c); // replace b → c
|
||||||
|
|
||||||
|
const loaded = roundTrip(w);
|
||||||
|
const snap = loaded.toJSON();
|
||||||
|
|
||||||
|
const edges = snap.relationships.childOf;
|
||||||
|
expect(Object.keys(edges)).toHaveLength(1);
|
||||||
|
|
||||||
|
// Child pointer resolves forward
|
||||||
|
const aId = Object.keys(snap.entities).find(
|
||||||
|
(id) =>
|
||||||
|
!snap.relationships.childOf[id] &&
|
||||||
|
!Object.values(snap.relationships.childOf).includes(id),
|
||||||
|
);
|
||||||
|
// Actually, find a via exclusion: a has no components, b and c are targets.
|
||||||
|
// Let's just check the edge count is right.
|
||||||
|
expect(Object.keys(edges)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Extended: nested / array data ────────────────────
|
||||||
|
describe("Serialization — nested data", () => {
|
||||||
|
const Inventory = defineComponent("inventory", {
|
||||||
|
items: [] as string[],
|
||||||
|
gold: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const Transform = defineComponent("transform", {
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
scale: { x: 1, y: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips components with array values", () => {
|
||||||
|
const w = new World();
|
||||||
|
const e = w.spawn();
|
||||||
|
w.add(e, Inventory, { items: ["sword", "shield", "potion"], gold: 42 });
|
||||||
|
|
||||||
|
const loaded = roundTrip(w, [Inventory]);
|
||||||
|
const loadedE = [...loaded.query(query(Inventory))][0];
|
||||||
|
|
||||||
|
const inv = loaded.get(loadedE, Inventory);
|
||||||
|
expect(inv.items).toEqual(["sword", "shield", "potion"]);
|
||||||
|
expect(inv.gold).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips components with nested object values", () => {
|
||||||
|
const w = new World();
|
||||||
|
const e = w.spawn();
|
||||||
|
w.add(e, Transform, {
|
||||||
|
position: { x: 10, y: 20 },
|
||||||
|
scale: { x: 2, y: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = roundTrip(w, [Transform]);
|
||||||
|
const loadedE = [...loaded.query(query(Transform))][0];
|
||||||
|
|
||||||
|
const t = loaded.get(loadedE, Transform);
|
||||||
|
expect(t.position).toEqual({ x: 10, y: 20 });
|
||||||
|
expect(t.scale).toEqual({ x: 2, y: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty arrays survive round-trip", () => {
|
||||||
|
const w = new World();
|
||||||
|
const e = w.spawn();
|
||||||
|
w.add(e, Inventory, { items: [], gold: 0 });
|
||||||
|
|
||||||
|
const loaded = roundTrip(w, [Inventory]);
|
||||||
|
const loadedE = [...loaded.query(query(Inventory))][0];
|
||||||
|
|
||||||
|
expect(loaded.get(loadedE, Inventory).items).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Extended: observables still work after load ──────
|
||||||
|
describe("Serialization — observables after load", () => {
|
||||||
|
it("loaded world emits events on mutation", () => {
|
||||||
|
const w = new World();
|
||||||
|
w.spawn();
|
||||||
|
|
||||||
|
const loaded = roundTrip(w);
|
||||||
|
const events: WorldEvent[] = [];
|
||||||
|
loaded.events$.subscribe((e) => events.push(e));
|
||||||
|
|
||||||
|
const e = loaded.spawn();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].type).toBe("spawned");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loaded world query observables work", () => {
|
||||||
|
const w = new World();
|
||||||
|
const e = w.spawn();
|
||||||
|
w.add(e, Position, { x: 1, y: 2 });
|
||||||
|
|
||||||
|
const loaded = roundTrip(w);
|
||||||
|
const updates: QueryUpdate[] = [];
|
||||||
|
loaded.observe(query(Position)).subscribe((u) => {
|
||||||
|
if (u.added.length || u.removed.length || u.changed.length) {
|
||||||
|
updates.push(u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a new Position entity
|
||||||
|
const e2 = loaded.spawn();
|
||||||
|
loaded.add(e2, Position, { x: 3, y: 4 });
|
||||||
|
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
expect(updates[0].added).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loaded world relationship observables work", () => {
|
||||||
|
const w = new World();
|
||||||
|
const a = w.spawn();
|
||||||
|
const b = w.spawn();
|
||||||
|
w.relate(a, ChildOf, b);
|
||||||
|
|
||||||
|
const loaded = roundTrip(w);
|
||||||
|
const relUpdates: RelationshipUpdate[] = [];
|
||||||
|
loaded.observeRelated(ChildOf).subscribe((u) => relUpdates.push(u));
|
||||||
|
|
||||||
|
const c = loaded.spawn();
|
||||||
|
const d = loaded.spawn();
|
||||||
|
loaded.relate(c, ChildOf, d);
|
||||||
|
|
||||||
|
expect(relUpdates).toHaveLength(1);
|
||||||
|
expect(relUpdates[0].added).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Extended: stress ─────────────────────────────────
|
||||||
|
describe("Serialization — stress", () => {
|
||||||
|
it("round-trips 500 entities with mixed components", () => {
|
||||||
|
const w = new World();
|
||||||
|
|
||||||
|
for (let i = 0; i < 500; i++) {
|
||||||
|
const e = w.spawn();
|
||||||
|
w.add(e, Position, { x: i, y: i * 2 });
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
w.add(e, Velocity, { vx: 1, vy: 0 });
|
||||||
|
}
|
||||||
|
if (i % 3 === 0) {
|
||||||
|
w.add(e, Health, { current: i, max: 1000 });
|
||||||
|
}
|
||||||
|
if (i % 5 === 0) {
|
||||||
|
w.add(e, Name, { value: `entity_${i}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add some relationships
|
||||||
|
const all = [...w.query(query(Position))];
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const src = all[i * 2];
|
||||||
|
const tgt = all[i * 2 + 1];
|
||||||
|
if (src && tgt) {
|
||||||
|
w.relate(src, ChildOf, tgt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = roundTrip(w);
|
||||||
|
expect(loaded.entityCount).toBe(500);
|
||||||
|
|
||||||
|
const withPos = [...loaded.query(query(Position))];
|
||||||
|
const withVel = [...loaded.query(query(Velocity))];
|
||||||
|
const withHealth = [...loaded.query(query(Health))];
|
||||||
|
|
||||||
|
expect(withPos).toHaveLength(500);
|
||||||
|
expect(withVel).toHaveLength(250); // every 2nd
|
||||||
|
expect(withHealth).toHaveLength(167); // every 3rd ≈ floor(499/3)+1
|
||||||
|
|
||||||
|
// Verify a few random entities
|
||||||
|
const e0 = withPos[0];
|
||||||
|
expect(loaded.get(e0, Position).x).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repeated round-trips are idempotent", () => {
|
||||||
|
const w = new World();
|
||||||
|
const e = w.spawn();
|
||||||
|
w.add(e, Position, { x: 10, y: 20 });
|
||||||
|
w.add(e, Health, { current: 75, max: 100 });
|
||||||
|
|
||||||
|
const loaded1 = roundTrip(w);
|
||||||
|
const loaded2 = roundTrip(loaded1);
|
||||||
|
|
||||||
|
expect(loaded2.entityCount).toBe(loaded1.entityCount);
|
||||||
|
|
||||||
|
const e2 = [...loaded2.query(query(Position))][0];
|
||||||
|
expect(loaded2.get(e2, Position)).toEqual({ x: 10, y: 20 });
|
||||||
|
expect(loaded2.get(e2, Health)).toEqual({ current: 75, max: 100 });
|
||||||
|
});
|
||||||
|
});
|
||||||
-132
@@ -1,132 +0,0 @@
|
|||||||
import {
|
|
||||||
World,
|
|
||||||
defineComponent,
|
|
||||||
query,
|
|
||||||
QueryUpdate,
|
|
||||||
WorldEvent,
|
|
||||||
} from "../src/index";
|
|
||||||
|
|
||||||
// ── Define components ─────────────────────────────────
|
|
||||||
const Position = defineComponent({ x: 0, y: 0 });
|
|
||||||
const Velocity = defineComponent({ vx: 0, vy: 0 });
|
|
||||||
const Health = defineComponent({ current: 100, max: 100 });
|
|
||||||
const Dead = defineComponent({ timestamp: 0 });
|
|
||||||
|
|
||||||
// Type inference check
|
|
||||||
const _p: { x: number; y: number } = Position.defaults;
|
|
||||||
|
|
||||||
// ── World setup ──────────────────────────────────────
|
|
||||||
const world = new World();
|
|
||||||
|
|
||||||
let events: WorldEvent[] = [];
|
|
||||||
world.events$.subscribe((e) => events.push(e));
|
|
||||||
|
|
||||||
// ── Entity lifecycle ─────────────────────────────────
|
|
||||||
const player = world.spawn();
|
|
||||||
const enemy = world.spawn();
|
|
||||||
|
|
||||||
console.assert(events.length === 2, "spawn events");
|
|
||||||
console.assert(events[0].type === "spawned" && events[0].entity === player);
|
|
||||||
console.assert(events[1].type === "spawned" && events[1].entity === enemy);
|
|
||||||
|
|
||||||
console.assert(world.isAlive(player), "player alive");
|
|
||||||
console.assert(world.isAlive(enemy), "enemy alive");
|
|
||||||
console.assert(world.entityCount === 2, "two entities");
|
|
||||||
|
|
||||||
// ── Add components ───────────────────────────────────
|
|
||||||
const pos = world.add(player, Position, { x: 10, y: 20 });
|
|
||||||
world.add(player, Velocity, { vx: 1, vy: 0 });
|
|
||||||
world.add(enemy, Position, { x: 50, y: 0 });
|
|
||||||
world.add(enemy, Health, { current: 50 });
|
|
||||||
|
|
||||||
console.assert(pos.x === 10 && pos.y === 20, "add with init");
|
|
||||||
console.assert(world.has(player, Position), "has Position");
|
|
||||||
console.assert(world.has(player, Velocity), "has Velocity");
|
|
||||||
console.assert(!world.has(player, Health), "no Health");
|
|
||||||
console.assert(events.length === 6, "component add events");
|
|
||||||
|
|
||||||
// ── Sync query ───────────────────────────────────────
|
|
||||||
const movable = [...world.query(query(Position, Velocity))];
|
|
||||||
console.assert(movable.length === 1, "player only in movable");
|
|
||||||
console.assert(movable[0] === player);
|
|
||||||
|
|
||||||
const allPos = [...world.query(query(Position))];
|
|
||||||
console.assert(allPos.length === 2, "both have Position");
|
|
||||||
|
|
||||||
// ── Observable query ─────────────────────────────────
|
|
||||||
const queryLog: QueryUpdate[] = [];
|
|
||||||
world.observe(query(Position, Velocity)).subscribe((u) => {
|
|
||||||
if (u.added.length || u.removed.length || u.changed.length) {
|
|
||||||
queryLog.push(u);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Mutation + change tracking ───────────────────────
|
|
||||||
world.get(player, Position).x += 5;
|
|
||||||
world.markDirty(player, Position);
|
|
||||||
world.get(player, Velocity).vx *= 2;
|
|
||||||
world.markDirty(player, Velocity);
|
|
||||||
|
|
||||||
// flush should emit componentChanged events and update queries
|
|
||||||
world.flush();
|
|
||||||
|
|
||||||
console.assert(
|
|
||||||
events.some((e) => e.type === "componentChanged"),
|
|
||||||
"change events",
|
|
||||||
);
|
|
||||||
|
|
||||||
// The query observer should have received changed: [player]
|
|
||||||
const lastUpdate = queryLog[queryLog.length - 1];
|
|
||||||
console.assert(
|
|
||||||
lastUpdate.changed.length === 1 && lastUpdate.changed[0] === player,
|
|
||||||
"player in changed",
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Remove component ─────────────────────────────────
|
|
||||||
world.remove(player, Velocity);
|
|
||||||
|
|
||||||
console.assert(!world.has(player, Velocity), "Velocity removed");
|
|
||||||
const movableAfter = [...world.query(query(Position, Velocity))];
|
|
||||||
console.assert(movableAfter.length === 0, "no one movable after remove");
|
|
||||||
|
|
||||||
// The observer should have emitted {removed: [player]}
|
|
||||||
const remUpdate = queryLog[queryLog.length - 1];
|
|
||||||
console.assert(
|
|
||||||
remUpdate.removed.length === 1 && remUpdate.removed[0] === player,
|
|
||||||
"player removed from query",
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Destroy ──────────────────────────────────────────
|
|
||||||
world.destroy(enemy);
|
|
||||||
console.assert(!world.isAlive(enemy), "enemy destroyed");
|
|
||||||
console.assert(world.entityCount === 1, "one entity left");
|
|
||||||
|
|
||||||
// ── componentChanged query update ────────────────────
|
|
||||||
// Add enemy back, observe query(Health).without(Dead)
|
|
||||||
const enemy2 = world.spawn();
|
|
||||||
world.add(enemy2, Health, { current: 75 });
|
|
||||||
|
|
||||||
const healthLog: QueryUpdate[] = [];
|
|
||||||
world.observe(query(Health).without(Dead)).subscribe((u) => {
|
|
||||||
healthLog.push(u);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enemy won't be in the initial seed yet (subscribe happened after spawn)
|
|
||||||
// Let's add Dead to trigger the removal
|
|
||||||
world.add(enemy2, Dead, { timestamp: 123 });
|
|
||||||
world.flush();
|
|
||||||
|
|
||||||
console.assert(
|
|
||||||
healthLog.some((u) => u.removed[0] === enemy2),
|
|
||||||
"enemy removed from health-not-dead query after gaining Dead",
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Entity recycling ─────────────────────────────────
|
|
||||||
world.destroy(player);
|
|
||||||
const recycled = world.spawn();
|
|
||||||
|
|
||||||
console.assert(recycled !== player, "recycled entity has new generation");
|
|
||||||
console.assert(world.isAlive(recycled), "recycled entity is alive");
|
|
||||||
console.assert(!world.isAlive(player), "old handle is dead");
|
|
||||||
|
|
||||||
console.log("✅ All smoke tests passed.");
|
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
World,
|
||||||
|
defineComponent,
|
||||||
|
query,
|
||||||
|
type QueryUpdate,
|
||||||
|
type WorldEvent,
|
||||||
|
type Entity,
|
||||||
|
} from "../src/index";
|
||||||
|
|
||||||
|
// ── Components ──────────────────────────────────────
|
||||||
|
const Position = defineComponent("position", { x: 0, y: 0 });
|
||||||
|
const Velocity = defineComponent("velocity", { vx: 0, vy: 0 });
|
||||||
|
const Health = defineComponent("health", { current: 100, max: 100 });
|
||||||
|
const Dead = defineComponent("dead", { timestamp: 0 });
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────
|
||||||
|
function collectUpdates(obs$: { subscribe: Function }): QueryUpdate[] {
|
||||||
|
const log: QueryUpdate[] = [];
|
||||||
|
obs$.subscribe((u: QueryUpdate) => {
|
||||||
|
if (u.added.length || u.removed.length || u.changed.length) log.push(u);
|
||||||
|
});
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectEvents(world: World): WorldEvent[] {
|
||||||
|
const log: WorldEvent[] = [];
|
||||||
|
world.events$.subscribe((e: WorldEvent) => log.push(e));
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entity lifecycle ───────────────────────────────
|
||||||
|
describe("Entity lifecycle", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawns entities", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
expect(world.isAlive(e)).toBe(true);
|
||||||
|
expect(world.entityCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits spawn event", () => {
|
||||||
|
const events = collectEvents(world);
|
||||||
|
const e = world.spawn();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0]).toMatchObject({ type: "spawned", entity: e });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("destroys entities", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.destroy(e);
|
||||||
|
expect(world.isAlive(e)).toBe(false);
|
||||||
|
expect(world.entityCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits destroy event", () => {
|
||||||
|
const events = collectEvents(world);
|
||||||
|
const e = world.spawn();
|
||||||
|
world.destroy(e);
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
expect(events[1]).toMatchObject({ type: "destroyed", entity: e });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recycles entity indices with generation bump", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
world.destroy(a);
|
||||||
|
const b = world.spawn();
|
||||||
|
expect(b).not.toBe(a);
|
||||||
|
expect(world.isAlive(b)).toBe(true);
|
||||||
|
expect(world.isAlive(a)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on operations with dead entity", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.destroy(e);
|
||||||
|
expect(() => world.get(e, Position)).toThrow("not alive");
|
||||||
|
expect(() => world.has(e, Position)).not.toThrow(); // has() is safe
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Components ──────────────────────────────────────
|
||||||
|
describe("Components", () => {
|
||||||
|
let world: World;
|
||||||
|
let entity: Entity;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
entity = world.spawn() as Entity;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("add returns defaults", () => {
|
||||||
|
const pos = world.add(entity, Position);
|
||||||
|
expect(pos).toEqual({ x: 0, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("add overrides defaults with init", () => {
|
||||||
|
const pos = world.add(entity, Position, { x: 10, y: 20 });
|
||||||
|
expect(pos.x).toBe(10);
|
||||||
|
expect(pos.y).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get returns the live mutable object", () => {
|
||||||
|
world.add(entity, Position, { x: 5 });
|
||||||
|
const pos = world.get(entity, Position);
|
||||||
|
pos.x = 99;
|
||||||
|
expect(world.get(entity, Position).x).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tryGet returns undefined when absent", () => {
|
||||||
|
expect(world.tryGet(entity, Position)).toBeUndefined();
|
||||||
|
world.add(entity, Position);
|
||||||
|
expect(world.tryGet(entity, Position)).toEqual({ x: 0, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has checks component presence", () => {
|
||||||
|
expect(world.has(entity, Position)).toBe(false);
|
||||||
|
world.add(entity, Position);
|
||||||
|
expect(world.has(entity, Position)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remove removes the component", () => {
|
||||||
|
world.add(entity, Position);
|
||||||
|
world.remove(entity, Position);
|
||||||
|
expect(world.has(entity, Position)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remove is idempotent", () => {
|
||||||
|
expect(() => world.remove(entity, Position)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set replaces and marks dirty", () => {
|
||||||
|
world.add(entity, Position);
|
||||||
|
world.set(entity, Position, { x: 42, y: 99 });
|
||||||
|
expect(world.get(entity, Position)).toEqual({ x: 42, y: 99 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set throws if component not added first", () => {
|
||||||
|
expect(() => world.set(entity, Position, { x: 1, y: 2 })).toThrow(
|
||||||
|
"Use add()",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits componentAdded event", () => {
|
||||||
|
const events = collectEvents(world);
|
||||||
|
world.add(entity, Position);
|
||||||
|
expect(events.find((e) => e.type === "componentAdded")).toMatchObject({
|
||||||
|
type: "componentAdded",
|
||||||
|
entity,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits componentRemoved event", () => {
|
||||||
|
world.add(entity, Position);
|
||||||
|
const events = collectEvents(world);
|
||||||
|
world.remove(entity, Position);
|
||||||
|
expect(events.find((e) => e.type === "componentRemoved")).toMatchObject({
|
||||||
|
type: "componentRemoved",
|
||||||
|
entity,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Queries ─────────────────────────────────────────
|
||||||
|
describe("Sync queries", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns matching entities", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
world.add(a, Position);
|
||||||
|
world.add(a, Velocity);
|
||||||
|
|
||||||
|
const b = world.spawn();
|
||||||
|
world.add(b, Position);
|
||||||
|
|
||||||
|
const result = [...world.query(query(Position, Velocity))];
|
||||||
|
expect(result).toEqual([a]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty when no match", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Position);
|
||||||
|
const result = [...world.query(query(Position, Velocity))];
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes with .without()", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
world.add(a, Health);
|
||||||
|
world.add(a, Dead);
|
||||||
|
|
||||||
|
const b = world.spawn();
|
||||||
|
world.add(b, Health);
|
||||||
|
|
||||||
|
const result = [...world.query(query(Health).without(Dead))];
|
||||||
|
expect(result).toEqual([b]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Observable queries ──────────────────────────────
|
||||||
|
describe("Observable queries", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits added when an entity later matches", () => {
|
||||||
|
const log = collectUpdates(world.observe(query(Position)));
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Position);
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].added).toEqual([e]);
|
||||||
|
expect(log[0].removed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits removed when an entity stops matching", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Position);
|
||||||
|
|
||||||
|
const log = collectUpdates(world.observe(query(Position)));
|
||||||
|
world.remove(e, Position);
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].removed).toEqual([e]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits removed on entity destroy", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Position);
|
||||||
|
|
||||||
|
const log = collectUpdates(world.observe(query(Position)));
|
||||||
|
world.destroy(e);
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].removed).toEqual([e]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits changed on matching entities after flush", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Position);
|
||||||
|
|
||||||
|
const log = collectUpdates(world.observe(query(Position)));
|
||||||
|
world.get(e, Position).x += 1;
|
||||||
|
world.markDirty(e, Position);
|
||||||
|
world.flush();
|
||||||
|
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].changed).toEqual([e]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds with currently matching entities on subscribe", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Position);
|
||||||
|
|
||||||
|
// Next subscription should know e already matches
|
||||||
|
const log = collectUpdates(world.observe(query(Position)));
|
||||||
|
|
||||||
|
// Remove to trigger an event
|
||||||
|
world.remove(e, Position);
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].removed).toEqual([e]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles .without() queries", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Health);
|
||||||
|
|
||||||
|
const log = collectUpdates(world.observe(query(Health).without(Dead)));
|
||||||
|
|
||||||
|
world.add(e, Dead);
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].removed).toEqual([e]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Change tracking ─────────────────────────────────
|
||||||
|
describe("Change tracking", () => {
|
||||||
|
let world: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
world = new World();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits componentChanged on flush", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Position);
|
||||||
|
|
||||||
|
const events = collectEvents(world);
|
||||||
|
world.get(e, Position).x = 42;
|
||||||
|
world.markDirty(e, Position);
|
||||||
|
world.flush();
|
||||||
|
|
||||||
|
expect(events.some((ev) => ev.type === "componentChanged")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("batches multiple dirty marks into one flush", () => {
|
||||||
|
const a = world.spawn();
|
||||||
|
const b = world.spawn();
|
||||||
|
world.add(a, Position);
|
||||||
|
world.add(b, Position);
|
||||||
|
|
||||||
|
let changeCount = 0;
|
||||||
|
world.observe(query(Position)).subscribe((u) => {
|
||||||
|
changeCount += u.changed.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
world.markDirty(a, Position);
|
||||||
|
world.markDirty(b, Position);
|
||||||
|
world.flush();
|
||||||
|
|
||||||
|
expect(changeCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set() implicitly marks dirty", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Position);
|
||||||
|
|
||||||
|
const log = collectUpdates(world.observe(query(Position)));
|
||||||
|
world.set(e, Position, { x: 1, y: 2 });
|
||||||
|
world.flush();
|
||||||
|
|
||||||
|
expect(log).toHaveLength(1);
|
||||||
|
expect(log[0].changed).toEqual([e]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears dirty after flush", () => {
|
||||||
|
const e = world.spawn();
|
||||||
|
world.add(e, Position);
|
||||||
|
|
||||||
|
let changeCount = 0;
|
||||||
|
world.observe(query(Position)).subscribe((u) => {
|
||||||
|
changeCount += u.changed.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
world.markDirty(e, Position);
|
||||||
|
world.flush();
|
||||||
|
expect(changeCount).toBe(1);
|
||||||
|
|
||||||
|
world.flush();
|
||||||
|
expect(changeCount).toBe(1); // no new emissions
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TypeScript inference ────────────────────────────
|
||||||
|
describe("Type safety", () => {
|
||||||
|
it("infers component type from defaults", () => {
|
||||||
|
const Shield = defineComponent("shield", { armor: 5, broken: false });
|
||||||
|
const s = Shield.defaults;
|
||||||
|
// compile-time check: these should be the inferred types
|
||||||
|
const _armor: number = s.armor;
|
||||||
|
const _broken: boolean = s.broken;
|
||||||
|
expect(typeof _armor).toBe("number");
|
||||||
|
expect(typeof _broken).toBe("boolean");
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-3
@@ -11,8 +11,7 @@
|
|||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src"
|
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src", "test", "examples"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"],
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
import { defineConfig } from 'tsup';
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
entry: ['src/index.ts'],
|
entry: ["src/index.ts", "src/commands/index.ts", "src/bt/index.ts"],
|
||||||
format: ['esm', 'cjs'],
|
format: ["esm", "cjs"],
|
||||||
dts: true,
|
dts: true,
|
||||||
clean: true,
|
clean: true,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["test/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user