@brass-lantern/mcp 0.0.1-stage → 2.3.1

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +37 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +19 -0
  5. package/dist/docs/llms.txt +35 -0
  6. package/dist/docs/pages/guide/building-worlds/index.md +218 -0
  7. package/dist/docs/pages/guide/building-worlds/recipes.md +1406 -0
  8. package/dist/docs/pages/guide/building-worlds/two-rooms.md +339 -0
  9. package/dist/docs/pages/guide/coding-agents.md +124 -0
  10. package/dist/docs/pages/guide/deploying.md +56 -0
  11. package/dist/docs/pages/guide/getting-started.md +72 -0
  12. package/dist/docs/pages/guide/how-it-works.md +152 -0
  13. package/dist/docs/pages/guide/intent-server.md +92 -0
  14. package/dist/docs/pages/guide/porting-zork.md +115 -0
  15. package/dist/docs/pages/guide/testing.md +100 -0
  16. package/dist/docs/pages/guide/using-the-library.md +272 -0
  17. package/dist/docs/pages/guide/your-first-world.md +206 -0
  18. package/dist/docs/pages/guide/z-machine.md +96 -0
  19. package/dist/docs/pages/index.md +58 -0
  20. package/dist/docs/pages/reference/cartridges.md +47 -0
  21. package/dist/docs/pages/reference/commands.md +79 -0
  22. package/dist/docs/pages/reference/conditions-and-events.md +129 -0
  23. package/dist/docs/pages/reference/world-schema.md +592 -0
  24. package/dist/docs/pages.json +74 -0
  25. package/dist/docs.d.ts +21 -0
  26. package/dist/docs.js +89 -0
  27. package/dist/engine.d.ts +16 -0
  28. package/dist/engine.js +42 -0
  29. package/dist/job.d.ts +22 -0
  30. package/dist/job.js +29 -0
  31. package/dist/load.d.ts +22 -0
  32. package/dist/load.js +106 -0
  33. package/dist/run.d.ts +16 -0
  34. package/dist/run.js +57 -0
  35. package/dist/server.d.ts +47 -0
  36. package/dist/server.js +97 -0
  37. package/dist/stdio.d.ts +25 -0
  38. package/dist/stdio.js +75 -0
  39. package/dist/tools.d.ts +8 -0
  40. package/dist/tools.js +82 -0
  41. package/dist/worker.d.ts +1 -0
  42. package/dist/worker.js +6 -0
  43. package/package.json +47 -1
