Fixing tests and comments

This commit is contained in:
2026-07-05 23:41:19 -05:00
parent c16b21c603
commit b7063277e7
8 changed files with 214 additions and 174 deletions
+160 -130
View File
@@ -5,6 +5,10 @@ file. Each scripted object references a script by name via `script_name`. A scri
hooks (any may be omitted) and may define arbitrary handler functions reachable via `send()` and
scroll choices.
Every hook receives the same first two parameters — `me` (this object) and `state` (the world) —
through which all reads happen. Writes go through free functions (`move`, `say`, `set_tile`, …) that
implicitly act on the calling object.
> **Status:** this document describes the API as it currently exists. It has grown organically and
> has known rough edges — duplication, naming inconsistency, and some unwieldy machinery. Those are
> catalogued in [Design notes](#design-notes-rough-edges) at the end, as the starting point for a
@@ -15,33 +19,36 @@ scroll choices.
## Lifecycle hooks
Hooks are detected by name **and** arity (parameter count). A function with the right name but wrong
number of parameters is not recognized as a hook.
number of parameters is not recognized as a hook. Every hook takes `me` (an [`ObjectInfo`](#self-and-other-objects-objectinfo)
for the object running the script) and `state` (a [`State`](#the-world-state) handle to the world),
in that order, before any hook-specific argument.
### `fn init()`
### `fn init(me, state)`
Called once for every scripted object after the entire map is loaded, before the first frame.
Called once for every scripted object after the entire map is loaded, before the first frame (and
again after a board transition re-enters this board's script host).
```rhai
fn init() {
fn init(me, state) {
set_tile(42);
log("ready");
}
```
### `fn tick(dt)`
### `fn tick(me, state, dt)`
Called every frame. `dt` is the elapsed time since the last tick, in seconds (a `f64`). Timed
actions pace themselves through the queue; you do not track time manually for simple walking.
```rhai
fn tick(dt) {
if Queue.length() == 0 && !blocked(North) {
fn tick(me, state, dt) {
if !me.waiting && !me.blocked(North) {
move(North);
}
}
```
### `fn bump(id)`
### `fn bump(me, state, id)`
Called when another entity walks into this object's cell.
@@ -49,13 +56,13 @@ Called when another entity walks into this object's cell.
- `id == -1` means the **player** bumped this object.
```rhai
fn bump(id) {
fn bump(me, state, id) {
if id == -1 { say("Hey! Watch it."); }
else { log(`object ${id} bumped me`); }
}
```
### `fn grab()`
### `fn grab(me, state)`
Called when the **player walks onto** this object and the object is a *grab* thing (gems, hearts —
`solid + grab`). Unlike `bump`, the player ends up on the object's cell; the hook typically adjusts a
@@ -63,7 +70,7 @@ player stat and removes the object. It fires followed by an immediate resolve, s
`add_gems()` / `alter_health()` applies before the player's move returns.
```rhai
fn grab() {
fn grab(me, state) {
add_gems(1);
die();
}
@@ -78,129 +85,148 @@ Any other function can be invoked on an object two ways:
- Another object calls `send(target_id, "fn_name" [, arg])`.
- A `scroll()` **choice** is selected: the choice key is dispatched to the source object as a
zero-argument function call of that name.
function call of that name.
A handler may take **zero or one** parameter; the host calls the 1-arg form when an arg is supplied,
else the 0-arg form. If neither arity exists, the call is silently dropped.
The host picks the handler's parameter list by arity, in this order:
- **3 params** → `(me, state, arg)``arg` is the value passed to `send` (or unit for a scroll choice).
- **2 params** → `(me, state)`.
- **1 param** → `(arg)`.
- **0 params** → `()`.
If no matching function name exists, the call is silently dropped.
```rhai
// reached via send(my_id, "open") or a scroll choice keyed "open"
fn open() { set_tile(47); }
fn set_level(n){ set_tag(Me.id, "level", true); log(`level ${n}`); }
fn open(me, state) { set_tile(47); }
fn set_level(me, state, n) { set_tag(me.id, "level", true); log(`level ${n}`); }
```
---
## Constants in scope
Pre-set in every object's scope. The object handles are scope constants; direction/color constants
come from a global module visible at any call depth (including Rhai→Rhai calls).
Unlike the `me`/`state` parameters (which every hook receives), these are injected directly into
every object's scope.
| Name | Type | Description |
|------|------|-------------|
| `Board` | `Board` | Read-only handle to the current board / world. |
| `Queue` | `Queue` | This object's own pending-action queue. |
| `Me` | `Me` | Self-reference (id, name, position, tags, glyph). |
| `Registry` | `Registry` | Board-scoped key→value store; persists across board transitions. |
| `Player` | `Player` | Read-only snapshot of the player's game-global stats. |
| `North` / `South` / `East` / `West` | `Direction` | Cardinal directions. |
| `Black``White` (16) | `String` | EGA/VGA palette colors as `"#RRGGBB"` strings. |
`Registry` is a **scope constant**: it is only visible at the top level of an engine-called hook, not
inside helper functions you define (those run at a deeper call depth — see [Pitfalls](#pitfalls-and-sharp-edges)).
The direction/color constants come from a global module and *are* visible at any call depth (including
Rhai→Rhai calls).
The 16 color names, in palette order: `Black`, `Blue`, `Green`, `Cyan`, `Red`, `Magenta`, `Brown`,
`LightGray`, `DarkGray`, `BrightBlue`, `BrightGreen`, `BrightCyan`, `BrightRed`, `BrightMagenta`,
`Yellow`, `White`. They are plain hex strings, so they are accepted anywhere a color string is (e.g.
`set_fg(BrightRed)`).
> There is **no** `MY_ID` constant any more — use `Me.id`.
---
## Self-reference: `Me`
## Self and other objects: `ObjectInfo`
`me` is an `ObjectInfo` — a snapshot of one board object. The same type comes back from
[`state.board.get` / `.named` / `.tagged`](#board-reads), so an object you look up has exactly the
same members as `me`.
| Member | Kind | Returns | Description |
|--------|------|---------|-------------|
| `Me.id` | getter | `i64` | This object's stable `ObjectId`. |
| `Me.name` | getter | `String` | Name, or `""` if unnamed. |
| `Me.x` / `Me.y` | getter | `i64` | Current cell position. |
| `Me.has_tag(tag)` | method | `bool` | Whether this object carries `tag`. |
| `Me.tags()` | method | `Array` | All tag strings on this object. |
| `Me.glyph()` | method | `Glyph` | This object's glyph (`.tile`, `.fg`, `.bg`). |
| `.id` | getter | `i64` | The object's stable `ObjectId`. |
| `.name` | getter | `String` or `()` | Name, or unit if unnamed. |
| `.x` / `.y` | getter | `i64` | Current cell position. |
| `.tags` | getter | `Array` | All tag strings on this object. |
| `.glyph` | getter | `Glyph` | The object's glyph (`.tile`, `.fg`, `.bg`). |
| `.waiting` | getter | `bool` | Whether the front of the object's queue is a delay (i.e. it is "busy"). |
| `.queue` | getter | `Queue` | The object's [pending-action queue](#queue-object). |
| `.has_tag(tag)` | method | `bool` | Whether the object carries `tag`. |
| `.delay(secs)` | method | — | Append a pause to the object's queue. |
| `.blocked(dir)` | method | `bool` | Whether moving the object one step in `dir` is impossible (off-board or a solid). |
| `.can_push(dir)` | method | `bool` | Whether the pushable chain ahead of the object in `dir` can be shoved. |
```rhai
if Me.has_tag("locked") { say(`${Me.name} is locked`); }
set_tag(Me.id, "seen", true); // self-mutation still needs the explicit id
fn tick(me, state, dt) {
if me.has_tag("asleep") { return; }
let here = me.glyph;
log(`my tile is ${here.tile}`);
if !me.waiting && !me.blocked(East) { move(East); }
}
```
A `Glyph` value (from `Me.glyph()` or `ObjectInfo` lookups) exposes `.tile` (`i64`), `.fg`, `.bg`
(both `"#RRGGBB"` strings).
A `Glyph` value (from `me.glyph` or a looked-up object) exposes `.tile` (`i64`), `.fg`, `.bg` (both
`"#RRGGBB"` strings).
> Because hooks receive `me`/`state` as **parameters**, they are local to the hook. To use them in a
> helper function you define, pass them down explicitly: `fn dance(me) { … }` called as `dance(me)`.
---
## Player stats: `Player`
## The world: `state`
`state` is a handle bundling the two read surfaces:
| Member | Type | Description |
|--------|------|-------------|
| `state.player` | `Player` | The player's game-global stats and position. |
| `state.board` | `Board` | Read-only handle to the current board. |
### Player stats: `state.player`
A read-only snapshot of the game-global player state, refreshed before each hook call.
| Property | Type | Description |
|----------|------|-------------|
| `Player.gems` | `i64` | Gems collected. |
| `Player.health` | `i64` | Current health. |
| `Player.max_health` | `i64` | Health ceiling. |
| `state.player.gems` | `i64` | Gems collected. |
| `state.player.health` | `i64` | Current health. |
| `state.player.max_health` | `i64` | Health ceiling. |
| `state.player.x` / `.y` | `i64` | Player's current cell. |
| `state.player.keys` | `Keyring` | The player's key inventory (see below). |
```rhai
if Player.health < 2 { say("You look hurt."); }
if state.player.health < 2 { say("You look hurt."); }
if state.player.keys.red { say("You have the red key."); }
```
> The player's **keys** are not readable from scripts yet (you can `set_key` but not query it).
The `Keyring` from `state.player.keys` exposes one `bool` getter per color: `.red`, `.orange`,
`.yellow`, `.green`, `.blue`, `.cyan`, `.purple`, `.white`.
---
### Board reads
## Board reads
### Properties
#### Properties
| Property | Type | Description |
|----------|------|-------------|
| `Board.player_x` | `i64` | Player's column. |
| `Board.player_y` | `i64` | Player's row. |
| `Board.width` | `i64` | Board width in cells. |
| `Board.height` | `i64` | Board height in cells. |
| `state.board.width` | `i64` | Board width in cells. |
| `state.board.height` | `i64` | Board height in cells. |
### Object lookups
#### Object lookups
| Call | Returns | Description |
|------|---------|-------------|
| `Board.tagged(tag)` | `Array` of `ObjectInfo` | Every object carrying `tag`. |
| `Board.named(name)` | `ObjectInfo` or `()` | The object with that name, else unit. |
| `Board.get(id)` | `ObjectInfo` or `()` | Look up by id; `-1` returns the player; an invalid/unknown id logs an error and returns unit. |
An `ObjectInfo` exposes `.id`, `.x`, `.y`, `.name` (`""` if unnamed) and `.tags` (an `Array`). Note
it has **no** `has_tag()` or `glyph()` — those exist only on `Me`.
| `state.board.tagged(tag)` | `Array` of `ObjectInfo` | Every object carrying `tag`. |
| `state.board.named(name)` | `ObjectInfo` or `()` | The object with that name, else unit. |
| `state.board.get(id)` | `ObjectInfo` or `()` | Look up by id; an invalid (`id <= 0`) or unknown id logs an error and returns unit. |
```rhai
let enemies = Board.tagged("enemy");
let enemies = state.board.tagged("enemy");
log(`there are ${enemies.len()} enemies`);
let door = Board.named("door");
let door = state.board.named("door");
if door != () { send(door.id, "open"); }
```
---
## Cell queries (free functions)
#### Cell queries
| Call | Returns | Description |
|------|---------|-------------|
| `blocked(dir)` | `bool` | Would the **caller** be unable to move one step in `dir` (off-board, a solid, or another object's already-queued move targets that cell). The caller's own queued moves don't count. |
| `can_push(x, y, dir)` | `bool` | Does a pushable chain starting at `(x, y)` end in open space when shoved in `dir`? `false` off-board. |
| `can_shift(x, y, dir)` | `bool` | Like `can_push` but checks only the **one** cell ahead (pushable source + empty-or-pushable neighbor). The right gate for `shift()`. |
| `passable(x, y)` | `bool` | Is `(x, y)` on-board and free of any solid (an enterable hole)? Distinguishes a hole from a blocked solid. |
| `combinable(x1, y1, x2, y2)` | `bool` | Can the solids at the two cells share a cell (one empty, or a grab thing + the player)? |
| `state.board.can_push(x, y, dir)` | `bool` | Does a pushable chain starting at `(x, y)` end in open space when shoved in `dir`? `false` off-board. |
| `state.board.passable(x, y)` | `bool` | Is `(x, y)` on-board and free of any solid (an enterable hole)? Distinguishes a hole from a blocked solid. |
```rhai
fn tick(dt) {
if !blocked(East) { move(East); }
}
```
For queries about the calling object itself, use `me.blocked(dir)` and `me.can_push(dir)`
([above](#self-and-other-objects-objectinfo)).
---
@@ -215,17 +241,23 @@ Board-scoped, persists across board transitions. Stores primitive values.
| `Registry.get_or(key, default)` | Stored value **only if** it has the same type as `default`; otherwise `default`. |
```rhai
let count = Registry.get_or("visits", 0);
Registry.set("visits", count + 1);
fn tick(me, state, dt) {
// Per-object counter that survives across ticks and board transitions.
let key = `count_${me.id}`;
let n = Registry.get_or(key, 0);
Registry.set(key, n + 1);
}
```
The Registry is shared by the whole board, so namespace per-instance keys by `me.id` as above.
---
## Write functions
Scripts never mutate the world directly. Each write appends an `Action` to this object's **output
queue**; actions drain to a shared board queue between script calls and are resolved by the engine
after the batch. The *subject* of a write is implicit and varies by function (see the table).
Scripts never mutate the world directly. Each write appends an `Action` to the calling object's
**output queue**; actions drain to a shared board queue between script calls and are resolved by the
engine after the batch. The *subject* of a write is implicit and varies by function (see the table).
| Call | Subject | Cost | Description |
|------|---------|------|-------------|
@@ -237,7 +269,7 @@ after the batch. The *subject* of a write is implicit and varies by function (se
| `set_fg(color)` | self | 0 | Set foreground color (hex string). |
| `set_bg(color)` | self | 0 | Set background color. |
| `set_color(fg, bg)` | self | 0 | Set both colors. |
| `set_tag(target_id, tag, present)` | any object | 0 | Add (`true`) / remove (`false`) a tag. Use `Me.id` for self. |
| `set_tag(target_id, tag, present)` | any object | 0 | Add (`true`) / remove (`false`) a tag. Use `me.id` for self. |
| `say(msg)` / `say(msg, secs)` | self | 0 | Speech bubble above this object; default 3 s, or `secs`. Replaces any current bubble. |
| `log(msg)` | log | 0 | Append a plain-text line to the game log. |
| `scroll(lines)` | UI | 0 | Open a full-screen overlay (see below). |
@@ -249,6 +281,9 @@ after the batch. The *subject* of a write is implicit and varies by function (se
| `set_key(color, present)` | player | 0 | Give/take a key color (`"blue"`, `"green"`, `"cyan"`, `"red"`, `"purple"`, `"orange"`, `"yellow"`, `"white"`). Unknown name logs and is ignored. |
| `die()` | self | 0 | Remove the calling object from the board. |
`delay`/`now` also exist as methods on `me` and on a queue handle: `me.delay(secs)` and
`me.queue.delay(secs)` are equivalent to the free `delay(secs)` for the calling object.
### `scroll(lines)`
Opens a full-screen scrollable overlay and pauses game ticks until the player closes it. Each element
@@ -258,10 +293,11 @@ of `lines` is either:
- a **2-element array** `[choice_key, display_text]` — a selectable choice.
When the player selects a choice, `choice_key` is dispatched back to the **source object** as a
zero-arg function call named `choice_key`.
function call named `choice_key` (see [custom handler functions](#custom-handler-functions) for the
arity rules — a choice handler is called with no `arg`).
```rhai
fn bump(id) {
fn bump(me, state, id) {
scroll([
"The muffin looks delicious.",
"",
@@ -269,32 +305,28 @@ fn bump(id) {
["ignore", "Walk away"],
]);
}
fn eat() { say("Yeah it was poisoned."); alter_health(-2); }
fn ignore() { log("Wise."); }
fn eat(me, state) { say("Yeah it was poisoned."); alter_health(-2); }
fn ignore(me, state) { log("Wise."); }
```
---
## `Queue` object
Inspection and control of this object's own pending-action queue.
Inspection and control of an object's own pending-action queue, reached via `me.queue`. Most scripts
do not need it directly — `me.waiting` covers the common "am I busy?" check.
| Method | Returns | Description |
|--------|---------|-------------|
| `Queue.length()` | `i64` | Number of pending actions. |
| `Queue.clear()` | — | Discard all pending actions (including delays). |
| `Queue.peek()` | map or `()` | Front action as a map, or unit if empty. |
| `Queue.pop()` | map or `()` | Same, removing the front. |
`peek`/`pop` return an untyped map with a `"type"` key (`"Move"`, `"SetTile"`, `"Say"`, …) plus
variant-specific payload keys (e.g. `"dir"`, `"tile"`, `"msg"`). `Scroll` and `Shift` report `type`
only — their payloads are not inspectable through this API.
| Member | Kind | Returns | Description |
|--------|------|---------|-------------|
| `me.queue.length` | getter | `i64` | Number of pending actions. |
| `me.queue.clear()` | method | — | Discard all pending actions (including delays). |
| `me.queue.delay(secs)` | method | — | Append a pause to the queue. |
```rhai
fn tick(dt) {
if Queue.length() == 0 {
if !blocked(East) { move(East); }
else if !blocked(West) { move(West); }
fn tick(me, state, dt) {
if !me.waiting {
if !me.blocked(East) { move(East); }
else if !me.blocked(West) { move(West); }
}
}
```
@@ -306,11 +338,13 @@ fn tick(dt) {
- `move(dir)` costs **0.25 s** of queue delay (~4 moves/second max).
- `delay(secs)` adds an arbitrary pause; `now()` can bypass a delay for a zero-cost action already in
the queue.
- `Queue.clear()` cancels everything, including pending delays.
- `me.queue.clear()` cancels everything, including pending delays.
- Adjacent delays are always merged: two `delay(0.5)` calls become one `1.0 s` delay.
- All other writes (`set_tile`, `say`, `push`, `shift`, `teleport`, stat changes, `die`, …) are
zero-cost: a run of them resolves together, and at most one *timed* action (a `move`) is released
per object per cycle.
- `me.waiting` is `true` while a delay sits at the front of the queue — the idiom for "don't issue a
new action until the last one finished" is `if !me.waiting { … }`.
---
@@ -326,18 +360,18 @@ crash the game. A script that throws during `tick` logs the error and resumes on
```rhai
// A guard that patrols east/west and greets the player when bumped.
fn init() {
fn init(me, state) {
set_tile(2);
log(`${Me.name} standing watch`);
log(`${me.name} standing watch`);
}
fn tick(dt) {
if Queue.length() > 0 { return; } // still moving
if !blocked(East) { move(East); }
else if !blocked(West) { move(West); }
fn tick(me, state, dt) {
if me.waiting { return; } // still moving
if !me.blocked(East) { move(East); }
else if !me.blocked(West) { move(West); }
}
fn bump(id) {
fn bump(me, state, id) {
if id == -1 { say("Halt! Who goes there?"); now(); }
else { say("Watch it!"); now(); }
}
@@ -352,15 +386,13 @@ they are shape problems.
### Duplication — several ways to do one thing
- **Object introspection has two parallel types with different members.** Self uses `Me`
(`Me.has_tag()`, `Me.tags()`, `Me.glyph()`, plus getters); other objects come back as `ObjectInfo`
(`.id`, `.x`, `.y`, `.name`, `.tags`) which has **no** `has_tag()` and **no** `glyph()`. The same
conceptual "an object" is two incompatible types depending on whether it's you.
- **Player position/state is reachable three ways:** `Board.player_x` / `Board.player_y`,
`Board.get(-1).x` / `.y`, and the `Player` constant (stats only). Player *position* lives on
`Board`, player *stats* live on `Player` — the player is split across two surfaces.
- **Tag reads come in three shapes:** `Me.has_tag(t)` (method, bool), `Me.tags()` (method, array),
`ObjectInfo.tags` (property, array). Method-vs-property is inconsistent even for the same data.
- **Player position/state is reachable two ways.** Player *stats* and *position* both live on
`state.player` now (`.gems`/`.health`/`.x`/`.y`/`.keys`), which is an improvement — but a looked-up
player no longer exists (`state.board.get(-1)` is gone), so code that wants "the player as an
object" has nothing to look up, while every other entity is an `ObjectInfo`.
- **Reads are split across three handles** (`me`, `state.player`, `state.board`) with no single
"query" entry point; which handle owns a given fact (e.g. cell queries on `state.board` vs. self
queries on `me`) has to be memorized.
### Inconsistency — naming and conventions
@@ -372,11 +404,14 @@ they are shape problems.
**self**; `add_gems`/`alter_health`/`set_key` act on the **player**; `set_tag`/`send` act on an
**arbitrary target**; `push`/`shift`/`teleport` act on **cells**. The `set_*` prefix in particular
means three different subjects.
- **Mixed addressing for movement.** `move`/`blocked`/`push` take a typed `Direction`, while
`teleport`/`passable`/`can_push`/`can_shift`/`shift` take raw `i64` coordinates. There's no cell or
position value type.
- **Self-mutation can't go through `Me`.** `Me` is read-only, so writing your own tag is
`set_tag(Me.id, …)` — you pass your own id back to a free function instead of `Me.set_tag(…)`.
- **Reads are methods/getters on handles, but writes are free functions.** `me.blocked(dir)` reads,
but `move(dir)` (the matching write) is a bare global that acts on `me` implicitly — the symmetry is
invisible.
- **Mixed addressing for movement.** `move`/`push` take a typed `Direction`, while
`teleport`/`passable`/`can_push`/`shift` take raw `i64` coordinates. There's no cell or position
value type.
- **Self-mutation can't go through `me`.** `me` is read-only, so writing your own tag is
`set_tag(me.id, …)` — you pass your own id back to a free function instead of `me.set_tag(…)`.
- **Colors are stringly-typed** (`"#RRGGBB"`, with the named constants being strings) while tiles are
bare integers with no symbolic names; glyph reads hand back colors as hex strings.
- **Overload coverage is uneven.** `say` has a duration overload, `log` does not; `delay` has int and
@@ -384,22 +419,17 @@ they are shape problems.
### Unwieldy — machinery that leaks
- **Queue introspection is stringly-typed.** `peek`/`pop` return untyped maps keyed by a `"type"`
string, and `Scroll`/`Shift` aren't introspectable at all (type only). There is no typed action
value.
- **The two-tier queue + `now()`/`delay()` model is subtle.** `now()` reorders by *enqueue
recency* ("most recently enqueued action to the front"), which is position-dependent and easy to
get wrong; the object-queue → board-queue promotion plus per-object ready timer is a lot of
get wrong; the object-queue → board-queue promotion plus per-object delay draining is a lot of
implicit state to reason about for "do X, wait, do Y".
- **Two persistence mechanisms.** Tags and the `Registry` are separate stores with separate APIs;
cross-board state must pick one.
- **Stringly-typed dispatch.** `send` and scroll choices route by function *name*; a scroll choice
key is silently also a function name. Renaming a handler breaks callers with no signal.
- **Engine internals leak into the script API.** `combinable(x1,y1,x2,y2)` encodes a very specific
rule (a grab thing may share a cell with the player) that exists only to support `shift`/`swap`
edge cases.
- **Read/write asymmetry on player keys.** `set_key` writes keys, but no read exists — `Player`
exposes `gems`/`health`/`max_health` only (there is a literal `TODO` to expose keys).
- **Doc/name drift across the codebase.** The function is `shift([[x, y], …])`, but the engine's own
module docs and `CLAUDE.md` still describe a `swap([[src_x, src_y, dst_x, dst_y], …])`. The two
concepts (rotate-a-cycle vs. batch directed moves) are conflated under both names.
- **`Registry` alone is a scope constant.** Now that `me`/`state` are parameters, `Registry` is the
one handle with the "invisible inside helper functions" gotcha — an inconsistency with everything
else a hook can reach.
- **Read/write asymmetry on player keys is now closed** (`state.player.keys.*` reads, `set_key`
writes) — but reads are per-color getters while the write is a stringly-named color, so the two
sides still don't mirror each other.