@brass-lantern/mcp 0.0.1-stage → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +37 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +16 -0
- package/dist/docs/llms.txt +35 -0
- package/dist/docs/pages/guide/building-worlds/index.md +218 -0
- package/dist/docs/pages/guide/building-worlds/recipes.md +1406 -0
- package/dist/docs/pages/guide/building-worlds/two-rooms.md +339 -0
- package/dist/docs/pages/guide/coding-agents.md +120 -0
- package/dist/docs/pages/guide/deploying.md +56 -0
- package/dist/docs/pages/guide/getting-started.md +72 -0
- package/dist/docs/pages/guide/how-it-works.md +152 -0
- package/dist/docs/pages/guide/intent-server.md +92 -0
- package/dist/docs/pages/guide/porting-zork.md +115 -0
- package/dist/docs/pages/guide/testing.md +100 -0
- package/dist/docs/pages/guide/using-the-library.md +272 -0
- package/dist/docs/pages/guide/your-first-world.md +206 -0
- package/dist/docs/pages/guide/z-machine.md +96 -0
- package/dist/docs/pages/index.md +58 -0
- package/dist/docs/pages/reference/cartridges.md +47 -0
- package/dist/docs/pages/reference/commands.md +79 -0
- package/dist/docs/pages/reference/conditions-and-events.md +129 -0
- package/dist/docs/pages/reference/world-schema.md +592 -0
- package/dist/docs/pages.json +74 -0
- package/dist/docs.d.ts +21 -0
- package/dist/docs.js +89 -0
- package/dist/engine.d.ts +16 -0
- package/dist/engine.js +40 -0
- package/dist/load.d.ts +22 -0
- package/dist/load.js +105 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +96 -0
- package/dist/stdio.d.ts +19 -0
- package/dist/stdio.js +49 -0
- package/dist/tools.d.ts +8 -0
- package/dist/tools.js +82 -0
- package/package.json +47 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# How it works
|
|
2
|
+
|
|
3
|
+
Three parts, kept deliberately apart, and shipped as three library packages ([Using the library](https://mrballistic.github.io/brass-lantern/guide/using-the-library)), plus an MCP server for coding agents ([Coding agents](https://mrballistic.github.io/brass-lantern/guide/coding-agents)): the engine and parser are `@brass-lantern/engine`, the terminal and stores are `@brass-lantern/vue`, and the intent server is `@brass-lantern/server`. The engine imports no Vue, Pinia or browser globals, so the same turn loop runs in a browser, in Node (`createGame`) and in tests.
|
|
4
|
+
|
|
5
|
+
This page is a tour of the internals. File paths are in the [repo](https://github.com/mrballistic/brass-lantern), for readers of the source; nothing here is needed to use the packages.
|
|
6
|
+
|
|
7
|
+
1. **The engine** (`packages/engine/src/engine/`) is a deterministic state machine. It never knows which game it’s running: everything specific to a world, including its rules, is data.
|
|
8
|
+
2. **The parser** turns what the player typed into `{ action, target, indirect }`. A regex parser in the browser handles the canonical commands instantly. Anything it can’t parse, or parses into something the engine can’t act on, can go to the optional [intent server](https://mrballistic.github.io/brass-lantern/guide/intent-server), where an LLM reads it more loosely. The LLM only *classifies* input; it never writes story text.
|
|
9
|
+
3. **The world** (`packages/engine/src/worlds/`) is one object. Swap it and you have a different game.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
browser intent server (optional)
|
|
13
|
+
┌───────────────────────────────────┐ ┌────────────────────────┐
|
|
14
|
+
│ terminal ─► store │ POST │ /api/parse-intent │
|
|
15
|
+
│ │ splitCommands │ ─────► │ rate limit, size caps │──► Gemini
|
|
16
|
+
│ ▼ regex parser │ │ model chain, deadline │
|
|
17
|
+
│ ▼ engine ◄── world │ ◄───── │ reply sanitizing │
|
|
18
|
+
│ │ (miss? ask LLM) │ └────────────────────────┘
|
|
19
|
+
│ ▼ output + save │
|
|
20
|
+
└───────────────────────────────────┘
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## From keystroke to reply
|
|
24
|
+
|
|
25
|
+
The store (`packages/vue/src/stores/game.ts`) runs every line through the same steps:
|
|
26
|
+
|
|
27
|
+
1. **Meta commands** (SAVE, RESTORE, LOAD, UNDO, RESTART, SCRIPT, THEME, BLOOM, EFFECTS, EJECT, COOKIES) are handled by the store itself, and so is the answer to a SAVE or RESTORE prompt.
|
|
28
|
+
2. **Split.** `splitCommands` (`packages/engine/src/engine/parser.ts`) breaks the line into commands:
|
|
29
|
+
- First the line is cleaned (`cleanInput`): trimmed, with every run of whitespace made one space. A line still longer than `MAX_INPUT_LENGTH` (1,000 characters) is refused whole: the parsers return nothing, and `createGame().send`, `interpret` and the Vue store answer “[That’s too long for me to follow. Nothing happened.]” (`TOO_LONG_REPLY`) before anything runs. Every pattern is written for the cleaned form, so no line costs more than linear time to read.
|
|
30
|
+
- Clauses always split on `then`, `;` and full stops, except after `dr.`, `mr.` and the like.
|
|
31
|
+
- Within a clause, `and` and commas split only when every piece is a recognized command, or an object after a list verb: `get key and wallet` becomes `get key` and `take wallet`.
|
|
32
|
+
- A clause that isn’t clearly a list (“could you grab my keys and wallet”) stays whole.
|
|
33
|
+
- Anything inside quotes is masked first, so a quoted “. ” or “, ” never splits a line (`answer “a well. yes”` is one command). A text verb (SAY, ANSWER) takes the rest of the line, and ends it.
|
|
34
|
+
- Names that contain “and” (`andNames(world)`: every item and character name and alias with the word in it, longest first) are masked too, so `take flair from lost and found and go north` is two commands. The store and `createGame` work the list out once per world.
|
|
35
|
+
|
|
36
|
+
Each command may be **captured** first: a room’s or the world’s `capture` (`captureLine` in `packages/engine/src/engine/engine.ts`) can take it before it’s parsed, which ends the line. Captured input never reaches the intent server.
|
|
37
|
+
3. **The conversation.** `interpret` (`packages/engine/src/engine/conversation.ts`) looks at the command in the light of the last one:
|
|
38
|
+
- **an answer** to a question the engine just asked (“Which door do you mean?” → `trap`) fills in the waiting command and runs it, **without** asking the intent server;
|
|
39
|
+
- **AGAIN** (`g`) runs the last command again;
|
|
40
|
+
- **OOPS *word*** swaps the word nobody understood in the last line and runs that.
|
|
41
|
+
|
|
42
|
+
Anything else is a fresh command, and drops any waiting question.
|
|
43
|
+
4. **Parse.** `fallbackParse` maps each command to an action, with synonyms, a second object (“give X to Y”, “put X in Y”), ALL and EXCEPT, and a bare word meaning GO. Besides `target` and `indirect`, an action can carry:
|
|
44
|
+
- **`number`**: a number in an object slot (`turn dial to 4`, `set year to 776`), the value alongside the digits typed (`parseNumber`: digits to 1000, or H:MM as minutes); rules see that slot as `number`;
|
|
45
|
+
- **`text`**: for a world verb with `target: 'text'`, the rest of the line, outer double quotes dropped;
|
|
46
|
+
- **`prep`**: `in`, `on`, `under`, `behind`, `off`, `over` or `through`, for the forms that take one (PUT X UNDER Y, THROW X OFF Y, READ X THROUGH Y);
|
|
47
|
+
- **`direction`**: for PUSH X NORTH (and CLIMB UP/DOWN a thing);
|
|
48
|
+
- **ME, MYSELF, SELF and YOURSELF** stay as typed: the engine decides they name the player (the reserved ID `player`, for rules), and never matches them, or `player` from the intent server, against things’ names.
|
|
49
|
+
5. **Pronouns.** `it`, `them` and `that` become the last thing the engine acted on; `him` and `her`, the last person.
|
|
50
|
+
6. **Execute.** `execute(action, { world, state })` returns the lines to print, whether anything changed, and `understood: false` if it couldn’t make sense of the command (no such exit, no such item, no rule that applies). When a noun matches more than one thing, or a verb is missing its object, it returns a **question** (`ask`) instead, before touching anything. Before running a command that changes the game, the store keeps a snapshot for UNDO (the last 50, for this session only).
|
|
51
|
+
7. **Retry on a miss.** If the regex couldn’t parse the command, or the engine didn’t understand it, the store asks the intent server how to read it. If the LLM’s reading is different and the engine can act on it, that result is shown instead; otherwise the literal reply stands.
|
|
52
|
+
8. **Time passes.** After a command the engine acted on (never after a misunderstood one, a question, or a command like VERBOSE that takes no time), the room’s end routines run (`onEnd`), then the move counter goes up, wounds heal, timers fire, daemons run, characters fight, and ambient lines print, in that order (`packages/engine/src/engine/time.ts`, after Zork’s CLOCKER). If the light changed, the reply says so.
|
|
53
|
+
9. **Output.** Lines are styled by their prefix and typed out, and the game is saved if anything changed.
|
|
54
|
+
|
|
55
|
+
**The one rule to keep**
|
|
56
|
+
Step 7 runs the engine on the literal reading first, and possibly again on the LLM’s reading. That’s only safe because **a miss never changes the game**. When you add an engine handler, decide whether you can act before you touch any state. `packages/engine/tests/engine/engine-hooks.test.ts` checks this.
|
|
57
|
+
|
|
58
|
+
The intent server sees the room by ID and name (`red_mug (red coffee mug)`), so its answer uses IDs the engine matches exactly. Everything still goes through the same fuzzy matcher (`packages/engine/src/engine/fuzzy.ts`), so a slightly-off answer still lands. Names match strongest first: the exact name, an alias or the ID; a whole word of a name or alias; a whole word of the ID, or part of a name; part of the ID; the starts of words (every typed word of three or more letters must match, so an extra word like “red ball” for a plain “ball” misses); and, in brass style, a close misspelling (a typed word of five or more letters at least 80% alike, comparing the start of a longer word too, so “lunberg” finds Lumbergh; among misspellings, a close match to a whole name word beats one found only through an ID word or a word’s start). Objects and people are compared together, so EXAMINE LUMBERGH finds Lumbergh rather than his mug; on a tie EXAMINE prefers objects, and ATTACK, FOLLOW and the person in GIVE/SHOW … TO prefer people. Equally good matches ask “Which do you mean?”. Infocom style never guesses misspellings.
|
|
59
|
+
|
|
60
|
+
## Cartridges and sessions
|
|
61
|
+
|
|
62
|
+
The terminal talks to whatever is running through one interface (`useSession()` in `packages/vue/src/stores/session.ts`):
|
|
63
|
+
|
|
64
|
+
- **The cartridge menu**, when nothing is inserted.
|
|
65
|
+
- **A native world**, run by the engine above.
|
|
66
|
+
- **A Z-machine story**, run by ifvms. See [Playing story files](https://mrballistic.github.io/brass-lantern/guide/z-machine).
|
|
67
|
+
|
|
68
|
+
Inserting or ejecting a cartridge clears the screen.
|
|
69
|
+
|
|
70
|
+
## The engine
|
|
71
|
+
|
|
72
|
+
`execute` (`packages/engine/src/engine/engine.ts`) dispatches on the action to one handler per verb (`packages/engine/src/engine/verbs/`), and each handler reads the world’s rules rather than knowing any particular game.
|
|
73
|
+
|
|
74
|
+
**The object tree.** Every item has one parent: a room, the player, another item, or nowhere yet (`GameState.locations`, in `packages/engine/src/engine/model.ts`). Inventory, a room’s contents and a container’s contents are all read from that one map. Containers and surfaces nest. What the player can *see* (through glass) and *reach* (into open things) is computed from the tree, and the parser only matches what can be seen.
|
|
75
|
+
|
|
76
|
+
| Concept | Where in the world | Notes |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| Conditions | anywhere | `flag:`, `has:`, `held:`, `in:`, `visited:`, `inside:`, `open:`, `locked:`, `on:`, `here:`, `var:`, `number:`, `said:`, `target:`, `indirect:`, `direction:`, `terrain:`, `water:`, `aboard`, `seen:`, `following:`, `carrying`, `lit:`, `alive:`, `awake:`, `fighting:`, `with:`, `!`, `&`. One parser, `packages/engine/src/engine/conditions.ts`. |
|
|
79
|
+
| Rules | `instead`, `after` on items and rooms | Replace a verb’s default, or follow it. `packages/engine/src/engine/rules.ts`. |
|
|
80
|
+
| Containers, doors | `item.container`, `item.surface`, `item.door` | Open, close, lock, put in, take from. |
|
|
81
|
+
| Exits | `room.exits` | A room ID, or `{ to, if, denial, door, denials, then }`. GO runs through rules. |
|
|
82
|
+
| Vehicles | `item.vehicle`, `room.terrain` / `water` / `air`, `world.onFoot` | BOARD, DISEMBARK, and Zork’s rules for water and land, generalized to named terrains: a vehicle `travels` on some, rests on others (`packages/engine/src/engine/verbs/vehicle.ts`, `movement.ts`). |
|
|
83
|
+
| Effects | `events` | Lines and typed effects: flags, moves, variables, timers, chance, death, endings (`packages/engine/src/engine/effects.ts`). |
|
|
84
|
+
| Darkness | `room.dark`, `item.light`, `world.darkness` | In an unlit dark room you can only find what you carry (`packages/engine/src/engine/model.ts` `isLit`). |
|
|
85
|
+
| Death, endings | `world.death`, `world.endings` | `packages/engine/src/engine/death.ts`, `packages/engine/src/engine/endings.ts`. |
|
|
86
|
+
| Characters | `npcs`, `room.npcs` | Places, held things, states, hiding and descriptions (`GameState.npcs`, read through `packages/engine/src/engine/model.ts`). |
|
|
87
|
+
| Topics, orders | `npc.topics`, `npc.orders`, `npc.obeys`, `npc.refuseOrder`, `instead.order` | ASK *X* ABOUT *Y* and “*X*, do this” (`packages/engine/src/engine/verbs/talk.ts`; see [Orders](#orders) below). |
|
|
88
|
+
| Followers | `npc.follows`, `{ follow }` | Characters who go where the player goes (`packages/engine/src/engine/verbs/movement.ts`). |
|
|
89
|
+
| Combat, health | `npc.combat`, `world.combat` | Zork’s blows, tables, wounds and healing (`packages/engine/src/engine/combat.ts`). |
|
|
90
|
+
| Weight | `world.carry`, `item.size` | `packages/engine/src/engine/weight.ts`. |
|
|
91
|
+
| Scripts | `world.scripts` | The code hatch: functions that return steps (`packages/engine/src/engine/scripts.ts`). `descriptionScript` also builds descriptions from state; `{var:NAME}` and `{number}` fill in text (`packages/engine/src/engine/text.ts`). |
|
|
92
|
+
| Events | `events` | Line lists. `[Flag set: …]`, `[Added to inventory: …]` and `[… consumed]` lines change state. Events from `onEnter`, `onTake`, `onWear`, `onSmash` and `bareHanded` fire once; use-rule and gift events run every time. |
|
|
93
|
+
| Flags | `flagLabels` | The friendly label in an event line, mapped to a flag ID. |
|
|
94
|
+
| Use rules | `item.onUse` | The older form of `instead.use`. |
|
|
95
|
+
| Gifts | `npc.onGive`, `npc.refuse` | Giving takes the item; a refusal keeps it. |
|
|
96
|
+
| Gated rooms | `room.requires`, `room.denial` | |
|
|
97
|
+
| The ending | `finale` | Smash X in room Y holding Z: event, epilogue, score, footer. |
|
|
98
|
+
| Timers | `ambient` | Lines every N turns while a condition holds. Turns count only commands the engine acted on. |
|
|
99
|
+
| Score | `scoring`, `ranks`, `maxScore`, `scoreLine`, `rankLine` | Summed from flags, conditions that hold, and the `score` variable; the lines are templates. |
|
|
100
|
+
| Status line | `style`, `statusLine` | Zork’s room, score and moves in Infocom style; `MOVES: n` (or score and moves) otherwise. |
|
|
101
|
+
|
|
102
|
+
When a world needs behavior the schema can’t express, the engine gets a *generic* hook (in `packages/engine/src/types/world.ts` and the engine); it never branches on a world’s IDs. [CONTRIBUTING.md](https://github.com/mrballistic/brass-lantern/blob/main/CONTRIBUTING.md) has the details.
|
|
103
|
+
|
|
104
|
+
### Orders
|
|
105
|
+
|
|
106
|
+
“*X*, *command*” (and TELL *X* TO *command*) is its own verb, `order`, handled by `handleOrder` in `packages/engine/src/engine/verbs/talk.ts`. The pipeline, in order:
|
|
107
|
+
|
|
108
|
+
1. **Address.** The character is matched in the player’s room, or among those whose `heardFrom` names it. No one matching is a miss.
|
|
109
|
+
2. **`instead.order` rules** answer first, and the order’s words stay words (they aren’t resolved as things).
|
|
110
|
+
3. **Parse the inner command** with `fallbackParse`, as the player would have typed it. A character with neither `orders` nor `obeys` stops here with `refuseOrder`.
|
|
111
|
+
4. **Resolve its objects before anything changes**, among what the *character* can reach (its room’s things and what it holds), plus ME (the player), a typed number, and other characters present. A name that matches nothing is a miss; one that matches several is a question, and neither takes time or changes state.
|
|
112
|
+
5. **`orders` rules**, keyed by the inner verb (or the first typed word when the parsed verb has no table, so the parsed verb wins a clash). Their conditions see the inner command through the same command record rules use: `target:`, `indirect:`, `number:`, `said:`, `direction:`.
|
|
113
|
+
6. **`obeys`**: if no rule answered, the engine performs GO, TAKE, DROP or GIVE ME itself, moving the character or its things. A refusal here is understood and takes a turn.
|
|
114
|
+
7. **Ends the line.** A result for a character with `orders` or `obeys` carries `stopLine`, and the store drops the rest of the typed line (Zork’s P-CONT). A miss never does, so the intent-server retry isn’t affected.
|
|
115
|
+
|
|
116
|
+
**Adding a verb** is usually a world change: declare it in `world.verbs` and give things `instead` rules for it. The parser learns its words from the world, and the browser tells the intent server which world verbs to accept, so neither needs editing.
|
|
117
|
+
|
|
118
|
+
A *built-in* verb, with default behavior in the engine, is a change to the engine itself (for contributors; see [CONTRIBUTING.md](https://github.com/mrballistic/brass-lantern/blob/main/CONTRIBUTING.md)), and takes three edits:
|
|
119
|
+
- the regex in `packages/engine/src/engine/parser.ts`, plus its words in `BUILT_IN_WORDS`;
|
|
120
|
+
- the dispatcher in `packages/engine/src/engine/engine.ts` (wrapped in `withRules`), plus its HELP text;
|
|
121
|
+
- `ACTION_VOCAB` in `packages/server/src/llm.ts`. Without that last one, the server throws away the LLM’s answer as an unknown verb.
|
|
122
|
+
|
|
123
|
+
**Saves** are format 2.0. A 1.0 save (from before the object tree) is converted on load by `packages/engine/src/engine/migrate.ts`, so players keep their games. A 2.0 save that has no place for an item (one a later version of the world added) gets that item’s starting place; an item recorded as `null` (used up) stays gone.
|
|
124
|
+
|
|
125
|
+
## The terminal
|
|
126
|
+
|
|
127
|
+
The CRT is hand-written CSS (`packages/vue/src/styles/crt.css`), with no canvas and no UI framework. Every rule is scoped to the game’s shell, which fills its container, so the effects stay inside the game’s box on any page:
|
|
128
|
+
- a boot sequence, scanlines, phosphor bloom and a barrel vignette;
|
|
129
|
+
- flicker at two rates, plus occasional glitches;
|
|
130
|
+
- phosphor decay on older lines, and a block cursor with a square-wave blink.
|
|
131
|
+
|
|
132
|
+
The default look, `crt-amber`, is deliberately period-accurate rather than WCAG-compliant. The colors are twelve `--bl-*` role variables set by a **theme**, so a game can ship `crt-green`, a plain `simple` look (light or dark, following the system) or a palette of its own, and the player can switch with THEME. Reduced motion turns off flicker, glitch and noise. [Using the library](https://mrballistic.github.io/brass-lantern/guide/using-the-library#themes) has the details.
|
|
133
|
+
|
|
134
|
+
Each output line is classified by its first characters (`packages/engine/src/engine/output.ts`). The class sets both its style and its typewriter speed:
|
|
135
|
+
|
|
136
|
+
| Prefix | Type | Speed |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `> ` | your input | instant |
|
|
139
|
+
| `📍` | room header | 10ms/char |
|
|
140
|
+
| event emoji (`💼🌀👔💕💻💾🔨💥📎✨🪄🤜😖😴🐟🌺📬📞`) | scripted moment | 18ms/char |
|
|
141
|
+
| `═`, `“` | banner, narration | 12ms/char |
|
|
142
|
+
| `[` | system | instant |
|
|
143
|
+
| anything else | prose | 10ms/char |
|
|
144
|
+
|
|
145
|
+
A restored session renders instantly, with no typewriter replay, and its boot sequence is shortened. The header shows the game’s `terminalName` and `version` options, then the cartridge’s title (in builds with a menu); on the right, a native world’s status line (`MOVES: n`, or Zork’s “West of House Score: 0 Moves: 0” in Infocom style) or the story’s own, and a COOKIES button when analytics are configured.
|
|
146
|
+
|
|
147
|
+
## Saves and analytics
|
|
148
|
+
|
|
149
|
+
- **Saves** live in `localStorage` only, one per cartridge, under `<storagePrefix>:save:<cartridge id>` (or the cartridge’s `saveKey`), with up to 500 lines of history. [Cartridges and storage](https://mrballistic.github.io/brass-lantern/reference/cartridges#browser-storage) lists every key. They’re written after every change. SAVE *name* keeps an extra copy under `<save key>:named:<name>`, and RESTORE *name* brings it back. If storage is unavailable (private browsing), play continues and SAVE says so.
|
|
150
|
+
- **UNDO** history is memory only: up to 50 snapshots, gone on reload, RESTART or RESTORE.
|
|
151
|
+
- **SCRIPT** downloads a transcript as a text file when it stops; nothing is stored.
|
|
152
|
+
- **Analytics** are the app’s, not the library’s: the packages send nothing anywhere. A game tells `GameOptions.analytics.onEvent` about `game_start` and `session_resumed` (each with a `cartridge` parameter for story files) and, for native worlds, `game_completed` with the move count; what happens next, and whether the player has consented, is up to the app. Given `analytics.openConsent`, the header shows a COOKIES link and the COOKIES command calls it. The demo site, for example, sends those events to Google Analytics only when `VITE_GA_MEASUREMENT_ID` is set at build time, and only after the player accepts its `ConsentBanner`; it honors Do Not Track, and a build without an ID shows no banner.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# The intent server
|
|
2
|
+
|
|
3
|
+
Players don’t type like parsers. The intent server hears what the regex parser couldn’t handle, such as “make that thing stop beeping” or “pocket my billfold”, and asks an LLM which of the engine’s verbs and IDs that means. It’s optional: without it the game is fully playable, and loose phrasing just gets a “didn’t understand” reply.
|
|
4
|
+
|
|
5
|
+
It ships as `@brass-lantern/server`, with `parseIntent` (a function) and `intentRoute` (an Express router). The repo also has a small Express app around them for the demo, described under [Run the repo’s server](#run-the-repo-s-server).
|
|
6
|
+
|
|
7
|
+
## Use it in your own backend
|
|
8
|
+
|
|
9
|
+
The package reads no environment variables; you pass the key in. [Using the library](https://mrballistic.github.io/brass-lantern/guide/using-the-library#the-intent-server) has the full wiring:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { intentRoute } from '@brass-lantern/server/express';
|
|
13
|
+
|
|
14
|
+
app.set('trust proxy', 'loopback'); // behind a reverse proxy on the same machine
|
|
15
|
+
app.use(express.json({ limit: '32kb' }));
|
|
16
|
+
app.use('/api', intentRoute({ apiKey, models, timeoutMs, rateLimitPerMinute }));
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Behind Apache or nginx, `trust proxy` lets the rate limit see each player’s address; without it every request seems to come from the proxy, and all your players share one bucket. Then set `intentEndpoint: '/api/parse-intent'` in the game’s options. Without it (or with `null` or an empty string) the game never asks.
|
|
20
|
+
|
|
21
|
+
## Run the repo’s server
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install # at the repo root: installs every workspace
|
|
25
|
+
cd packages/server
|
|
26
|
+
cp .env.example .env # then set GEMINI_KEY (get one at https://aistudio.google.com/apikey)
|
|
27
|
+
npm run dev # http://127.0.0.1:3001
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
With `npm run dev` running at the repo root too, Vite proxies `/api/*` to it, so the browser just calls its own origin. This app reads its settings from the environment (below); the library does not.
|
|
31
|
+
|
|
32
|
+
**Keep the key on the server**
|
|
33
|
+
The browser never talks to Google; only this server does, with the key in a request header. **Never give the key a `VITE_` prefix.** Vite inlines `VITE_*` variables into the public bundle.
|
|
34
|
+
|
|
35
|
+
## The API
|
|
36
|
+
|
|
37
|
+
`POST /api/parse-intent`
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"input": "hand gary his mug",
|
|
42
|
+
"context": {
|
|
43
|
+
"roomName": "Hallway",
|
|
44
|
+
"exits": ["south", "cubicle", "north", "break_room"],
|
|
45
|
+
"items": ["mug"],
|
|
46
|
+
"npcs": ["gary (Gary)"],
|
|
47
|
+
"inventory": ["stapler"],
|
|
48
|
+
"verbs": []
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Response: `{ "action": "give", "target": "mug", "indirect": "gary" }`, or `{ "action": "unknown" }` when there’s no good reading or every model fails. Items whose name matches their ID are sent as the bare ID; others as `id (name)`.
|
|
54
|
+
|
|
55
|
+
The reply can also carry a `prep` (`put … under`, `throw … off`, `read … through`), a `direction` (`push … north`), and a `number` (`turn dial to 4`, 0 to 1000; the client keeps it only when `target` or `indirect` is the word `number` or its digits). For an order (`tell robot to take lamp`), `indirect` is the command as a few plain lowercase words instead of an identifier; the engine parses it as it would typed text.
|
|
56
|
+
|
|
57
|
+
`verbs` lists the world’s own verbs, which the server accepts alongside its built-in list. Bad requests and rate limiting get an error status instead: 400 with only an `error` field for a missing input or malformed context; 400 with `fallback` as well for input over 200 characters; 429 (with `Retry-After`) and 500, both with `fallback`. The `fallback` is always `{ "action": "unknown" }`. The bundled client treats any non-OK response as `unknown`, so the player just sees the literal reply.
|
|
58
|
+
|
|
59
|
+
The repo’s server app also answers `GET /health` with `{"ok":true}`; `intentRoute` itself serves only `POST /parse-intent`.
|
|
60
|
+
|
|
61
|
+
## How it decides
|
|
62
|
+
|
|
63
|
+
The server calls Gemini’s REST API directly, with no SDK:
|
|
64
|
+
|
|
65
|
+
- **Structured output.** `responseMimeType: application/json` with a schema whose verb field is an enum of the engine’s actions. Temperature 0, minimal thinking, 150 output tokens.
|
|
66
|
+
- **A model chain inside one deadline.** It tries `gemini-3.5-flash-lite`, then `gemini-3.6-flash`, within 5 seconds in total. Every attempt but the last is capped at 2.5s, so a stalled model still leaves time for the fallback.
|
|
67
|
+
- **Error handling.** A retired model (404), rate limiting (429) and server errors (5xx) move on to the next model. Bad requests and bad keys (400/401/403) stop, since every model would repeat them.
|
|
68
|
+
- **Sanitizing.** The reply must name a known verb, and `target` / `indirect` must look like identifiers. Anything else is dropped, so model output can never carry prose to the player.
|
|
69
|
+
- **The prompt** treats player input as data, and tells the model to answer with IDs from the context.
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
These apply to the repo’s own server app. All of them are read in `packages/server/src/config.ts`; the library entry points take the same settings as parameters instead.
|
|
74
|
+
|
|
75
|
+
| Variable | Default | |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `GEMINI_KEY` | (required) | The server refuses to start without it. |
|
|
78
|
+
| `GEMINI_MODELS` | `gemini-3.5-flash-lite,gemini-3.6-flash` | Comma-separated, tried in order. `GEMINI_MODEL` (one model) also works. |
|
|
79
|
+
| `PORT` | `3001` | |
|
|
80
|
+
| `HOST` | `127.0.0.1` | Loopback by default; put a reverse proxy in front. |
|
|
81
|
+
| `RATE_LIMIT_PER_MINUTE` | `30` | Per client, per process. |
|
|
82
|
+
|
|
83
|
+
Google retires models regularly. A retired model just answers 404 and the chain moves on, so update the list when you notice, before the last one goes.
|
|
84
|
+
|
|
85
|
+
## Abuse limits
|
|
86
|
+
|
|
87
|
+
There’s no auth, by design: the endpoint only classifies short commands. Its cost is bounded instead:
|
|
88
|
+
|
|
89
|
+
- **Input caps:** 200 characters of input, and context lists of at most 50 entries of 100 characters each.
|
|
90
|
+
- **Rate limit:** per client, with IPv6 bucketed by /64 so one user can’t rotate addresses, plus a global ceiling.
|
|
91
|
+
- **No CORS headers**, so other websites can’t spend your quota from their visitors’ browsers.
|
|
92
|
+
- **Your provider’s quota** as the backstop. Keep a spending limit on the key.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Porting Zork
|
|
2
|
+
|
|
3
|
+
Brass Lantern ships Zork I twice: the original story file, run by the Z-machine interpreter, and **ZORK I · NATIVE**, a rebuild as an ordinary Brass Lantern world (`packages/engine/src/worlds/zork1.ts`). The native version is how the engine proves it can carry an Infocom-class game. A test plays both side by side and fails if they disagree.
|
|
4
|
+
|
|
5
|
+
## How ZIL maps to a world
|
|
6
|
+
|
|
7
|
+
Zork was written in ZIL, Infocom’s language. Its source is MIT licensed ([historicalsource/zork1](https://github.com/historicalsource/zork1)), and most of it translates directly:
|
|
8
|
+
|
|
9
|
+
| ZIL | Brass Lantern |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `<ROOM …>` with `LDESC` | a room with `description` |
|
|
12
|
+
| `<ROOM …>` with an `M-LOOK` routine | `descriptions: [{ if, text }]`, the first that holds |
|
|
13
|
+
| `(NORTH TO X)` | `exits: { north: 'x' }` |
|
|
14
|
+
| `(EAST "The door is boarded…")` | `exits: { east: { denial: '…' } }` |
|
|
15
|
+
| `(WEST TO KITCHEN IF KITCHEN-WINDOW IS OPEN)` | `exits: { west: { to: 'kitchen', door: 'kitchen_window' } }` |
|
|
16
|
+
| `<OBJECT …>` | an item |
|
|
17
|
+
| `CONTBIT`, `OPENBIT`, `TRANSBIT` | `container: { openable, open, transparent }` |
|
|
18
|
+
| `SURFACEBIT` | `surface: true` |
|
|
19
|
+
| `NDESCBIT` | `scenery: true` |
|
|
20
|
+
| `LOCAL-GLOBALS` and a room’s `GLOBAL` list | `room.scenery: [...]` |
|
|
21
|
+
| `FDESC` / `LDESC` on an object | `initialDescription` / `roomDescription` |
|
|
22
|
+
| `TEXT` | `text` (READ) |
|
|
23
|
+
| `LIGHTBIT` | `switchable: true, light: true` |
|
|
24
|
+
| an object’s `ACTION` routine | `instead` and `after` rules |
|
|
25
|
+
| a room’s `VALUE` | an `onEnter` event that sets a scored flag |
|
|
26
|
+
| a verb only some objects understand (MOVE, COUNT) | a [world verb](https://mrballistic.github.io/brass-lantern/reference/world-schema#world-verbs) |
|
|
27
|
+
| a room without `ONBIT` | `dark: true` |
|
|
28
|
+
| `LIT?` | the engine’s light check (a `light` item switched on, in sight) |
|
|
29
|
+
| an interrupt (`QUEUE`, `ENABLE`) | a fuse (`schedule`) or a daemon. Zork’s clock counts a `QUEUE X n` from a command down that same turn and the engine’s from the next, so it becomes `in: n-1`; an interrupt requeuing itself stays `in: n` |
|
|
30
|
+
| `I-LANTERN` and `LAMP-TABLE` | a `lamp_fuel` variable and daemons that warn at its thresholds |
|
|
31
|
+
| `JIGS-UP` | the `die` effect and `world.death` |
|
|
32
|
+
| `PROB` | the `chance` effect |
|
|
33
|
+
| a room’s `M-ENTER` | `onEnter`, which Infocom style runs before the description |
|
|
34
|
+
| a `PER` exit routine with several refusals (the chimney) | an exit’s `denials` |
|
|
35
|
+
| the parser’s ORPHAN and WHICH-PRINT (“Which door do you mean, the wooden door or the trap door?”) | built in: a question, answered by the next line |
|
|
36
|
+
| the parser’s OOPS and AGAIN | built in |
|
|
37
|
+
| an object with no `TEXT` (EXAMINE lists a container’s contents) | `description: ''` |
|
|
38
|
+
| `ACTORBIT` objects | characters (`npcs`), with places, things they hold (`holds`) and descriptions by state |
|
|
39
|
+
| `VILLAINS`, `HERO-BLOW`, `VILLAIN-BLOW`, the melee tables | a character’s `combat` block and `world.combat` (the tables are the engine’s) |
|
|
40
|
+
| a villain’s ACTION modes (`F-DEAD`, `F-UNCONSCIOUS`, `F-CONSCIOUS`, `F-BUSY?`, `F-FIRST?`) | `onDeath`, `onUnconscious`, `onWake`, `onBusy`, `firstStrike` |
|
|
41
|
+
| `SIZE`, `CAPACITY`, `LOAD-ALLOWED`, `FUMBLE-NUMBER` | `size`, `container.weight`, `carry` |
|
|
42
|
+
| `V-DIAGNOSE`, `I-CURE` | DIAGNOSE and healing, built in |
|
|
43
|
+
| an `ACTION` routine data can’t express (`I-SWORD`, the troll catching what you throw) | a [script](https://mrballistic.github.io/brass-lantern/reference/world-schema#scripts) |
|
|
44
|
+
| a `PER` exit routine with a side effect (`UP-CHIMNEY-FUNCTION`) | an exit’s `then` event |
|
|
45
|
+
| the parser’s GWIM for ATTACK (“(with the sword)”) | built in, Infocom style |
|
|
46
|
+
| `TVALUE` (points while a treasure is in the case) | a score entry with a condition: `{ if: 'inside:painting:trophy_case', points: 6 }` |
|
|
47
|
+
| `TVALUE` as the thief reads it (what’s worth stealing) | `treasure: 6` on the item, read by `ctx.treasure` |
|
|
48
|
+
| `INVISIBLE` on a character (the thief, lurking) | `hidden: true`, or `{ npcState, hidden }` to change it |
|
|
49
|
+
| `INVISIBLE` on an object (the stiletto until the thief dies) | the `hide` and `reveal` effects |
|
|
50
|
+
| `SACREDBIT`, `MAZEBIT` | room `tags: ['sacred']`, `tags: ['maze']`, read by `ctx.tags` |
|
|
51
|
+
| `I-THIEF` and `ROBBER-FUNCTION` | a daemon running a script (`thief_turn`), with combat hooks and rules on the thief |
|
|
52
|
+
| the cyclops’s daemon and `CYCLOWRATH` | a daemon script and a variable |
|
|
53
|
+
| `ASK`/`TELL … ABOUT` and orders (“thief, give me the bag”) | `topics`, `refuseOrder` and `instead.order` on a character |
|
|
54
|
+
| an object `ACTION` that prints and returns false, so the verb goes on | an `instead` rule with `continue: true` |
|
|
55
|
+
| `JIGS-UP` resetting things (the trap door) | `death.then` |
|
|
56
|
+
| a raw-input loop in a room’s M-ENTER (the Loud Room) | a room’s `capture`, with `{ free: true }` replies |
|
|
57
|
+
| a room’s M-END | the room’s `onEnd` |
|
|
58
|
+
| V-WAIT’s three turns of the clock | `world.wait: { turns: 3 }` |
|
|
59
|
+
| `FCLEAR … TOUCHBIT` on a room | `{ unvisit: 'room' }` |
|
|
60
|
+
| `FSET … NDESCBIT` in play (the tied rope) | `{ unlist }` and `{ relist }` |
|
|
61
|
+
| `GOTO room <>` (no description: the mirror) | `{ go: 'room', quiet: true }` |
|
|
62
|
+
| `PICK-ONE` (no repeats until all are used) | a script: `pickOne` in `packages/engine/src/worlds/zork1.ts` |
|
|
63
|
+
| `PROB n m` (ZPROB: worse odds once LUCKY is false) | a script checking the `unlucky` flag |
|
|
64
|
+
| PRSI’s routine before PRSO’s | the rule order in Infocom style |
|
|
65
|
+
| `SACREDBIT` on an object (the platinum bar) | the item tag `sacred`, which the thief script respects |
|
|
66
|
+
| JIGS-UP’s branches: Hades once you’ve seen the Altar, dying while dead, “Bad luck, huh?” | `death.variants`, `death.instead`, a conditional `death.message` line |
|
|
67
|
+
| `ALWAYS-LIT` for a spirit | `darkness.litIf` |
|
|
68
|
+
| DEAD-FUNCTION | a world `capture` while `flag:dead` |
|
|
69
|
+
| BURN (LIGHT … WITH) and FLAMEBIT/BURNBIT | the BURN verb, `flaming` and `burnable` |
|
|
70
|
+
| VEHBIT and VTYPE (the magic boat) | an item’s `vehicle: { travels: 'water' }`, BOARD and DISEMBARK |
|
|
71
|
+
| NONLANDBIT on a room (the river, the reservoir at high tide) | the room’s `water` (a condition for the reservoir) |
|
|
72
|
+
| a vehicle’s M-BEG and M-END | its rules (asked before the room’s) and its `onEnd`; Zork’s RBOAT M-BEG is in the world capture |
|
|
73
|
+
| I-RIVER, RIVER-SPEEDS, RIVER-LAUNCH | a self-requeuing fuse and LAUNCH’s table, in scripts |
|
|
74
|
+
| GOTO’s grue from one dark room into another | `darkness.stumble` |
|
|
75
|
+
| PRINT-CONT’s “(outside the magic boat)” and first-seen-first order | built in, Infocom style |
|
|
76
|
+
| EMPTY-HANDED and WEIGHT (the narrow passage: nothing heavier than 4) | an exit’s `if: 'heaviest<=4'` |
|
|
77
|
+
| a room’s M-ENTER that acts before you look (the bat’s FLY-ME) | `onEnter` with `{ look: true }` first, then a script that picks a room |
|
|
78
|
+
| a room’s M-END that checks the turn’s command (BOOM-ROOM) | the room’s `onEnd` running a script that reads `ctx.command` |
|
|
79
|
+
| two objects standing for one thing at two places (the basket on its chain) | two items swapped by the RAISE and LOWER rules; things inside travel with the real one |
|
|
80
|
+
| NO-OBJS’ LIGHT-SHAFT score on the first lit turn | the room’s `onEnd` setting a flag that `scoring` counts |
|
|
81
|
+
| HACK-HACK’s random endings (V-WAVE, V-RAISE) | a world verb’s fixed `reply`, naming its object with `{target}` |
|
|
82
|
+
| SCORE-UPD’s win at 350 (the whisper, the map, the secret path) | a daemon on `score>=350 & !flag:won` |
|
|
83
|
+
| FINISH (the score, then RESTART/RESTORE/QUIT) | an `endings` entry with `score: true` and a footer |
|
|
84
|
+
| GOTO’s “You have moved into a dark place.” | `darkness.arrive` |
|
|
85
|
+
| TOUCHBIT set by OPEN, ROB and the like | built in for OPEN (Infocom style); the `{ touch }` step elsewhere |
|
|
86
|
+
| the parser’s GWIM for a missing tool (“(with the shovel)”) | a script picking the one TOOLBIT thing held |
|
|
87
|
+
| MOVE putting things first in a room (newest-first listings) | built in for things; a character who arrived this turn is listed first |
|
|
88
|
+
|
|
89
|
+
`style: 'infocom'` makes the engine follow Zork’s conventions:
|
|
90
|
+
- “There is a sword here.”;
|
|
91
|
+
- brief descriptions of rooms you’ve seen;
|
|
92
|
+
- newest-first listings;
|
|
93
|
+
- Zork’s SCORE line and status line (“West of House Score: 0 Moves: 0”);
|
|
94
|
+
- Zork’s questions (“What do you want to take?”) and TAKE ALL (“lamp: Taken.”, including what can’t be taken, and why).
|
|
95
|
+
|
|
96
|
+
## The differential test
|
|
97
|
+
|
|
98
|
+
`packages/engine/tests/worlds/zork1-diff.test.ts` runs a walkthrough through both versions and compares every reply, ignoring case, spacing and quote style. A mismatch prints both sides:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
> open sack
|
|
102
|
+
native: Opening the brown sack reveals a lunch, and a clove of garlic.
|
|
103
|
+
original: Opening the brown sack reveals a clove of garlic, and a lunch.
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- **The walkthrough** covers the early slices: the house and forest, the cellar and the gallery with the lamp lit, the parser (a bare TAKE and its answer, “Which door do you mean?”, AGAIN, OOPS, TAKE ALL and DROP ALL), the troll, the maze, and the cyclops (ULYSSES).
|
|
107
|
+
- **Scripted sessions** (`packages/engine/tests/worlds/zork1-sessions.test.ts`) check every puzzle: each is a short command list played from the Round Room in both versions and compared reply by reply, its wrong orders and side branches included (the dam, the leak, the Loud Room, the mirrors, the rope, the exorcism, the candles, ghost mode). **Both sides are seeded**: a test-only option seeds the interpreter’s own generator (`new ZMachineSession(…, { seed })`), so each session plays the same way every time, with a seed pinned per side where the thief stays away.
|
|
108
|
+
- **Fights are random,** and our dice aren’t Zork’s, so the walkthrough has **sync points**: walking into the Troll Room, each side retries until the troll doesn’t strike first, and at the fight each side attacks until its troll is dead. The original retries by starting its session again, the native port by trying another seed. The thief wanders at random from the first move, so either side starts over on the next seed whenever he shows up.
|
|
109
|
+
- **`packages/engine/tests/worlds/zork1-fight.test.ts`** checks the fight itself: it fights the real troll 300 times, collects everything it prints, and requires every line of 200 native fights to be one the original prints. It found a difference between Zork’s source and its story file: the source gives a knocked-out player’s foes extra rounds, and Release 119 doesn’t. The story file wins.
|
|
110
|
+
- **`packages/engine/tests/worlds/zork1-thief.test.ts`** does the same for the thief: 60 original sessions wait in the dark cellar with a treasure until he comes, and every line 100 native runs print must be one the original prints.
|
|
111
|
+
- **The whole game** (`packages/engine/tests/worlds/zork1-full.ts`, adapted from the walkthrough Microsoft’s Jericho project ships for Zork I) runs natively from the first move to the barrow on a pinned seed, and must end with every treasure in the case, 350 points, the whisper, the map’s text and the closing text. **`packages/engine/tests/worlds/zork1-chapters.test.ts`** compares it with the original in nine chapters: for chapter *k* both sides replay chapters 1 to *k* and compare chapter *k* reply by reply, fights being sync points (`@fight <foe> with <weapon>`). Each chapter has a seed pair on which the two sides agree; none falls back to line sets.
|
|
112
|
+
- **`packages/engine/tests/worlds/zork1-thief-timing.test.ts`** measures the thief: 40 seeds a side wait in the Round Room until he first shows, and the medians must agree within 25% (measured: 17 turns each).
|
|
113
|
+
- **The room order is read from the story file.** `packages/engine/tests/helpers/zobjects.ts` decodes `zork1.z3`’s object tree, and the native world’s rooms must follow it, since the thief walks rooms in that order.
|
|
114
|
+
- **Expected differences** go in `packages/engine/tests/worlds/zork1-allowlist.ts`, each with a reason, and the test fails if one stops being different. Two kinds: a misspelled word, where Zork says “I don’t know the word” and the native engine says it sees no such thing (and, in the app, asks the intent server; the OOPS that follows it matches); and replies after the fight that depend on how it went (DIAGNOSE, SCORE’s move count).
|
|
115
|
+
- **Random lines** the original prints (the distant songbird) are filtered out: the engine’s generator is seeded and reproducible, but it can’t replay Zork’s own. Random outcomes (the grue, where things scatter when you die) are pinned by seeded unit tests instead, and the death texts are checked against a real death in the original.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Testing a world
|
|
2
|
+
|
|
3
|
+
A world is data, and data rots quietly: an exit points at a renamed room, a flag label gets a typo, the ending becomes unreachable. Two kinds of test catch nearly all of it, and both run in plain Node with any test runner. The examples here use [Vitest](https://vitest.dev/).
|
|
4
|
+
|
|
5
|
+
## Play it
|
|
6
|
+
|
|
7
|
+
`createGame` from `@brass-lantern/engine` runs a world with no browser: feed it the lines a player would type, and read the replies. A test reads like a transcript:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
import { createGame } from '@brass-lantern/engine';
|
|
12
|
+
import { tutorial } from '@brass-lantern/engine/worlds';
|
|
13
|
+
|
|
14
|
+
describe('Snack Attack', () => {
|
|
15
|
+
it('plays to the best ending', () => {
|
|
16
|
+
const game = createGame(tutorial, { seed: 1 });
|
|
17
|
+
const text = [
|
|
18
|
+
'open drawer',
|
|
19
|
+
'take stapler',
|
|
20
|
+
'north',
|
|
21
|
+
'take mug and give mug to gary',
|
|
22
|
+
'talk to gary',
|
|
23
|
+
'north',
|
|
24
|
+
'smash machine with stapler',
|
|
25
|
+
].flatMap((line) => game.send(line).lines).join('\n');
|
|
26
|
+
|
|
27
|
+
expect(game.state.gameOver).toBe(true);
|
|
28
|
+
expect(text).toContain('You share the pretzels');
|
|
29
|
+
expect(text).toContain('[Score: 50 of 50');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Swap `tutorial` for your own world (`import { myWorld } from './my-world'`). `seed` makes the game’s randomness repeatable, so a world with chance effects or fights plays the same way on every run.
|
|
35
|
+
|
|
36
|
+
Write at least two: the **shortest win**, and a run that **earns every point**. When either breaks, you’ve changed the critical path or the scoring, on purpose or not.
|
|
37
|
+
|
|
38
|
+
To pin every word, compare the whole transcript: `expect(text).toMatchInlineSnapshot()` fills itself in on the first `npx vitest -u`, and from then on any change to the text fails the test until you look at it.
|
|
39
|
+
|
|
40
|
+
`send` takes a whole line, as the player would type it: chained commands, pronouns, AGAIN, OOPS, UNDO and the answer to a question all work. When the game asks “Which door do you mean?”, the reply’s `awaiting` is true and the next `send` answers it. There’s no intent server in a test, so write commands the regex parser understands, and name things with words the world knows. Every word of three or more letters has to be in the thing’s name, an alias or its ID, so `take red ball` misses a thing called only “ball” (“You don’t see a “red ball” here.”). In brass style a close misspelling of a word in the name also matches. In the browser the intent server might rescue that; in a test it fails, which is how you find the alias players will need. Since 2.1.0 this is stricter than it was, so a test that passed on 2.0 can start failing here: add the alias (see [How names are matched](https://mrballistic.github.io/brass-lantern/guide/building-worlds/#how-names-are-matched)).
|
|
41
|
+
|
|
42
|
+
## Check the data
|
|
43
|
+
|
|
44
|
+
`auditWorld(world)` returns a list of problems, one string each, and an empty list when it finds none:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { expect, it } from 'vitest';
|
|
48
|
+
import { auditWorld } from '@brass-lantern/engine';
|
|
49
|
+
import { myWorld } from './my-world';
|
|
50
|
+
|
|
51
|
+
it('has no broken references', () => {
|
|
52
|
+
expect(auditWorld(myWorld)).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
It finds the mistakes that fail silently in play:
|
|
57
|
+
|
|
58
|
+
- effects naming items, rooms, events or endings that don’t exist, and unknown effects;
|
|
59
|
+
- events named by rules, `onEnter`, `onTake`, `onWear`, `onSmash`, `onGive`, daemons and the finale that don’t exist;
|
|
60
|
+
- conditions of unknown kinds, or naming items and rooms that don’t exist (in rules, triggers, exits, `requires`, daemons, hints and scoring); `direction:` takes any lowercase word, since an `orders.go` rule sees the place as typed (`direction:basement`);
|
|
61
|
+
- exits to nowhere, doors that aren’t items, items listed in rooms or containers that don’t exist;
|
|
62
|
+
- a world verb word that a built-in verb already owns, and the reserved IDs `player` and `number`.
|
|
63
|
+
|
|
64
|
+
It doesn’t walk the map. A few more checks are worth writing for your own world. Every room reachable from the start:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
it('every room is reachable from the start', () => {
|
|
68
|
+
const seen = new Set<string>();
|
|
69
|
+
const queue = [myWorld.startRoom];
|
|
70
|
+
while (queue.length) {
|
|
71
|
+
const id = queue.shift()!;
|
|
72
|
+
if (seen.has(id)) continue;
|
|
73
|
+
seen.add(id);
|
|
74
|
+
for (const exit of Object.values(myWorld.rooms[id]?.exits ?? {})) {
|
|
75
|
+
const to = typeof exit === 'string' ? exit : exit.to;
|
|
76
|
+
if (to) queue.push(to);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
expect(Object.keys(myWorld.rooms).filter((id) => !seen.has(id))).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
More along the same lines: every `[Flag set: …]` label is in `flagLabels`; every flag in a `requires`, hint or scoring entry can actually be set by some event; every gated room has a `denial`; every `listExits` label is a real exit.
|
|
84
|
+
|
|
85
|
+
**Copy style** is easy to test too. If you use curly quotes, fail on straight ones:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
it('uses smart punctuation', () => {
|
|
89
|
+
const strings: string[] = [];
|
|
90
|
+
const walk = (v: unknown): void => {
|
|
91
|
+
if (typeof v === 'string') strings.push(v);
|
|
92
|
+
else if (Array.isArray(v)) v.forEach(walk);
|
|
93
|
+
else if (v && typeof v === 'object') Object.values(v).forEach(walk);
|
|
94
|
+
};
|
|
95
|
+
walk(myWorld);
|
|
96
|
+
expect(strings.filter((s) => /["']/.test(s))).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
That walks every string in the world, IDs and conditions included, which is fine as long as those don’t use quotes either.
|