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
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
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 (8–11) 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.
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { dispatch, initialStateFor } from './dispatcher'
|
||||
import type { World } from '../world/types'
|
||||
import type { GameState } from './types'
|
||||
import type { GameState, ParsedCommand } from './types'
|
||||
import { SCHEMA_VERSION } from './types'
|
||||
|
||||
const world: World = {
|
||||
@@ -94,13 +94,60 @@ describe('dispatcher — go', () => {
|
||||
expect(r.state.location).toBe('hallway')
|
||||
expect(r.appended.some((l) => l.text.includes('locked'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a locked exit when required item is in inventory', () => {
|
||||
// Locked-exit-with-key happy path is covered by the playthrough integration
|
||||
// test in Task 8. The sample world above doesn't have an unlocked path to
|
||||
// pick up the brass key without first traversing the locked door, so this
|
||||
// test is intentionally a placeholder.
|
||||
expect(true).toBe(true)
|
||||
describe('locked exits', () => {
|
||||
function makeWorld(): World {
|
||||
return {
|
||||
startingRoom: 'antechamber',
|
||||
startingInventory: [],
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
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
@@ -29,7 +29,6 @@ export function initialStateFor(world: World): GameState {
|
||||
lastNoun: null,
|
||||
pendingDisambiguation: null,
|
||||
transcript: opening,
|
||||
theme: 'amber',
|
||||
endedWith: null,
|
||||
}
|
||||
}
|
||||
@@ -41,8 +40,8 @@ function append(state: GameState, lines: TranscriptLine[]): GameState {
|
||||
|
||||
export function getItemsInRoom(state: GameState, world: World, roomId: string): string[] {
|
||||
const baseItems = world.rooms[roomId]?.items ?? []
|
||||
const dropped = (state.roomState[roomId]?.['droppedItems'] as string[] | undefined) ?? []
|
||||
const taken = (state.roomState[roomId]?.['takenItems'] as string[] | undefined) ?? []
|
||||
const dropped = (state.roomState[roomId]?.['droppedItems'] ?? []) as string[]
|
||||
const taken = (state.roomState[roomId]?.['takenItems'] ?? []) as string[]
|
||||
return [...baseItems.filter((i) => !taken.includes(i)), ...dropped]
|
||||
}
|
||||
|
||||
@@ -51,11 +50,39 @@ function setRoomFlag(state: GameState, roomId: string, key: string, value: strin
|
||||
...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 {
|
||||
// Disambiguation reply: re-issue the original verb with the chosen target.
|
||||
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') {
|
||||
const text =
|
||||
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') {
|
||||
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.verb === 'look') return handleLook(state, world)
|
||||
if (command.verb === 'inventory') return handleInventory(state, world)
|
||||
if (command.verb === 'wait') return narrate(state, [{ kind: 'narration', text: 'Time passes.' }])
|
||||
if (command.verb === 'look') return withEndingCheck(handleLook(state, world), world)
|
||||
if (command.verb === 'inventory') return withEndingCheck(handleInventory(state, world), world)
|
||||
if (command.verb === 'wait') return withEndingCheck(narrate(state, [{ kind: 'narration', text: 'Time passes.' }]), world)
|
||||
}
|
||||
|
||||
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'.
|
||||
const encResult = applyVerbToEncounter(stateWithNoun, command, world)
|
||||
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 === 'drop') return handleDrop(stateWithNoun, command.target.canonical, world)
|
||||
if (command.verb === 'examine' || command.verb === 'look') return handleExamine(stateWithNoun, command.target.canonical, world)
|
||||
return narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }])
|
||||
if (command.verb === 'take') return withEndingCheck(handleTake(stateWithNoun, command.target.canonical, world), world)
|
||||
if (command.verb === 'drop') return withEndingCheck(handleDrop(stateWithNoun, command.target.canonical, world), world)
|
||||
if (command.verb === 'examine' || command.verb === 'look') return withEndingCheck(handleExamine(stateWithNoun, command.target.canonical, world), world)
|
||||
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.' }])
|
||||
@@ -115,10 +181,10 @@ function narrate(state: GameState, lines: TranscriptLine[]): 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 === 'theme') {
|
||||
const newTheme = state.theme === 'amber' ? 'ansi' : 'amber'
|
||||
return narrate({ ...state, theme: newTheme }, [{ kind: 'system', text: `Theme: ${newTheme}.` }])
|
||||
}
|
||||
// 'theme' is a UI preference: the terminal intercepts it before dispatch and
|
||||
// dispatches a 'halfstreet-toggle-theme' DOM event. The engine no-ops here so
|
||||
// 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
|
||||
// require coordination with the save layer and route navigation). The
|
||||
// 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 } }],
|
||||
}
|
||||
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])
|
||||
} 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))
|
||||
}
|
||||
return narrate(next, [{ kind: 'narration', text: 'Taken.' }])
|
||||
@@ -235,7 +301,7 @@ function handleDrop(state: GameState, itemId: string, world: World): DispatchRes
|
||||
...state,
|
||||
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])
|
||||
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.' }])
|
||||
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.' }])
|
||||
}
|
||||
|
||||
@@ -61,9 +61,9 @@ const world: World = {
|
||||
},
|
||||
},
|
||||
endings: {
|
||||
true: { whenFlags: {}, narration: '' },
|
||||
wrong: { whenFlags: {}, narration: '' },
|
||||
bad: { whenFlags: {}, narration: '' },
|
||||
true: { whenFlags: { _never: true }, narration: '' },
|
||||
wrong: { whenFlags: { _never: true }, narration: '' },
|
||||
bad: { whenFlags: { _never: true }, narration: '' },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -66,12 +66,17 @@ export function applyVerbToEncounter(
|
||||
const phaseDef = def.phases[currentPhase]
|
||||
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 targetId: string | null = null
|
||||
let instrumentId: string | null = null
|
||||
if (command.kind === 'verb-target') {
|
||||
verb = command.verb
|
||||
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') {
|
||||
verb = command.verb
|
||||
} else {
|
||||
@@ -91,6 +96,7 @@ export function applyVerbToEncounter(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (t.requires && instrumentId && t.requires.item !== instrumentId) return false
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
+130
-7
@@ -158,7 +158,7 @@ describe('parser — verb + target', () => {
|
||||
})
|
||||
|
||||
describe('parser — disambiguation', () => {
|
||||
it('returns disambiguation request when two candidates match', () => {
|
||||
it('returns ambiguous when two candidates match', () => {
|
||||
const ctx: ParserContext = {
|
||||
knownItems: ['brass-key', 'iron-key'],
|
||||
knownEncounters: [],
|
||||
@@ -171,12 +171,11 @@ describe('parser — disambiguation', () => {
|
||||
awaitingDisambiguation: null,
|
||||
}
|
||||
const result = parse('take key', ctx)
|
||||
expect(result.kind).toBe('unknown')
|
||||
if (result.kind === 'unknown') {
|
||||
// Parser flags ambiguity by returning unknown-noun; the dispatcher
|
||||
// turns this into a PendingDisambiguation. (Keeping parser pure: it
|
||||
// signals; the dispatcher decides UI flow.)
|
||||
expect(result.reason).toBe('unknown-noun')
|
||||
expect(result.kind).toBe('ambiguous')
|
||||
if (result.kind === 'ambiguous') {
|
||||
expect(result.verb).toBe('take')
|
||||
expect(result.rawNoun).toBe('key')
|
||||
expect(result.candidates).toEqual(['brass-key', 'iron-key'])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -231,3 +230,127 @@ describe('parser — pronouns', () => {
|
||||
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
@@ -59,6 +59,27 @@ const VERB_ONLY_VERBS = new Set<string>(['look', 'inventory', 'wait'])
|
||||
/** Two-word verb prefixes (e.g. "pick up X"). */
|
||||
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[] {
|
||||
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' }
|
||||
}
|
||||
|
||||
// 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 (VERB_ONLY_VERBS.has(verb)) {
|
||||
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' }
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (rest.length === 1 && rest[0] === 'it') {
|
||||
if (!ctx.lastNoun) {
|
||||
@@ -165,11 +218,16 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
|
||||
return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' }
|
||||
}
|
||||
|
||||
// Multiple candidates → ambiguous. Parser signals; the dispatcher records the
|
||||
// PendingDisambiguation in state so the next turn's input is interpreted as
|
||||
// a disambiguation reply.
|
||||
// Multiple candidates → ambiguous. Dedupe by id; if only one distinct id
|
||||
// remains, two aliases of the same item matched — not truly ambiguous.
|
||||
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]!
|
||||
|
||||
@@ -14,7 +14,6 @@ const baseState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
lastNoun: null,
|
||||
pendingDisambiguation: null,
|
||||
transcript: [],
|
||||
theme: 'amber',
|
||||
endedWith: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -48,6 +48,9 @@ export function loadState(): GameState | 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
|
||||
}
|
||||
|
||||
|
||||
+5
-6
@@ -24,24 +24,24 @@ export type ParsedCommand =
|
||||
| { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' }
|
||||
| { kind: 'verb-target'; verb: Verb; target: 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: 'meta'; verb: MetaVerb }
|
||||
| { kind: 'disambiguation'; chosen: string }
|
||||
| { kind: 'unknown'; raw: string; reason: 'unknown-verb' | 'unknown-noun' | 'malformed' }
|
||||
|
||||
export type ResolveLevel = 'steady' | 'shaken' | 'reeling' | 'returning'
|
||||
export type Theme = 'amber' | 'ansi'
|
||||
|
||||
export interface ItemInstance {
|
||||
id: ItemId
|
||||
/** 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 interface TranscriptLine {
|
||||
kind: 'narration' | 'player' | 'system'
|
||||
kind: 'narration' | 'player' | 'system' | 'ending'
|
||||
text: string
|
||||
}
|
||||
|
||||
@@ -56,9 +56,9 @@ export interface GameState {
|
||||
location: RoomId
|
||||
inventory: ItemInstance[]
|
||||
/** 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'). */
|
||||
flags: Record<string, string | boolean | number>
|
||||
flags: Record<string, string | boolean | number | string[]>
|
||||
resolveLevel: ResolveLevel
|
||||
/** Active encounter phase by encounter id, or null if no encounter is mid-flight. */
|
||||
encounterState: Record<EncounterId, EncounterPhase>
|
||||
@@ -68,7 +68,6 @@ export interface GameState {
|
||||
pendingDisambiguation: PendingDisambiguation | null
|
||||
/** Capped at 200 entries; older entries are dropped on append. */
|
||||
transcript: TranscriptLine[]
|
||||
theme: Theme
|
||||
/** Set true when the player has reached an ending. UI shows ending screen. */
|
||||
endedWith: 'true' | 'wrong' | 'bad' | null
|
||||
}
|
||||
|
||||
@@ -169,3 +169,16 @@
|
||||
@media (pointer: coarse) {
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -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 room = world.rooms[s.location]
|
||||
const visibleNouns: { id: string; aliases: string[] }[] = []
|
||||
@@ -81,6 +89,7 @@ if (!transcriptEl || !inputEl) {
|
||||
|
||||
renderAll(state.transcript)
|
||||
refreshChips()
|
||||
syncEndedUI()
|
||||
inputEl.focus()
|
||||
|
||||
inputEl.addEventListener('keydown', (e) => {
|
||||
@@ -91,6 +100,15 @@ if (!transcriptEl || !inputEl) {
|
||||
if (!raw.trim()) return
|
||||
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.
|
||||
const trimmed = raw.trim().toLowerCase()
|
||||
if (trimmed === 'restart') {
|
||||
@@ -105,6 +123,7 @@ if (!transcriptEl || !inputEl) {
|
||||
renderAll(state.transcript)
|
||||
saveState(state)
|
||||
refreshChips()
|
||||
syncEndedUI()
|
||||
return
|
||||
}
|
||||
if (trimmed === 'undo') {
|
||||
@@ -114,6 +133,7 @@ if (!transcriptEl || !inputEl) {
|
||||
appendLines([{ kind: 'system', text: '(undone)' }])
|
||||
saveState(state)
|
||||
refreshChips()
|
||||
syncEndedUI()
|
||||
} else {
|
||||
appendLines([{ kind: 'system', text: 'There is no further back.' }])
|
||||
}
|
||||
@@ -139,6 +159,7 @@ if (!transcriptEl || !inputEl) {
|
||||
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
|
||||
}
|
||||
refreshChips()
|
||||
syncEndedUI()
|
||||
} catch (err) {
|
||||
console.error('[halfstreet] dispatch error', err)
|
||||
appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
id: bad
|
||||
whenFlags: {}
|
||||
whenFlags:
|
||||
_never: true
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
id: wrong
|
||||
whenFlags: {}
|
||||
whenFlags:
|
||||
_never: true
|
||||
---
|
||||
|
||||
|
||||
@@ -3,8 +3,15 @@ id: lamp
|
||||
names: ["lamp", "oil lamp", "torch"]
|
||||
short: "an oil lamp"
|
||||
takeable: true
|
||||
lightable: true
|
||||
initialState:
|
||||
lit: false
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
@@ -3,6 +3,10 @@ id: letter
|
||||
names: ["letter", "folded letter", "paper"]
|
||||
short: "a folded letter"
|
||||
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."
|
||||
|
||||
@@ -3,6 +3,13 @@ id: matches
|
||||
names: ["matches", "safety matches", "box"]
|
||||
short: "a box of safety matches"
|
||||
takeable: true
|
||||
lighter: true
|
||||
lighterUses: 4
|
||||
initialState:
|
||||
uses: 4
|
||||
---
|
||||
|
||||
A small cardboard box of safety matches. Half-full.
|
||||
|
||||
## lighter-empty
|
||||
The last match flares, burns down, and goes out. The book is empty.
|
||||
|
||||
@@ -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', () => {
|
||||
it('throws a clear error when a header has spaces', () => {
|
||||
const md = `---
|
||||
|
||||
+35
-5
@@ -130,27 +130,57 @@ export function parseRoom(raw: string, sourcePath: string): 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 {
|
||||
const parsed = matter(raw)
|
||||
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
|
||||
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`)
|
||||
}
|
||||
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,
|
||||
names: fm.names,
|
||||
short: fm.short,
|
||||
long,
|
||||
long: longRaw,
|
||||
initialState: fm.initialState,
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -79,3 +79,27 @@ describe('encounterFrontmatterSchema', () => {
|
||||
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
@@ -1,6 +1,6 @@
|
||||
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)
|
||||
|
||||
export const roomFrontmatterSchema = z.object({
|
||||
@@ -37,6 +37,10 @@ export const itemFrontmatterSchema = z.object({
|
||||
short: z.string().min(1),
|
||||
takeable: z.boolean(),
|
||||
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>
|
||||
|
||||
+20
-4
@@ -37,9 +37,25 @@ export interface Item {
|
||||
/** Long description shown when examined. */
|
||||
long: string
|
||||
/** 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. */
|
||||
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 {
|
||||
@@ -83,8 +99,8 @@ export interface World {
|
||||
encounters: Record<EncounterId, EncounterDef>
|
||||
/** Story flag definitions and the endings they unlock. */
|
||||
endings: {
|
||||
true: { whenFlags: Record<string, string | boolean | number>; narration: string }
|
||||
wrong: { whenFlags: Record<string, string | boolean | number>; narration: string }
|
||||
bad: { 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 | string[]>; narration: string }
|
||||
bad: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user