@@ -0,0 +1,1406 @@
1
+ # Recipes
2
+
3
+ Each recipe is a small, complete world about one idea, in [`packages/engine/src/worlds/examples/`](https://github.com/mrballistic/brass-lantern/tree/main/packages/engine/src/worlds/examples). Every transcript on this page is what [a test](https://github.com/mrballistic/brass-lantern/blob/main/packages/engine/tests/worlds/examples/pages.test.ts) gets by typing its commands into that world.
4
+
5
+ **How a recipe fits into a world.** A recipe is a whole world file, built like the [two-room game](https://mrballistic.github.io/brass-lantern/guide/building-worlds/two-rooms): one `World` object with rooms, items, characters and events. The code blocks under each heading show the part of the file the recipe is about; the rest (the rooms around it, the events it names, its `seed`) is in **The whole world**, folded under the notes. To put an idea in your own game, copy the parts it shows into your world’s object, along with anything they name.
6
+
7
+ **To play a recipe**, you don’t need to copy anything: every one ships with the engine under the name its file exports. Set up a project as in [Play it](https://mrballistic.github.io/brass-lantern/guide/building-worlds/two-rooms#play-it) and type each `> ` line of a transcript, without the `> `:
8
+
9
+ ```ts
10
+ import { createGame } from '@brass-lantern/engine';
11
+ import { containers } from '@brass-lantern/engine/worlds';
12
+
13
+ const game = createGame(containers);
14
+ for (const command of ['take key', 'open jar', 'take key from jar']) {
15
+ console.log(`> ${command}`);
16
+ console.log(game.send(command).lines.join('\n'));
17
+ }
18
+ ```
19
+
20
+ Each transcript starts from a new game (a fresh `createGame`), just after its opening lines (`game.opening`). Where randomness matters, the world’s `seed` fixes it, so the same commands give the same replies every time.
21
+
22
+ **Rules** appear in every recipe. They hang off a verb (`instead: { examine: [ … ] }`), as a list:
23
+
24
+ - **`instead`** rules replace what the verb does; **`after`** rules run once it has worked.
25
+ - **A rule’s keys:** `if` is a [condition](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#conditions) that has to hold; `with` names the second object it applies to (`unlock tin with key`); `then` names an event to run; `say` prints lines instead.
26
+ - **The first rule that fits wins.** The list is tried in order, so put the specific rules before a catch-all `{ say: [ … ] }`. The thing’s own rules are asked before the room’s.
27
+
28
+ [Rules](https://mrballistic.github.io/brass-lantern/reference/world-schema#rules) has the whole shape, and [Events and effects](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#events-and-effects) lists the steps an event can take.
29
+
30
+ ## Containers and keys
31
+
32
+ A shelf with a glass jar on it, a key in the jar, and a locked tin the key opens.
33
+
34
+ ```ts
35
+ items: {
36
+ // A surface: what's on it is always in view and in reach.
37
+ shelf: { name: 'shelf', description: 'A sagging pine shelf.', portable: false, tags: [], scenery: true, surface: true, contains: ['jar'] },
38
+ // Transparent: you can see the key through the glass, but not take it until it's open.
39
+ jar: {
40
+ name: 'glass jar',
41
+ aliases: ['jar'],
42
+ description: 'A jar with a screw-top lid.',
43
+ portable: true,
44
+ tags: [],
45
+ container: { openable: true, transparent: true },
46
+ contains: ['key'],
47
+ },
48
+ key: { name: 'tin key', aliases: ['key'], description: 'A tiny key.', portable: true, tags: [] },
49
+ // Locked, opened by the key, with room for two things.
50
+ tin: {
51
+ name: 'cookie tin',
52
+ aliases: ['tin'],
53
+ description: 'A dented tin with a keyhole.',
54
+ portable: false,
55
+ refusal: 'It’s wedged in tight.',
56
+ tags: [],
57
+ container: { openable: true, locked: true, key: 'key', capacity: 2, opened: 'The lid pops off with a sigh of cinnamon.' },
58
+ contains: ['cookie'],
59
+ },
60
+ cookie: { name: 'cookie', description: 'Snickerdoodle.', portable: true, tags: [] },
61
+ },
62
+ ```
63
+
64
+ - **A `surface`** (the shelf) shows what’s on it, and you can reach it.
65
+ - **A `container`** holds things. `openable` gives it a lid; `locked` and `key` lock it; `capacity` limits how many things fit directly inside; `opened` replaces the default “Opened.”.
66
+ - **`transparent`** lets the player see inside while it’s closed, but not reach in: TAKE KEY says which container is in the way.
67
+ - **`contains`** says what starts inside or on it.
68
+
69
+ **The whole world: containers.ts**
70
+ ```ts
71
+ import type { World } from '@brass-lantern/engine';
72
+
73
+ /** Recipe: containers, surfaces and keys. See docs/guide/building-worlds/recipes.md. */
74
+ export const containers: World = {
75
+ startRoom: 'pantry',
76
+ rooms: {
77
+ pantry: {
78
+ name: 'Pantry',
79
+ description: 'Narrow shelves, a smell of cinnamon.',
80
+ exits: {},
81
+ items: ['shelf', 'tin'],
82
+ npcs: [],
83
+ onEnter: [],
84
+ },
85
+ },
86
+ items: {
87
+ // A surface: what's on it is always in view and in reach.
88
+ shelf: { name: 'shelf', description: 'A sagging pine shelf.', portable: false, tags: [], scenery: true, surface: true, contains: ['jar'] },
89
+ // Transparent: you can see the key through the glass, but not take it until it's open.
90
+ jar: {
91
+ name: 'glass jar',
92
+ aliases: ['jar'],
93
+ description: 'A jar with a screw-top lid.',
94
+ portable: true,
95
+ tags: [],
96
+ container: { openable: true, transparent: true },
97
+ contains: ['key'],
98
+ },
99
+ key: { name: 'tin key', aliases: ['key'], description: 'A tiny key.', portable: true, tags: [] },
100
+ // Locked, opened by the key, with room for two things.
101
+ tin: {
102
+ name: 'cookie tin',
103
+ aliases: ['tin'],
104
+ description: 'A dented tin with a keyhole.',
105
+ portable: false,
106
+ refusal: 'It’s wedged in tight.',
107
+ tags: [],
108
+ container: { openable: true, locked: true, key: 'key', capacity: 2, opened: 'The lid pops off with a sigh of cinnamon.' },
109
+ contains: ['cookie'],
110
+ },
111
+ cookie: { name: 'cookie', description: 'Snickerdoodle.', portable: true, tags: [] },
112
+ },
113
+ npcs: {},
114
+ dialogue: {},
115
+ events: {},
116
+ flagLabels: {},
117
+ };
118
+ ```
119
+
120
+ ```
121
+ > take key
122
+ The glass jar is closed.
123
+ > open jar
124
+ Opened.
125
+ > take key from jar
126
+ Taken: tin key.
127
+ > unlock tin with key
128
+ Unlocked.
129
+ > open tin
130
+ The lid pops off with a sigh of cinnamon.
131
+ > take cookie
132
+ Taken: cookie.
133
+ > put key in tin
134
+ Done.
135
+ ```
136
+
137
+ Doors between rooms are the same block on an item with `door: true`; the [two-room game](https://mrballistic.github.io/brass-lantern/guide/building-worlds/two-rooms) has one.
138
+
139
+ ## Darkness and death
140
+
141
+ A shed with a lamp, and a dark cellar below it.
142
+
143
+ ```ts
144
+ cellar: {
145
+ name: 'Cellar',
146
+ description: 'Damp stone walls. Steps lead up.',
147
+ dark: true,
148
+ exits: { up: 'shed' },
149
+ items: ['jam'],
150
+ npcs: [],
151
+ onEnter: [],
152
+ },
153
+ ```
154
+
155
+ ```ts
156
+ lamp: { name: 'oil lamp', aliases: ['lamp'], description: 'A brass oil lamp.', portable: true, tags: [], switchable: true, light: true },
157
+ ```
158
+
159
+ ```ts
160
+ darkness: {
161
+ look: 'It is pitch black. Something breathes nearby.',
162
+ // A direction with no exit, in the dark: Zork's grue, minus the 20% you live.
163
+ blunder: [{ die: 'You stumble into something with far too many teeth.' }],
164
+ },
165
+ death: {
166
+ message: [' **** You have died ****'],
167
+ lives: 1,
168
+ respawn: 'shed',
169
+ resurrection: ['You wake on the shed floor with a headache and a new respect for the dark.'],
170
+ final: ['That was your last chance. The cellar keeps you.'],
171
+ },
172
+ ```
173
+
174
+ - **`dark: true`** makes a room need light. A **`light`** item gives it while it’s on (it has to be **`switchable`** to turn on), whether it’s carried, on the floor, or inside something open.
175
+ - **In the dark** the player can only find what they’re carrying. Anything else gets `tooDark` (“It’s too dark to see.”), and no time passes.
176
+ - **`blunder`** runs when the player tries a direction with no exit in the dark. Zork’s grue is a [`chance`](https://mrballistic.github.io/brass-lantern/reference/conditions-and-events#events-and-effects) of death; this one always bites.
177
+ - **`death`** says what dying does. With `lives: 1` the player gets one resurrection in `respawn`; the next death plays `final` and ends the game. Without a `death` block, dying ends the game at once.
178
+
179
+ **The whole world: darkness.ts**
180
+ ```ts
181
+ import type { World } from '@brass-lantern/engine';
182
+
183
+ /** Recipe: dark rooms, a lamp, and a death that isn't the end. See docs/guide/building-worlds/recipes.md. */
184
+ export const darkness: World = {
185
+ startRoom: 'shed',
186
+ rooms: {
187
+ shed: {
188
+ name: 'Shed',
189
+ description: 'A garden shed. A hatch in the floor leads down.',
190
+ exits: { down: 'cellar' },
191
+ items: ['lamp'],
192
+ npcs: [],
193
+ onEnter: [],
194
+ },
195
+ cellar: {
196
+ name: 'Cellar',
197
+ description: 'Damp stone walls. Steps lead up.',
198
+ dark: true,
199
+ exits: { up: 'shed' },
200
+ items: ['jam'],
201
+ npcs: [],
202
+ onEnter: [],
203
+ },
204
+ },
205
+ items: {
206
+ lamp: { name: 'oil lamp', aliases: ['lamp'], description: 'A brass oil lamp.', portable: true, tags: [], switchable: true, light: true },
207
+ jam: { name: 'jar of jam', aliases: ['jam', 'jar'], description: 'Damson, 1987.', portable: true, tags: [] },
208
+ },
209
+ darkness: {
210
+ look: 'It is pitch black. Something breathes nearby.',
211
+ // A direction with no exit, in the dark: Zork's grue, minus the 20% you live.
212
+ blunder: [{ die: 'You stumble into something with far too many teeth.' }],
213
+ },
214
+ death: {
215
+ message: [' **** You have died ****'],
216
+ lives: 1,
217
+ respawn: 'shed',
218
+ resurrection: ['You wake on the shed floor with a headache and a new respect for the dark.'],
219
+ final: ['That was your last chance. The cellar keeps you.'],
220
+ },
221
+ npcs: {},
222
+ dialogue: {},
223
+ events: {},
224
+ flagLabels: {},
225
+ };
226
+ ```
227
+
228
+ ```
229
+ > down
230
+ 📍 Darkness
231
+ It is pitch black. Something breathes nearby.
232
+ > take jam
233
+ It’s too dark to see.
234
+ > east
235
+ You stumble into something with far too many teeth.
236
+ **** You have died ****
237
+ You wake on the shed floor with a headache and a new respect for the dark.
238
+ 📍 Shed
239
+ A garden shed. A hatch in the floor leads down.
240
+ You can see: oil lamp.
241
+ Exits: down.
242
+ > take lamp
243
+ Taken: oil lamp.
244
+ > turn on lamp
245
+ The oil lamp is now on.
246
+ > down
247
+ 📍 Cellar
248
+ Damp stone walls. Steps lead up.
249
+ You can see: jar of jam.
250
+ Exits: up.
251
+ ```
252
+
253
+ A lamp that runs out is a variable and a few daemons: see [Time](https://mrballistic.github.io/brass-lantern/reference/world-schema#time).
254
+
255
+ ## Timers
256
+
257
+ A kettle that boils three turns after you turn it on, a clock, and a dripping tap.
258
+
259
+ ```ts
260
+ items: {
261
+ kettle: {
262
+ name: 'kettle',
263
+ description: 'A whistling kettle.',
264
+ portable: false,
265
+ tags: [],
266
+ switchable: true,
267
+ // Turning it on lights a three-turn fuse; turning it off puts it out.
268
+ after: { turn_on: [{ then: 'heat' }], turn_off: [{ then: 'cool' }] },
269
+ },
270
+ },
271
+ events: {
272
+ heat: [{ schedule: 'boil', in: 3 }],
273
+ cool: [{ cancel: 'boil' }],
274
+ boil: ['The kettle shrieks. Tea time.', { switch: 'kettle', on: false }, { set: 'boiled' }],
275
+ chime: ['The clock chimes the hour.'],
276
+ },
277
+ ```
278
+
279
+ ```ts
280
+ vars: { minutes: 0 },
281
+ daemons: [
282
+ // Every turn is a minute; on the fifth, the clock chimes.
283
+ { if: 'var:minutes>=0', then: [{ add: 'minutes', by: 1 }] },
284
+ { if: 'var:minutes=5', then: 'chime' },
285
+ ],
286
+ ambient: [{ if: '!flag:boiled', every: 2, lines: ['The tap drips.', 'The tap drips again.'] }],
287
+ ```
288
+
289
+ - **A fuse** is the `schedule` effect: run an event after so many turns. `cancel` stops it. Here **`after`** rules start and stop it: they run once TURN ON or TURN OFF has worked.
290
+ - **A daemon** runs after every turn while its `if` holds. Two daemons make a clock: one counts minutes in a **variable**, the other chimes when it reaches 5.
291
+ - **`ambient`** lines interrupt every few turns while their condition holds.
292
+ - **A turn** is a command the engine acted on. Misunderstood commands, questions, and VERBOSE or UNDO take no time.
293
+
294
+ **The whole world: timers.ts**
295
+ ```ts
296
+ import type { World } from '@brass-lantern/engine';
297
+
298
+ /** Recipe: fuses, daemons and ambient lines. See docs/guide/building-worlds/recipes.md. */
299
+ export const timers: World = {
300
+ startRoom: 'kitchen',
301
+ rooms: {
302
+ kitchen: {
303
+ name: 'Kitchen',
304
+ description: 'A small kitchen with a kettle on the stove.',
305
+ exits: {},
306
+ items: ['kettle'],
307
+ npcs: [],
308
+ onEnter: [],
309
+ },
310
+ },
311
+ items: {
312
+ kettle: {
313
+ name: 'kettle',
314
+ description: 'A whistling kettle.',
315
+ portable: false,
316
+ tags: [],
317
+ switchable: true,
318
+ // Turning it on lights a three-turn fuse; turning it off puts it out.
319
+ after: { turn_on: [{ then: 'heat' }], turn_off: [{ then: 'cool' }] },
320
+ },
321
+ },
322
+ events: {
323
+ heat: [{ schedule: 'boil', in: 3 }],
324
+ cool: [{ cancel: 'boil' }],
325
+ boil: ['The kettle shrieks. Tea time.', { switch: 'kettle', on: false }, { set: 'boiled' }],
326
+ chime: ['The clock chimes the hour.'],
327
+ },
328
+ vars: { minutes: 0 },
329
+ daemons: [
330
+ // Every turn is a minute; on the fifth, the clock chimes.
331
+ { if: 'var:minutes>=0', then: [{ add: 'minutes', by: 1 }] },
332
+ { if: 'var:minutes=5', then: 'chime' },
333
+ ],
334
+ ambient: [{ if: '!flag:boiled', every: 2, lines: ['The tap drips.', 'The tap drips again.'] }],
335
+ npcs: {},
336
+ dialogue: {},
337
+ flagLabels: {},
338
+ };
339
+ ```
340
+
341
+ ```
342
+ > turn on kettle
343
+ The kettle is now on.
344
+ > wait
345
+ Time passes.
346
+ The tap drips.
347
+ > wait
348
+ Time passes.
349
+ > wait
350
+ Time passes.
351
+ The kettle shrieks. Tea time.
352
+ > wait
353
+ Time passes.
354
+ The clock chimes the hour.
355
+ ```
356
+
357
+ ## Endings and verbs of your own
358
+
359
+ A garden with something buried in it, and a chapel. Digging and praying aren’t built in, so the world declares them.
360
+
361
+ ```ts
362
+ verbs: {
363
+ dig: { words: ['dig', 'dig in'], target: 'optional', indirect: ['with'], reply: 'There’s nowhere to dig here.' },
364
+ pray: { words: ['pray'], target: 'none', reply: 'Your prayer goes unanswered here.' },
365
+ },
366
+ ```
367
+
368
+ ```ts
369
+ events: {
370
+ treasure: ['You dig. Your spade rings on a strongbox full of coins.', { score: 50 }, { end: 'rich' }],
371
+ blessing: ['A warm calm settles over you.', { score: 10 }, { end: 'peace' }],
372
+ },
373
+ scoring: [],
374
+ maxScore: 50,
375
+ endings: {
376
+ rich: { lines: ['✨ You are rich, and the garden is a mess.'], score: true, footer: ['Type RESTART to try another ending.'] },
377
+ peace: { lines: ['✨ You leave lighter than you came.'], score: true, footer: ['Type RESTART to try another ending.'] },
378
+ },
379
+ ```
380
+
381
+ - **A world verb** does nothing by itself. Rules give it meaning: the flowerbed has an `instead.dig` rule, and so does the garden, for DIG with no object. The chapel has `instead.pray`. Anywhere else, the verb’s `reply` answers.
382
+ - **`end`** plays an ending: its lines, the score (with `score: true`), then the footer.
383
+ - **The score** here comes from the `score` effect, out of `maxScore`. Worlds can also award points for flags with [`scoring`](https://mrballistic.github.io/brass-lantern/reference/world-schema#world).
384
+
385
+ **The whole world: endings.ts**
386
+ ```ts
387
+ import type { World } from '@brass-lantern/engine';
388
+
389
+ /** Recipe: verbs of your own, and more than one way to end. See docs/guide/building-worlds/recipes.md. */
390
+ export const endings: World = {
391
+ startRoom: 'garden',
392
+ rooms: {
393
+ garden: {
394
+ name: 'Garden',
395
+ description: 'An overgrown garden. A chapel stands to the east. Something glints in a flowerbed.',
396
+ exits: { east: 'chapel' },
397
+ items: ['spade', 'flowerbed'],
398
+ npcs: [],
399
+ onEnter: [],
400
+ // DIG with no target asks the room.
401
+ instead: { dig: [{ if: 'has:spade', then: 'treasure' }, { say: ['The soil is packed hard. You need a spade.'] }] },
402
+ },
403
+ chapel: {
404
+ name: 'Chapel',
405
+ description: 'Cool and quiet. The garden is west.',
406
+ exits: { west: 'garden' },
407
+ items: [],
408
+ npcs: [],
409
+ onEnter: [],
410
+ instead: { pray: [{ then: 'blessing' }] },
411
+ },
412
+ },
413
+ verbs: {
414
+ dig: { words: ['dig', 'dig in'], target: 'optional', indirect: ['with'], reply: 'There’s nowhere to dig here.' },
415
+ pray: { words: ['pray'], target: 'none', reply: 'Your prayer goes unanswered here.' },
416
+ },
417
+ items: {
418
+ spade: { name: 'spade', description: 'A rusty spade.', portable: true, tags: [] },
419
+ flowerbed: {
420
+ name: 'flowerbed',
421
+ aliases: ['bed', 'flowers', 'soil'],
422
+ description: 'Something glints in the soil.',
423
+ portable: false,
424
+ tags: [],
425
+ scenery: true,
426
+ instead: { dig: [{ if: 'has:spade', then: 'treasure' }] },
427
+ },
428
+ },
429
+ events: {
430
+ treasure: ['You dig. Your spade rings on a strongbox full of coins.', { score: 50 }, { end: 'rich' }],
431
+ blessing: ['A warm calm settles over you.', { score: 10 }, { end: 'peace' }],
432
+ },
433
+ scoring: [],
434
+ maxScore: 50,
435
+ endings: {
436
+ rich: { lines: ['✨ You are rich, and the garden is a mess.'], score: true, footer: ['Type RESTART to try another ending.'] },
437
+ peace: { lines: ['✨ You leave lighter than you came.'], score: true, footer: ['Type RESTART to try another ending.'] },
438
+ },
439
+ npcs: {},
440
+ dialogue: {},
441
+ flagLabels: {},
442
+ };
443
+ ```
444
+
445
+ ```
446
+ > dig
447
+ The soil is packed hard. You need a spade.
448
+ > take spade
449
+ Taken: spade.
450
+ > dig in flowerbed
451
+ You dig. Your spade rings on a strongbox full of coins.
452
+ ✨ You are rich, and the garden is a mess.
453
+ [Score: 50 of 50, in 2 moves.]
454
+ Type RESTART to try another ending.
455
+ ```
456
+
457
+ The other ending, from a new game:
458
+
459
+ ```
460
+ > east
461
+ 📍 Chapel
462
+ Cool and quiet. The garden is west.
463
+ Exits: west.
464
+ > pray
465
+ A warm calm settles over you.
466
+ ✨ You leave lighter than you came.
467
+ [Score: 10 of 50, in 1 move.]
468
+ Type RESTART to try another ending.
469
+ ```
470
+
471
+ ## A guard to fight
472
+
473
+ An armory with a sword and an anvil too heavy to lift, and a guard who won’t let you through.
474
+
475
+ ```ts
476
+ items: {
477
+ sword: { name: 'sword', description: 'Notched, but sharp.', portable: true, tags: [], weapon: true, size: 10 },
478
+ anvil: { name: 'anvil', description: 'Black iron, very heavy.', portable: true, tags: [], size: 60 },
479
+ club: { name: 'club', description: 'A knotted club.', portable: true, tags: [], weapon: true, size: 10 },
480
+ },
481
+ // Nothing heavier than 50 in all.
482
+ carry: { limit: 50 },
483
+ ```
484
+
485
+ ```ts
486
+ npcs: {
487
+ guard: {
488
+ name: 'guard',
489
+ description: 'A guard with a club blocks the arch.',
490
+ descriptions: [{ if: '!awake:guard', text: 'The guard lies senseless on the flagstones.' }],
491
+ holds: ['club'],
492
+ combat: {
493
+ strength: 2,
494
+ weapon: 'club',
495
+ firstStrike: 50,
496
+ // Without his club, he spends his turns looking for it.
497
+ onBusy: 'guard_gropes',
498
+ onDeath: 'guard_drops_club',
499
+ onUnconscious: 'guard_drops_club',
500
+ messages: {
501
+ missed: ['The guard’s club whistles past your head.'],
502
+ lightWound: ['The club catches your shoulder.'],
503
+ seriousWound: ['The club cracks against your ribs.'],
504
+ stagger: ['The guard shoves you back.'],
505
+ unconscious: ['The club comes down on your head.'],
506
+ killed: ['The guard’s club finds your temple.'],
507
+ loseWeapon: ['The guard knocks the {weapon} from your hand.'],
508
+ },
509
+ },
510
+ },
511
+ },
512
+ combat: {
513
+ strength: { min: 2, max: 2 },
514
+ // The engine's own lines say “their” and “themselves”; these make the guard “he”.
515
+ messages: { loseWeapon: ['The club flies out of the guard’s hand.'] },
516
+ texts: { defenseless: 'The guard can’t defend himself.' },
517
+ },
518
+ ```
519
+
520
+ ```ts
521
+ gate: {
522
+ name: 'Gatehouse',
523
+ description: 'A stone arch. Beyond it, daylight. The armory is south.',
524
+ exits: {
525
+ south: 'armory',
526
+ // The guard bars the way until he's dead or out cold.
527
+ north: { to: 'courtyard', denials: [{ if: 'awake:guard', text: 'The guard steps in front of you.' }] },
528
+ },
529
+ items: [],
530
+ npcs: ['guard'],
531
+ onEnter: [],
532
+ },
533
+ ```
534
+
535
+ - **`carry`** turns on weight. Each item weighs its `size` (5 if unset) plus what’s inside it, and TAKE refuses what would go over `limit`.
536
+ - **`combat`** on a character makes it someone you can fight with a `weapon`. The engine runs the fight: your strength against theirs, a seeded roll, and Zork’s tables of results. The guard’s `messages` are its blows at you; `world.combat` holds yours (short defaults here).
537
+ - **The engine’s own fight lines say “they”** (“The guard drops their weapon.”), since it doesn’t know who a character is. The world’s own `combat` block (not the guard’s) replaces the two that would say it here, `messages.loseWeapon` and `texts.defenseless`, so the guard is “he” throughout.
538
+ - **`holds`** gives the guard his club. While he has it he fights back; knock it away and `onBusy` runs instead of a swing.
539
+ - **`awake:guard`** in the exit’s `denials` keeps the way shut until he’s dead or out cold.
540
+ - **`seed`** (here `seed: 1`, in the whole world below) fixes the world’s random generator, so this transcript plays the same way every time. Leave it out and every game differs.
541
+ - **DIAGNOSE** says how hurt you are; wounds heal with time.
542
+
543
+ **The whole world: guard.ts**
544
+ ```ts
545
+ import type { World } from '@brass-lantern/engine';
546
+
547
+ /** Recipe: a guard to fight, and something too heavy to carry. See docs/guide/building-worlds/recipes.md. */
548
+ export const guard: World = {
549
+ startRoom: 'armory',
550
+ // A fixed seed, so the fight in the recipe's transcript plays the same way every time.
551
+ seed: 1,
552
+ rooms: {
553
+ armory: {
554
+ name: 'Armory',
555
+ description: 'Racks of rusted weapons. An anvil squats in the corner. A gate is north.',
556
+ exits: { north: 'gate' },
557
+ items: ['sword', 'anvil'],
558
+ npcs: [],
559
+ onEnter: [],
560
+ },
561
+ gate: {
562
+ name: 'Gatehouse',
563
+ description: 'A stone arch. Beyond it, daylight. The armory is south.',
564
+ exits: {
565
+ south: 'armory',
566
+ // The guard bars the way until he's dead or out cold.
567
+ north: { to: 'courtyard', denials: [{ if: 'awake:guard', text: 'The guard steps in front of you.' }] },
568
+ },
569
+ items: [],
570
+ npcs: ['guard'],
571
+ onEnter: [],
572
+ },
573
+ courtyard: {
574
+ name: 'Courtyard',
575
+ description: 'Sunlight, at last.',
576
+ exits: {},
577
+ items: [],
578
+ npcs: [],
579
+ onEnter: [{ if: 'in:courtyard', then: 'escape' }],
580
+ },
581
+ },
582
+ items: {
583
+ sword: { name: 'sword', description: 'Notched, but sharp.', portable: true, tags: [], weapon: true, size: 10 },
584
+ anvil: { name: 'anvil', description: 'Black iron, very heavy.', portable: true, tags: [], size: 60 },
585
+ club: { name: 'club', description: 'A knotted club.', portable: true, tags: [], weapon: true, size: 10 },
586
+ },
587
+ // Nothing heavier than 50 in all.
588
+ carry: { limit: 50 },
589
+ npcs: {
590
+ guard: {
591
+ name: 'guard',
592
+ description: 'A guard with a club blocks the arch.',
593
+ descriptions: [{ if: '!awake:guard', text: 'The guard lies senseless on the flagstones.' }],
594
+ holds: ['club'],
595
+ combat: {
596
+ strength: 2,
597
+ weapon: 'club',
598
+ firstStrike: 50,
599
+ // Without his club, he spends his turns looking for it.
600
+ onBusy: 'guard_gropes',
601
+ onDeath: 'guard_drops_club',
602
+ onUnconscious: 'guard_drops_club',
603
+ messages: {
604
+ missed: ['The guard’s club whistles past your head.'],
605
+ lightWound: ['The club catches your shoulder.'],
606
+ seriousWound: ['The club cracks against your ribs.'],
607
+ stagger: ['The guard shoves you back.'],
608
+ unconscious: ['The club comes down on your head.'],
609
+ killed: ['The guard’s club finds your temple.'],
610
+ loseWeapon: ['The guard knocks the {weapon} from your hand.'],
611
+ },
612
+ },
613
+ },
614
+ },
615
+ combat: {
616
+ strength: { min: 2, max: 2 },
617
+ // The engine's own lines say “their” and “themselves”; these make the guard “he”.
618
+ messages: { loseWeapon: ['The club flies out of the guard’s hand.'] },
619
+ texts: { defenseless: 'The guard can’t defend himself.' },
620
+ },
621
+ dialogue: { guard: { default: '“Move along.”' } },
622
+ flagLabels: {},
623
+ events: {
624
+ guard_drops_club: [{ move: 'club', to: 'here' }],
625
+ guard_gropes: ['The guard gropes for his club.'],
626
+ escape: ['✨ You walk out into the sun.', { end: 'free' }],
627
+ },
628
+ endings: { free: { lines: ['You’re free.'] } },
629
+ };
630
+ ```
631
+
632
+ ```
633
+ > take anvil
634
+ [That’s too heavy to carry with everything else.]
635
+ > take sword
636
+ Taken: sword.
637
+ > north
638
+ 📍 Gatehouse
639
+ A stone arch. Beyond it, daylight. The armory is south.
640
+ Present: guard.
641
+ Exits: south, north.
642
+ > north
643
+ The guard steps in front of you.
644
+ The guard shoves you back.
645
+ > attack guard with sword
646
+ You’re still reeling from that last blow.
647
+ The guard’s club whistles past your head.
648
+ > attack guard with sword
649
+ You wound the guard.
650
+ The club catches your shoulder.
651
+ > attack guard with sword
652
+ The club flies out of the guard’s hand.
653
+ The guard gropes for his club.
654
+ > attack guard with sword
655
+ The guard can’t defend himself.
656
+ The guard is dead.
657
+ > diagnose
658
+ [You have a light wound, which will be cured after 28 moves.]
659
+ [You can be killed by one more light wound.]
660
+ > north
661
+ 📍 Courtyard
662
+ Sunlight, at last.
663
+ ✨ You walk out into the sun.
664
+ You’re free.
665
+ ```
666
+
667
+ In [Infocom style](https://mrballistic.github.io/brass-lantern/reference/world-schema#style) (`style: 'infocom'` on the world) the same fight uses Zork’s words, and `kill guard` picks the one weapon you’re holding. The native Zork I that ships with the engine (`import { zork1 } from '@brass-lantern/engine/worlds'`) has a troll that is this recipe with Zork’s own numbers and messages.
668
+
669
+ ## Scripts
670
+
671
+ A fortune teller who tells you one of three fortunes, then sticks to it.
672
+
673
+ ```ts
674
+ verbs: { consult: { words: ['consult'], target: 'required' } },
675
+ events: { reading: [{ script: 'fortune' }] },
676
+ vars: { fortune: 0 },
677
+ scripts: {
678
+ // Picks one of three fortunes the first time, then sticks to it.
679
+ fortune: (ctx) => {
680
+ const told = ctx.state.vars?.fortune ?? 0;
681
+ const pick = told || ctx.roll(3);
682
+ const text = ['“A stranger will bring you soup.”', '“Beware of geese.”', '“You will find what you lost under the sofa.”'][pick - 1];
683
+ return told ? ['“I have told you already,” she sighs.', text] : [{ setVar: 'fortune', to: pick }, text];
684
+ },
685
+ },
686
+ ```
687
+
688
+ - **A script** is a function in `scripts` that returns ordinary steps: lines and effects. Events call it with `{ script: 'name' }`. It gets one argument, `ctx`, the game as the script sees it; [Scripts](https://mrballistic.github.io/brass-lantern/reference/world-schema#scripts) lists what’s on it.
689
+ - **It reads the game, it doesn’t change it.** `ctx.state` is read-only; to change things, return effects (`setVar` here). That keeps the engine’s guarantees: a command it didn’t understand changes nothing, and saves replay exactly.
690
+ - **`ctx.roll(3)`** is 1 to 3 from the game’s seeded generator, never `Math.random()`. With this world’s `seed: 3` the fortune is the sofa one; another seed picks another.
691
+ - **A rule on a character** (`instead.consult` on Madame Zora) answers a world verb aimed at her.
692
+
693
+ **The whole world: fortune.ts**
694
+ ```ts
695
+ import type { World } from '@brass-lantern/engine';
696
+
697
+ /** Recipe: a script, for what data alone can't say. See docs/guide/building-worlds/recipes.md. */
698
+ export const fortune: World = {
699
+ startRoom: 'tent',
700
+ seed: 3,
701
+ rooms: {
702
+ tent: {
703
+ name: 'Fortune Teller’s Tent',
704
+ description: 'Velvet, incense, and a crystal ball.',
705
+ exits: {},
706
+ items: [],
707
+ npcs: ['madame'],
708
+ onEnter: [],
709
+ },
710
+ },
711
+ items: {},
712
+ npcs: {
713
+ madame: {
714
+ name: 'Madame Zora',
715
+ article: '',
716
+ description: 'Madame Zora peers at you over the crystal ball.',
717
+ instead: { consult: [{ then: 'reading' }] },
718
+ },
719
+ },
720
+ verbs: { consult: { words: ['consult'], target: 'required' } },
721
+ events: { reading: [{ script: 'fortune' }] },
722
+ vars: { fortune: 0 },
723
+ scripts: {
724
+ // Picks one of three fortunes the first time, then sticks to it.
725
+ fortune: (ctx) => {
726
+ const told = ctx.state.vars?.fortune ?? 0;
727
+ const pick = told || ctx.roll(3);
728
+ const text = ['“A stranger will bring you soup.”', '“Beware of geese.”', '“You will find what you lost under the sofa.”'][pick - 1];
729
+ return told ? ['“I have told you already,” she sighs.', text] : [{ setVar: 'fortune', to: pick }, text];
730
+ },
731
+ },
732
+ dialogue: {},
733
+ flagLabels: {},
734
+ };
735
+ ```
736
+
737
+ ```
738
+ > consult madame
739
+ “You will find what you lost under the sofa.”
740
+ > consult madame
741
+ “I have told you already,” she sighs.
742
+ “You will find what you lost under the sofa.”
743
+ ```
744
+
745
+ Reach for a script only when data can’t say it. Most behavior is rules, conditions and effects.
746
+
747
+ ## Topics and orders
748
+
749
+ A librarian who answers questions, and won’t be bossed about.
750
+
751
+ ```ts
752
+ npcs: {
753
+ librarian: {
754
+ name: 'librarian',
755
+ description: 'The librarian peers at you over her glasses.',
756
+ topics: {
757
+ books: '“Shelved by color. Don’t ask.”',
758
+ archive: [
759
+ { if: 'has:brass_key', text: '“You have the key. Go on, then.”' },
760
+ { text: 'lend_key' },
761
+ ],
762
+ dragons: '“Second floor, between the cookbooks and the tax law.”',
763
+ // ASK LIBRARIAN ABOUT ME (or MYSELF).
764
+ me: '“You look like someone with an overdue book.”',
765
+ },
766
+ topicAliases: { archive: ['the archive', 'archives', 'old letters', 'key'], books: ['book', 'shelves'] },
767
+ noTopic: '“I couldn’t say, dear.”',
768
+ refuseOrder: '“Shh.”',
769
+ },
770
+ },
771
+ events: {
772
+ // A topic entry can name an event: asking about the archive lends you the key.
773
+ lend_key: ['“Oh, the archive.” She slides a brass key across the desk.', { move: 'brass_key', to: 'player' }],
774
+ found_it: ['✨ The letters are all here.', { end: 'found' }],
775
+ },
776
+ ```
777
+
778
+ - **`topics`** answer ASK (or TELL) *librarian* ABOUT *something*. Each topic is a line, or a list of entries tried in order; the first whose `if` holds wins.
779
+ - **An entry can name an event**, which runs instead of printing. Asking about the archive lends you the key, once; after that the `has:brass_key` entry answers first.
780
+ - **`topicAliases`** are other words for a topic, matched the same way item names are, so “the archive”, “archives” and “key” all find `archive`.
781
+ - **A topic keyed `me`** answers ASK *librarian* ABOUT ME, and ABOUT MYSELF too.
782
+ - **`noTopic`** answers anything else. Without it, ASK falls back to what TALK TO would say.
783
+ - **`refuseOrder`** answers an order: “librarian, open the door” or “tell librarian to open the door”. Without it the reply is “librarian ignores you.” A rule on the character (`instead.order`) can answer a particular order instead; in a script it runs, `ctx.command.words.indirect` is what was ordered (“open the door”).
784
+
785
+ **The whole world: topics.ts**
786
+ ```ts
787
+ import type { World } from '@brass-lantern/engine';
788
+
789
+ /** Recipe: topics and orders. See docs/guide/building-worlds/recipes.md. */
790
+ export const topics: World = {
791
+ startRoom: 'library',
792
+ rooms: {
793
+ library: {
794
+ name: 'Library',
795
+ description: 'Tall shelves, a reading lamp, and a locked door marked ARCHIVE.',
796
+ exits: { north: { to: 'archive', door: 'archive_door' } },
797
+ items: [],
798
+ npcs: ['librarian'],
799
+ onEnter: [],
800
+ scenery: ['archive_door'],
801
+ },
802
+ archive: {
803
+ name: 'Archive',
804
+ description: 'Boxes of old letters. You found it.',
805
+ exits: { south: { to: 'library', door: 'archive_door' } },
806
+ items: [],
807
+ npcs: [],
808
+ onEnter: [{ if: 'in:archive', then: 'found_it' }],
809
+ scenery: ['archive_door'],
810
+ },
811
+ },
812
+ items: {
813
+ archive_door: {
814
+ name: 'archive door',
815
+ aliases: ['door'],
816
+ description: 'A heavy door marked ARCHIVE.',
817
+ portable: false,
818
+ tags: [],
819
+ door: true,
820
+ container: { openable: true, locked: true, key: 'brass_key' },
821
+ },
822
+ brass_key: { name: 'brass key', aliases: ['key'], description: 'Small and brass.', portable: true, tags: [] },
823
+ },
824
+ npcs: {
825
+ librarian: {
826
+ name: 'librarian',
827
+ description: 'The librarian peers at you over her glasses.',
828
+ topics: {
829
+ books: '“Shelved by color. Don’t ask.”',
830
+ archive: [
831
+ { if: 'has:brass_key', text: '“You have the key. Go on, then.”' },
832
+ { text: 'lend_key' },
833
+ ],
834
+ dragons: '“Second floor, between the cookbooks and the tax law.”',
835
+ // ASK LIBRARIAN ABOUT ME (or MYSELF).
836
+ me: '“You look like someone with an overdue book.”',
837
+ },
838
+ topicAliases: { archive: ['the archive', 'archives', 'old letters', 'key'], books: ['book', 'shelves'] },
839
+ noTopic: '“I couldn’t say, dear.”',
840
+ refuseOrder: '“Shh.”',
841
+ },
842
+ },
843
+ events: {
844
+ // A topic entry can name an event: asking about the archive lends you the key.
845
+ lend_key: ['“Oh, the archive.” She slides a brass key across the desk.', { move: 'brass_key', to: 'player' }],
846
+ found_it: ['✨ The letters are all here.', { end: 'found' }],
847
+ },
848
+ endings: { found: { lines: ['You found what you came for.'] } },
849
+ dialogue: { librarian: { default: '“Can I help you find something?”' } },
850
+ flagLabels: {},
851
+ };
852
+ ```
853
+
854
+ ```
855
+ > ask librarian about books
856
+ “Shelved by color. Don’t ask.”
857
+ > ask librarian about me
858
+ “You look like someone with an overdue book.”
859
+ > ask librarian about the weather
860
+ “I couldn’t say, dear.”
861
+ > librarian, open the door
862
+ “Shh.”
863
+ > ask librarian about the archive
864
+ “Oh, the archive.” She slides a brass key across the desk.
865
+ > ask librarian about the archive
866
+ “You have the key. Go on, then.”
867
+ ```
868
+
869
+ A character with no `orders` or `obeys` only answers; see [Orders, numbers and a buggy](#orders-numbers-and-a-buggy) for one that carries them out.
870
+
871
+ ## A wandering character
872
+
873
+ A cat who wanders three rooms and walks off with your sock.
874
+
875
+ ```ts
876
+ // The cat's turn: a daemon that runs a script after every turn the engine acts on.
877
+ daemons: [{ if: 'alive:cat', then: [{ script: 'cat_turn' }] }],
878
+ scripts: {
879
+ cat_turn: (ctx) => {
880
+ const rooms = ['hall', 'kitchen', 'garden'];
881
+ const at = rooms.find((r) => ctx.npcIn('cat', r))!;
882
+ // Half the time it stays put.
883
+ if (ctx.roll(2) === 1) return [];
884
+ const next = rooms[(rooms.indexOf(at) + 1) % rooms.length];
885
+ const here = ctx.room();
886
+ const steps: EventStep[] = [{ moveNpc: 'cat', to: next }];
887
+ if (at === here) steps.push('The cat stalks off.');
888
+ if (next === here) steps.push('The cat pads in.');
889
+ // A sock on the floor where it arrives goes with it.
890
+ if (ctx.children(next).includes('sock')) {
891
+ steps.push({ move: 'sock', to: 'cat' });
892
+ if (next === here) steps.push('The cat bats the sock away somewhere. It’s gone.');
893
+ }
894
+ return steps;
895
+ },
896
+ },
897
+ ```
898
+
899
+ - **A daemon** runs after every turn the engine acts on. This one’s `if` (`alive:cat`) keeps the cat moving for as long as it lives, and its step is a script.
900
+ - **The script decides; its steps act.** `ctx.npcIn` finds the cat, `ctx.roll` decides whether it moves, and `{ moveNpc }` moves it. Lines are printed only when the player can see the cat leave or arrive (`ctx.room()` is where the player is).
901
+ - **`ctx.children(room)`** lists what’s on the floor there. `{ move: 'sock', to: 'cat' }` puts the sock in the cat’s keeping; a character’s holdings aren’t listed in a room.
902
+ - The world’s **`seed`** (`seed: 4`) fixes the cat’s path, so the transcript below plays the same way every time: the cat stays in the kitchen for six turns, then comes to the hall and takes the sock, which starts on the hall floor. It keeps wandering after that (hall, kitchen, garden and round again), which is why it turns up in the kitchen behind you. `ctx` is described under [Scripts](https://mrballistic.github.io/brass-lantern/reference/world-schema#scripts).
903
+
904
+ **The whole world: wanderer.ts**
905
+ ```ts
906
+ import type { EventStep, World } from '@brass-lantern/engine';
907
+
908
+ /** Recipe: a character who wanders and steals. See docs/guide/building-worlds/recipes.md. */
909
+ export const wanderer: World = {
910
+ startRoom: 'hall',
911
+ seed: 4,
912
+ rooms: {
913
+ hall: { name: 'Hall', description: 'A narrow hall. The kitchen is east, the garden south.', exits: { east: 'kitchen', south: 'garden' }, items: ['sock'], npcs: [], onEnter: [] },
914
+ kitchen: { name: 'Kitchen', description: 'A warm kitchen. The hall is west.', exits: { west: 'hall' }, items: [], npcs: ['cat'], onEnter: [] },
915
+ garden: { name: 'Garden', description: 'A small garden. The hall is north.', exits: { north: 'hall' }, items: [], npcs: [], onEnter: [] },
916
+ },
917
+ items: {
918
+ sock: { name: 'sock', description: 'A single woolly sock.', portable: true, tags: [] },
919
+ },
920
+ npcs: {
921
+ cat: { name: 'cat', description: 'A gray cat with opinions.', holds: [] },
922
+ },
923
+ // The cat's turn: a daemon that runs a script after every turn the engine acts on.
924
+ daemons: [{ if: 'alive:cat', then: [{ script: 'cat_turn' }] }],
925
+ scripts: {
926
+ cat_turn: (ctx) => {
927
+ const rooms = ['hall', 'kitchen', 'garden'];
928
+ const at = rooms.find((r) => ctx.npcIn('cat', r))!;
929
+ // Half the time it stays put.
930
+ if (ctx.roll(2) === 1) return [];
931
+ const next = rooms[(rooms.indexOf(at) + 1) % rooms.length];
932
+ const here = ctx.room();
933
+ const steps: EventStep[] = [{ moveNpc: 'cat', to: next }];
934
+ if (at === here) steps.push('The cat stalks off.');
935
+ if (next === here) steps.push('The cat pads in.');
936
+ // A sock on the floor where it arrives goes with it.
937
+ if (ctx.children(next).includes('sock')) {
938
+ steps.push({ move: 'sock', to: 'cat' });
939
+ if (next === here) steps.push('The cat bats the sock away somewhere. It’s gone.');
940
+ }
941
+ return steps;
942
+ },
943
+ },
944
+ events: {},
945
+ dialogue: {},
946
+ flagLabels: {},
947
+ };
948
+ ```
949
+
950
+ ```
951
+ > wait
952
+ Time passes.
953
+ > wait
954
+ Time passes.
955
+ > wait
956
+ Time passes.
957
+ > wait
958
+ Time passes.
959
+ > wait
960
+ Time passes.
961
+ > wait
962
+ Time passes.
963
+ The cat pads in.
964
+ The cat bats the sock away somewhere. It’s gone.
965
+ > look
966
+ 📍 Hall
967
+ A narrow hall. The kitchen is east, the garden south.
968
+ Present: cat.
969
+ Exits: east, south.
970
+ > east
971
+ 📍 Kitchen
972
+ A warm kitchen. The hall is west.
973
+ Exits: west.
974
+ The cat pads in.
975
+ ```
976
+
977
+ Native Zork I’s thief is this recipe grown up: he moves through the rooms, steals what’s worth stealing, and stays out of sight (`hidden`) until he chooses to show himself.
978
+
979
+ ## A room that listens
980
+
981
+ A cave that hears everything you say as an echo, until you say the magic word.
982
+
983
+ ```ts
984
+ cave: {
985
+ name: 'Echoing Cave',
986
+ description: 'A vast cave. Every sound comes back to you, louder. The way out is out.',
987
+ descriptions: [{ if: 'flag:quiet', text: 'A vast cave, quiet now. The way out is out.' }],
988
+ exits: { out: 'ledge' },
989
+ items: [],
990
+ npcs: [],
991
+ onEnter: [],
992
+ // Until it's quiet, whatever you say is heard as noise.
993
+ capture: { if: '!flag:quiet', script: 'echo' },
994
+ },
995
+ ```
996
+
997
+ ```ts
998
+ scripts: {
999
+ echo: (ctx) => {
1000
+ // Captures also see commands that arrive already parsed (AGAIN); this one only hears raw words.
1001
+ if (ctx.line === undefined) return;
1002
+ const words = ctx.line.toLowerCase().trim().split(/\s+/);
1003
+ // Declining (returning nothing) lets the line be parsed as usual: OUT still works.
1004
+ if (words[0] === 'out') return;
1005
+ if (words[0] === 'echo') return [{ set: 'quiet' }, 'The cave falls silent.', { free: true }];
1006
+ const last = words[words.length - 1];
1007
+ return [`${last} ${last} ...`, { free: true }];
1008
+ },
1009
+ },
1010
+ ```
1011
+
1012
+ - **This world is in [Infocom style](https://mrballistic.github.io/brass-lantern/reference/world-schema#style)** (`style: 'infocom'`), like Zork: rooms show no exit line, and a room you’ve been in before shows just its name.
1013
+ - **[`capture`](https://mrballistic.github.io/brass-lantern/reference/world-schema#capture)** sees each command before the parser does, while its `if` holds. The script reads the raw words from `ctx.line`.
1014
+ - **Returning steps takes the command**, and drops the rest of the line. **Returning nothing declines**: the command is parsed as usual, so OUT still leaves.
1015
+ - **A capture also sees commands that arrive already parsed** (AGAIN, the intent server’s reading), with `ctx.line` unset; this one only hears raw words, so it returns nothing then.
1016
+ - **`{ free: true }`** makes the reply take no time. The transcript below counts three moves: IN, LOOK and OUT.
1017
+ - Captured words never reach the intent server, and SAVE, UNDO and RESTART are handled before any capture, so a player can’t get stuck.
1018
+
1019
+ **The whole world: echo.ts**
1020
+ ```ts
1021
+ import type { World } from '@brass-lantern/engine';
1022
+
1023
+ /** Recipe: a room that listens. See docs/guide/building-worlds/recipes.md. */
1024
+ export const echo: World = {
1025
+ startRoom: 'ledge',
1026
+ style: 'infocom',
1027
+ rooms: {
1028
+ ledge: {
1029
+ name: 'Ledge',
1030
+ description: 'A narrow ledge outside a cave. The cave mouth is in.',
1031
+ exits: { in: 'cave' },
1032
+ items: [],
1033
+ npcs: [],
1034
+ onEnter: [],
1035
+ },
1036
+ cave: {
1037
+ name: 'Echoing Cave',
1038
+ description: 'A vast cave. Every sound comes back to you, louder. The way out is out.',
1039
+ descriptions: [{ if: 'flag:quiet', text: 'A vast cave, quiet now. The way out is out.' }],
1040
+ exits: { out: 'ledge' },
1041
+ items: [],
1042
+ npcs: [],
1043
+ onEnter: [],
1044
+ // Until it's quiet, whatever you say is heard as noise.
1045
+ capture: { if: '!flag:quiet', script: 'echo' },
1046
+ },
1047
+ },
1048
+ items: {},
1049
+ npcs: {},
1050
+ scripts: {
1051
+ echo: (ctx) => {
1052
+ // Captures also see commands that arrive already parsed (AGAIN); this one only hears raw words.
1053
+ if (ctx.line === undefined) return;
1054
+ const words = ctx.line.toLowerCase().trim().split(/\s+/);
1055
+ // Declining (returning nothing) lets the line be parsed as usual: OUT still works.
1056
+ if (words[0] === 'out') return;
1057
+ if (words[0] === 'echo') return [{ set: 'quiet' }, 'The cave falls silent.', { free: true }];
1058
+ const last = words[words.length - 1];
1059
+ return [`${last} ${last} ...`, { free: true }];
1060
+ },
1061
+ },
1062
+ events: {},
1063
+ dialogue: {},
1064
+ flagLabels: {},
1065
+ };
1066
+ ```
1067
+
1068
+ ```
1069
+ > in
1070
+ 📍 Echoing Cave
1071
+ A vast cave. Every sound comes back to you, louder. The way out is out.
1072
+ > hello there
1073
+ there there ...
1074
+ > echo
1075
+ The cave falls silent.
1076
+ > look
1077
+ 📍 Echoing Cave
1078
+ A vast cave, quiet now. The way out is out.
1079
+ > out
1080
+ 📍 Ledge
1081
+ ```
1082
+
1083
+ Native Zork I’s Loud Room is this recipe with Zork’s rules: ECHO quiets it, and when the dam’s gates are open at high tide its end routine (`onEnd`) throws you out.
1084
+
1085
+ ## A raft on a pond
1086
+
1087
+ A raft, a pond you can’t wade, and an island beyond it.
1088
+
1089
+ ```ts
1090
+ raft: {
1091
+ name: 'raft',
1092
+ description: 'A raft of lashed logs.',
1093
+ portable: false,
1094
+ tags: [],
1095
+ vehicle: { travels: 'water' },
1096
+ container: { open: true },
1097
+ },
1098
+ ```
1099
+
1100
+ ```ts
1101
+ pond: {
1102
+ name: 'Pond',
1103
+ description: 'The middle of the pond. Lily pads drift by. An island lies to the east, the bank to the west.',
1104
+ // Only something that floats goes here.
1105
+ water: true,
1106
+ exits: { west: 'bank', east: 'island' },
1107
+ items: [],
1108
+ npcs: [],
1109
+ onEnter: [],
1110
+ },
1111
+ ```
1112
+
1113
+ - **This world is in [Infocom style](https://mrballistic.github.io/brass-lantern/reference/world-schema#style)** too, so the vehicle lines are Zork’s: “Pond, in the raft”, “(raft)” when the parser fills in what you meant, and “(outside the raft)” on the room’s things.
1114
+ - **`vehicle`** makes an item something to get into: BOARD it while it’s on the ground, DISEMBARK to get out.
1115
+ - **`water`** marks a room only a water vehicle reaches. On foot it’s “You can’t go there without a vehicle.”; aboard, getting out there is refused.
1116
+ - **The raft goes where you go**, and comes to rest on the island’s shore with you still aboard. Things you drop land in the raft, and the room’s things are “(outside the raft)”.
1117
+
1118
+ **The whole world: raft.ts**
1119
+ ```ts
1120
+ import type { World } from '@brass-lantern/engine';
1121
+
1122
+ /** Recipe: a raft on a pond. See docs/guide/building-worlds/recipes.md. */
1123
+ export const raft: World = {
1124
+ startRoom: 'bank',
1125
+ style: 'infocom',
1126
+ rooms: {
1127
+ bank: {
1128
+ name: 'Pond Bank',
1129
+ description: 'A muddy bank. A pond stretches east.',
1130
+ exits: { east: 'pond' },
1131
+ items: ['raft'],
1132
+ npcs: [],
1133
+ onEnter: [],
1134
+ },
1135
+ pond: {
1136
+ name: 'Pond',
1137
+ description: 'The middle of the pond. Lily pads drift by. An island lies to the east, the bank to the west.',
1138
+ // Only something that floats goes here.
1139
+ water: true,
1140
+ exits: { west: 'bank', east: 'island' },
1141
+ items: [],
1142
+ npcs: [],
1143
+ onEnter: [],
1144
+ },
1145
+ island: {
1146
+ name: 'Island',
1147
+ description: 'A tiny island with one tree. The pond is west.',
1148
+ exits: { west: 'pond' },
1149
+ items: ['pinecone'],
1150
+ npcs: [],
1151
+ onEnter: [],
1152
+ },
1153
+ },
1154
+ items: {
1155
+ raft: {
1156
+ name: 'raft',
1157
+ description: 'A raft of lashed logs.',
1158
+ portable: false,
1159
+ tags: [],
1160
+ vehicle: { travels: 'water' },
1161
+ container: { open: true },
1162
+ },
1163
+ pinecone: { name: 'pine cone', aliases: ['cone', 'pinecone'], description: 'A fine pine cone.', portable: true, tags: [] },
1164
+ },
1165
+ npcs: {},
1166
+ events: {},
1167
+ dialogue: {},
1168
+ flagLabels: {},
1169
+ };
1170
+ ```
1171
+
1172
+ ```
1173
+ > east
1174
+ You can’t go there without a vehicle.
1175
+ > board raft
1176
+ You are now in the raft.
1177
+ > east
1178
+ 📍 Pond, in the raft
1179
+ The middle of the pond. Lily pads drift by. An island lies to the east, the bank to the west.
1180
+ > disembark
1181
+ (raft)
1182
+ You realize that getting out here would be fatal.
1183
+ > east
1184
+ The raft comes to a rest on the shore.
1185
+
1186
+ 📍 Island, in the raft
1187
+ A tiny island with one tree. The pond is west.
1188
+ There is a pine cone here. (outside the raft)
1189
+ ```
1190
+
1191
+ Native Zork I’s magic boat is this recipe with Zork’s rules on top: inflating it, the label, LAUNCH, punctures and the river’s current.
1192
+
1193
+ ## Orders, numbers and a buggy
1194
+
1195
+ A workshop with a robot that does as it’s told, a dial you turn to a number, and a dune buggy for the sand beyond the yard.
1196
+
1197
+ ```ts
1198
+ npcs: {
1199
+ robot: {
1200
+ name: 'robot',
1201
+ description: 'A squat robot on treads, with one clamp for an arm.',
1202
+ // It does these built-in orders itself: “robot, go east”, “robot, take wrench”.
1203
+ obeys: ['go', 'take'],
1204
+ obeyReplies: { go: 'Whirr, click!', take: 'Click!' },
1205
+ refuseOrder: 'The robot beeps, puzzled.',
1206
+ // And these through rules, keyed by the verb: “robot, push the button”.
1207
+ orders: {
1208
+ push: [{ if: 'target:button', then: 'robot_pushes' }, { say: ['The robot has no use for that.'] }],
1209
+ // “robot, go to the dunes”: in a go rule, `direction:` is where it was told to go, as typed.
1210
+ go: [{ if: 'direction:dunes', say: ['The robot looks at its treads, then at the sand, and stays put.'] }],
1211
+ },
1212
+ },
1213
+ },
1214
+ ```
1215
+
1216
+ - **`obeys`** lists the built-in orders the robot carries out itself: GO, TAKE, DROP and GIVE (“robot, give me the wrench”). `obeyReplies` is what it says; the default is “Okay.”
1217
+ - **`orders`** is a table of rules per verb, for anything else (“robot, push the button”). Its rules read the order as the player wrote it: `target:button` is what it was told to push. “Push” is also the word the parser files under USE; the word typed is tried first, so `orders.push` answers it even when `orders.use` exists.
1218
+ - **`direction:`** in an `orders.go` rule is the place the character was told to go, as typed: “robot, go to the dunes” is `direction:dunes`. A rule that answers stops the robot obeying; anything else (“robot, go east”) falls through to `obeys`.
1219
+ - **An order ends the rest of the line**, and the objects of an order are looked up in *its* room, not the player’s.
1220
+
1221
+ ```ts
1222
+ dial: {
1223
+ name: 'dial',
1224
+ aliases: ['brass dial'],
1225
+ // `{var:dial}` fills in from the game; it's 0 until the dial is set.
1226
+ description: 'The brass dial points at {var:dial}.',
1227
+ portable: false,
1228
+ tags: [],
1229
+ instead: {
1230
+ turn: [
1231
+ { with: 'number', if: 'number:4', then: 'hatch_opens' },
1232
+ { with: 'number', then: 'dial_set' },
1233
+ ],
1234
+ },
1235
+ },
1236
+ ```
1237
+
1238
+ - **A number** is a second object: `turn dial to 4`. A rule says `with: 'number'` and tests `number:4`; `{ setVar: 'dial', from: 'number' }` keeps it and `{number}` prints it. `{var:dial}` fills in a description from the game, so EXAMINE shows where the dial points now.
1239
+
1240
+ ```ts
1241
+ dune: {
1242
+ name: 'Dune',
1243
+ description: 'Soft sand rolls away in every direction. The yard is west; a bigger dune lies east.',
1244
+ // A terrain of the world's own: only something that travels on sand goes here.
1245
+ terrain: 'sand',
1246
+ exits: { west: 'yard', east: 'crest' },
1247
+ items: [],
1248
+ npcs: [],
1249
+ onEnter: [],
1250
+ },
1251
+ crest: {
1252
+ name: 'Crest',
1253
+ description: 'The top of the biggest dune. You can see the whole desert, and it is all sand.',
1254
+ terrain: 'sand',
1255
+ exits: { west: 'dune' },
1256
+ items: [],
1257
+ npcs: [],
1258
+ onEnter: [],
1259
+ },
1260
+ ```
1261
+
1262
+ ```ts
1263
+ buggy: {
1264
+ name: 'dune buggy',
1265
+ aliases: ['buggy'],
1266
+ description: 'A rattling dune buggy with fat tires.',
1267
+ portable: false,
1268
+ tags: [],
1269
+ // It travels on land and on sand; its `leave` lines are said as it drives off with you.
1270
+ vehicle: { travels: ['land', 'sand'], leave: ['The engine roars.', 'Sand sprays behind you.'] },
1271
+ container: { open: true },
1272
+ },
1273
+ ```
1274
+
1275
+ - **A terrain** is any name a room gives itself ([Vehicles](https://mrballistic.github.io/brass-lantern/reference/world-schema#vehicles) has the fields). On foot you can walk only where `onFoot` allows (default `['land']`), so the dunes need a vehicle that `travels` on `'sand'`. `leave` (and `arrive`, `landing`) are lines the vehicle says as it goes; with more than one, the seeded generator picks (this world’s `seed: 4` picks “Sand sprays behind you.” first).
1276
+
1277
+ **The whole world: workshop.ts**
1278
+ ```ts
1279
+ import type { World } from '@brass-lantern/engine';
1280
+
1281
+ /** Recipe: orders, numbers and a buggy. See docs/guide/building-worlds/recipes.md. */
1282
+ export const workshop: World = {
1283
+ startRoom: 'workshop',
1284
+ // Fixes the buggy's choice of line, so the recipe's transcript plays the same every time.
1285
+ seed: 4,
1286
+ rooms: {
1287
+ workshop: {
1288
+ name: 'Workshop',
1289
+ description: 'Benches, a big brass dial on the wall and a red button. The yard is east.',
1290
+ exits: { east: 'yard' },
1291
+ items: ['dial', 'button', 'wrench'],
1292
+ npcs: ['robot'],
1293
+ onEnter: [],
1294
+ },
1295
+ yard: {
1296
+ name: 'Yard',
1297
+ description: 'A gravel yard. The workshop is west, and dunes begin to the east.',
1298
+ exits: { west: 'workshop', east: 'dune' },
1299
+ items: ['buggy'],
1300
+ npcs: [],
1301
+ onEnter: [],
1302
+ },
1303
+ dune: {
1304
+ name: 'Dune',
1305
+ description: 'Soft sand rolls away in every direction. The yard is west; a bigger dune lies east.',
1306
+ // A terrain of the world's own: only something that travels on sand goes here.
1307
+ terrain: 'sand',
1308
+ exits: { west: 'yard', east: 'crest' },
1309
+ items: [],
1310
+ npcs: [],
1311
+ onEnter: [],
1312
+ },
1313
+ crest: {
1314
+ name: 'Crest',
1315
+ description: 'The top of the biggest dune. You can see the whole desert, and it is all sand.',
1316
+ terrain: 'sand',
1317
+ exits: { west: 'dune' },
1318
+ items: [],
1319
+ npcs: [],
1320
+ onEnter: [],
1321
+ },
1322
+ },
1323
+ items: {
1324
+ dial: {
1325
+ name: 'dial',
1326
+ aliases: ['brass dial'],
1327
+ // `{var:dial}` fills in from the game; it's 0 until the dial is set.
1328
+ description: 'The brass dial points at {var:dial}.',
1329
+ portable: false,
1330
+ tags: [],
1331
+ instead: {
1332
+ turn: [
1333
+ { with: 'number', if: 'number:4', then: 'hatch_opens' },
1334
+ { with: 'number', then: 'dial_set' },
1335
+ ],
1336
+ },
1337
+ },
1338
+ button: { name: 'red button', aliases: ['button'], description: 'A red button. It begs to be pushed.', portable: false, tags: [] },
1339
+ wrench: { name: 'wrench', description: 'A big, greasy wrench.', portable: true, tags: [] },
1340
+ buggy: {
1341
+ name: 'dune buggy',
1342
+ aliases: ['buggy'],
1343
+ description: 'A rattling dune buggy with fat tires.',
1344
+ portable: false,
1345
+ tags: [],
1346
+ // It travels on land and on sand; its `leave` lines are said as it drives off with you.
1347
+ vehicle: { travels: ['land', 'sand'], leave: ['The engine roars.', 'Sand sprays behind you.'] },
1348
+ container: { open: true },
1349
+ },
1350
+ },
1351
+ npcs: {
1352
+ robot: {
1353
+ name: 'robot',
1354
+ description: 'A squat robot on treads, with one clamp for an arm.',
1355
+ // It does these built-in orders itself: “robot, go east”, “robot, take wrench”.
1356
+ obeys: ['go', 'take'],
1357
+ obeyReplies: { go: 'Whirr, click!', take: 'Click!' },
1358
+ refuseOrder: 'The robot beeps, puzzled.',
1359
+ // And these through rules, keyed by the verb: “robot, push the button”.
1360
+ orders: {
1361
+ push: [{ if: 'target:button', then: 'robot_pushes' }, { say: ['The robot has no use for that.'] }],
1362
+ // “robot, go to the dunes”: in a go rule, `direction:` is where it was told to go, as typed.
1363
+ go: [{ if: 'direction:dunes', say: ['The robot looks at its treads, then at the sand, and stays put.'] }],
1364
+ },
1365
+ },
1366
+ },
1367
+ events: {
1368
+ hatch_opens: ['The dial clicks to 4, and a hatch in the floor swings open. Inside is a tin of biscuits.', { setVar: 'dial', from: 'number' }, { end: 'biscuits' }],
1369
+ dial_set: [{ setVar: 'dial', from: 'number' }, 'The dial clicks round to {number}. Nothing else happens.'],
1370
+ robot_pushes: ['The robot extends its clamp and presses the button. Somewhere, a bell rings.'],
1371
+ },
1372
+ endings: { biscuits: { lines: ['A good day in the workshop.'] } },
1373
+ dialogue: { robot: { default: 'The robot beeps.' } },
1374
+ flagLabels: {},
1375
+ };
1376
+ ```
1377
+
1378
+ ```
1379
+ > robot, go to the dunes
1380
+ The robot looks at its treads, then at the sand, and stays put.
1381
+ > robot, push the button
1382
+ The robot extends its clamp and presses the button. Somewhere, a bell rings.
1383
+ > robot, go east
1384
+ Whirr, click!
1385
+ > turn dial to 2
1386
+ The dial clicks round to 2. Nothing else happens.
1387
+ > examine dial
1388
+ The brass dial points at 2.
1389
+ > east
1390
+ 📍 Yard
1391
+ A gravel yard. The workshop is west, and dunes begin to the east.
1392
+ You can see: dune buggy.
1393
+ Present: robot.
1394
+ Exits: west, east.
1395
+ > east
1396
+ You can’t go there without a vehicle.
1397
+ > board buggy
1398
+ You are now in the dune buggy.
1399
+ > east
1400
+ Sand sprays behind you.
1401
+ 📍 Dune (in the dune buggy)
1402
+ Soft sand rolls away in every direction. The yard is west; a bigger dune lies east.
1403
+ Exits: west, east.
1404
+ ```
1405
+
1406
+ Turn the dial to 4 instead and the hatch opens, which ends the game.