Compare commits

...

20 Commits

Author SHA1 Message Date
ejlewis f9b6fc164f Merge pull request 'docs(mystery): spec for engine prereqs (verbs, disambiguation, ending UI)' (#1) from feat/engine-prereqs into main
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #1
2026-05-09 15:10:20 -05:00
ejlewis 5f8e3b1a34 fix(ui): keep input typable post-end so player can type restart/undo
Disabling the input via the disabled attribute blocks keydown events
entirely, so players couldn't type 'restart' or 'undo' after reaching an
ending. Switch to a CSS class for the faded visual state; the keydown
handler already restricts post-end input to those two commands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:06:37 -05:00
ejlewis e167979fa7 feat(ui): render ending lines distinctly and lock input on end-state
Ending-kind lines get a separator and italic styling. Once endedWith is
set, the terminal disables the input and rejects all commands except
restart and undo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:57:52 -05:00
ejlewis 19d1efc586 feat(engine): detect endings on every successful turn
After each state-mutating dispatch, evaluate world.endings in priority
order (true > wrong > bad). The first whose whenFlags are all satisfied
sets state.endedWith and appends a kind:'ending' transcript line. Once
ended, further dispatches return a "story has ended" narration.

Also update test-world fixtures and placeholder ending markdown files
to use whenFlags: { _never: true } instead of {} so that vacuously-true
empty flags don't accidentally fire on every successful turn.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:53:07 -05:00
ejlewis 0d9db9bb55 test(engine): self-contained locked-exit fixture replaces the stub
Verifies blocked movement, key-permitted passage, and that the key is
not consumed by passing through.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:42:22 -05:00
ejlewis b870d884ef feat(engine): wire verb-target-prep — explicit \light X with Y\ and \use\ routing
light X with Y validates the named instrument and reuses handleLight.
use X / use X on Y route through the encounter dispatcher; if no encounter
consumes it, the dispatcher narrates the fallback. The encounter matcher
also rejects transitions whose required item doesn't match the typed
instrument, so a mistyped instrument fails cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:22:03 -05:00
ejlewis 8401e7d281 feat(engine): light/extinguish verbs with implicit lighter selection
\`light X\` finds a lighter (item with lighter:true and remaining state.uses)
in inventory, decrements its charges, and toggles target.state.lit. The
target's litText / extinguishedText / the lighter's lighterEmptyText
provide narration. Refuses politely on each error path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:18:54 -05:00
ejlewis dac8487dbe feat(engine): read verb narrates item.readableText
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:15:35 -05:00
ejlewis 2fecc7878d feat(world): annotate lamp/matches/letter for read/light/extinguish
Adds the new schema flags and per-state body sections so the dispatcher's
new verb handlers have content to narrate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:12:10 -05:00
ejlewis ee3cfcc00d feat(world): parseItem extracts optional ## read / lit / extinguished / lighter-empty sections
Existing items with no body sections continue to load unchanged. New items
can author per-state prose in dedicated sections; the dispatcher will read
these in subsequent commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:08:33 -05:00
ejlewis df50afa479 feat(world): item schema — readable, lightable, lighter, lighterUses
Optional fields used by the new read/light/extinguish dispatcher branches.
Loader updates and dispatcher logic follow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:01:31 -05:00
ejlewis ab8c17fdd5 feat(engine): dispatcher handles ambiguous parses with a disambiguation prompt
Sets pendingDisambiguation on state and emits "Which X — A, or B?" using
each candidate item's short text. The existing disambiguation reply path
then re-issues the original verb against the chosen target.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:58:32 -05:00
ejlewis b318747840 feat(parser): emit verb-target-prep on 'with'/'on'/'in'/'to' separators
Enables `light lamp with matches`, `use shears on vines`, and similar
multi-noun forms. Both the target and indirect noun must resolve;
otherwise the command falls back to unknown-noun.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:56:19 -05:00
ejlewis 46f851bc3a feat(parser): return ambiguous variant when noun matches multiple aliases
Replaces the previous behavior of returning unknown-noun. The dispatcher
will use this in the next commit to prompt the player to disambiguate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:53:38 -05:00
ejlewis b325f04b02 feat(parser): strip leading stop-words (at, the, a, an) from noun phrase
Allows `look at lamp`, `examine the letter`, `take a key`, `take an oil lamp`.
Stop-words are only removed from the head of the noun phrase, not from
anywhere in the middle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:36:34 -05:00
ejlewis 14a58481b1 refactor(engine): theme is a UI preference; remove it from GameState
Previously, clicking the theme button updated localStorage and the DOM but
not state.theme, so the engine's `theme` meta-verb toggled from stale state.
Theme is now exclusively UI/storage concern. Old saves with the field still
load; the field is silently ignored.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:33:57 -05:00
ejlewis 657ed22b48 refactor(engine): drop redundant string[] casts now that RoomState includes arrays
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:30:35 -05:00
ejlewis 6cffb87a63 feat(engine): widen state value unions and add 'ending' transcript kind
Drops redundant string[] casts in dispatcher paths and prepares the
TranscriptLine kind for the ending-screen render path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:28:14 -05:00
ejlewis e21a308e9d docs(mystery): implementation plan for engine prereqs
17 tasks covering type widening, theme removal, parser stop-words +
ambiguous + verb-target-prep variants, dispatcher handlers for
read/light/extinguish/use, ending detection, and UI ending screen.
Includes self-contained locked-exit fixture and manual playthrough.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:21:13 -05:00
ejlewis bcff8a42f9 docs(mystery): spec for engine prereqs (verbs, disambiguation, ending UI)
Hard prereqs from halfstreet-followon-notes plus should-fix items.
Polish items deferred. Phase 2 (full bible content draft) follows after
this lands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:07:24 -05:00
23 changed files with 3508 additions and 61 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,200 @@
# Mystery Engine Prereqs — Design
**Status:** approved 2026-05-09
**Goal:** land the engine work that the Halfstreet content bible depends on, so that Phase 2 (full bible content draft into markdown) can author every room/item/encounter/ending the bible specifies without engine gaps.
**Scope:** the hard prerequisites and "should-fix while you're in there" items from `docs/superpowers/specs/halfstreet-followon-notes.md`. Polish items (811) are explicitly deferred.
**Out of scope:** any new prose authoring. This round is engine-only. Phase 2 gets its own spec.
---
## 1. New verbs: `read`, `light`, `extinguish`
The parser already aliases these to canonical `Verb` values; the dispatcher does not handle them.
### Schema additions (`Item`)
All optional. Items that don't set them behave as today.
| field | type | meaning |
|---|---|---|
| `readable` | `boolean` | item supports `read X` |
| `lightable` | `boolean` | item supports `light X` / `extinguish X`; toggles `state.lit` |
| `lighter` | `boolean` | item can act as the light source for another item |
| `lighterUses` | `number` | optional remaining-charges counter (matches: 4 by convention; absence = unlimited) |
Body sections (markdown):
- `## read` — prose returned by `read X`. Required iff `readable: true`.
- `## lit` — narration when `light X` succeeds. Optional; falls back to `"It catches."`
- `## extinguished` — narration when `extinguish X` succeeds. Optional; falls back to `"The flame dies."`
- `## lighter-empty` — narration when a lighter's `lighterUses` reaches 0. Optional; falls back to `"It is spent."`
### Dispatcher behavior
`read X`:
- if `!item.readable``"There's nothing to read on it."`
- else → narrate the item's `## read` text.
`light X` (implicit lighter):
- if `!item.lightable``"You can't light that."`
- if `state.lit === true``"It's already lit."`
- find an inventory item with `lighter: true` (and either no `lighterUses` field, or `lighterUses > 0`):
- if none and inventory contains a depleted lighter → `"You have nothing to light it with."` (with a hint that the spent lighter is depleted)
- if none at all → `"You have nothing to light it with."`
- else: decrement `lighterUses` on the chosen lighter (if present); set `target.state.lit = true`; narrate the target's `## lit` section. If decrement reached 0, additionally narrate the lighter's `## lighter-empty` section.
`light X with Y`:
- as above but Y must be the inventory item used. Errors: Y not in inventory, Y not a lighter, Y depleted.
- on success, same state mutations.
`extinguish X`:
- if `!item.lightable``"You can't extinguish that."`
- if `state.lit !== true``"It isn't lit."`
- else: set `state.lit = false`; narrate `## extinguished`.
### Parser additions
New command kind:
```ts
{ kind: 'verb-target-instrument', verb, target: NounRef, instrument: NounRef }
```
Parsing rule: when the noun-phrase tokens (after the verb and any pronoun handling) contain the literal word `with` between two recognized nouns, split into target (left of `with`) and instrument (right of `with`). Both must resolve via the same noun-resolution path used today (visible nouns + inventory). If either fails to resolve, fall back to the current `unknown-noun` behavior.
`with` is also legal as a no-op separator for verbs that don't accept instruments (e.g. `examine letter with care`) — for safety, only `light` consumes the instrument arm in this round; other verbs treat the instrument tail as part of the target phrase and re-resolve as today (no behavior change).
---
## 2. `use` verb
`use X` and `use X on Y` route through the existing encounter dispatcher (`applyVerbToEncounter`). Encounters declare resolutions in their phase transitions; `use` is intentionally thin.
- `use X``verb-target` with verb=`use`, target=X. If no encounter consumes it: `"You can't think how to use that here."`
- `use X on Y``verb-target-instrument` with verb=`use`, target=X, instrument=Y. The dispatcher routes the verb+target into `applyVerbToEncounter` as today; the encounter's `requires.item` mechanism (already implemented) gates the transition on the instrument being in inventory. The parser-level instrument is therefore a UX nicety: it lets the player type the form the bible uses, and we can validate the typed instrument matches the transition's `requires.item` when present (mismatch → fallback narration "That isn't going to help.").
No special-case dispatcher handler beyond the fallback narration. No new fields on `EncounterDef`.
---
## 3. Disambiguation
Today the parser returns `unknown-noun` when multiple visible aliases match a noun phrase. Replace with an explicit ambiguous variant.
### Parser
New `ParsedCommand` variant:
```ts
{ kind: 'ambiguous', verb: Verb, rawNoun: string, candidates: string[] }
```
Returned when ≥2 entries in `visibleNouns inventory` match the same alias. `candidates` is the list of canonical ids.
The existing `disambiguation` reply variant stays as-is; the parser already handles single-word reply matching.
### Dispatcher
On `kind: 'ambiguous'`:
- set `state.pendingDisambiguation = { verb, candidates }`
- emit a single narration: `"Which X — A, B, or C?"` where `X` is the `rawNoun` and A/B/C are the `short` strings of each candidate item.
On `kind: 'disambiguation'` reply (already wired):
- read `state.pendingDisambiguation`, clear it, re-issue the original verb against the chosen canonical id.
### Edge cases
- If the player issues a fresh command (not a single-word disambiguation reply) while `pendingDisambiguation` is set, clear it and proceed normally.
- If candidates resolve to identical canonical ids (shouldn't happen given current schema), prefer the first; no error.
---
## 4. Ending UI + flag matching
### Dispatcher
After every dispatched turn (and only on turns that succeeded — meta-commands and parse failures don't trigger ending evaluation), iterate `world.endings`:
- For each `endingId` in a fixed priority order (`true` > `wrong` > `bad` — declared order in `world.endings`), check that **every** key in `whenFlags` is present in `state.flags` and has the matching value. First match wins.
- On match: set `state.endedWith = endingId`; append a narration event with `kind: 'ending'` and the body prose (already loaded from the ending markdown). Mark the run as ended (subsequent turns are rejected with a "the game has ended" narration until restart).
Ending evaluation is pure — same inputs, same outputs.
### UI (terminal renderer)
- `kind: 'ending'` events render with a separator line above, the prose centered or left-aligned with extra vertical gap, no `>` prompt afterward.
- Input field disables (`disabled` attribute or readonly + faded styling).
- Footer chips replace the regular set with `[R] Restart`, `[U] Undo`.
- `restart` (typed or button) resets state and re-enables input.
- `undo` (typed or button) pops the last turn (existing behavior) and re-enables input if the popped state had no ending.
---
## 5. Should-fix items (in scope)
### 5a. Parser stop-word strip
Before noun resolution, strip leading occurrences of `at`, `the`, `a`, `an` from the noun-phrase tokens. So `look at the lamp` → tokens `[lamp]` for matching. The verb itself is not touched.
Affects only the noun phrase. Stop-words *between* meaningful tokens are left alone (e.g. `light lamp with the matches` strips the leading-of-instrument `the`).
### 5b. Remove `theme` from `GameState`
`theme` is a UI preference, not game state. Today, clicking `[B]/[C]` updates DOM + localStorage but not `state.theme`, then the next `theme` meta-verb toggles from a stale value.
Changes:
- delete `theme` field from `GameState` and from save format
- `theme` meta-verb becomes a UI-layer action: dispatcher emits a `kind: 'ui-toggle-theme'` event that the terminal handles
- migration: existing saves drop the `theme` field on load (forward-compatible)
### 5c. Widen `RoomState` type
Today: `Record<string, string | boolean | number>`, but `droppedItems` / `takenItems` store `string[]` via `as` casts.
Change: `Record<string, string | boolean | number | string[]>`. Remove all `as` casts in dispatcher and engine paths that touch `roomState`. No runtime change.
### 5d. Self-contained locked-exit dispatcher test
The current `dispatcher.test.ts` "opens a locked exit" test is a stub. Add a 15-line synthetic world fixture (two rooms, one key item, one locked exit) and exercise:
- locked exit blocks movement with the `lockedNarration`
- carrying the required item permits movement
- the key is not consumed by passage (unless flagged so in future; current behavior: not consumed)
---
## 6. Test plan
TDD throughout, per existing project conventions.
| area | test types |
|---|---|
| schema additions | Zod parse tests (valid + each invalid shape) |
| `read` verb | dispatcher unit (readable, non-readable, missing item) |
| `light` / `extinguish` | dispatcher unit (lightable + lighter, lighter-with-charges decrement, lighter-empty fallthrough, explicit `with`, error paths) |
| `use` verb | dispatcher unit (no encounter consumes → fallback; encounter consumes → routed) |
| disambiguation | parser unit (ambiguous candidates), dispatcher round-trip (prompt → reply → re-issue) |
| ending detection | dispatcher unit per ending; priority order; ended-state turn rejection |
| stop-word strip | parser unit (`look at lamp`, `read the letter`, etc.) |
| theme removal | save round-trip drops field; `theme` meta-verb emits UI event, no state mutation |
| roomState widen | type-level (compile passes); existing tests unchanged |
| locked-exit | new synthetic-world dispatcher test |
| manual playthrough | golden path on current 3-room world after all changes |
---
## 7. Out of scope — follow-up tracker
These are tracked but not part of this round:
- Transcript scrolling (PageUp/PageDown)
- Cursor blink at 1.05 Hz
- Old-line opacity fade at top of transcript
- Scanline accessibility toggle (`[?]` settings dropdown — important for photosensitivity)
---
## 8. After this lands
Phase 2 brainstorming → spec → plan: full bible content draft. All 25+ rooms, all bible items, all encounters, all 5 endings authored to markdown for Obsidian editing. Engine will support every interaction the bible specifies by then.
+380 -7
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { dispatch, initialStateFor } from './dispatcher' import { dispatch, initialStateFor } from './dispatcher'
import type { World } from '../world/types' import type { World } from '../world/types'
import type { GameState } from './types' import type { GameState, ParsedCommand } from './types'
import { SCHEMA_VERSION } from './types' import { SCHEMA_VERSION } from './types'
const world: World = { const world: World = {
@@ -94,13 +94,60 @@ describe('dispatcher — go', () => {
expect(r.state.location).toBe('hallway') expect(r.state.location).toBe('hallway')
expect(r.appended.some((l) => l.text.includes('locked'))).toBe(true) expect(r.appended.some((l) => l.text.includes('locked'))).toBe(true)
}) })
})
it('opens a locked exit when required item is in inventory', () => { describe('locked exits', () => {
// Locked-exit-with-key happy path is covered by the playthrough integration function makeWorld(): World {
// test in Task 8. The sample world above doesn't have an unlocked path to return {
// pick up the brass key without first traversing the locked door, so this startingRoom: 'antechamber',
// test is intentionally a placeholder. startingInventory: [],
expect(true).toBe(true) rooms: {
antechamber: {
id: 'antechamber',
title: '[ Antechamber ]',
descriptions: { firstVisit: '.', revisit: '.', examined: '.' },
exits: { n: 'vault' },
lockedExits: { n: { requires: 'rusted-key', lockedNarration: 'The door is locked.' } },
items: ['rusted-key'],
},
vault: {
id: 'vault',
title: '[ Vault ]',
descriptions: { firstVisit: 'You are inside.', revisit: '.', examined: '.' },
exits: {},
items: [],
},
},
items: {
'rusted-key': { id: 'rusted-key', names: ['rusted key', 'key'], short: 'a rusted key', long: '.', initialState: {}, takeable: true },
},
encounters: {},
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
}
}
it('blocks movement without the key', () => {
const world = makeWorld()
const state = initialStateFor(world)
const result = dispatch(state, { kind: 'go', direction: 'n' }, world)
expect(result.appended.at(-1)?.text).toBe('The door is locked.')
expect(result.state.location).toBe('antechamber')
})
it('permits movement once the key is in inventory', () => {
const world = makeWorld()
let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'rusted-key', state: {} }] }
const result = dispatch(state, { kind: 'go', direction: 'n' }, world)
expect(result.state.location).toBe('vault')
})
it('does not consume the key on passage', () => {
const world = makeWorld()
let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'rusted-key', state: {} }] }
const result = dispatch(state, { kind: 'go', direction: 'n' }, world)
expect(result.state.inventory.find((i) => i.id === 'rusted-key')).toBeDefined()
}) })
}) })
@@ -157,3 +204,329 @@ describe('dispatcher — inventory', () => {
expect(r.appended.some((l) => /empty-handed|carrying nothing/i.test(l.text))).toBe(true) expect(r.appended.some((l) => /empty-handed|carrying nothing/i.test(l.text))).toBe(true)
}) })
}) })
describe('ambiguous → disambiguation flow', () => {
function makeAmbiguousWorld(): World {
return {
startingRoom: 'r',
startingInventory: [],
rooms: {
r: {
id: 'r',
title: '[ R ]',
descriptions: { firstVisit: 'r', revisit: 'r', examined: 'r' },
exits: {},
items: ['iron-key', 'brass-key'],
},
},
items: {
'iron-key': { id: 'iron-key', names: ['key', 'iron key'], short: 'an iron key', long: '.', initialState: {}, takeable: true },
'brass-key': { id: 'brass-key', names: ['key', 'brass key'], short: 'a brass key', long: '.', initialState: {}, takeable: true },
},
encounters: {},
endings: {
true: { whenFlags: { _never: true }, narration: '' },
wrong: { whenFlags: { _never: true }, narration: '' },
bad: { whenFlags: { _never: true }, narration: '' },
},
}
}
it('sets pendingDisambiguation and prompts when the parser returns ambiguous', () => {
const world = makeAmbiguousWorld()
const state = initialStateFor(world)
const cmd: ParsedCommand = {
kind: 'ambiguous', verb: 'take', rawNoun: 'key', candidates: ['iron-key', 'brass-key'],
}
const result = dispatch(state, cmd, world)
expect(result.state.pendingDisambiguation).toEqual({
verb: 'take',
candidates: ['iron-key', 'brass-key'],
prompt: 'Which key — an iron key, or a brass key?',
})
expect(result.appended[0]?.text).toBe('Which key — an iron key, or a brass key?')
})
it('handles a single-word disambiguation reply by re-issuing the verb', () => {
const world = makeAmbiguousWorld()
let state = initialStateFor(world)
state = {
...state,
pendingDisambiguation: { verb: 'take', candidates: ['iron-key', 'brass-key'], prompt: '...' },
}
const result = dispatch(state, { kind: 'disambiguation', chosen: 'iron-key' }, world)
expect(result.state.pendingDisambiguation).toBeNull()
expect(result.state.inventory.find((i) => i.id === 'iron-key')).toBeDefined()
})
})
function readWorld(): World {
return {
startingRoom: 'r',
startingInventory: [],
rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } },
items: {
letter: { id: 'letter', names: ['letter'], short: 'a letter', long: 'A letter.', initialState: {}, takeable: true, readable: true, readableText: 'You loved Halfstreet, the letter says.' },
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: 'A rock.', initialState: {}, takeable: true },
},
encounters: {},
endings: {
true: { whenFlags: { _never: true }, narration: '' },
wrong: { whenFlags: { _never: true }, narration: '' },
bad: { whenFlags: { _never: true }, narration: '' },
},
}
}
describe('read verb', () => {
it('narrates readableText for a readable item in inventory', () => {
const world = readWorld()
let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'letter', state: {} }] }
const result = dispatch(state, {
kind: 'verb-target', verb: 'read', target: { canonical: 'letter', raw: 'letter' },
}, world)
expect(result.appended.at(-1)?.text).toBe('You loved Halfstreet, the letter says.')
})
it('errors politely on non-readable items', () => {
const world = readWorld()
let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'rock', state: {} }] }
const result = dispatch(state, {
kind: 'verb-target', verb: 'read', target: { canonical: 'rock', raw: 'rock' },
}, world)
expect(result.appended.at(-1)?.text).toBe("There's nothing to read on it.")
})
})
describe('light/extinguish verbs (implicit lighter)', () => {
function w(): World {
return {
startingRoom: 'r',
startingInventory: [],
rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } },
items: {
lamp: { id: 'lamp', names: ['lamp'], short: 'an oil lamp', long: '.', initialState: { lit: false }, takeable: true, lightable: true, litText: 'The wick catches.', extinguishedText: 'The flame dies.' },
matches: { id: 'matches', names: ['matches'], short: 'a matchbook', long: '.', initialState: {}, takeable: true, lighter: true, lighterUses: 2, lighterEmptyText: 'The book is empty.' },
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true },
},
encounters: {},
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
}
}
it('lights a lamp using the matchbook implicitly', () => {
const world = w()
let state = initialStateFor(world)
state = { ...state, inventory: [
{ id: 'lamp', state: { lit: false } },
{ id: 'matches', state: { uses: 2 } },
] }
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world)
expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(true)
expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(1)
expect(result.appended.at(-1)?.text).toBe('The wick catches.')
})
it('refuses when the target is already lit', () => {
const world = w()
let state = initialStateFor(world)
state = { ...state, inventory: [
{ id: 'lamp', state: { lit: true } },
{ id: 'matches', state: { uses: 2 } },
] }
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world)
expect(result.appended.at(-1)?.text).toBe("It's already lit.")
})
it('refuses when no lighter is in inventory', () => {
const world = w()
let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'lamp', state: { lit: false } }] }
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world)
expect(result.appended.at(-1)?.text).toBe('You have nothing to light it with.')
})
it('refuses when the target is not lightable', () => {
const world = w()
let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'rock', state: {} }, { id: 'matches', state: { uses: 2 } }] }
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'rock', raw: 'rock' } }, world)
expect(result.appended.at(-1)?.text).toBe("You can't light that.")
})
it('emits the lighter-empty message when matches reach 0', () => {
const world = w()
let state = initialStateFor(world)
state = { ...state, inventory: [
{ id: 'lamp', state: { lit: false } },
{ id: 'matches', state: { uses: 1 } },
] }
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world)
expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(0)
const texts = result.appended.map((l) => l.text)
expect(texts).toContain('The wick catches.')
expect(texts).toContain('The book is empty.')
})
it('extinguishes a lit lamp', () => {
const world = w()
let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'lamp', state: { lit: true } }] }
const result = dispatch(state, { kind: 'verb-target', verb: 'extinguish', target: { canonical: 'lamp', raw: 'lamp' } }, world)
expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(false)
expect(result.appended.at(-1)?.text).toBe('The flame dies.')
})
it("refuses to extinguish what isn't lit", () => {
const world = w()
let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'lamp', state: { lit: false } }] }
const result = dispatch(state, { kind: 'verb-target', verb: 'extinguish', target: { canonical: 'lamp', raw: 'lamp' } }, world)
expect(result.appended.at(-1)?.text).toBe("It isn't lit.")
})
})
describe('light X with Y (explicit lighter)', () => {
function w(): World {
return {
startingRoom: 'r',
startingInventory: [],
rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } },
items: {
lamp: { id: 'lamp', names: ['lamp'], short: 'an oil lamp', long: '.', initialState: { lit: false }, takeable: true, lightable: true, litText: 'The wick catches.', extinguishedText: 'The flame dies.' },
matches: { id: 'matches', names: ['matches'], short: 'a matchbook', long: '.', initialState: {}, takeable: true, lighter: true, lighterUses: 2, lighterEmptyText: 'The book is empty.' },
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true },
},
encounters: {},
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
}
}
it('lights with the explicit instrument', () => {
const world = w()
let state = initialStateFor(world)
state = { ...state, inventory: [
{ id: 'lamp', state: { lit: false } },
{ id: 'matches', state: { uses: 2 } },
] }
const result = dispatch(state, {
kind: 'verb-target-prep', verb: 'light',
target: { canonical: 'lamp', raw: 'lamp' },
preposition: 'with',
indirect: { canonical: 'matches', raw: 'matches' },
}, world)
expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(true)
expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(1)
})
it('refuses when the named instrument is not a lighter', () => {
const world = w()
let state = initialStateFor(world)
state = { ...state, inventory: [
{ id: 'lamp', state: { lit: false } },
{ id: 'rock', state: {} },
] }
const result = dispatch(state, {
kind: 'verb-target-prep', verb: 'light',
target: { canonical: 'lamp', raw: 'lamp' },
preposition: 'with',
indirect: { canonical: 'rock', raw: 'rock' },
}, world)
expect(result.appended.at(-1)?.text).toBe("That isn't going to help.")
})
})
describe('use verb routing', () => {
function w(): World {
return {
startingRoom: 'r',
startingInventory: [],
rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } },
items: {
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true },
},
encounters: {},
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
}
}
it('falls back when no encounter consumes use', () => {
const world = w()
let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'rock', state: {} }] }
const result = dispatch(state, {
kind: 'verb-target', verb: 'use', target: { canonical: 'rock', raw: 'rock' },
}, world)
expect(result.appended.at(-1)?.text).toBe("You can't think how to use that here.")
})
})
describe('ending detection', () => {
function makeWorld(): World {
return {
startingRoom: 'r',
startingInventory: [],
rooms: {
r: {
id: 'r',
title: '[ R ]',
descriptions: { firstVisit: '.', revisit: '.', examined: '.' },
exits: { n: 'r2' },
items: [],
},
r2: {
id: 'r2',
title: '[ R2 ]',
descriptions: { firstVisit: '.', revisit: '.', examined: '.' },
exits: {},
items: [],
},
},
items: {},
encounters: {},
endings: {
true: { whenFlags: { reachedR2: true }, narration: 'You stand at the top of the stair.' },
wrong: { whenFlags: {}, narration: 'You disturb what should not be disturbed.' },
bad: { whenFlags: { tookPhoto: true }, narration: 'The child in it is you.' },
},
}
}
it('sets endedWith and emits an ending line when flags match', () => {
const world = makeWorld()
let state = initialStateFor(world)
state = { ...state, flags: { reachedR2: true } }
const result = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world)
expect(result.state.endedWith).toBe('true')
const last = result.appended.at(-1)!
expect(last.kind).toBe('ending')
expect(last.text).toBe('You stand at the top of the stair.')
})
it('honors priority order: true beats wrong beats bad', () => {
const world = makeWorld()
let state = initialStateFor(world)
state = { ...state, flags: { reachedR2: true } }
const result = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world)
expect(result.state.endedWith).toBe('true')
})
it('rejects further input once ended', () => {
const world = makeWorld()
let state = initialStateFor(world)
state = { ...state, flags: { reachedR2: true } }
const ended = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world).state
const result = dispatch(ended, { kind: 'verb-only', verb: 'wait' }, world)
expect(result.appended.at(-1)?.text).toBe('The story has ended. Type `restart` or `undo`.')
expect(result.state.location).toBe(ended.location)
})
it('does not fire on unknown turns (no state mutation)', () => {
const world = makeWorld()
const state = initialStateFor(world)
const result = dispatch(state, { kind: 'unknown', raw: 'fnord', reason: 'unknown-verb' }, world)
expect(result.state.endedWith).toBeNull()
})
})
+172 -20
View File
@@ -29,7 +29,6 @@ export function initialStateFor(world: World): GameState {
lastNoun: null, lastNoun: null,
pendingDisambiguation: null, pendingDisambiguation: null,
transcript: opening, transcript: opening,
theme: 'amber',
endedWith: null, endedWith: null,
} }
} }
@@ -41,8 +40,8 @@ function append(state: GameState, lines: TranscriptLine[]): GameState {
export function getItemsInRoom(state: GameState, world: World, roomId: string): string[] { export function getItemsInRoom(state: GameState, world: World, roomId: string): string[] {
const baseItems = world.rooms[roomId]?.items ?? [] const baseItems = world.rooms[roomId]?.items ?? []
const dropped = (state.roomState[roomId]?.['droppedItems'] as string[] | undefined) ?? [] const dropped = (state.roomState[roomId]?.['droppedItems'] ?? []) as string[]
const taken = (state.roomState[roomId]?.['takenItems'] as string[] | undefined) ?? [] const taken = (state.roomState[roomId]?.['takenItems'] ?? []) as string[]
return [...baseItems.filter((i) => !taken.includes(i)), ...dropped] return [...baseItems.filter((i) => !taken.includes(i)), ...dropped]
} }
@@ -51,11 +50,39 @@ function setRoomFlag(state: GameState, roomId: string, key: string, value: strin
...state, ...state,
roomState: { roomState: {
...state.roomState, ...state.roomState,
[roomId]: { ...(state.roomState[roomId] ?? {}), [key]: value as string | boolean | number }, [roomId]: { ...(state.roomState[roomId] ?? {}), [key]: value },
}, },
} }
} }
const ENDING_PRIORITY: ('true' | 'wrong' | 'bad')[] = ['true', 'wrong', 'bad']
function evaluateEndings(state: GameState, world: World): GameState | null {
if (state.endedWith) return null
for (const id of ENDING_PRIORITY) {
const ending = world.endings[id]
const flags = ending.whenFlags
let allMatch = true
for (const [k, v] of Object.entries(flags)) {
if (state.flags[k] !== v) { allMatch = false; break }
}
if (!allMatch) continue
return {
...state,
endedWith: id,
transcript: [...state.transcript, { kind: 'ending', text: ending.narration }],
}
}
return null
}
function withEndingCheck(result: DispatchResult, world: World): DispatchResult {
const updated = evaluateEndings(result.state, world)
if (!updated) return result
const endingLine: TranscriptLine = updated.transcript[updated.transcript.length - 1]!
return { state: updated, appended: [...result.appended, endingLine] }
}
export function dispatch(state: GameState, command: ParsedCommand, world: World): DispatchResult { export function dispatch(state: GameState, command: ParsedCommand, world: World): DispatchResult {
// Disambiguation reply: re-issue the original verb with the chosen target. // Disambiguation reply: re-issue the original verb with the chosen target.
if (command.kind === 'disambiguation') { if (command.kind === 'disambiguation') {
@@ -71,6 +98,11 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
) )
} }
// Once the game has ended, only restart/undo (handled by the UI) can clear state.
if (state.endedWith) {
return narrate(state, [{ kind: 'narration', text: 'The story has ended. Type `restart` or `undo`.' }])
}
if (command.kind === 'unknown') { if (command.kind === 'unknown') {
const text = const text =
command.reason === 'unknown-verb' ? 'You consider the words, but they don\'t fit this place.' command.reason === 'unknown-verb' ? 'You consider the words, but they don\'t fit this place.'
@@ -84,13 +116,27 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
} }
if (command.kind === 'go') { if (command.kind === 'go') {
return handleGo(state, command.direction, world) return withEndingCheck(handleGo(state, command.direction, world), world)
}
if (command.kind === 'ambiguous') {
const candidateShorts = command.candidates.map((id) => world.items[id]?.short ?? id)
const list =
candidateShorts.length === 2
? `${candidateShorts[0]}, or ${candidateShorts[1]}`
: candidateShorts.slice(0, -1).join(', ') + ', or ' + candidateShorts[candidateShorts.length - 1]
const prompt = `Which ${command.rawNoun}${list}?`
const next: GameState = {
...state,
pendingDisambiguation: { verb: command.verb, candidates: command.candidates, prompt },
}
return narrate(next, [{ kind: 'narration', text: prompt }])
} }
if (command.kind === 'verb-only') { if (command.kind === 'verb-only') {
if (command.verb === 'look') return handleLook(state, world) if (command.verb === 'look') return withEndingCheck(handleLook(state, world), world)
if (command.verb === 'inventory') return handleInventory(state, world) if (command.verb === 'inventory') return withEndingCheck(handleInventory(state, world), world)
if (command.verb === 'wait') return narrate(state, [{ kind: 'narration', text: 'Time passes.' }]) if (command.verb === 'wait') return withEndingCheck(narrate(state, [{ kind: 'narration', text: 'Time passes.' }]), world)
} }
if (command.kind === 'verb-target') { if (command.kind === 'verb-target') {
@@ -98,12 +144,32 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
// Try the active encounter first — it may consume verbs like 'attack', 'hold'. // Try the active encounter first — it may consume verbs like 'attack', 'hold'.
const encResult = applyVerbToEncounter(stateWithNoun, command, world) const encResult = applyVerbToEncounter(stateWithNoun, command, world)
if (encResult?.consumed) { if (encResult?.consumed) {
return { state: encResult.state, appended: encResult.lines } return withEndingCheck({ state: encResult.state, appended: encResult.lines }, world)
} }
if (command.verb === 'take') return handleTake(stateWithNoun, command.target.canonical, world) if (command.verb === 'take') return withEndingCheck(handleTake(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'drop') return handleDrop(stateWithNoun, command.target.canonical, world) if (command.verb === 'drop') return withEndingCheck(handleDrop(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'examine' || command.verb === 'look') return handleExamine(stateWithNoun, command.target.canonical, world) if (command.verb === 'examine' || command.verb === 'look') return withEndingCheck(handleExamine(stateWithNoun, command.target.canonical, world), world)
return narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]) if (command.verb === 'read') return withEndingCheck(handleRead(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'light') return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, null, world), world)
if (command.verb === 'extinguish') return withEndingCheck(handleExtinguish(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'use') return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world)
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world)
}
if (command.kind === 'verb-target-prep') {
const stateWithNoun: GameState = { ...state, lastNoun: command.target }
// Try the encounter first — it may consume verbs like 'cut vines with shears'.
const encResult = applyVerbToEncounter(stateWithNoun, command, world)
if (encResult?.consumed) {
return withEndingCheck({ state: encResult.state, appended: encResult.lines }, world)
}
if (command.verb === 'light' && command.preposition === 'with') {
return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world), world)
}
if (command.verb === 'use') {
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world)
}
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world)
} }
return narrate(state, [{ kind: 'narration', text: 'Nothing happens.' }]) return narrate(state, [{ kind: 'narration', text: 'Nothing happens.' }])
@@ -115,10 +181,10 @@ function narrate(state: GameState, lines: TranscriptLine[]): DispatchResult {
function handleMeta(state: GameState, verb: 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme'): DispatchResult { function handleMeta(state: GameState, verb: 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme'): DispatchResult {
if (verb === 'save') return narrate(state, [{ kind: 'system', text: '(your progress is saved automatically)' }]) if (verb === 'save') return narrate(state, [{ kind: 'system', text: '(your progress is saved automatically)' }])
if (verb === 'theme') { // 'theme' is a UI preference: the terminal intercepts it before dispatch and
const newTheme = state.theme === 'amber' ? 'ansi' : 'amber' // dispatches a 'halfstreet-toggle-theme' DOM event. The engine no-ops here so
return narrate({ ...state, theme: newTheme }, [{ kind: 'system', text: `Theme: ${newTheme}.` }]) // typing the verb still produces transcript output if the UI ever misses it.
} if (verb === 'theme') return narrate(state, [{ kind: 'system', text: '(theme)' }])
// restart / undo / hint / quit are handled by the UI layer (state mutations // restart / undo / hint / quit are handled by the UI layer (state mutations
// require coordination with the save layer and route navigation). The // require coordination with the save layer and route navigation). The
// engine acknowledges them with a no-op narration; the UI intercepts before // engine acknowledges them with a no-op narration; the UI intercepts before
@@ -218,10 +284,10 @@ function handleTake(state: GameState, itemId: string, world: World): DispatchRes
inventory: [...state.inventory, { id: itemId, state: { ...item.initialState } }], inventory: [...state.inventory, { id: itemId, state: { ...item.initialState } }],
} }
if (wasInRoomBase) { if (wasInRoomBase) {
const taken = (next.roomState[state.location]?.['takenItems'] as string[] | undefined) ?? [] const taken = (next.roomState[state.location]?.['takenItems'] ?? []) as string[]
next = setRoomFlag(next, state.location, 'takenItems', [...taken, itemId]) next = setRoomFlag(next, state.location, 'takenItems', [...taken, itemId])
} else { } else {
const dropped = (next.roomState[state.location]?.['droppedItems'] as string[] | undefined) ?? [] const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== itemId)) next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== itemId))
} }
return narrate(next, [{ kind: 'narration', text: 'Taken.' }]) return narrate(next, [{ kind: 'narration', text: 'Taken.' }])
@@ -235,7 +301,7 @@ function handleDrop(state: GameState, itemId: string, world: World): DispatchRes
...state, ...state,
inventory: state.inventory.filter((i) => i.id !== itemId), inventory: state.inventory.filter((i) => i.id !== itemId),
} }
const dropped = (next.roomState[state.location]?.['droppedItems'] as string[] | undefined) ?? [] const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
next = setRoomFlag(next, state.location, 'droppedItems', [...dropped, itemId]) next = setRoomFlag(next, state.location, 'droppedItems', [...dropped, itemId])
return narrate(next, [{ kind: 'narration', text: 'Dropped.' }]) return narrate(next, [{ kind: 'narration', text: 'Dropped.' }])
} }
@@ -249,3 +315,89 @@ function handleExamine(state: GameState, itemId: string, world: World): Dispatch
if (!visible) return narrate(state, [{ kind: 'narration', text: 'You don\'t see anything like that.' }]) if (!visible) return narrate(state, [{ kind: 'narration', text: 'You don\'t see anything like that.' }])
return narrate(state, [{ kind: 'narration', text: item.long }]) return narrate(state, [{ kind: 'narration', text: item.long }])
} }
function handleRead(state: GameState, itemId: string, world: World): DispatchResult {
const item = world.items[itemId]
if (!item) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
const visible =
state.inventory.find((i) => i.id === itemId) ||
getItemsInRoom(state, world, state.location).includes(itemId)
if (!visible) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
if (!item.readable || !item.readableText) {
return narrate(state, [{ kind: 'narration', text: "There's nothing to read on it." }])
}
return narrate(state, [{ kind: 'narration', text: item.readableText }])
}
function handleLight(state: GameState, targetId: string, instrumentId: string | null, world: World): DispatchResult {
const target = world.items[targetId]
if (!target) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
if (!target.lightable) return narrate(state, [{ kind: 'narration', text: "You can't light that." }])
const targetInst = state.inventory.find((i) => i.id === targetId) ?? null
const visibleInRoom = getItemsInRoom(state, world, state.location).includes(targetId)
if (!targetInst && !visibleInRoom) {
return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
}
// The 'lit' state lives on the inventory instance for inventory items, or
// (eventually) on roomState for items left in a room. For now we only
// support lighting items the player is carrying.
if (!targetInst) {
return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }])
}
if (targetInst.state['lit'] === true) {
return narrate(state, [{ kind: 'narration', text: "It's already lit." }])
}
// Pick an instrument. If explicit, validate it; if implicit, find any.
let lighterInst = null as typeof state.inventory[number] | null
if (instrumentId) {
lighterInst = state.inventory.find((i) => i.id === instrumentId) ?? null
if (!lighterInst) return narrate(state, [{ kind: 'narration', text: "You don't have that." }])
const lighterDef = world.items[instrumentId]
if (!lighterDef?.lighter) return narrate(state, [{ kind: 'narration', text: "That isn't going to help." }])
if (typeof lighterInst.state['uses'] === 'number' && lighterInst.state['uses'] <= 0) {
return narrate(state, [{ kind: 'narration', text: "It is spent." }])
}
} else {
for (const inst of state.inventory) {
const def = world.items[inst.id]
if (!def?.lighter) continue
if (typeof inst.state['uses'] === 'number' && inst.state['uses'] <= 0) continue
lighterInst = inst
break
}
if (!lighterInst) {
return narrate(state, [{ kind: 'narration', text: 'You have nothing to light it with.' }])
}
}
// Apply state changes immutably.
const lighterDef = world.items[lighterInst.id]!
const lighterUsesField = typeof lighterInst.state['uses'] === 'number' ? lighterInst.state['uses'] : null
const newLighterUses = lighterUsesField === null ? null : lighterUsesField - 1
const newInventory = state.inventory.map((i) => {
if (i.id === targetInst.id) return { ...i, state: { ...i.state, lit: true } }
if (i.id === lighterInst!.id && newLighterUses !== null) return { ...i, state: { ...i.state, uses: newLighterUses } }
return i
})
const lines: TranscriptLine[] = [{ kind: 'narration', text: target.litText ?? 'It catches.' }]
if (newLighterUses === 0) {
lines.push({ kind: 'narration', text: lighterDef.lighterEmptyText ?? 'It is spent.' })
}
return narrate({ ...state, inventory: newInventory }, lines)
}
function handleExtinguish(state: GameState, targetId: string, world: World): DispatchResult {
const target = world.items[targetId]
if (!target) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
if (!target.lightable) return narrate(state, [{ kind: 'narration', text: "You can't extinguish that." }])
const targetInst = state.inventory.find((i) => i.id === targetId)
if (!targetInst) return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }])
if (targetInst.state['lit'] !== true) {
return narrate(state, [{ kind: 'narration', text: "It isn't lit." }])
}
const newInventory = state.inventory.map((i) =>
i.id === targetId ? { ...i, state: { ...i.state, lit: false } } : i,
)
return narrate({ ...state, inventory: newInventory }, [{ kind: 'narration', text: target.extinguishedText ?? 'The flame dies.' }])
}
+3 -3
View File
@@ -61,9 +61,9 @@ const world: World = {
}, },
}, },
endings: { endings: {
true: { whenFlags: {}, narration: '' }, true: { whenFlags: { _never: true }, narration: '' },
wrong: { whenFlags: {}, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' },
bad: { whenFlags: {}, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' },
}, },
} }
+7 -1
View File
@@ -66,12 +66,17 @@ export function applyVerbToEncounter(
const phaseDef = def.phases[currentPhase] const phaseDef = def.phases[currentPhase]
if (!phaseDef) return null if (!phaseDef) return null
// Only verb-target and verb-only commands engage with encounters. // Only verb-target, verb-target-prep, and verb-only commands engage with encounters.
let verb: string | null = null let verb: string | null = null
let targetId: string | null = null let targetId: string | null = null
let instrumentId: string | null = null
if (command.kind === 'verb-target') { if (command.kind === 'verb-target') {
verb = command.verb verb = command.verb
targetId = command.target.canonical targetId = command.target.canonical
} else if (command.kind === 'verb-target-prep') {
verb = command.verb
targetId = command.target.canonical
instrumentId = command.indirect.canonical
} else if (command.kind === 'verb-only' && command.verb !== 'inventory') { } else if (command.kind === 'verb-only' && command.verb !== 'inventory') {
verb = command.verb verb = command.verb
} else { } else {
@@ -91,6 +96,7 @@ export function applyVerbToEncounter(
} }
} }
} }
if (t.requires && instrumentId && t.requires.item !== instrumentId) return false
return true return true
}) })
+130 -7
View File
@@ -158,7 +158,7 @@ describe('parser — verb + target', () => {
}) })
describe('parser — disambiguation', () => { describe('parser — disambiguation', () => {
it('returns disambiguation request when two candidates match', () => { it('returns ambiguous when two candidates match', () => {
const ctx: ParserContext = { const ctx: ParserContext = {
knownItems: ['brass-key', 'iron-key'], knownItems: ['brass-key', 'iron-key'],
knownEncounters: [], knownEncounters: [],
@@ -171,12 +171,11 @@ describe('parser — disambiguation', () => {
awaitingDisambiguation: null, awaitingDisambiguation: null,
} }
const result = parse('take key', ctx) const result = parse('take key', ctx)
expect(result.kind).toBe('unknown') expect(result.kind).toBe('ambiguous')
if (result.kind === 'unknown') { if (result.kind === 'ambiguous') {
// Parser flags ambiguity by returning unknown-noun; the dispatcher expect(result.verb).toBe('take')
// turns this into a PendingDisambiguation. (Keeping parser pure: it expect(result.rawNoun).toBe('key')
// signals; the dispatcher decides UI flow.) expect(result.candidates).toEqual(['brass-key', 'iron-key'])
expect(result.reason).toBe('unknown-noun')
} }
}) })
@@ -231,3 +230,127 @@ describe('parser — pronouns', () => {
expect(result.kind).toBe('unknown') expect(result.kind).toBe('unknown')
}) })
}) })
describe('stop-word stripping', () => {
const ctx: ParserContext = {
knownItems: ['lamp'],
knownEncounters: [],
visibleNouns: [{ id: 'lamp', aliases: ['lamp', 'oil lamp'] }],
inventoryItemIds: [],
lastNoun: null,
awaitingDisambiguation: null,
}
it('strips a leading "at" from the noun phrase', () => {
const cmd = parse('look at lamp', ctx)
expect(cmd).toEqual({
kind: 'verb-target',
verb: 'look',
target: { canonical: 'lamp', raw: 'lamp' },
})
})
it('strips a leading "the"', () => {
const cmd = parse('examine the lamp', ctx)
expect(cmd.kind).toBe('verb-target')
})
it('strips "a" and "an"', () => {
expect(parse('take a lamp', ctx).kind).toBe('verb-target')
expect(parse('take an oil lamp', ctx).kind).toBe('verb-target')
})
it('does not strip stop-words mid-phrase', () => {
// 'oil lamp' is the alias; 'oil at lamp' is not. Stop-words only strip from
// the head of the noun phrase, not anywhere in the middle.
const cmd = parse('take oil lamp', ctx)
expect(cmd.kind).toBe('verb-target')
})
})
describe('ambiguous noun', () => {
const ctx: ParserContext = {
knownItems: ['iron-key', 'brass-key'],
knownEncounters: [],
visibleNouns: [
{ id: 'iron-key', aliases: ['key', 'iron key'] },
{ id: 'brass-key', aliases: ['key', 'brass key'] },
],
inventoryItemIds: [],
lastNoun: null,
awaitingDisambiguation: null,
}
it('returns ambiguous when two aliases match the same noun phrase', () => {
const cmd = parse('take key', ctx)
expect(cmd).toEqual({
kind: 'ambiguous',
verb: 'take',
rawNoun: 'key',
candidates: ['iron-key', 'brass-key'],
})
})
it('still returns verb-target when the phrase is unambiguous', () => {
const cmd = parse('take iron key', ctx)
expect(cmd.kind).toBe('verb-target')
if (cmd.kind === 'verb-target') expect(cmd.target.canonical).toBe('iron-key')
})
})
describe('verb-target-prep with "with"', () => {
const ctx: ParserContext = {
knownItems: ['lamp', 'matches'],
knownEncounters: [],
visibleNouns: [
{ id: 'lamp', aliases: ['lamp'] },
{ id: 'matches', aliases: ['matches', 'matchbook'] },
],
inventoryItemIds: ['matches'],
lastNoun: null,
awaitingDisambiguation: null,
}
it('parses "light lamp with matches" into verb-target-prep', () => {
const cmd = parse('light lamp with matches', ctx)
expect(cmd).toEqual({
kind: 'verb-target-prep',
verb: 'light',
target: { canonical: 'lamp', raw: 'lamp' },
preposition: 'with',
indirect: { canonical: 'matches', raw: 'matches' },
})
})
it('parses "use shears on vines" into verb-target-prep', () => {
const localCtx: ParserContext = {
knownItems: ['shears', 'ivy-figure'],
knownEncounters: [],
visibleNouns: [
{ id: 'shears', aliases: ['shears'] },
{ id: 'ivy-figure', aliases: ['vines', 'ivy'] },
],
inventoryItemIds: ['shears'],
lastNoun: null,
awaitingDisambiguation: null,
}
const cmd = parse('use shears on vines', localCtx)
expect(cmd).toEqual({
kind: 'verb-target-prep',
verb: 'use',
target: { canonical: 'shears', raw: 'shears' },
preposition: 'on',
indirect: { canonical: 'ivy-figure', raw: 'vines' },
})
})
it('still parses verb-target when no preposition is present', () => {
const cmd = parse('take lamp', ctx)
expect(cmd.kind).toBe('verb-target')
})
it('falls back to unknown-noun when one side fails to resolve', () => {
const cmd = parse('light lamp with feathers', ctx)
expect(cmd).toEqual({ kind: 'unknown', raw: 'light lamp with feathers', reason: 'unknown-noun' })
})
})
+62 -4
View File
@@ -59,6 +59,27 @@ const VERB_ONLY_VERBS = new Set<string>(['look', 'inventory', 'wait'])
/** Two-word verb prefixes (e.g. "pick up X"). */ /** Two-word verb prefixes (e.g. "pick up X"). */
const TWO_WORD_VERBS = ['pick up'] const TWO_WORD_VERBS = ['pick up']
/** Leading stop-words stripped from the noun phrase before matching. */
const STOP_WORDS = new Set(['at', 'the', 'a', 'an'])
const PREPOSITIONS = new Set(['with', 'on', 'in', 'to'])
function resolveNoun(rawTokens: string[], ctx: ParserContext): { id: string; alias: string } | null {
const phrase = rawTokens.join(' ')
if (phrase === 'it' && ctx.lastNoun) {
return { id: ctx.lastNoun.canonical, alias: 'it' }
}
for (const noun of ctx.visibleNouns) {
for (const alias of noun.aliases) {
if (alias === phrase) return { id: noun.id, alias }
}
}
for (const itemId of ctx.inventoryItemIds) {
if (itemId === phrase) return { id: itemId, alias: phrase }
}
return null
}
function tokenize(input: string): string[] { function tokenize(input: string): string[] {
return input.trim().toLowerCase().split(/\s+/).filter(Boolean) return input.trim().toLowerCase().split(/\s+/).filter(Boolean)
} }
@@ -126,6 +147,11 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
return { kind: 'unknown', raw: trimmed, reason: 'unknown-verb' } return { kind: 'unknown', raw: trimmed, reason: 'unknown-verb' }
} }
// Strip leading stop-words from the noun phrase (e.g. "at", "the", "a", "an").
while (rest.length > 0 && STOP_WORDS.has(rest[0]!)) {
rest = rest.slice(1)
}
if (rest.length === 0) { if (rest.length === 0) {
if (VERB_ONLY_VERBS.has(verb)) { if (VERB_ONLY_VERBS.has(verb)) {
return { kind: 'verb-only', verb: verb as 'look' | 'inventory' | 'wait' } return { kind: 'verb-only', verb: verb as 'look' | 'inventory' | 'wait' }
@@ -133,6 +159,33 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
return { kind: 'unknown', raw: trimmed, reason: 'malformed' } return { kind: 'unknown', raw: trimmed, reason: 'malformed' }
} }
// Detect a preposition splitting target | indirect.
const prepIdx = rest.findIndex((tok) => PREPOSITIONS.has(tok))
if (prepIdx > 0 && prepIdx < rest.length - 1) {
const targetTokens = rest.slice(0, prepIdx)
const prep = rest[prepIdx]!
let indirectTokens = rest.slice(prepIdx + 1)
// Strip stop-words at the head of the indirect phrase too ("on the table").
while (indirectTokens.length > 0 && STOP_WORDS.has(indirectTokens[0]!)) {
indirectTokens = indirectTokens.slice(1)
}
if (indirectTokens.length > 0) {
const target = resolveNoun(targetTokens, ctx)
const indirect = resolveNoun(indirectTokens, ctx)
if (target && indirect) {
return {
kind: 'verb-target-prep',
verb,
target: { canonical: target.id, raw: target.alias },
preposition: prep,
indirect: { canonical: indirect.id, raw: indirect.alias },
}
}
// Either side failed to resolve → fall through to unknown-noun below.
return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' }
}
}
// Pronoun resolution: "it" maps to lastNoun. // Pronoun resolution: "it" maps to lastNoun.
if (rest.length === 1 && rest[0] === 'it') { if (rest.length === 1 && rest[0] === 'it') {
if (!ctx.lastNoun) { if (!ctx.lastNoun) {
@@ -165,11 +218,16 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' } return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' }
} }
// Multiple candidates → ambiguous. Parser signals; the dispatcher records the // Multiple candidates → ambiguous. Dedupe by id; if only one distinct id
// PendingDisambiguation in state so the next turn's input is interpreted as // remains, two aliases of the same item matched — not truly ambiguous.
// a disambiguation reply.
if (candidates.length > 1) { if (candidates.length > 1) {
return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' } const uniqueIds = [...new Set(candidates.map((c) => c.id))]
if (uniqueIds.length === 1) {
// Two aliases of the same item — not actually ambiguous.
const id = uniqueIds[0]!
return { kind: 'verb-target', verb, target: { canonical: id, raw: candidates[0]!.alias } }
}
return { kind: 'ambiguous', verb, rawNoun: targetRaw, candidates: uniqueIds }
} }
const target = candidates[0]! const target = candidates[0]!
-1
View File
@@ -14,7 +14,6 @@ const baseState = (overrides: Partial<GameState> = {}): GameState => ({
lastNoun: null, lastNoun: null,
pendingDisambiguation: null, pendingDisambiguation: null,
transcript: [], transcript: [],
theme: 'amber',
endedWith: null, endedWith: null,
...overrides, ...overrides,
}) })
+3
View File
@@ -48,6 +48,9 @@ export function loadState(): GameState | null {
return null return null
} }
// Older saves may carry fields no longer in GameState (e.g. `theme` before
// it became a UI-only preference). TypeScript ignores extra fields at runtime;
// bump SCHEMA_VERSION only when the meaning of an existing field changes.
return parsed as GameState return parsed as GameState
} }
+5 -6
View File
@@ -24,24 +24,24 @@ export type ParsedCommand =
| { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' } | { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' }
| { kind: 'verb-target'; verb: Verb; target: NounRef } | { kind: 'verb-target'; verb: Verb; target: NounRef }
| { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef } | { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef }
| { kind: 'ambiguous'; verb: Verb; rawNoun: string; candidates: string[] }
| { kind: 'go'; direction: Direction } | { kind: 'go'; direction: Direction }
| { kind: 'meta'; verb: MetaVerb } | { kind: 'meta'; verb: MetaVerb }
| { kind: 'disambiguation'; chosen: string } | { kind: 'disambiguation'; chosen: string }
| { kind: 'unknown'; raw: string; reason: 'unknown-verb' | 'unknown-noun' | 'malformed' } | { kind: 'unknown'; raw: string; reason: 'unknown-verb' | 'unknown-noun' | 'malformed' }
export type ResolveLevel = 'steady' | 'shaken' | 'reeling' | 'returning' export type ResolveLevel = 'steady' | 'shaken' | 'reeling' | 'returning'
export type Theme = 'amber' | 'ansi'
export interface ItemInstance { export interface ItemInstance {
id: ItemId id: ItemId
/** Per-instance state: lit/unlit, broken/whole, etc. */ /** Per-instance state: lit/unlit, broken/whole, etc. */
state: Record<string, string | boolean | number> state: Record<string, string | boolean | number | string[]>
} }
export type EncounterPhase = string // phase names are encounter-specific export type EncounterPhase = string // phase names are encounter-specific
export interface TranscriptLine { export interface TranscriptLine {
kind: 'narration' | 'player' | 'system' kind: 'narration' | 'player' | 'system' | 'ending'
text: string text: string
} }
@@ -56,9 +56,9 @@ export interface GameState {
location: RoomId location: RoomId
inventory: ItemInstance[] inventory: ItemInstance[]
/** Per-room state: visited, items dropped, descriptive flags. */ /** Per-room state: visited, items dropped, descriptive flags. */
roomState: Record<RoomId, Record<string, string | boolean | number>> roomState: Record<RoomId, Record<string, string | boolean | number | string[]>>
/** Story-wide flags (e.g. 'gateOpened', 'mirrorTarnished'). */ /** Story-wide flags (e.g. 'gateOpened', 'mirrorTarnished'). */
flags: Record<string, string | boolean | number> flags: Record<string, string | boolean | number | string[]>
resolveLevel: ResolveLevel resolveLevel: ResolveLevel
/** Active encounter phase by encounter id, or null if no encounter is mid-flight. */ /** Active encounter phase by encounter id, or null if no encounter is mid-flight. */
encounterState: Record<EncounterId, EncounterPhase> encounterState: Record<EncounterId, EncounterPhase>
@@ -68,7 +68,6 @@ export interface GameState {
pendingDisambiguation: PendingDisambiguation | null pendingDisambiguation: PendingDisambiguation | null
/** Capped at 200 entries; older entries are dropped on append. */ /** Capped at 200 entries; older entries are dropped on append. */
transcript: TranscriptLine[] transcript: TranscriptLine[]
theme: Theme
/** Set true when the player has reached an ending. UI shows ending screen. */ /** Set true when the player has reached an ending. UI shows ending screen. */
endedWith: 'true' | 'wrong' | 'bad' | null endedWith: 'true' | 'wrong' | 'bad' | null
} }
+13
View File
@@ -169,3 +169,16 @@
@media (pointer: coarse) { @media (pointer: coarse) {
.mystery-chips { display: flex; } .mystery-chips { display: flex; }
} }
.mystery-transcript .ending {
margin-top: 2em;
margin-bottom: 1em;
padding-top: 1em;
border-top: 1px solid currentColor;
font-style: italic;
white-space: pre-wrap;
}
[data-mystery-input].ended {
opacity: 0.55;
}
+21
View File
@@ -33,6 +33,14 @@ if (!transcriptEl || !inputEl) {
}) })
} }
const syncEndedUI = (): void => {
// Don't disable the input — the player still needs to type `restart` or
// `undo`. A `disabled` input rejects keydown events entirely. Use a class
// for visual styling instead; the keydown handler enforces the input
// restriction.
inputEl!.classList.toggle('ended', state.endedWith !== null)
}
const buildParserContext = (s: GameState): ParserContext => { const buildParserContext = (s: GameState): ParserContext => {
const room = world.rooms[s.location] const room = world.rooms[s.location]
const visibleNouns: { id: string; aliases: string[] }[] = [] const visibleNouns: { id: string; aliases: string[] }[] = []
@@ -81,6 +89,7 @@ if (!transcriptEl || !inputEl) {
renderAll(state.transcript) renderAll(state.transcript)
refreshChips() refreshChips()
syncEndedUI()
inputEl.focus() inputEl.focus()
inputEl.addEventListener('keydown', (e) => { inputEl.addEventListener('keydown', (e) => {
@@ -91,6 +100,15 @@ if (!transcriptEl || !inputEl) {
if (!raw.trim()) return if (!raw.trim()) return
appendLines([{ kind: 'player', text: raw }]) appendLines([{ kind: 'player', text: raw }])
// Once the game has ended, only restart and undo are allowed.
if (state.endedWith !== null) {
const lower = raw.trim().toLowerCase()
if (lower !== 'restart' && lower !== 'undo') {
appendLines([{ kind: 'system', text: 'The story has ended. Type `restart` or `undo`.' }])
return
}
}
// Engine-level meta-commands handled here so the engine stays pure. // Engine-level meta-commands handled here so the engine stays pure.
const trimmed = raw.trim().toLowerCase() const trimmed = raw.trim().toLowerCase()
if (trimmed === 'restart') { if (trimmed === 'restart') {
@@ -105,6 +123,7 @@ if (!transcriptEl || !inputEl) {
renderAll(state.transcript) renderAll(state.transcript)
saveState(state) saveState(state)
refreshChips() refreshChips()
syncEndedUI()
return return
} }
if (trimmed === 'undo') { if (trimmed === 'undo') {
@@ -114,6 +133,7 @@ if (!transcriptEl || !inputEl) {
appendLines([{ kind: 'system', text: '(undone)' }]) appendLines([{ kind: 'system', text: '(undone)' }])
saveState(state) saveState(state)
refreshChips() refreshChips()
syncEndedUI()
} else { } else {
appendLines([{ kind: 'system', text: 'There is no further back.' }]) appendLines([{ kind: 'system', text: 'There is no further back.' }])
} }
@@ -139,6 +159,7 @@ if (!transcriptEl || !inputEl) {
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme')) document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
} }
refreshChips() refreshChips()
syncEndedUI()
} catch (err) { } catch (err) {
console.error('[halfstreet] dispatch error', err) console.error('[halfstreet] dispatch error', err)
appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }]) appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }])
+2 -1
View File
@@ -1,5 +1,6 @@
--- ---
id: bad id: bad
whenFlags: {} whenFlags:
_never: true
--- ---
+2 -1
View File
@@ -1,5 +1,6 @@
--- ---
id: wrong id: wrong
whenFlags: {} whenFlags:
_never: true
--- ---
+7
View File
@@ -3,8 +3,15 @@ id: lamp
names: ["lamp", "oil lamp", "torch"] names: ["lamp", "oil lamp", "torch"]
short: "an oil lamp" short: "an oil lamp"
takeable: true takeable: true
lightable: true
initialState: initialState:
lit: false lit: false
--- ---
An iron oil lamp with a glass chimney. Currently unlit. An iron oil lamp with a glass chimney. Currently unlit.
## lit
The wick catches. Warm yellow light pushes the dark back.
## extinguished
You smother the wick. The room closes around you again.
+4
View File
@@ -3,6 +3,10 @@ id: letter
names: ["letter", "folded letter", "paper"] names: ["letter", "folded letter", "paper"]
short: "a folded letter" short: "a folded letter"
takeable: true takeable: true
readable: true
--- ---
A folded letter. The wax seal has been broken once already.
## read
A folded letter on yellowed paper. The hand is unfamiliar. It reads: "Come at once. The thing in the cellar is waking." A folded letter on yellowed paper. The hand is unfamiliar. It reads: "Come at once. The thing in the cellar is waking."
+7
View File
@@ -3,6 +3,13 @@ id: matches
names: ["matches", "safety matches", "box"] names: ["matches", "safety matches", "box"]
short: "a box of safety matches" short: "a box of safety matches"
takeable: true takeable: true
lighter: true
lighterUses: 4
initialState:
uses: 4
--- ---
A small cardboard box of safety matches. Half-full. A small cardboard box of safety matches. Half-full.
## lighter-empty
The last match flares, burns down, and goes out. The book is empty.
+112
View File
@@ -311,6 +311,118 @@ describe('narration registry', () => {
}) })
}) })
describe('parseItem — body sections', () => {
it('extracts ## read into readableText', () => {
const md = `---
id: letter
names: [letter, note]
short: a folded letter
takeable: true
readable: true
---
A folded letter, sealed with wax.
## read
You loved Halfstreet, the letter says. I loved it too.
`
const item = parseItem(md, 'items/letter.md')
expect(item.long).toBe('A folded letter, sealed with wax.')
expect(item.readable).toBe(true)
expect(item.readableText).toBe('You loved Halfstreet, the letter says. I loved it too.')
})
it('extracts ## lit and ## extinguished', () => {
const md = `---
id: lamp
names: [lamp]
short: an oil lamp
takeable: true
lightable: true
initialState:
lit: false
---
An iron oil lamp.
## lit
The wick catches; warm yellow light fills the space.
## extinguished
You smother the flame. The room darkens.
`
const item = parseItem(md, 'items/lamp.md')
expect(item.long).toBe('An iron oil lamp.')
expect(item.litText).toBe('The wick catches; warm yellow light fills the space.')
expect(item.extinguishedText).toBe('You smother the flame. The room darkens.')
})
it('extracts ## lighter-empty', () => {
const md = `---
id: matches
names: [matches]
short: a matchbook
takeable: true
lighter: true
lighterUses: 4
---
A matchbook from the Halfstreet Hotel.
## lighter-empty
The last match flares and dies. The book is empty.
`
const item = parseItem(md, 'items/matches.md')
expect(item.lighterEmptyText).toBe('The last match flares and dies. The book is empty.')
})
it('throws when readable: true but ## read is missing', () => {
const md = `---
id: x
names: [x]
short: x
takeable: true
readable: true
---
A thing.
`
expect(() => parseItem(md, 'items/x.md')).toThrow(/## read.*required when readable/i)
})
it('still parses items with no body sections (back-compat)', () => {
const md = `---
id: lamp
names: [lamp]
short: an oil lamp
takeable: true
---
An iron oil lamp with a glass chimney.
`
const item = parseItem(md, 'items/lamp.md')
expect(item.long).toBe('An iron oil lamp with a glass chimney.')
expect(item.readable).toBeUndefined()
expect(item.readableText).toBeUndefined()
})
it('throws for unknown section keys', () => {
const md = `---
id: x
names: [x]
short: x
takeable: true
---
A thing.
## badkey
Content.
`
expect(() => parseItem(md, 'items/x.md')).toThrow(/unknown item section "## badkey".*Allowed:.*read.*lit.*extinguished.*lighter-empty/i)
})
})
describe('parseRoom invalid headers', () => { describe('parseRoom invalid headers', () => {
it('throws a clear error when a header has spaces', () => { it('throws a clear error when a header has spaces', () => {
const md = `--- const md = `---
+35 -5
View File
@@ -130,27 +130,57 @@ export function parseRoom(raw: string, sourcePath: string): Room {
return room return room
} }
const ITEM_SECTION_KEYS = ['read', 'lit', 'extinguished', 'lighter-empty'] as const
type ItemSectionKey = typeof ITEM_SECTION_KEYS[number]
export function parseItem(raw: string, sourcePath: string): Item { export function parseItem(raw: string, sourcePath: string): Item {
const parsed = matter(raw) const parsed = matter(raw)
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown> const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
const fm = itemFrontmatterSchema.parse(frontmatter) const fm = itemFrontmatterSchema.parse(frontmatter)
const long = parsed.content.trim()
if (long.length === 0) { // Split body into long-description prefix + sectioned remainder.
// The first `## key` header (if any) marks the boundary.
const body = parsed.content
const firstHeader = body.match(/^##\s+[\w-]+\s*$/m)
const longRaw = firstHeader ? body.slice(0, firstHeader.index!).trim() : body.trim()
if (longRaw.length === 0) {
throw new Error(`${sourcePath}: empty long description`) throw new Error(`${sourcePath}: empty long description`)
} }
return { const sections = firstHeader ? splitSections(body.slice(firstHeader.index!)) : {}
// Validate that only known section keys appear.
for (const key of Object.keys(sections)) {
if (!ITEM_SECTION_KEYS.includes(key as ItemSectionKey)) {
throw new Error(`${sourcePath}: unknown item section "## ${key}". Allowed: ${ITEM_SECTION_KEYS.join(', ')}`)
}
}
if (fm.readable && !sections['read']) {
throw new Error(`${sourcePath}: ## read section is required when readable: true`)
}
const item: Item = {
id: fm.id, id: fm.id,
names: fm.names, names: fm.names,
short: fm.short, short: fm.short,
long, long: longRaw,
initialState: fm.initialState, initialState: fm.initialState,
takeable: fm.takeable, takeable: fm.takeable,
} }
if (fm.readable !== undefined) item.readable = fm.readable
if (fm.lightable !== undefined) item.lightable = fm.lightable
if (fm.lighter !== undefined) item.lighter = fm.lighter
if (fm.lighterUses !== undefined) item.lighterUses = fm.lighterUses
if (sections['read']) item.readableText = sections['read']
if (sections['lit']) item.litText = sections['lit']
if (sections['extinguished']) item.extinguishedText = sections['extinguished']
if (sections['lighter-empty']) item.lighterEmptyText = sections['lighter-empty']
return item
} }
export interface ParsedEnding { export interface ParsedEnding {
id: 'true' | 'wrong' | 'bad' id: 'true' | 'wrong' | 'bad'
ending: { whenFlags: Record<string, string | boolean | number>; narration: string } ending: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
} }
export function parseEnding(raw: string, _sourcePath: string): ParsedEnding { export function parseEnding(raw: string, _sourcePath: string): ParsedEnding {
+24
View File
@@ -79,3 +79,27 @@ describe('encounterFrontmatterSchema', () => {
expect(() => encounterFrontmatterSchema.parse(data)).not.toThrow() expect(() => encounterFrontmatterSchema.parse(data)).not.toThrow()
}) })
}) })
describe('itemFrontmatterSchema — bible additions', () => {
it('accepts readable + lighter fields', () => {
const data = {
id: 'matches',
names: ['matches', 'matchbook'],
short: 'a matchbook',
takeable: true,
lighter: true,
lighterUses: 4,
}
expect(() => itemFrontmatterSchema.parse(data)).not.toThrow()
})
it('accepts lightable on its own', () => {
const data = { id: 'lamp', names: ['lamp'], short: 'a lamp', takeable: true, lightable: true }
expect(() => itemFrontmatterSchema.parse(data)).not.toThrow()
})
it('rejects negative lighterUses', () => {
const data = { id: 'matches', names: ['matches'], short: 'matches', takeable: true, lighter: true, lighterUses: -1 }
expect(() => itemFrontmatterSchema.parse(data)).toThrow()
})
})
+5 -1
View File
@@ -1,6 +1,6 @@
import { z } from 'zod' import { z } from 'zod'
const stateValueSchema = z.union([z.string(), z.boolean(), z.number()]) const stateValueSchema = z.union([z.string(), z.boolean(), z.number(), z.array(z.string())])
const stateRecordSchema = z.record(z.string(), stateValueSchema) const stateRecordSchema = z.record(z.string(), stateValueSchema)
export const roomFrontmatterSchema = z.object({ export const roomFrontmatterSchema = z.object({
@@ -37,6 +37,10 @@ export const itemFrontmatterSchema = z.object({
short: z.string().min(1), short: z.string().min(1),
takeable: z.boolean(), takeable: z.boolean(),
initialState: stateRecordSchema.default({}), initialState: stateRecordSchema.default({}),
readable: z.boolean().optional(),
lightable: z.boolean().optional(),
lighter: z.boolean().optional(),
lighterUses: z.number().int().nonnegative().optional(),
}) })
export type ItemFrontmatter = z.infer<typeof itemFrontmatterSchema> export type ItemFrontmatter = z.infer<typeof itemFrontmatterSchema>
+20 -4
View File
@@ -37,9 +37,25 @@ export interface Item {
/** Long description shown when examined. */ /** Long description shown when examined. */
long: string long: string
/** Initial per-instance state (e.g. `{ lit: false }`). */ /** Initial per-instance state (e.g. `{ lit: false }`). */
initialState: Record<string, string | boolean | number> initialState: Record<string, string | boolean | number | string[]>
/** True if the player can pick it up. */ /** True if the player can pick it up. */
takeable: boolean takeable: boolean
/** True if `read X` should narrate the item's `## read` section. */
readable?: boolean
/** True if `light X` / `extinguish X` apply; toggles state.lit. */
lightable?: boolean
/** True if this item can light other items. */
lighter?: boolean
/** Optional remaining-charges counter; absent means unlimited. */
lighterUses?: number
/** Prose returned by `read X`. Required iff readable is true. */
readableText?: string
/** Prose narrated when `light X` succeeds. Falls back to "It catches." */
litText?: string
/** Prose narrated when `extinguish X` succeeds. Falls back to "The flame dies." */
extinguishedText?: string
/** Prose narrated when this item's lighterUses reaches 0. Falls back to "It is spent." */
lighterEmptyText?: string
} }
export interface EncounterPhaseDef { export interface EncounterPhaseDef {
@@ -83,8 +99,8 @@ export interface World {
encounters: Record<EncounterId, EncounterDef> encounters: Record<EncounterId, EncounterDef>
/** Story flag definitions and the endings they unlock. */ /** Story flag definitions and the endings they unlock. */
endings: { endings: {
true: { whenFlags: Record<string, string | boolean | number>; narration: string } true: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
wrong: { whenFlags: Record<string, string | boolean | number>; narration: string } wrong: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
bad: { whenFlags: Record<string, string | boolean | number>; narration: string } bad: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
} }
} }