Merge feature: mystery text adventure (Halfstreet) — engine + UI + bible
14 implementation tasks + 1 critical-fix commit + 1 follow-on notes commit. Adds: - Pure-function text-adventure engine (parser, dispatcher, encounters, save) with 53 vitest tests passing - /mystery route with fullscreen CRT terminal (amber + ANSI themes) - Mobile tap-chip input system above the soft keyboard - 3-room sample world for engine validation and UI scaffolding - Halfstreet content bible (gating artifact for room-prose follow-on plan) - Launch script for the future MysteryCard click on the homepage Bundle: 6.2 KB gzipped (target <80 KB). No runtime LLM calls. Spec: docs/superpowers/specs/2026-05-08-mystery-text-adventure-design.md Plan: docs/superpowers/plans/2026-05-08-mystery-text-adventure.md Bible: docs/superpowers/specs/halfstreet-bible.md Follow-on notes: docs/superpowers/specs/halfstreet-followon-notes.md
This commit is contained in:
@@ -0,0 +1,125 @@
|
|||||||
|
# Halfstreet — Content Bible
|
||||||
|
|
||||||
|
**For:** the mystery text adventure shipped at `/mystery`.
|
||||||
|
**Style anchor:** Le Fanu's *Carmilla*, Shirley Jackson's *The Haunting of Hill House*, M.R. James's ghost stories. Second person, present tense, sparse, never explains.
|
||||||
|
**Length target:** 18–22 rooms, ~30–60 minute first playthrough.
|
||||||
|
|
||||||
|
## Voice rules
|
||||||
|
|
||||||
|
1. Second person, present tense, throughout.
|
||||||
|
2. Sentences are short; the silences between them do most of the work.
|
||||||
|
3. The narrator never explains the supernatural — it observes.
|
||||||
|
4. The narrator never addresses the player as a player ("you, the visitor"). Only as the character ("you").
|
||||||
|
5. No metafiction, no winks, no "you are clearly in a video game" jokes.
|
||||||
|
6. No proper-noun villains. The things in Halfstreet are nameless.
|
||||||
|
|
||||||
|
## Rooms
|
||||||
|
|
||||||
|
> Format: `id` · title · one-sentence first-visit prose · exits · items · encounter · safe?
|
||||||
|
|
||||||
|
| id | title | first-visit summary | exits | items | encounter | safe |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| `outside-gate` | [ The Gate ] | The road behind you is gone; the gate is unlocked. | n: foyer | letter, matches | — | yes |
|
||||||
|
| `foyer` | [ Foyer ] | A foyer of cold paper and colder air, with a hallway running impossibly far. | s: outside-gate, n: hallway | — | — | yes |
|
||||||
|
| `hallway` | [ Hallway ] | A hallway that runs longer than the house should be wide. | s: foyer, e: study, w: parlor, n: stair-up | lamp | — | — |
|
||||||
|
| `parlor` | [ Parlor ] | A parlor of stopped clocks and empty chairs, set as if for company. | e: hallway | brass-key | parlor-figure | — |
|
||||||
|
| `study` | [ Study ] | A study where the books have been left open at pages they were not written for. | w: hallway | folded-letter-2 | — | — |
|
||||||
|
| `stair-up` | [ Upper Stair ] | A stair that turns once and arrives at the wrong landing. | s: hallway, u: bedroom | — | — | — |
|
||||||
|
| `bedroom` | [ Bedroom ] | A bedroom kept ready for a sleeper who is not you. | d: stair-up, e: nursery | mirror | — | — |
|
||||||
|
| `nursery` | [ Nursery ] | A nursery whose toys have been arranged tonight. | w: bedroom | iron-key | nursery-presence | — |
|
||||||
|
| `kitchen` | [ Kitchen ] | A kitchen with a pot still warm on the stove and no one to have warmed it. | (added via locked door from hallway: `n` requires brass-key) | bread-knife | — | — |
|
||||||
|
| `back-door` | [ Back Door ] | A door in the kitchen, opening onto the grounds. | s: kitchen, e: garden | — | — | yes |
|
||||||
|
| `garden` | [ Garden ] | A garden gone to seed in the dark. | w: back-door, n: well, e: chapel | — | — | — |
|
||||||
|
| `well` | [ The Well ] | An old well, dry, with rope going down further than the well is deep. | s: garden, d: well-shaft | rope | — | — |
|
||||||
|
| `well-shaft` | [ Well Shaft ] | The shaft, descending past the water-line into the dry. | u: well, n: tunnel | — | — | — |
|
||||||
|
| `tunnel` | [ Tunnel ] | A stone tunnel that knows you are here. | s: well-shaft, e: chamber | — | hound | — |
|
||||||
|
| `chamber` | [ Antechamber ] | An antechamber whose door is locked with a lock that takes the iron key. | w: tunnel, e: vault (locked, requires iron-key) | — | — | — |
|
||||||
|
| `vault` | [ Vault ] | A vault, plain, holding what was buried at Halfstreet. | w: chamber | the-thing-itself | revenant | — |
|
||||||
|
| `chapel` | [ Chapel ] | A chapel, deconsecrated, on the edge of the grounds. | w: garden | silver-vial | chapel-watcher | yes |
|
||||||
|
| `attic` | [ Attic ] | An attic reached by a staircase that wasn't there before. | d: bedroom (appears after a flag is set) | childhood-photograph | — | — |
|
||||||
|
| `cistern` | [ Cistern ] | A cistern beneath the house, found through a grate in the kitchen. | u: kitchen | — | — | — |
|
||||||
|
| `endings-room` | (synthetic) | The endings narration room, never directly entered. | — | — | — | — |
|
||||||
|
|
||||||
|
(Total authored rooms: 19, plus the synthetic endings node.)
|
||||||
|
|
||||||
|
## Items
|
||||||
|
|
||||||
|
| id | names | purpose | state |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `letter` | letter, folded letter | Opening exposition; the call to come. | — |
|
||||||
|
| `matches` | matches, safety matches | Light the lamp. | — |
|
||||||
|
| `lamp` | lamp, oil lamp, torch | Illuminates dark rooms. | `lit: false` |
|
||||||
|
| `brass-key` | brass key, key | Unlocks the kitchen door. | — |
|
||||||
|
| `iron-key` | iron key, key | Unlocks the vault. | — |
|
||||||
|
| `mirror` | mirror, tarnished mirror | The revenant's resolution. | — |
|
||||||
|
| `silver-vial` | silver vial, vial | The chapel-watcher's resolution. | — |
|
||||||
|
| `bread-knife` | knife, bread knife | A weapon for the hound. | — |
|
||||||
|
| `rope` | rope | Required to descend the well shaft. | — |
|
||||||
|
| `folded-letter-2` | second letter, page | Bible-context: the burial register page. Reveals the truth needed for the true ending. | — |
|
||||||
|
| `the-thing-itself` | (unnamed in prose) | The McGuffin in the vault. State changes based on chosen ending. | `disturbed: false` |
|
||||||
|
| `childhood-photograph` | photograph, photo | Triggers the bad-ending choice. | — |
|
||||||
|
|
||||||
|
## Encounters
|
||||||
|
|
||||||
|
| id | room | initial phase | resolution path | failure path |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `parlor-figure` | parlor | seated | `wait` (twice) → `examine figure` → resolved (the figure was a coat). Wrong verbs cost resolve. | retreat to foyer |
|
||||||
|
| `nursery-presence` | nursery | listening | `wait` → `extinguish lamp` → resolved (it does not show itself in the dark). Wrong: light costs resolve. | retreat to bedroom |
|
||||||
|
| `hound` | tunnel | tracking | `attack hound with knife` → wounded → `attack` → resolved. Pure HP-style fight. | retreat to well-shaft |
|
||||||
|
| `chapel-watcher` | chapel | observing | `pour silver-vial on watcher` → resolved. Wrong: any aggressive verb fails the encounter (chapel-watcher is harmless if undisturbed). | exit chapel forced |
|
||||||
|
| `revenant` | vault | wary | `examine revenant` → `hold mirror to revenant` → resolved. Other verbs cost resolve. | retreat to chamber |
|
||||||
|
|
||||||
|
## Story flags
|
||||||
|
|
||||||
|
- `letterRead` — set after reading the opening letter; gates first hint
|
||||||
|
- `revenantLaid` — set on revenant resolution; required for true ending
|
||||||
|
- `houndPassed` — set on hound resolution; required to reach vault
|
||||||
|
- `watcherSpared` — set on chapel-watcher resolution; alternate path to a hint
|
||||||
|
- `photographSeen` — set on examining the attic photograph; unlocks bad ending
|
||||||
|
- `theThingDisturbed` — set if the player attacks the thing in the vault; forces wrong ending
|
||||||
|
- `theThingRecognised` — set if the player has read the burial register and laid the revenant before reaching the vault; forces true ending
|
||||||
|
|
||||||
|
## Endings
|
||||||
|
|
||||||
|
### True ending (when `theThingRecognised` and not `theThingDisturbed`)
|
||||||
|
|
||||||
|
> You stand in the vault. What is buried at Halfstreet is buried because it was loved, and grieved, and finally let go.
|
||||||
|
> You set the lamp down beside it. You speak its name aloud — the name from the page in the study — and the name is enough.
|
||||||
|
> You go up. The door opens onto a road that is, suddenly, on every map.
|
||||||
|
|
||||||
|
### Wrong ending (when `theThingDisturbed`)
|
||||||
|
|
||||||
|
> You stand in the vault. The thing under the cloth shifts. It was not waiting to be freed.
|
||||||
|
> You climb back, fast, but the house has rearranged its rooms. The door you came in by is now north, then west, then nowhere.
|
||||||
|
> You walk a corridor that is longer than the house, and longer, and you do not stop.
|
||||||
|
|
||||||
|
### Bad ending (when `photographSeen` and the player chooses `take photograph` after reading it)
|
||||||
|
|
||||||
|
> You take the photograph from the attic. The child in it is you. The date is older than you are.
|
||||||
|
> Behind you, on the stairs, someone has come up to meet you.
|
||||||
|
> You will not go down again.
|
||||||
|
|
||||||
|
## Opening scene (full prose, used verbatim)
|
||||||
|
|
||||||
|
```
|
||||||
|
[ The Gate ]
|
||||||
|
|
||||||
|
You have arrived at the address you were given. There is no sign,
|
||||||
|
no number on the gate — only an iron star, twisted and bent, set
|
||||||
|
into the rust like a wound. The road behind you is gone.
|
||||||
|
|
||||||
|
A wind rises from somewhere under the house. The gate, you find,
|
||||||
|
is not locked.
|
||||||
|
|
||||||
|
You are carrying: a folded letter, a box of safety matches.
|
||||||
|
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Closing notes for the room-prose authoring
|
||||||
|
|
||||||
|
- Each room gets three description blocks: `firstVisit` (180–280 chars), `revisit` (40–80 chars), `examined` (300–450 chars).
|
||||||
|
- Per-object descriptions: `short` (under 30 chars), `long` (200–400 chars).
|
||||||
|
- Encounter narration: each transition gets one sentence, max two; default-wrong-verb narration for each encounter is one sentence.
|
||||||
|
- Style sample: see the opening scene above.
|
||||||
|
- Style anti-patterns to avoid: words like *spooky*, *creepy*, *eerie*. Adjectives that announce mood. Exclamation marks. The word *suddenly*.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Halfstreet — notes for the room-prose follow-on plan
|
||||||
|
|
||||||
|
These notes carry over from the final code review of the engine/UI/bible plan (2026-05-08). Read before generating the room-prose implementation plan.
|
||||||
|
|
||||||
|
## Hard prerequisites — land BEFORE authoring rooms
|
||||||
|
|
||||||
|
These are gaps the engine ships with that will block specific rooms or items in the bible. Make them the first commits of the follow-on plan.
|
||||||
|
|
||||||
|
1. **Add `read`, `light`, `extinguish`, `use` verb handlers to the dispatcher.** The bible's items include a lamp (`light`/`extinguish`), letter and burial-register page (`read`), lamp+matches combination (`use` or `light with`). None have dispatcher handlers in the shipped engine. Authoring rooms around items players can't interact with creates blocked playthroughs.
|
||||||
|
|
||||||
|
2. **Wire disambiguation end-to-end.** The parser already returns `unknown-noun` for ambiguous nouns and the type system has `PendingDisambiguation`, but the dispatcher never sets it. The bible has two keys (`brass-key`, `iron-key`) both aliased `key`; without disambiguation, `take key` silently fails for any player who doesn't type the full noun. Plan: change the parser to return a dedicated `{kind: 'ambiguous', candidates}` variant, and have the dispatcher convert it into a `pendingDisambiguation` set + a "Which key — …?" narration.
|
||||||
|
|
||||||
|
3. **Implement the ending-screen UI and `endedWith` flag-checking logic.** All three endings are written verbatim in the bible. The engine has the `endedWith` field but never sets it; the terminal never checks it. Without this, a true-ending playthrough sets `revenantLaid = true` and the player keeps typing into a terminal that should have shown the ending. Land this as the first commit of the prose plan, then author the vault and ending rooms last so they can be tested end-to-end.
|
||||||
|
|
||||||
|
## Should-fix while you're in there
|
||||||
|
|
||||||
|
4. **`look at X` parser polish.** Strip leading stop-words (`at`, `the`, `a`, `an`) from the noun token list before matching. `look at lamp` currently fails noun resolution; only `examine lamp` and `x lamp` work. Spec says `look at X` should examine X.
|
||||||
|
|
||||||
|
5. **Theme-state divergence.** When the player clicks `[B]/[C]`, `theme.ts` updates the DOM and localStorage but does not update `state.theme`. Next `theme` meta-command then toggles from the stale `state.theme`. Cleanest fix: remove `theme` from `GameState` entirely (it's a UI preference, not game state) and have the engine's `theme` meta-verb read the DOM via the UI layer.
|
||||||
|
|
||||||
|
6. **Type lie in `roomState`.** The type is `Record<string, string | boolean | number>` but the code stores `string[]` for `droppedItems`/`takenItems` via `as` casts in both directions. Widen the union to include `string[]`.
|
||||||
|
|
||||||
|
7. **Add a self-contained locked-exit-with-key dispatcher test.** The current `dispatcher.test.ts` "opens a locked exit" test is a stub (`expect(true).toBe(true)`). The sample world's locked exit is unreachable without the key behind it, so the playthrough test can't cover this either. Add a 15-line synthetic world to dispatcher.test.ts.
|
||||||
|
|
||||||
|
## Polish / nice-to-have
|
||||||
|
|
||||||
|
8. PageUp/PageDown transcript scrolling (spec calls it out, not implemented).
|
||||||
|
9. Cursor blink at 1.05Hz (currently uses native ~0.53Hz `caret-color` only).
|
||||||
|
10. Old-line opacity fade at the top of the transcript (spec, not implemented).
|
||||||
|
11. Scanline accessibility toggle (`[?]` settings dropdown, spec, not implemented). Important for photosensitivity.
|
||||||
|
|
||||||
|
## What's already good — don't refactor
|
||||||
|
|
||||||
|
- Three-layer architecture (world data → engine → UI) holds end-to-end. Don't introduce cross-layer shortcuts.
|
||||||
|
- Engine purity (no Date, Math.random, console, DOM) is verified. Keep new verbs pure.
|
||||||
|
- Type contract and `verbatimModuleSyntax` discipline are clean. Use `import type` for type-only imports.
|
||||||
|
- Test coverage is meaningful, not gamed. Match this density for new verbs.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { dispatch, initialStateFor } from './dispatcher'
|
||||||
|
import type { World } from '../world/types'
|
||||||
|
import type { GameState } from './types'
|
||||||
|
import { SCHEMA_VERSION } from './types'
|
||||||
|
|
||||||
|
const world: World = {
|
||||||
|
startingRoom: 'foyer',
|
||||||
|
startingInventory: ['matches'],
|
||||||
|
rooms: {
|
||||||
|
foyer: {
|
||||||
|
id: 'foyer',
|
||||||
|
title: '[ Foyer ]',
|
||||||
|
descriptions: {
|
||||||
|
firstVisit: 'A dim foyer. A door creaks north.',
|
||||||
|
revisit: 'The dim foyer.',
|
||||||
|
examined: 'A dim foyer with peeling paper. A door creaks north.',
|
||||||
|
},
|
||||||
|
exits: { n: 'hallway' },
|
||||||
|
items: ['torch'],
|
||||||
|
safe: true,
|
||||||
|
},
|
||||||
|
hallway: {
|
||||||
|
id: 'hallway',
|
||||||
|
title: '[ Hallway ]',
|
||||||
|
descriptions: {
|
||||||
|
firstVisit: 'A long hallway. The cellar door is south. A heavy door is east.',
|
||||||
|
revisit: 'The long hallway.',
|
||||||
|
examined: 'A long hallway. Dust thick on the floor.',
|
||||||
|
},
|
||||||
|
exits: { s: 'foyer', e: 'study' },
|
||||||
|
lockedExits: { e: { requires: 'brass-key', lockedNarration: 'The east door is locked.' } },
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
study: {
|
||||||
|
id: 'study',
|
||||||
|
title: '[ Study ]',
|
||||||
|
descriptions: {
|
||||||
|
firstVisit: 'A small study, full of papers.',
|
||||||
|
revisit: 'The small study.',
|
||||||
|
examined: 'A small study. Papers everywhere.',
|
||||||
|
},
|
||||||
|
exits: { w: 'hallway' },
|
||||||
|
items: ['brass-key'],
|
||||||
|
safe: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
matches: { id: 'matches', names: ['matches', 'safety matches'], short: 'a box of safety matches', long: 'A small cardboard box of safety matches.', initialState: {}, takeable: true },
|
||||||
|
torch: { id: 'torch', names: ['torch', 'lamp'], short: 'an oil lamp', long: 'An iron oil lamp, unlit.', initialState: { lit: false }, takeable: true },
|
||||||
|
'brass-key': { id: 'brass-key', names: ['brass key', 'key'], short: 'a brass key', long: 'A small brass key, warm to the touch.', initialState: {}, takeable: true },
|
||||||
|
},
|
||||||
|
encounters: {},
|
||||||
|
endings: {
|
||||||
|
true: { whenFlags: { reachedTrueEnd: true }, narration: 'true ending' },
|
||||||
|
wrong: { whenFlags: { reachedWrongEnd: true }, narration: 'wrong ending' },
|
||||||
|
bad: { whenFlags: { reachedBadEnd: true }, narration: 'bad ending' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dispatcher — initial state', () => {
|
||||||
|
it('starts in the starting room with starting inventory', () => {
|
||||||
|
const s = initialStateFor(world)
|
||||||
|
expect(s.schemaVersion).toBe(SCHEMA_VERSION)
|
||||||
|
expect(s.location).toBe('foyer')
|
||||||
|
expect(s.inventory.map((i) => i.id)).toEqual(['matches'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends the firstVisit description on initial state', () => {
|
||||||
|
const s = initialStateFor(world)
|
||||||
|
expect(s.transcript.some((line) => line.text.includes('dim foyer'))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dispatcher — go', () => {
|
||||||
|
it('moves through a valid exit and narrates the new room', () => {
|
||||||
|
const s = initialStateFor(world)
|
||||||
|
const r = dispatch(s, { kind: 'go', direction: 'n' }, world)
|
||||||
|
expect(r.state.location).toBe('hallway')
|
||||||
|
expect(r.appended.some((l) => l.text.includes('long hallway'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses an invalid exit', () => {
|
||||||
|
const s = initialStateFor(world)
|
||||||
|
const r = dispatch(s, { kind: 'go', direction: 'e' }, world)
|
||||||
|
expect(r.state.location).toBe('foyer')
|
||||||
|
expect(r.appended.some((l) => /can't go|no way/i.test(l.text))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a locked exit without the required item', () => {
|
||||||
|
let s = initialStateFor(world)
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
|
const r = dispatch(s, { kind: 'go', direction: 'e' }, world)
|
||||||
|
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('dispatcher — look', () => {
|
||||||
|
it('verb-only look re-narrates the room with the examined description', () => {
|
||||||
|
const s = initialStateFor(world)
|
||||||
|
const r = dispatch(s, { kind: 'verb-only', verb: 'look' }, world)
|
||||||
|
expect(r.appended.some((l) => l.text.includes('peeling paper'))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dispatcher — take and drop', () => {
|
||||||
|
it('takes an item from the room and adds it to inventory', () => {
|
||||||
|
const s = initialStateFor(world)
|
||||||
|
const r = dispatch(s, { kind: 'verb-target', verb: 'take', target: { canonical: 'torch', raw: 'torch' } }, world)
|
||||||
|
expect(r.state.inventory.map((i) => i.id)).toContain('torch')
|
||||||
|
expect(r.appended.some((l) => /taken/i.test(l.text))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to take an item that is not present', () => {
|
||||||
|
const s = initialStateFor(world)
|
||||||
|
const r = dispatch(s, { kind: 'verb-target', verb: 'take', target: { canonical: 'brass-key', raw: 'brass key' } }, world)
|
||||||
|
expect(r.state.inventory.find((i) => i.id === 'brass-key')).toBeUndefined()
|
||||||
|
expect(r.appended.some((l) => /don't see|isn't here/i.test(l.text))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops an item from inventory into the current room', () => {
|
||||||
|
let s = initialStateFor(world)
|
||||||
|
s = dispatch(s, { kind: 'verb-target', verb: 'take', target: { canonical: 'torch', raw: 'torch' } }, world).state
|
||||||
|
const r = dispatch(s, { kind: 'verb-target', verb: 'drop', target: { canonical: 'torch', raw: 'torch' } }, world)
|
||||||
|
expect(r.state.inventory.find((i) => i.id === 'torch')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dispatcher — examine', () => {
|
||||||
|
it('returns the long description for an item', () => {
|
||||||
|
let s = initialStateFor(world)
|
||||||
|
s = dispatch(s, { kind: 'verb-target', verb: 'take', target: { canonical: 'torch', raw: 'torch' } }, world).state
|
||||||
|
const r = dispatch(s, { kind: 'verb-target', verb: 'examine', target: { canonical: 'torch', raw: 'torch' } }, world)
|
||||||
|
expect(r.appended.some((l) => l.text.includes('iron oil lamp'))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dispatcher — inventory', () => {
|
||||||
|
it('lists held items', () => {
|
||||||
|
const s = initialStateFor(world)
|
||||||
|
const r = dispatch(s, { kind: 'verb-only', verb: 'inventory' }, world)
|
||||||
|
expect(r.appended.some((l) => l.text.includes('safety matches'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says empty-handed when inventory is empty', () => {
|
||||||
|
const empty: GameState = { ...initialStateFor(world), inventory: [] }
|
||||||
|
const r = dispatch(empty, { kind: 'verb-only', verb: 'inventory' }, world)
|
||||||
|
expect(r.appended.some((l) => /empty-handed|carrying nothing/i.test(l.text))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import type { World } from '../world/types'
|
||||||
|
import type { GameState, ParsedCommand, DispatchResult, ItemInstance, TranscriptLine, NounRef } from './types'
|
||||||
|
import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types'
|
||||||
|
import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters'
|
||||||
|
|
||||||
|
export function initialStateFor(world: World): GameState {
|
||||||
|
const startingRoom = world.rooms[world.startingRoom]
|
||||||
|
if (!startingRoom) throw new Error(`World has invalid startingRoom: ${world.startingRoom}`)
|
||||||
|
|
||||||
|
const inventory: ItemInstance[] = world.startingInventory.map((id) => {
|
||||||
|
const item = world.items[id]
|
||||||
|
if (!item) throw new Error(`Starting inventory references unknown item: ${id}`)
|
||||||
|
return { id, state: { ...item.initialState } }
|
||||||
|
})
|
||||||
|
|
||||||
|
const opening: TranscriptLine[] = [
|
||||||
|
{ kind: 'system', text: startingRoom.title },
|
||||||
|
{ kind: 'narration', text: startingRoom.descriptions.firstVisit },
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
location: world.startingRoom,
|
||||||
|
inventory,
|
||||||
|
roomState: { [world.startingRoom]: { visited: true } },
|
||||||
|
flags: {},
|
||||||
|
resolveLevel: 'steady',
|
||||||
|
encounterState: {},
|
||||||
|
lastNoun: null,
|
||||||
|
pendingDisambiguation: null,
|
||||||
|
transcript: opening,
|
||||||
|
theme: 'amber',
|
||||||
|
endedWith: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function append(state: GameState, lines: TranscriptLine[]): GameState {
|
||||||
|
const transcript = [...state.transcript, ...lines]
|
||||||
|
return { ...state, transcript: transcript.slice(-TRANSCRIPT_CAP) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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) ?? []
|
||||||
|
return [...baseItems.filter((i) => !taken.includes(i)), ...dropped]
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRoomFlag(state: GameState, roomId: string, key: string, value: string | boolean | number | string[]): GameState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
roomState: {
|
||||||
|
...state.roomState,
|
||||||
|
[roomId]: { ...(state.roomState[roomId] ?? {}), [key]: value as string | boolean | number },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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') {
|
||||||
|
const pending = state.pendingDisambiguation
|
||||||
|
if (!pending) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: 'Nothing to choose between.' }])
|
||||||
|
}
|
||||||
|
const cleared: GameState = { ...state, pendingDisambiguation: null }
|
||||||
|
return dispatch(
|
||||||
|
cleared,
|
||||||
|
{ kind: 'verb-target', verb: pending.verb, target: { canonical: command.chosen, raw: command.chosen } },
|
||||||
|
world,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.kind === 'unknown') {
|
||||||
|
const text =
|
||||||
|
command.reason === 'unknown-verb' ? 'You consider the words, but they don\'t fit this place.'
|
||||||
|
: command.reason === 'unknown-noun' ? 'You don\'t see anything like that here.'
|
||||||
|
: 'You hesitate.'
|
||||||
|
return narrate(state, [{ kind: 'narration', text }])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.kind === 'meta') {
|
||||||
|
return handleMeta(state, command.verb)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.kind === 'go') {
|
||||||
|
return handleGo(state, command.direction, world)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.kind === 'verb-target') {
|
||||||
|
const stateWithNoun: GameState = { ...state, lastNoun: command.target }
|
||||||
|
// 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 }
|
||||||
|
}
|
||||||
|
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.` }])
|
||||||
|
}
|
||||||
|
|
||||||
|
return narrate(state, [{ kind: 'narration', text: 'Nothing happens.' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function narrate(state: GameState, lines: TranscriptLine[]): DispatchResult {
|
||||||
|
return { state: append(state, lines), appended: lines }
|
||||||
|
}
|
||||||
|
|
||||||
|
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}.` }])
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
// calling dispatch for these.
|
||||||
|
return narrate(state, [{ kind: 'system', text: `(${verb})` }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd', world: World): DispatchResult {
|
||||||
|
const room = world.rooms[state.location]
|
||||||
|
if (!room) return narrate(state, [{ kind: 'narration', text: 'You are nowhere.' }])
|
||||||
|
|
||||||
|
const dest = room.exits[direction]
|
||||||
|
if (!dest) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: 'You can\'t go that way.' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const lock = room.lockedExits?.[direction]
|
||||||
|
if (lock) {
|
||||||
|
const hasKey = state.inventory.some((i) => i.id === lock.requires) || !!state.flags[lock.requires]
|
||||||
|
if (!hasKey) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: lock.lockedNarration }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const destRoom = world.rooms[dest]
|
||||||
|
if (!destRoom) return narrate(state, [{ kind: 'narration', text: 'The way ahead is unfinished.' }])
|
||||||
|
|
||||||
|
const visited = !!state.roomState[dest]?.['visited']
|
||||||
|
const description = visited ? destRoom.descriptions.revisit : destRoom.descriptions.firstVisit
|
||||||
|
|
||||||
|
let next: GameState = { ...state, location: dest }
|
||||||
|
next = setRoomFlag(next, dest, 'visited', true)
|
||||||
|
|
||||||
|
if (destRoom.safe) {
|
||||||
|
const ladder = ['steady', 'shaken', 'reeling', 'returning'] as const
|
||||||
|
const idx = ladder.indexOf(state.resolveLevel)
|
||||||
|
if (idx > 0) next = { ...next, resolveLevel: ladder[idx - 1]! }
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrivalLines: TranscriptLine[] = [
|
||||||
|
{ kind: 'system', text: destRoom.title },
|
||||||
|
{ kind: 'narration', text: description },
|
||||||
|
]
|
||||||
|
const result = narrate(next, arrivalLines)
|
||||||
|
|
||||||
|
// Trigger any encounter waiting in this room.
|
||||||
|
const triggered = maybeTriggerEncounter(result.state, world)
|
||||||
|
if (triggered) {
|
||||||
|
return { state: triggered.state, appended: [...arrivalLines, ...triggered.appended] }
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLook(state: GameState, world: World): DispatchResult {
|
||||||
|
const room = world.rooms[state.location]
|
||||||
|
if (!room) return narrate(state, [{ kind: 'narration', text: 'You see nothing.' }])
|
||||||
|
const items = getItemsInRoom(state, world, state.location)
|
||||||
|
const itemNarration = items.length > 0 ? `You see here: ${items.map((id) => world.items[id]?.short ?? id).join(', ')}.` : ''
|
||||||
|
return narrate(state, [
|
||||||
|
{ kind: 'system', text: room.title },
|
||||||
|
{ kind: 'narration', text: room.descriptions.examined },
|
||||||
|
...(itemNarration ? [{ kind: 'narration' as const, text: itemNarration }] : []),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInventory(state: GameState, world: World): DispatchResult {
|
||||||
|
if (state.inventory.length === 0) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: 'You are empty-handed.' }])
|
||||||
|
}
|
||||||
|
const lines = state.inventory.map((inst) => {
|
||||||
|
const item = world.items[inst.id]
|
||||||
|
const litSuffix = inst.state['lit'] === true ? ' (lit)' : ''
|
||||||
|
return ` ${item?.short ?? inst.id}${litSuffix}`
|
||||||
|
})
|
||||||
|
return narrate(state, [
|
||||||
|
{ kind: 'narration', text: 'You are carrying:' },
|
||||||
|
{ kind: 'narration', text: lines.join('\n') },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTake(state: GameState, itemId: string, world: World): DispatchResult {
|
||||||
|
const item = world.items[itemId]
|
||||||
|
if (!item) return narrate(state, [{ kind: 'narration', text: 'You don\'t see that here.' }])
|
||||||
|
if (!item.takeable) return narrate(state, [{ kind: 'narration', text: 'You can\'t take that.' }])
|
||||||
|
|
||||||
|
const itemsHere = getItemsInRoom(state, world, state.location)
|
||||||
|
if (!itemsHere.includes(itemId)) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: 'You don\'t see that here.' }])
|
||||||
|
}
|
||||||
|
if (state.inventory.find((i) => i.id === itemId)) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: 'You already have it.' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasInRoomBase = (world.rooms[state.location]?.items ?? []).includes(itemId)
|
||||||
|
let next: GameState = {
|
||||||
|
...state,
|
||||||
|
inventory: [...state.inventory, { id: itemId, state: { ...item.initialState } }],
|
||||||
|
}
|
||||||
|
if (wasInRoomBase) {
|
||||||
|
const taken = (next.roomState[state.location]?.['takenItems'] as string[] | undefined) ?? []
|
||||||
|
next = setRoomFlag(next, state.location, 'takenItems', [...taken, itemId])
|
||||||
|
} else {
|
||||||
|
const dropped = (next.roomState[state.location]?.['droppedItems'] as string[] | undefined) ?? []
|
||||||
|
next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== itemId))
|
||||||
|
}
|
||||||
|
return narrate(next, [{ kind: 'narration', text: 'Taken.' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(state: GameState, itemId: string, world: World): DispatchResult {
|
||||||
|
if (!state.inventory.find((i) => i.id === itemId)) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: 'You don\'t have that.' }])
|
||||||
|
}
|
||||||
|
let next: GameState = {
|
||||||
|
...state,
|
||||||
|
inventory: state.inventory.filter((i) => i.id !== itemId),
|
||||||
|
}
|
||||||
|
const dropped = (next.roomState[state.location]?.['droppedItems'] as string[] | undefined) ?? []
|
||||||
|
next = setRoomFlag(next, state.location, 'droppedItems', [...dropped, itemId])
|
||||||
|
return narrate(next, [{ kind: 'narration', text: 'Dropped.' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExamine(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.' }])
|
||||||
|
return narrate(state, [{ kind: 'narration', text: item.long }])
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { dispatch, initialStateFor } from './dispatcher'
|
||||||
|
import type { World } from '../world/types'
|
||||||
|
|
||||||
|
const world: World = {
|
||||||
|
startingRoom: 'foyer',
|
||||||
|
startingInventory: ['mirror'],
|
||||||
|
rooms: {
|
||||||
|
foyer: {
|
||||||
|
id: 'foyer',
|
||||||
|
title: '[ Foyer ]',
|
||||||
|
descriptions: { firstVisit: 'Foyer.', revisit: 'Foyer.', examined: 'Foyer.' },
|
||||||
|
exits: { n: 'stair' },
|
||||||
|
items: [],
|
||||||
|
safe: true,
|
||||||
|
},
|
||||||
|
stair: {
|
||||||
|
id: 'stair',
|
||||||
|
title: '[ Cellar Stair ]',
|
||||||
|
descriptions: { firstVisit: 'Stair.', revisit: 'Stair.', examined: 'Stair.' },
|
||||||
|
exits: { s: 'foyer', d: 'cellar' },
|
||||||
|
items: [],
|
||||||
|
encounter: 'revenant',
|
||||||
|
},
|
||||||
|
cellar: {
|
||||||
|
id: 'cellar',
|
||||||
|
title: '[ Cellar ]',
|
||||||
|
descriptions: { firstVisit: 'Cellar.', revisit: 'Cellar.', examined: 'Cellar.' },
|
||||||
|
exits: { u: 'stair' },
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
mirror: { id: 'mirror', names: ['mirror', 'tarnished mirror'], short: 'a tarnished mirror', long: 'A small mirror, tarnished black.', initialState: {}, takeable: true },
|
||||||
|
sword: { id: 'sword', names: ['sword', 'cane sword'], short: 'a cane sword', long: 'A slim cane sword.', initialState: {}, takeable: true },
|
||||||
|
},
|
||||||
|
encounters: {
|
||||||
|
revenant: {
|
||||||
|
id: 'revenant',
|
||||||
|
startsIn: 'stair',
|
||||||
|
initialPhase: 'wary',
|
||||||
|
phases: {
|
||||||
|
wary: {
|
||||||
|
description: 'A revenant rises from the wet stone.',
|
||||||
|
transitions: [
|
||||||
|
{ verb: 'attack', target: 'revenant', narration: 'Your blade passes through.', to: 'shaken', resolveCost: 1 },
|
||||||
|
{ verb: 'examine', target: 'revenant', narration: 'There is a tarnish around its eyes.', to: 'wary' },
|
||||||
|
{ verb: 'hold', target: 'revenant', requires: { item: 'mirror' }, narration: 'It looks into the silver.', to: 'resolved' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
shaken: {
|
||||||
|
description: 'The revenant comes closer.',
|
||||||
|
transitions: [
|
||||||
|
{ verb: 'hold', target: 'revenant', requires: { item: 'mirror' }, narration: 'It looks. It remembers.', to: 'resolved' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onResolved: { setFlags: { revenantLaid: true } },
|
||||||
|
onFailed: { narration: 'You stagger back.', retreatTo: 'foyer' },
|
||||||
|
defaultWrongVerbNarration: 'The revenant does not seem to notice.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
endings: {
|
||||||
|
true: { whenFlags: {}, narration: '' },
|
||||||
|
wrong: { whenFlags: {}, narration: '' },
|
||||||
|
bad: { whenFlags: {}, narration: '' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('encounters — phase advancement', () => {
|
||||||
|
it('triggers an encounter on entering its room', () => {
|
||||||
|
let s = initialStateFor(world)
|
||||||
|
const r = dispatch(s, { kind: 'go', direction: 'n' }, world)
|
||||||
|
expect(r.state.encounterState['revenant']).toBe('wary')
|
||||||
|
expect(r.appended.some((l) => l.text.includes('revenant rises'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('right verb resolves the encounter', () => {
|
||||||
|
let s = initialStateFor(world)
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
|
const r = dispatch(s, { kind: 'verb-target', verb: 'hold', target: { canonical: 'revenant', raw: 'revenant' } }, world)
|
||||||
|
expect(r.state.encounterState['revenant']).toBeUndefined()
|
||||||
|
expect(r.state.flags['revenantLaid']).toBe(true)
|
||||||
|
expect(r.appended.some((l) => l.text.includes('looks into the silver'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wrong verb costs resolve and surfaces a clue', () => {
|
||||||
|
let s = initialStateFor(world)
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
|
const r = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world)
|
||||||
|
expect(r.state.resolveLevel).toBe('shaken')
|
||||||
|
expect(r.state.encounterState['revenant']).toBe('shaken')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to defaultWrongVerbNarration for unrecognized verbs', () => {
|
||||||
|
let s = initialStateFor(world)
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
|
const r = dispatch(s, { kind: 'verb-target', verb: 'push', target: { canonical: 'revenant', raw: 'revenant' } }, world)
|
||||||
|
expect(r.appended.some((l) => l.text.includes('does not seem to notice'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retreats to safe room when resolve runs out', () => {
|
||||||
|
let s = initialStateFor(world)
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
|
// Force resolve to 'returning' so the next failure retreats.
|
||||||
|
s = { ...s, resolveLevel: 'returning' }
|
||||||
|
const r = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world)
|
||||||
|
expect(r.state.location).toBe('foyer')
|
||||||
|
expect(r.appended.some((l) => l.text.includes('stagger back'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('safe room entry regenerates resolve', () => {
|
||||||
|
let s = initialStateFor(world)
|
||||||
|
s = { ...s, resolveLevel: 'shaken' }
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 's' }, world).state
|
||||||
|
expect(s.resolveLevel).toBe('steady')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import type { World } from '../world/types'
|
||||||
|
import type { GameState, ParsedCommand, DispatchResult, TranscriptLine, ResolveLevel } from './types'
|
||||||
|
import { TRANSCRIPT_CAP, RESOLVE_LEVELS } from './types'
|
||||||
|
|
||||||
|
function append(state: GameState, lines: TranscriptLine[]): GameState {
|
||||||
|
const transcript = [...state.transcript, ...lines]
|
||||||
|
return { ...state, transcript: transcript.slice(-TRANSCRIPT_CAP) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function narrate(state: GameState, lines: TranscriptLine[]): DispatchResult {
|
||||||
|
return { state: append(state, lines), appended: lines }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the encounter id active in the current room, or null. */
|
||||||
|
export function activeEncounterId(state: GameState, world: World): string | null {
|
||||||
|
const roomEncounter = world.rooms[state.location]?.encounter
|
||||||
|
if (!roomEncounter) return null
|
||||||
|
const phase = state.encounterState[roomEncounter]
|
||||||
|
if (!phase) return null
|
||||||
|
return roomEncounter
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Triggers a fresh encounter when the player enters its starting room. */
|
||||||
|
export function maybeTriggerEncounter(state: GameState, world: World): DispatchResult | null {
|
||||||
|
const roomEncounter = world.rooms[state.location]?.encounter
|
||||||
|
if (!roomEncounter) return null
|
||||||
|
const def = world.encounters[roomEncounter]
|
||||||
|
if (!def) return null
|
||||||
|
if (state.encounterState[roomEncounter]) return null // already active or resolved
|
||||||
|
if (state.flags[`${roomEncounter}.resolved`]) return null // already done
|
||||||
|
|
||||||
|
const next: GameState = {
|
||||||
|
...state,
|
||||||
|
encounterState: { ...state.encounterState, [roomEncounter]: def.initialPhase },
|
||||||
|
}
|
||||||
|
const phase = def.phases[def.initialPhase]
|
||||||
|
if (!phase) return null
|
||||||
|
return narrate(next, [{ kind: 'narration', text: phase.description }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpResolve(level: ResolveLevel, cost: 0 | 1 | 2 | undefined): ResolveLevel {
|
||||||
|
if (!cost) return level
|
||||||
|
const idx = RESOLVE_LEVELS.indexOf(level)
|
||||||
|
const newIdx = Math.min(RESOLVE_LEVELS.length - 1, idx + cost)
|
||||||
|
return RESOLVE_LEVELS[newIdx]!
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncounterResolution {
|
||||||
|
state: GameState
|
||||||
|
lines: TranscriptLine[]
|
||||||
|
/** True if the encounter consumed the verb and the dispatcher should not handle it further. */
|
||||||
|
consumed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Try to apply a verb against the active encounter. Returns null if no encounter is active. */
|
||||||
|
export function applyVerbToEncounter(
|
||||||
|
state: GameState,
|
||||||
|
command: ParsedCommand,
|
||||||
|
world: World,
|
||||||
|
): EncounterResolution | null {
|
||||||
|
const encId = activeEncounterId(state, world)
|
||||||
|
if (!encId) return null
|
||||||
|
const def = world.encounters[encId]
|
||||||
|
if (!def) return null
|
||||||
|
const currentPhase = state.encounterState[encId]!
|
||||||
|
const phaseDef = def.phases[currentPhase]
|
||||||
|
if (!phaseDef) return null
|
||||||
|
|
||||||
|
// Only verb-target and verb-only commands engage with encounters.
|
||||||
|
let verb: string | null = null
|
||||||
|
let targetId: string | null = null
|
||||||
|
if (command.kind === 'verb-target') {
|
||||||
|
verb = command.verb
|
||||||
|
targetId = command.target.canonical
|
||||||
|
} else if (command.kind === 'verb-only' && command.verb !== 'inventory') {
|
||||||
|
verb = command.verb
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a matching transition.
|
||||||
|
const transition = phaseDef.transitions.find((t) => {
|
||||||
|
if (t.verb !== verb) return false
|
||||||
|
if (t.target && t.target !== '*' && t.target !== targetId) return false
|
||||||
|
if (t.requires) {
|
||||||
|
const inst = state.inventory.find((i) => i.id === t.requires!.item)
|
||||||
|
if (!inst) return false
|
||||||
|
if (t.requires.state) {
|
||||||
|
for (const [k, v] of Object.entries(t.requires.state)) {
|
||||||
|
if (inst.state[k] !== v) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!transition) {
|
||||||
|
// Wrong verb — apply default narration and resolve cost.
|
||||||
|
if (!verb || (targetId !== null && targetId !== encId)) return null // verb is unrelated to this encounter
|
||||||
|
const newResolve = bumpResolve(state.resolveLevel, 1)
|
||||||
|
if (state.resolveLevel === 'returning') {
|
||||||
|
// Retreat.
|
||||||
|
const retreat = def.onFailed
|
||||||
|
if (retreat) {
|
||||||
|
const next: GameState = { ...state, location: retreat.retreatTo, resolveLevel: 'shaken' }
|
||||||
|
const dest = world.rooms[retreat.retreatTo]
|
||||||
|
const lines: TranscriptLine[] = [
|
||||||
|
{ kind: 'narration', text: retreat.narration },
|
||||||
|
...(dest ? [{ kind: 'system' as const, text: dest.title }, { kind: 'narration' as const, text: dest.descriptions.revisit }] : []),
|
||||||
|
]
|
||||||
|
return { state: append(next, lines), lines, consumed: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const next: GameState = { ...state, resolveLevel: newResolve }
|
||||||
|
const lines: TranscriptLine[] = [
|
||||||
|
{ kind: 'narration', text: def.defaultWrongVerbNarration ?? 'That has no effect.' },
|
||||||
|
]
|
||||||
|
return { state: append(next, lines), lines, consumed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right verb — but if it has a resolve cost and player is already at 'returning', retreat.
|
||||||
|
if (transition.resolveCost && transition.resolveCost > 0 && state.resolveLevel === 'returning') {
|
||||||
|
const retreat = def.onFailed
|
||||||
|
if (retreat) {
|
||||||
|
const next: GameState = { ...state, location: retreat.retreatTo, resolveLevel: 'shaken' }
|
||||||
|
const dest = world.rooms[retreat.retreatTo]
|
||||||
|
const lines: TranscriptLine[] = [
|
||||||
|
{ kind: 'narration', text: transition.narration },
|
||||||
|
{ kind: 'narration', text: retreat.narration },
|
||||||
|
...(dest ? [{ kind: 'system' as const, text: dest.title }, { kind: 'narration' as const, text: dest.descriptions.revisit }] : []),
|
||||||
|
]
|
||||||
|
return { state: append(next, lines), lines, consumed: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right verb — narrate and transition.
|
||||||
|
let next: GameState = { ...state }
|
||||||
|
if (transition.resolveCost) {
|
||||||
|
next = { ...next, resolveLevel: bumpResolve(next.resolveLevel, transition.resolveCost) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transition.to === 'resolved') {
|
||||||
|
const newEncState = { ...next.encounterState }
|
||||||
|
delete newEncState[encId]
|
||||||
|
let resolvedFlags = { ...next.flags, [`${encId}.resolved`]: true }
|
||||||
|
if (def.onResolved?.setFlags) resolvedFlags = { ...resolvedFlags, ...def.onResolved.setFlags }
|
||||||
|
next = { ...next, encounterState: newEncState, flags: resolvedFlags }
|
||||||
|
} else if (transition.to === 'failed') {
|
||||||
|
const retreat = def.onFailed
|
||||||
|
if (retreat) {
|
||||||
|
const dest = world.rooms[retreat.retreatTo]
|
||||||
|
const newEncState = { ...next.encounterState }
|
||||||
|
delete newEncState[encId]
|
||||||
|
next = { ...next, location: retreat.retreatTo, encounterState: newEncState, resolveLevel: 'shaken' }
|
||||||
|
const lines: TranscriptLine[] = [
|
||||||
|
{ kind: 'narration', text: transition.narration },
|
||||||
|
{ kind: 'narration', text: retreat.narration },
|
||||||
|
...(dest ? [{ kind: 'system' as const, text: dest.title }, { kind: 'narration' as const, text: dest.descriptions.revisit }] : []),
|
||||||
|
]
|
||||||
|
return { state: append(next, lines), lines, consumed: true }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next = { ...next, encounterState: { ...next.encounterState, [encId]: transition.to } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: TranscriptLine[] = [{ kind: 'narration', text: transition.narration }]
|
||||||
|
return { state: append(next, lines), lines, consumed: true }
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parse } from './parser'
|
||||||
|
import type { ParserContext } from './parser'
|
||||||
|
|
||||||
|
const emptyCtx: ParserContext = {
|
||||||
|
knownItems: [],
|
||||||
|
knownEncounters: [],
|
||||||
|
visibleNouns: [],
|
||||||
|
inventoryItemIds: [],
|
||||||
|
lastNoun: null,
|
||||||
|
awaitingDisambiguation: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parser — verb-only commands', () => {
|
||||||
|
it('recognizes bare "look"', () => {
|
||||||
|
expect(parse('look', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'look' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recognizes bare "inventory" and short forms', () => {
|
||||||
|
expect(parse('inventory', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
|
||||||
|
expect(parse('inv', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
|
||||||
|
expect(parse('i', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is case-insensitive', () => {
|
||||||
|
expect(parse('LOOK', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'look' })
|
||||||
|
expect(parse('Inv', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims whitespace', () => {
|
||||||
|
expect(parse(' look ', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'look' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parser — direction shortcuts', () => {
|
||||||
|
it('maps single-letter directions', () => {
|
||||||
|
expect(parse('n', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
|
||||||
|
expect(parse('s', emptyCtx)).toEqual({ kind: 'go', direction: 's' })
|
||||||
|
expect(parse('e', emptyCtx)).toEqual({ kind: 'go', direction: 'e' })
|
||||||
|
expect(parse('w', emptyCtx)).toEqual({ kind: 'go', direction: 'w' })
|
||||||
|
expect(parse('u', emptyCtx)).toEqual({ kind: 'go', direction: 'u' })
|
||||||
|
expect(parse('d', emptyCtx)).toEqual({ kind: 'go', direction: 'd' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps full direction words', () => {
|
||||||
|
expect(parse('north', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
|
||||||
|
expect(parse('south', emptyCtx)).toEqual({ kind: 'go', direction: 's' })
|
||||||
|
expect(parse('go north', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
|
||||||
|
expect(parse('go up', emptyCtx)).toEqual({ kind: 'go', direction: 'u' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parser — meta-commands', () => {
|
||||||
|
it('recognizes restart, undo, hint, quit, save, theme', () => {
|
||||||
|
expect(parse('restart', emptyCtx)).toEqual({ kind: 'meta', verb: 'restart' })
|
||||||
|
expect(parse('undo', emptyCtx)).toEqual({ kind: 'meta', verb: 'undo' })
|
||||||
|
expect(parse('hint', emptyCtx)).toEqual({ kind: 'meta', verb: 'hint' })
|
||||||
|
expect(parse('quit', emptyCtx)).toEqual({ kind: 'meta', verb: 'quit' })
|
||||||
|
expect(parse('save', emptyCtx)).toEqual({ kind: 'meta', verb: 'save' })
|
||||||
|
expect(parse('theme', emptyCtx)).toEqual({ kind: 'meta', verb: 'theme' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parser — unknown input', () => {
|
||||||
|
it('returns unknown for empty input', () => {
|
||||||
|
expect(parse('', emptyCtx)).toEqual({ kind: 'unknown', raw: '', reason: 'malformed' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns unknown-verb for nonsense', () => {
|
||||||
|
expect(parse('flibbertigibbet', emptyCtx)).toEqual({
|
||||||
|
kind: 'unknown',
|
||||||
|
raw: 'flibbertigibbet',
|
||||||
|
reason: 'unknown-verb',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parser — verb + target', () => {
|
||||||
|
it('resolves a single visible noun', () => {
|
||||||
|
const ctx: ParserContext = {
|
||||||
|
knownItems: ['torch'],
|
||||||
|
knownEncounters: [],
|
||||||
|
visibleNouns: [{ id: 'torch', aliases: ['torch', 'lamp'] }],
|
||||||
|
inventoryItemIds: [],
|
||||||
|
lastNoun: null,
|
||||||
|
awaitingDisambiguation: null,
|
||||||
|
}
|
||||||
|
expect(parse('take torch', ctx)).toEqual({
|
||||||
|
kind: 'verb-target',
|
||||||
|
verb: 'take',
|
||||||
|
target: { canonical: 'torch', raw: 'torch' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches multi-word object names', () => {
|
||||||
|
const ctx: ParserContext = {
|
||||||
|
knownItems: ['brass-key'],
|
||||||
|
knownEncounters: [],
|
||||||
|
visibleNouns: [{ id: 'brass-key', aliases: ['brass key', 'key'] }],
|
||||||
|
inventoryItemIds: [],
|
||||||
|
lastNoun: null,
|
||||||
|
awaitingDisambiguation: null,
|
||||||
|
}
|
||||||
|
expect(parse('take brass key', ctx)).toEqual({
|
||||||
|
kind: 'verb-target',
|
||||||
|
verb: 'take',
|
||||||
|
target: { canonical: 'brass-key', raw: 'brass key' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches by alias', () => {
|
||||||
|
const ctx: ParserContext = {
|
||||||
|
knownItems: ['torch'],
|
||||||
|
knownEncounters: [],
|
||||||
|
visibleNouns: [{ id: 'torch', aliases: ['torch', 'lamp'] }],
|
||||||
|
inventoryItemIds: [],
|
||||||
|
lastNoun: null,
|
||||||
|
awaitingDisambiguation: null,
|
||||||
|
}
|
||||||
|
expect(parse('take lamp', ctx)).toEqual({
|
||||||
|
kind: 'verb-target',
|
||||||
|
verb: 'take',
|
||||||
|
target: { canonical: 'torch', raw: 'lamp' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns unknown-noun for noun not in scope', () => {
|
||||||
|
const ctx: ParserContext = {
|
||||||
|
knownItems: ['torch'],
|
||||||
|
knownEncounters: [],
|
||||||
|
visibleNouns: [],
|
||||||
|
inventoryItemIds: [],
|
||||||
|
lastNoun: null,
|
||||||
|
awaitingDisambiguation: null,
|
||||||
|
}
|
||||||
|
expect(parse('take torch', ctx)).toEqual({
|
||||||
|
kind: 'unknown',
|
||||||
|
raw: 'take torch',
|
||||||
|
reason: 'unknown-noun',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks inventory for noun resolution', () => {
|
||||||
|
const ctx: ParserContext = {
|
||||||
|
knownItems: ['torch'],
|
||||||
|
knownEncounters: [],
|
||||||
|
visibleNouns: [],
|
||||||
|
inventoryItemIds: ['torch'],
|
||||||
|
lastNoun: null,
|
||||||
|
awaitingDisambiguation: null,
|
||||||
|
}
|
||||||
|
expect(parse('drop torch', ctx)).toEqual({
|
||||||
|
kind: 'verb-target',
|
||||||
|
verb: 'drop',
|
||||||
|
target: { canonical: 'torch', raw: 'torch' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parser — disambiguation', () => {
|
||||||
|
it('returns disambiguation request when two candidates match', () => {
|
||||||
|
const ctx: ParserContext = {
|
||||||
|
knownItems: ['brass-key', 'iron-key'],
|
||||||
|
knownEncounters: [],
|
||||||
|
visibleNouns: [
|
||||||
|
{ id: 'brass-key', aliases: ['brass key', 'key'] },
|
||||||
|
{ id: 'iron-key', aliases: ['iron key', 'key'] },
|
||||||
|
],
|
||||||
|
inventoryItemIds: [],
|
||||||
|
lastNoun: null,
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disambiguation reply resolves the pending choice', () => {
|
||||||
|
const ctx: ParserContext = {
|
||||||
|
knownItems: ['brass-key', 'iron-key'],
|
||||||
|
knownEncounters: [],
|
||||||
|
visibleNouns: [
|
||||||
|
{ id: 'brass-key', aliases: ['brass key', 'key'] },
|
||||||
|
{ id: 'iron-key', aliases: ['iron key', 'key'] },
|
||||||
|
],
|
||||||
|
inventoryItemIds: [],
|
||||||
|
lastNoun: null,
|
||||||
|
awaitingDisambiguation: {
|
||||||
|
verb: 'take',
|
||||||
|
candidates: ['brass-key', 'iron-key'],
|
||||||
|
prompt: 'Which key — the brass key or the iron key?',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
expect(parse('brass', ctx)).toEqual({ kind: 'disambiguation', chosen: 'brass-key' })
|
||||||
|
expect(parse('iron', ctx)).toEqual({ kind: 'disambiguation', chosen: 'iron-key' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parser — pronouns', () => {
|
||||||
|
it('resolves "it" to lastNoun', () => {
|
||||||
|
const ctx: ParserContext = {
|
||||||
|
knownItems: ['torch'],
|
||||||
|
knownEncounters: [],
|
||||||
|
visibleNouns: [{ id: 'torch', aliases: ['torch'] }],
|
||||||
|
inventoryItemIds: [],
|
||||||
|
lastNoun: { canonical: 'torch', raw: 'torch' },
|
||||||
|
awaitingDisambiguation: null,
|
||||||
|
}
|
||||||
|
expect(parse('examine it', ctx)).toEqual({
|
||||||
|
kind: 'verb-target',
|
||||||
|
verb: 'examine',
|
||||||
|
target: { canonical: 'torch', raw: 'it' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns unknown-noun for "it" with no lastNoun', () => {
|
||||||
|
const ctx: ParserContext = {
|
||||||
|
knownItems: ['torch'],
|
||||||
|
knownEncounters: [],
|
||||||
|
visibleNouns: [{ id: 'torch', aliases: ['torch'] }],
|
||||||
|
inventoryItemIds: [],
|
||||||
|
lastNoun: null,
|
||||||
|
awaitingDisambiguation: null,
|
||||||
|
}
|
||||||
|
const result = parse('examine it', ctx)
|
||||||
|
expect(result.kind).toBe('unknown')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import type { ParsedCommand, NounRef, Verb, MetaVerb, Direction, PendingDisambiguation } from './types'
|
||||||
|
|
||||||
|
export interface ParserContext {
|
||||||
|
/** All item ids that exist in the world (for noun matching). */
|
||||||
|
knownItems: string[]
|
||||||
|
/** All encounter ids that exist in the world. */
|
||||||
|
knownEncounters: string[]
|
||||||
|
/** Nouns currently visible in this room (items + encounter targets). */
|
||||||
|
visibleNouns: { id: string; aliases: string[] }[]
|
||||||
|
/** Inventory item ids. */
|
||||||
|
inventoryItemIds: string[]
|
||||||
|
lastNoun: NounRef | null
|
||||||
|
awaitingDisambiguation: PendingDisambiguation | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verb synonym table: each entry maps an alias to the canonical Verb. */
|
||||||
|
const VERB_SYNONYMS: Record<string, Verb> = {
|
||||||
|
// movement
|
||||||
|
go: 'go', walk: 'go', move: 'go',
|
||||||
|
// perception
|
||||||
|
look: 'look', l: 'look',
|
||||||
|
examine: 'examine', x: 'examine', inspect: 'examine',
|
||||||
|
// inventory
|
||||||
|
inventory: 'inventory', inv: 'inventory', i: 'inventory',
|
||||||
|
// manipulation
|
||||||
|
take: 'take', get: 'take', grab: 'take', 'pick up': 'take',
|
||||||
|
drop: 'drop', put: 'drop', leave: 'drop',
|
||||||
|
use: 'use', combine: 'use',
|
||||||
|
open: 'open', close: 'close',
|
||||||
|
read: 'read', light: 'light', extinguish: 'extinguish', douse: 'extinguish',
|
||||||
|
attack: 'attack', kill: 'attack', fight: 'attack', strike: 'attack',
|
||||||
|
hold: 'hold', show: 'hold',
|
||||||
|
push: 'push', press: 'push',
|
||||||
|
pull: 'pull',
|
||||||
|
wait: 'wait', z: 'wait',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIRECTION_WORDS: Record<string, Direction> = {
|
||||||
|
n: 'n', north: 'n',
|
||||||
|
s: 's', south: 's',
|
||||||
|
e: 'e', east: 'e',
|
||||||
|
w: 'w', west: 'w',
|
||||||
|
u: 'u', up: 'u',
|
||||||
|
d: 'd', down: 'd',
|
||||||
|
}
|
||||||
|
|
||||||
|
const META_VERBS: Record<string, MetaVerb> = {
|
||||||
|
restart: 'restart',
|
||||||
|
undo: 'undo',
|
||||||
|
hint: 'hint',
|
||||||
|
save: 'save',
|
||||||
|
quit: 'quit',
|
||||||
|
theme: 'theme',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verbs that legally take no target. */
|
||||||
|
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']
|
||||||
|
|
||||||
|
function tokenize(input: string): string[] {
|
||||||
|
return input.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchTwoWordVerb(tokens: string[]): { verb: Verb; rest: string[] } | null {
|
||||||
|
if (tokens.length < 2) return null
|
||||||
|
const head = tokens.slice(0, 2).join(' ')
|
||||||
|
for (const phrase of TWO_WORD_VERBS) {
|
||||||
|
if (phrase === head) {
|
||||||
|
const verb = VERB_SYNONYMS[phrase]
|
||||||
|
if (verb) return { verb, rest: tokens.slice(2) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
|
||||||
|
const trimmed = rawInput.trim()
|
||||||
|
if (!trimmed) return { kind: 'unknown', raw: '', reason: 'malformed' }
|
||||||
|
|
||||||
|
const tokens = tokenize(trimmed)
|
||||||
|
const head = tokens[0]!
|
||||||
|
|
||||||
|
// Meta-commands take precedence (single-word).
|
||||||
|
if (META_VERBS[head] && tokens.length === 1) {
|
||||||
|
return { kind: 'meta', verb: META_VERBS[head]! }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direction shortcuts: "n", "north", "go n", "go north".
|
||||||
|
if (DIRECTION_WORDS[head] && tokens.length === 1) {
|
||||||
|
return { kind: 'go', direction: DIRECTION_WORDS[head]! }
|
||||||
|
}
|
||||||
|
if (head === 'go' && tokens.length === 2) {
|
||||||
|
const dir = DIRECTION_WORDS[tokens[1]!]
|
||||||
|
if (dir) return { kind: 'go', direction: dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disambiguation reply: a single-word answer matching one of the candidates.
|
||||||
|
// Must be checked before verb resolution so "brass" / "iron" etc. are caught.
|
||||||
|
if (ctx.awaitingDisambiguation && tokens.length === 1) {
|
||||||
|
const choice = tokens[0]!
|
||||||
|
for (const candidateId of ctx.awaitingDisambiguation.candidates) {
|
||||||
|
// Match if the choice is a substring of any alias or the id itself.
|
||||||
|
const candidate = ctx.visibleNouns.find((n) => n.id === candidateId)
|
||||||
|
const aliases = candidate?.aliases ?? [candidateId]
|
||||||
|
if (aliases.some((a) => a.toLowerCase().includes(choice))) {
|
||||||
|
return { kind: 'disambiguation', chosen: candidateId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-word verb (e.g. "pick up X").
|
||||||
|
const twoWord = matchTwoWordVerb(tokens)
|
||||||
|
let verb: Verb | undefined
|
||||||
|
let rest: string[]
|
||||||
|
if (twoWord) {
|
||||||
|
verb = twoWord.verb
|
||||||
|
rest = twoWord.rest
|
||||||
|
} else {
|
||||||
|
verb = VERB_SYNONYMS[head]
|
||||||
|
rest = tokens.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verb) {
|
||||||
|
return { kind: 'unknown', raw: trimmed, reason: 'unknown-verb' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rest.length === 0) {
|
||||||
|
if (VERB_ONLY_VERBS.has(verb)) {
|
||||||
|
return { kind: 'verb-only', verb: verb as 'look' | 'inventory' | 'wait' }
|
||||||
|
}
|
||||||
|
return { kind: 'unknown', raw: trimmed, reason: 'malformed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pronoun resolution: "it" maps to lastNoun.
|
||||||
|
if (rest.length === 1 && rest[0] === 'it') {
|
||||||
|
if (!ctx.lastNoun) {
|
||||||
|
return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'verb-target',
|
||||||
|
verb,
|
||||||
|
target: { canonical: ctx.lastNoun.canonical, raw: 'it' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-word noun matching: try the longest possible suffix first.
|
||||||
|
const targetRaw = rest.join(' ')
|
||||||
|
const candidates: { id: string; alias: string }[] = []
|
||||||
|
for (const noun of ctx.visibleNouns) {
|
||||||
|
for (const alias of noun.aliases) {
|
||||||
|
if (alias === targetRaw) candidates.push({ id: noun.id, alias })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check inventory items (id used directly as alias).
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
for (const itemId of ctx.inventoryItemIds) {
|
||||||
|
if (itemId === targetRaw) candidates.push({ id: itemId, alias: targetRaw })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
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.
|
||||||
|
if (candidates.length > 1) {
|
||||||
|
return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = candidates[0]!
|
||||||
|
return {
|
||||||
|
kind: 'verb-target',
|
||||||
|
verb,
|
||||||
|
target: { canonical: target.id, raw: target.alias },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parse } from './parser'
|
||||||
|
import type { ParserContext } from './parser'
|
||||||
|
import { dispatch, initialStateFor } from './dispatcher'
|
||||||
|
import { world } from '../world'
|
||||||
|
import type { GameState } from './types'
|
||||||
|
|
||||||
|
function ctxFor(state: GameState): ParserContext {
|
||||||
|
const room = world.rooms[state.location]
|
||||||
|
const visibleNouns: { id: string; aliases: string[] }[] = []
|
||||||
|
for (const itemId of room?.items ?? []) {
|
||||||
|
const item = world.items[itemId]
|
||||||
|
if (item) visibleNouns.push({ id: itemId, aliases: item.names })
|
||||||
|
}
|
||||||
|
for (const inst of state.inventory) {
|
||||||
|
const item = world.items[inst.id]
|
||||||
|
if (item) visibleNouns.push({ id: inst.id, aliases: item.names })
|
||||||
|
}
|
||||||
|
if (room?.encounter) {
|
||||||
|
visibleNouns.push({ id: room.encounter, aliases: [room.encounter] })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
knownItems: Object.keys(world.items),
|
||||||
|
knownEncounters: Object.keys(world.encounters),
|
||||||
|
visibleNouns,
|
||||||
|
inventoryItemIds: state.inventory.map((i) => i.id),
|
||||||
|
lastNoun: state.lastNoun,
|
||||||
|
awaitingDisambiguation: state.pendingDisambiguation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function play(commands: string[]): GameState {
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
for (const cmd of commands) {
|
||||||
|
const parsed = parse(cmd, ctxFor(state))
|
||||||
|
state = dispatch(state, parsed, world).state
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('playthrough — sample world', () => {
|
||||||
|
it('reaches the rat-gone flag via the canonical command sequence', () => {
|
||||||
|
const state = play([
|
||||||
|
'take letter',
|
||||||
|
'read letter', // verb is recognized but encounter takes priority elsewhere; here it's a no-op
|
||||||
|
'n', // foyer → hallway
|
||||||
|
'take lamp',
|
||||||
|
'e', // hallway → cellar-stair (triggers rat encounter)
|
||||||
|
'attack rat',
|
||||||
|
])
|
||||||
|
expect(state.flags['ratGone']).toBe(true)
|
||||||
|
expect(state.location).toBe('cellar-stair')
|
||||||
|
expect(state.encounterState['rat']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles invalid moves gracefully', () => {
|
||||||
|
const state = play([
|
||||||
|
'go up', // foyer has no up exit
|
||||||
|
'n',
|
||||||
|
's',
|
||||||
|
'flibbertigibbet', // unknown verb
|
||||||
|
])
|
||||||
|
expect(state.location).toBe('foyer')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { saveState, loadState, clearSave, SAVE_KEY } from './save'
|
||||||
|
import type { GameState, TranscriptLine } from './types'
|
||||||
|
import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types'
|
||||||
|
|
||||||
|
const baseState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
location: 'foyer',
|
||||||
|
inventory: [],
|
||||||
|
roomState: {},
|
||||||
|
flags: {},
|
||||||
|
resolveLevel: 'steady',
|
||||||
|
encounterState: {},
|
||||||
|
lastNoun: null,
|
||||||
|
pendingDisambiguation: null,
|
||||||
|
transcript: [],
|
||||||
|
theme: 'amber',
|
||||||
|
endedWith: null,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('save — round trip', () => {
|
||||||
|
let store: Record<string, string>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = {}
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (k: string) => (k in store ? store[k]! : null),
|
||||||
|
setItem: (k: string, v: string) => { store[k] = v },
|
||||||
|
removeItem: (k: string) => { delete store[k] },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips an identical state', () => {
|
||||||
|
const s = baseState({ location: 'cellar', flags: { gateOpened: true } })
|
||||||
|
saveState(s)
|
||||||
|
expect(loadState()).toEqual(s)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when nothing is saved', () => {
|
||||||
|
expect(loadState()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null and clears the slot on schema mismatch', () => {
|
||||||
|
store[SAVE_KEY] = JSON.stringify({ ...baseState(), schemaVersion: SCHEMA_VERSION + 99 })
|
||||||
|
expect(loadState()).toBeNull()
|
||||||
|
expect(store[SAVE_KEY]).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null and clears the slot on malformed JSON', () => {
|
||||||
|
store[SAVE_KEY] = 'not-json'
|
||||||
|
expect(loadState()).toBeNull()
|
||||||
|
expect(store[SAVE_KEY]).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('truncates transcript to TRANSCRIPT_CAP on save', () => {
|
||||||
|
const long: TranscriptLine[] = Array.from({ length: TRANSCRIPT_CAP + 50 }, (_, i) => ({
|
||||||
|
kind: 'narration',
|
||||||
|
text: `line ${i}`,
|
||||||
|
}))
|
||||||
|
saveState(baseState({ transcript: long }))
|
||||||
|
const loaded = loadState()
|
||||||
|
expect(loaded?.transcript).toHaveLength(TRANSCRIPT_CAP)
|
||||||
|
// Keeps the most recent lines (the tail).
|
||||||
|
expect(loaded?.transcript[0]?.text).toBe(`line ${50}`)
|
||||||
|
expect(loaded?.transcript[TRANSCRIPT_CAP - 1]?.text).toBe(`line ${TRANSCRIPT_CAP + 49}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('save — clear', () => {
|
||||||
|
let store: Record<string, string>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = { [SAVE_KEY]: JSON.stringify(baseState()) }
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (k: string) => (k in store ? store[k]! : null),
|
||||||
|
setItem: (k: string, v: string) => { store[k] = v },
|
||||||
|
removeItem: (k: string) => { delete store[k] },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes the save slot', () => {
|
||||||
|
clearSave()
|
||||||
|
expect(store[SAVE_KEY]).toBeUndefined()
|
||||||
|
expect(loadState()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { GameState } from './types'
|
||||||
|
import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types'
|
||||||
|
|
||||||
|
export const SAVE_KEY = 'halfstreet:save:v1'
|
||||||
|
|
||||||
|
/** Save the state to localStorage, truncating the transcript to TRANSCRIPT_CAP. */
|
||||||
|
export function saveState(state: GameState): void {
|
||||||
|
const trimmed: GameState = {
|
||||||
|
...state,
|
||||||
|
transcript:
|
||||||
|
state.transcript.length > TRANSCRIPT_CAP
|
||||||
|
? state.transcript.slice(-TRANSCRIPT_CAP)
|
||||||
|
: state.transcript,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SAVE_KEY, JSON.stringify(trimmed))
|
||||||
|
} catch (err) {
|
||||||
|
// Quota exceeded or storage disabled — silently fail. The game still runs;
|
||||||
|
// the player just won't have persistence.
|
||||||
|
if (typeof console !== 'undefined') console.warn('halfstreet save failed', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load the state, or return null if nothing is saved or the save is unusable. */
|
||||||
|
export function loadState(): GameState | null {
|
||||||
|
let raw: string | null
|
||||||
|
try {
|
||||||
|
raw = localStorage.getItem(SAVE_KEY)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
clearSave()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!parsed ||
|
||||||
|
typeof parsed !== 'object' ||
|
||||||
|
(parsed as { schemaVersion?: unknown }).schemaVersion !== SCHEMA_VERSION
|
||||||
|
) {
|
||||||
|
clearSave()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as GameState
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSave(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(SAVE_KEY)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Engine type definitions. No runtime code — these shapes are the contract
|
||||||
|
// between the world data, the engine, and the UI.
|
||||||
|
|
||||||
|
export type RoomId = string
|
||||||
|
export type ItemId = string
|
||||||
|
export type EncounterId = string
|
||||||
|
export type Direction = 'n' | 's' | 'e' | 'w' | 'u' | 'd'
|
||||||
|
|
||||||
|
export type Verb =
|
||||||
|
| 'go' | 'look' | 'examine' | 'take' | 'drop' | 'use' | 'open' | 'close'
|
||||||
|
| 'read' | 'light' | 'extinguish' | 'attack' | 'inventory' | 'wait'
|
||||||
|
| 'hold' | 'push' | 'pull'
|
||||||
|
|
||||||
|
export type MetaVerb = 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme'
|
||||||
|
|
||||||
|
export interface NounRef {
|
||||||
|
/** Canonical noun (matches an ItemId, EncounterId, or a directional/world noun). */
|
||||||
|
canonical: string
|
||||||
|
/** The raw token(s) the player typed, for narration. */
|
||||||
|
raw: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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: '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>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EncounterPhase = string // phase names are encounter-specific
|
||||||
|
|
||||||
|
export interface TranscriptLine {
|
||||||
|
kind: 'narration' | 'player' | 'system'
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingDisambiguation {
|
||||||
|
verb: Verb
|
||||||
|
candidates: string[] // canonical noun ids the player must choose between
|
||||||
|
prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameState {
|
||||||
|
schemaVersion: number
|
||||||
|
location: RoomId
|
||||||
|
inventory: ItemInstance[]
|
||||||
|
/** Per-room state: visited, items dropped, descriptive flags. */
|
||||||
|
roomState: Record<RoomId, Record<string, string | boolean | number>>
|
||||||
|
/** Story-wide flags (e.g. 'gateOpened', 'mirrorTarnished'). */
|
||||||
|
flags: Record<string, string | boolean | number>
|
||||||
|
resolveLevel: ResolveLevel
|
||||||
|
/** Active encounter phase by encounter id, or null if no encounter is mid-flight. */
|
||||||
|
encounterState: Record<EncounterId, EncounterPhase>
|
||||||
|
/** Last referenced noun, for pronoun resolution. */
|
||||||
|
lastNoun: NounRef | null
|
||||||
|
/** Pending multi-word disambiguation, set when the parser cannot decide. */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DispatchResult {
|
||||||
|
state: GameState
|
||||||
|
/** Lines to append to the transcript (already added to state.transcript). */
|
||||||
|
appended: TranscriptLine[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SCHEMA_VERSION = 1
|
||||||
|
export const TRANSCRIPT_CAP = 200
|
||||||
|
export const RESOLVE_LEVELS: ResolveLevel[] = ['steady', 'shaken', 'reeling', 'returning']
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
import '../mystery/ui/crt.css'
|
||||||
|
---
|
||||||
|
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>Halfstreet — Ethan J Lewis</title>
|
||||||
|
<meta name="description" content="A gothic mystery." />
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="mystery-root" data-mystery-root>
|
||||||
|
<div class="mystery-bezel">
|
||||||
|
<div class="mystery-theme-toggle" data-mystery-theme-toggle>
|
||||||
|
<button type="button" data-theme-choice="amber" aria-pressed="true">[B]</button>
|
||||||
|
<button type="button" data-theme-choice="ansi" aria-pressed="false">[C]</button>
|
||||||
|
</div>
|
||||||
|
<div class="mystery-transcript" data-mystery-transcript aria-live="polite" aria-atomic="false"></div>
|
||||||
|
<div class="mystery-chips" data-mystery-chips></div>
|
||||||
|
<div class="mystery-input-row">
|
||||||
|
<input
|
||||||
|
class="mystery-input"
|
||||||
|
data-mystery-input
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="none"
|
||||||
|
spellcheck="false"
|
||||||
|
aria-label="Command input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Theme attribute is set on :root before any rendering to avoid a flash
|
||||||
|
// of the wrong palette. The full theme toggle wiring lands in Task 11.
|
||||||
|
const stored = (() => {
|
||||||
|
try { return localStorage.getItem('halfstreet:theme:v1') } catch { return null }
|
||||||
|
})()
|
||||||
|
document.documentElement.setAttribute('data-mystery-theme', stored === 'ansi' ? 'ansi' : 'amber')
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
import '../mystery/ui/terminal.ts'
|
||||||
|
import '../mystery/ui/theme.ts'
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Chip } from './chips'
|
||||||
|
|
||||||
|
const CHIP_CONTAINER = '[data-mystery-chips]'
|
||||||
|
|
||||||
|
export function renderChips(chips: Chip[], onSelect: (command: string) => void): void {
|
||||||
|
const container = document.querySelector<HTMLDivElement>(CHIP_CONTAINER)
|
||||||
|
if (!container) return
|
||||||
|
container.innerHTML = ''
|
||||||
|
for (const chip of chips) {
|
||||||
|
const btn = document.createElement('button')
|
||||||
|
btn.type = 'button'
|
||||||
|
btn.className = 'mystery-chip'
|
||||||
|
btn.dataset['chipKind'] = chip.kind
|
||||||
|
btn.textContent = chip.label
|
||||||
|
if (chip.disabled) btn.disabled = true
|
||||||
|
else btn.addEventListener('click', () => onSelect(chip.command))
|
||||||
|
container.appendChild(btn)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { computeChips } from './chips'
|
||||||
|
import { world } from '../world'
|
||||||
|
import { initialStateFor } from '../engine/dispatcher'
|
||||||
|
import { dispatch } from '../engine/dispatcher'
|
||||||
|
|
||||||
|
describe('computeChips — sample world', () => {
|
||||||
|
it('shows valid exits as direction chips with the dim flag for invalid ones', () => {
|
||||||
|
const s = initialStateFor(world)
|
||||||
|
const chips = computeChips(s, world)
|
||||||
|
const directions = chips.filter((c) => c.kind === 'direction')
|
||||||
|
expect(directions.find((c) => c.label.includes('N'))?.disabled).toBe(false)
|
||||||
|
expect(directions.find((c) => c.label.includes('S'))?.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds TAKE chips for visible takeable items', () => {
|
||||||
|
const s = initialStateFor(world)
|
||||||
|
const chips = computeChips(s, world)
|
||||||
|
expect(chips.find((c) => c.kind === 'item' && c.command === 'take letter')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes TAKE chip after item is taken', () => {
|
||||||
|
let s = initialStateFor(world)
|
||||||
|
s = dispatch(s, { kind: 'verb-target', verb: 'take', target: { canonical: 'letter', raw: 'letter' } }, world).state
|
||||||
|
const chips = computeChips(s, world)
|
||||||
|
expect(chips.find((c) => c.command === 'take letter')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds an encounter verb chip when an encounter is active', () => {
|
||||||
|
let s = initialStateFor(world)
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 'e' }, world).state
|
||||||
|
const chips = computeChips(s, world)
|
||||||
|
expect(chips.find((c) => c.kind === 'encounter' && c.command.includes('rat'))).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('always includes LOOK and INV', () => {
|
||||||
|
const s = initialStateFor(world)
|
||||||
|
const chips = computeChips(s, world)
|
||||||
|
expect(chips.find((c) => c.command === 'look')).toBeTruthy()
|
||||||
|
expect(chips.find((c) => c.command === 'inventory')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { World } from '../world/types'
|
||||||
|
import type { GameState, Direction } from '../engine/types'
|
||||||
|
import { getItemsInRoom } from '../engine/dispatcher'
|
||||||
|
|
||||||
|
export type ChipKind = 'direction' | 'item' | 'encounter' | 'meta'
|
||||||
|
|
||||||
|
export interface Chip {
|
||||||
|
kind: ChipKind
|
||||||
|
label: string
|
||||||
|
command: string // the literal string to inject as input
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIRECTION_LABELS: Record<Direction, string> = {
|
||||||
|
n: '↑ N', s: '↓ S', e: '→ E', w: '← W', u: '↑ U', d: '↓ D',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeChips(state: GameState, world: World): Chip[] {
|
||||||
|
const out: Chip[] = []
|
||||||
|
const room = world.rooms[state.location]
|
||||||
|
if (!room) return out
|
||||||
|
|
||||||
|
// Direction chips: enabled if exit exists, dimmed otherwise.
|
||||||
|
const dirs: Direction[] = ['n', 's', 'e', 'w', 'u', 'd']
|
||||||
|
for (const d of dirs) {
|
||||||
|
const present = !!room.exits[d]
|
||||||
|
if (present || ['n', 's', 'e', 'w'].includes(d)) {
|
||||||
|
out.push({
|
||||||
|
kind: 'direction',
|
||||||
|
label: DIRECTION_LABELS[d],
|
||||||
|
command: d,
|
||||||
|
disabled: !present,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item chips: TAKE for visible items (dynamic list excludes taken items).
|
||||||
|
for (const itemId of getItemsInRoom(state, world, state.location)) {
|
||||||
|
const item = world.items[itemId]
|
||||||
|
if (!item || !item.takeable) continue
|
||||||
|
if (state.inventory.find((inst) => inst.id === itemId)) continue // already held
|
||||||
|
out.push({
|
||||||
|
kind: 'item',
|
||||||
|
label: `TAKE ${item.names[0]?.toUpperCase() ?? itemId.toUpperCase()}`,
|
||||||
|
command: `take ${item.names[0] ?? itemId}`,
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encounter chips: surface the verbs from the current phase as suggestions.
|
||||||
|
if (room.encounter && state.encounterState[room.encounter]) {
|
||||||
|
const def = world.encounters[room.encounter]
|
||||||
|
const phase = def?.phases[state.encounterState[room.encounter]!]
|
||||||
|
if (def && phase) {
|
||||||
|
for (const t of phase.transitions) {
|
||||||
|
const targetLabel = t.target && t.target !== '*' ? ` ${t.target.toUpperCase()}` : ''
|
||||||
|
const command = t.target && t.target !== '*' ? `${t.verb} ${t.target}` : t.verb
|
||||||
|
out.push({
|
||||||
|
kind: 'encounter',
|
||||||
|
label: `${t.verb.toUpperCase()}${targetLabel}`,
|
||||||
|
command,
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persistent meta chips.
|
||||||
|
out.push({ kind: 'meta', label: 'LOOK', command: 'look', disabled: false })
|
||||||
|
out.push({ kind: 'meta', label: 'INV', command: 'inventory', disabled: false })
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
:root[data-mystery-theme='amber'] {
|
||||||
|
--m-bg: #1a0d00;
|
||||||
|
--m-fg: #ffb000;
|
||||||
|
--m-accent-1: #ffb000;
|
||||||
|
--m-accent-2: #ffb000;
|
||||||
|
--m-dim: rgba(255, 176, 0, 0.55);
|
||||||
|
--m-bezel: #0a0500;
|
||||||
|
--m-divider-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-mystery-theme='ansi'] {
|
||||||
|
--m-bg: #000080;
|
||||||
|
--m-fg: #ffffff;
|
||||||
|
--m-accent-1: #ffff55;
|
||||||
|
--m-accent-2: #55ffff;
|
||||||
|
--m-dim: #aaaaaa;
|
||||||
|
--m-bezel: #000040;
|
||||||
|
--m-divider-style: double;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--m-bezel);
|
||||||
|
color: var(--m-fg);
|
||||||
|
font-family: 'Courier New', 'Cascadia Mono', 'Consolas', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-shadow: 0 0 1.5px currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-bezel {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--m-bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 22px 26px 14px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-bezel::before {
|
||||||
|
/* scanlines overlay */
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0,
|
||||||
|
transparent 2px,
|
||||||
|
rgba(0, 0, 0, 0.18) 2px,
|
||||||
|
rgba(0, 0, 0, 0.18) 3px
|
||||||
|
);
|
||||||
|
opacity: 0.6;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-theme-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-theme-toggle button {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--m-dim);
|
||||||
|
border: 1px solid var(--m-dim);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-theme-toggle button[aria-pressed='true'] {
|
||||||
|
color: var(--m-fg);
|
||||||
|
border-color: var(--m-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-transcript {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-transcript .system {
|
||||||
|
color: var(--m-accent-1);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-transcript .player {
|
||||||
|
color: var(--m-accent-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-transcript .player::before { content: '> '; }
|
||||||
|
|
||||||
|
.mystery-transcript .narration {
|
||||||
|
margin: 0.25em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-input-row::before {
|
||||||
|
content: '>';
|
||||||
|
color: var(--m-accent-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--m-fg);
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font: inherit;
|
||||||
|
text-shadow: inherit;
|
||||||
|
caret-color: var(--m-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-chips {
|
||||||
|
display: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 0 4px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
border-top: 1px var(--m-divider-style) var(--m-dim);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-chip {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--m-fg);
|
||||||
|
border: 1px solid var(--m-fg);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-chip[disabled] {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.mystery-chips { display: flex; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { parse } from '../engine/parser'
|
||||||
|
import type { ParserContext } from '../engine/parser'
|
||||||
|
import { dispatch, initialStateFor } from '../engine/dispatcher'
|
||||||
|
import { saveState, loadState, clearSave } from '../engine/save'
|
||||||
|
import { world } from '../world'
|
||||||
|
import type { GameState, TranscriptLine } from '../engine/types'
|
||||||
|
import { computeChips } from './chips'
|
||||||
|
import { renderChips } from './chip-render'
|
||||||
|
|
||||||
|
const transcriptEl = document.querySelector<HTMLDivElement>('[data-mystery-transcript]')
|
||||||
|
const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
|
||||||
|
|
||||||
|
if (!transcriptEl || !inputEl) {
|
||||||
|
console.error('[halfstreet] terminal mount points missing')
|
||||||
|
} else {
|
||||||
|
const restored = loadState()
|
||||||
|
let state: GameState = restored ?? initialStateFor(world)
|
||||||
|
let lastState: GameState | null = null // for one-step undo
|
||||||
|
|
||||||
|
if (!restored) {
|
||||||
|
// Fresh state already includes the opening narration in its transcript.
|
||||||
|
} else if (restored.transcript.length === 0) {
|
||||||
|
// Edge case: a restored state with no transcript (older save discarded
|
||||||
|
// and we fell back to fresh — handled above — or a corrupted slice).
|
||||||
|
state = initialStateFor(world)
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshChips(): void {
|
||||||
|
renderChips(computeChips(state, world), (command) => {
|
||||||
|
inputEl!.value = command
|
||||||
|
inputEl!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildParserContext = (s: GameState): ParserContext => {
|
||||||
|
const room = world.rooms[s.location]
|
||||||
|
const visibleNouns: { id: string; aliases: string[] }[] = []
|
||||||
|
if (room) {
|
||||||
|
for (const id of room.items) {
|
||||||
|
const it = world.items[id]
|
||||||
|
if (it) visibleNouns.push({ id, aliases: it.names })
|
||||||
|
}
|
||||||
|
if (room.encounter && s.encounterState[room.encounter]) {
|
||||||
|
visibleNouns.push({ id: room.encounter, aliases: [room.encounter] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const inst of s.inventory) {
|
||||||
|
const it = world.items[inst.id]
|
||||||
|
if (it) visibleNouns.push({ id: inst.id, aliases: it.names })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
knownItems: Object.keys(world.items),
|
||||||
|
knownEncounters: Object.keys(world.encounters),
|
||||||
|
visibleNouns,
|
||||||
|
inventoryItemIds: s.inventory.map((i) => i.id),
|
||||||
|
lastNoun: s.lastNoun,
|
||||||
|
awaitingDisambiguation: s.pendingDisambiguation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderAll = (lines: TranscriptLine[]): void => {
|
||||||
|
if (!transcriptEl) return
|
||||||
|
for (const line of lines) {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.className = line.kind
|
||||||
|
el.textContent = line.text
|
||||||
|
transcriptEl.appendChild(el)
|
||||||
|
}
|
||||||
|
transcriptEl.scrollTop = transcriptEl.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendLines = (lines: TranscriptLine[]): void => {
|
||||||
|
renderAll(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAll(state.transcript)
|
||||||
|
refreshChips()
|
||||||
|
inputEl.focus()
|
||||||
|
|
||||||
|
inputEl.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key !== 'Enter') return
|
||||||
|
e.preventDefault()
|
||||||
|
const raw = inputEl.value
|
||||||
|
inputEl.value = ''
|
||||||
|
if (!raw.trim()) return
|
||||||
|
appendLines([{ kind: 'player', text: raw }])
|
||||||
|
|
||||||
|
// Engine-level meta-commands handled here so the engine stays pure.
|
||||||
|
const trimmed = raw.trim().toLowerCase()
|
||||||
|
if (trimmed === 'restart') {
|
||||||
|
const confirmed = confirm('Restart? Your progress will be lost.')
|
||||||
|
if (!confirmed) {
|
||||||
|
appendLines([{ kind: 'system', text: '(restart cancelled)' }])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearSave()
|
||||||
|
state = initialStateFor(world)
|
||||||
|
transcriptEl.innerHTML = ''
|
||||||
|
renderAll(state.transcript)
|
||||||
|
saveState(state)
|
||||||
|
refreshChips()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (trimmed === 'undo') {
|
||||||
|
if (lastState) {
|
||||||
|
state = lastState
|
||||||
|
lastState = null
|
||||||
|
appendLines([{ kind: 'system', text: '(undone)' }])
|
||||||
|
saveState(state)
|
||||||
|
refreshChips()
|
||||||
|
} else {
|
||||||
|
appendLines([{ kind: 'system', text: 'There is no further back.' }])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (trimmed === 'quit') {
|
||||||
|
saveState(state)
|
||||||
|
window.location.href = '/'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine dispatch — wrapped so a thrown error doesn't kill the terminal.
|
||||||
|
try {
|
||||||
|
const ctx = buildParserContext(state)
|
||||||
|
const command = parse(raw, ctx)
|
||||||
|
lastState = state
|
||||||
|
const result = dispatch(state, command, world)
|
||||||
|
state = result.state
|
||||||
|
appendLines(result.appended)
|
||||||
|
saveState(state)
|
||||||
|
transcriptEl.scrollTop = transcriptEl.scrollHeight
|
||||||
|
if (raw.trim().toLowerCase() === 'theme') {
|
||||||
|
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
|
||||||
|
}
|
||||||
|
refreshChips()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[halfstreet] dispatch error', err)
|
||||||
|
appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
saveState(state)
|
||||||
|
window.location.href = '/'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
const STORAGE_KEY = 'halfstreet:theme:v1'
|
||||||
|
|
||||||
|
type Theme = 'amber' | 'ansi'
|
||||||
|
|
||||||
|
function getStored(): Theme {
|
||||||
|
try {
|
||||||
|
return (localStorage.getItem(STORAGE_KEY) as Theme | null) === 'ansi' ? 'ansi' : 'amber'
|
||||||
|
} catch {
|
||||||
|
return 'amber'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTheme(theme: Theme): void {
|
||||||
|
document.documentElement.setAttribute('data-mystery-theme', theme)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, theme)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
for (const btn of document.querySelectorAll<HTMLButtonElement>('[data-theme-choice]')) {
|
||||||
|
btn.setAttribute('aria-pressed', btn.dataset['themeChoice'] === theme ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = getStored()
|
||||||
|
setTheme(initial)
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('[data-theme-choice]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const next = (btn.dataset['themeChoice'] as Theme | undefined) ?? 'amber'
|
||||||
|
setTheme(next)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Allow the engine's `theme` meta-command (handled in terminal.ts) to flip
|
||||||
|
// without going through the button by listening for a custom event.
|
||||||
|
document.addEventListener('halfstreet-toggle-theme', () => {
|
||||||
|
const current = (document.documentElement.getAttribute('data-mystery-theme') as Theme | null) ?? 'amber'
|
||||||
|
setTheme(current === 'amber' ? 'ansi' : 'amber')
|
||||||
|
})
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { EncounterDef } from './types'
|
||||||
|
|
||||||
|
export const encounters: Record<string, EncounterDef> = {
|
||||||
|
rat: {
|
||||||
|
id: 'rat',
|
||||||
|
startsIn: 'cellar-stair',
|
||||||
|
initialPhase: 'lurking',
|
||||||
|
phases: {
|
||||||
|
lurking: {
|
||||||
|
description: 'A heavy rat watches you from the third step. Its eyes catch the light.',
|
||||||
|
transitions: [
|
||||||
|
{
|
||||||
|
verb: 'attack',
|
||||||
|
target: 'rat',
|
||||||
|
narration: 'You stamp. The rat squeals and is gone into the dark.',
|
||||||
|
to: 'resolved',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
verb: 'wait',
|
||||||
|
narration: 'The rat does not move. Neither do you.',
|
||||||
|
to: 'lurking',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onResolved: { setFlags: { ratGone: true } },
|
||||||
|
defaultWrongVerbNarration: 'The rat watches.',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { World } from './types'
|
||||||
|
import { rooms } from './rooms'
|
||||||
|
import { items } from './items'
|
||||||
|
import { encounters } from './encounters'
|
||||||
|
import { endings } from './story'
|
||||||
|
|
||||||
|
export const world: World = {
|
||||||
|
startingRoom: 'foyer',
|
||||||
|
startingInventory: ['matches'],
|
||||||
|
rooms,
|
||||||
|
items,
|
||||||
|
encounters,
|
||||||
|
endings,
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Item } from './types'
|
||||||
|
|
||||||
|
export const items: Record<string, Item> = {
|
||||||
|
matches: {
|
||||||
|
id: 'matches',
|
||||||
|
names: ['matches', 'safety matches', 'box'],
|
||||||
|
short: 'a box of safety matches',
|
||||||
|
long: 'A small cardboard box of safety matches. Half-full.',
|
||||||
|
initialState: {},
|
||||||
|
takeable: true,
|
||||||
|
},
|
||||||
|
lamp: {
|
||||||
|
id: 'lamp',
|
||||||
|
names: ['lamp', 'oil lamp', 'torch'],
|
||||||
|
short: 'an oil lamp',
|
||||||
|
long: 'An iron oil lamp with a glass chimney. Currently unlit.',
|
||||||
|
initialState: { lit: false },
|
||||||
|
takeable: true,
|
||||||
|
},
|
||||||
|
letter: {
|
||||||
|
id: 'letter',
|
||||||
|
names: ['letter', 'folded letter', 'paper'],
|
||||||
|
short: 'a folded letter',
|
||||||
|
long: 'A folded letter on yellowed paper. The hand is unfamiliar. It reads: "Come at once. The thing in the cellar is waking."',
|
||||||
|
initialState: {},
|
||||||
|
takeable: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Room } from './types'
|
||||||
|
|
||||||
|
export const rooms: Record<string, Room> = {
|
||||||
|
foyer: {
|
||||||
|
id: 'foyer',
|
||||||
|
title: '[ Foyer ]',
|
||||||
|
descriptions: {
|
||||||
|
firstVisit:
|
||||||
|
'You stand in the foyer of a house you do not remember entering. The door behind you has closed without sound. A folded letter lies on a small table. A hallway leads north.',
|
||||||
|
revisit: 'The foyer. The door behind you is still closed.',
|
||||||
|
examined:
|
||||||
|
'A foyer with peeling paper. A small table holds nothing but the letter. The air smells of cold stone. A hallway leads north.',
|
||||||
|
},
|
||||||
|
exits: { n: 'hallway' },
|
||||||
|
items: ['letter'],
|
||||||
|
safe: true,
|
||||||
|
},
|
||||||
|
hallway: {
|
||||||
|
id: 'hallway',
|
||||||
|
title: '[ Hallway ]',
|
||||||
|
descriptions: {
|
||||||
|
firstVisit:
|
||||||
|
'A long hallway, lit by nothing. An iron oil lamp sits on a side table. The foyer is south. A stair descends east.',
|
||||||
|
revisit: 'The long hallway.',
|
||||||
|
examined:
|
||||||
|
'The hallway runs further than the house should be wide. The dust on the floor is undisturbed except where you have walked. The oil lamp is on the side table.',
|
||||||
|
},
|
||||||
|
exits: { s: 'foyer', e: 'cellar-stair' },
|
||||||
|
items: ['lamp'],
|
||||||
|
},
|
||||||
|
'cellar-stair': {
|
||||||
|
id: 'cellar-stair',
|
||||||
|
title: '[ Cellar Stair ]',
|
||||||
|
descriptions: {
|
||||||
|
firstVisit:
|
||||||
|
'The stair drops into wet stone. The hallway is west. Something at the bottom is breathing.',
|
||||||
|
revisit: 'The stair to the cellar.',
|
||||||
|
examined: 'The stairs are stone, slick with damp. You can hear water below, and something else.',
|
||||||
|
},
|
||||||
|
exits: { w: 'hallway' },
|
||||||
|
items: [],
|
||||||
|
encounter: 'rat',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { World } from './types'
|
||||||
|
|
||||||
|
export const endings: World['endings'] = {
|
||||||
|
true: {
|
||||||
|
whenFlags: { ratGone: true },
|
||||||
|
narration:
|
||||||
|
'You stand at the top of the stair. The thing below has settled. The door behind you opens, and outside, finally, is morning.',
|
||||||
|
},
|
||||||
|
wrong: {
|
||||||
|
whenFlags: {},
|
||||||
|
narration: '', // unreachable in sample world
|
||||||
|
},
|
||||||
|
bad: {
|
||||||
|
whenFlags: {},
|
||||||
|
narration: '', // unreachable in sample world
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// World data type definitions. World modules export plain data conforming to
|
||||||
|
// these shapes; the engine reads but never mutates them.
|
||||||
|
|
||||||
|
import type { RoomId, ItemId, EncounterId, Direction, Verb, EncounterPhase } from '../engine/types'
|
||||||
|
|
||||||
|
export interface RoomDescriptions {
|
||||||
|
/** Shown the first time the player enters this room. */
|
||||||
|
firstVisit: string
|
||||||
|
/** Shown on subsequent entries. */
|
||||||
|
revisit: string
|
||||||
|
/** Shown when the player types `look` (richer detail). */
|
||||||
|
examined: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Room {
|
||||||
|
id: RoomId
|
||||||
|
title: string // e.g. "[ Foyer ]"
|
||||||
|
descriptions: RoomDescriptions
|
||||||
|
/** Direction → destination room id. Locked exits are listed in `lockedExits`. */
|
||||||
|
exits: Partial<Record<Direction, RoomId>>
|
||||||
|
/** Direction → unlock condition (item id or flag name). */
|
||||||
|
lockedExits?: Partial<Record<Direction, { requires: ItemId | string; lockedNarration: string }>>
|
||||||
|
/** Items that start in this room. Items the player drops are tracked in roomState. */
|
||||||
|
items: ItemId[]
|
||||||
|
/** Encounter that triggers when this room is entered, or null. */
|
||||||
|
encounter?: EncounterId
|
||||||
|
/** Optional "safe" flag: entering a safe room regenerates one resolve step. */
|
||||||
|
safe?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Item {
|
||||||
|
id: ItemId
|
||||||
|
/** Canonical name and any aliases for the parser. */
|
||||||
|
names: string[]
|
||||||
|
/** Short description shown in inventory. */
|
||||||
|
short: string
|
||||||
|
/** Long description shown when examined. */
|
||||||
|
long: string
|
||||||
|
/** Initial per-instance state (e.g. `{ lit: false }`). */
|
||||||
|
initialState: Record<string, string | boolean | number>
|
||||||
|
/** True if the player can pick it up. */
|
||||||
|
takeable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncounterPhaseDef {
|
||||||
|
/** Description shown each turn while in this phase. */
|
||||||
|
description: string
|
||||||
|
transitions: EncounterTransition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncounterTransition {
|
||||||
|
verb: Verb
|
||||||
|
/** Required target noun id, or '*' for any target, or undefined for verb-only. */
|
||||||
|
target?: ItemId | EncounterId | '*'
|
||||||
|
/** Required item id in inventory (and optional state predicate). */
|
||||||
|
requires?: { item: ItemId; state?: Record<string, string | boolean | number> }
|
||||||
|
/** Phase to transition to, or 'resolved' / 'failed'. */
|
||||||
|
to: EncounterPhase | 'resolved' | 'failed'
|
||||||
|
/** Narration on this transition. */
|
||||||
|
narration: string
|
||||||
|
/** Resolve cost for the player on this transition (0–2). */
|
||||||
|
resolveCost?: 0 | 1 | 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncounterDef {
|
||||||
|
id: EncounterId
|
||||||
|
startsIn: RoomId
|
||||||
|
initialPhase: EncounterPhase
|
||||||
|
phases: Record<EncounterPhase, EncounterPhaseDef>
|
||||||
|
/** Effects on resolution (set flags, unlock exits). */
|
||||||
|
onResolved?: { setFlags?: Record<string, string | boolean | number>; unlockExits?: { room: RoomId; direction: Direction }[] }
|
||||||
|
/** What happens at 'failed' (e.g. retreat to previous safe room). */
|
||||||
|
onFailed?: { narration: string; retreatTo: RoomId }
|
||||||
|
/** Default narration for wrong-verb attempts not matching any transition. */
|
||||||
|
defaultWrongVerbNarration?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface World {
|
||||||
|
startingRoom: RoomId
|
||||||
|
startingInventory: ItemId[]
|
||||||
|
rooms: Record<RoomId, Room>
|
||||||
|
items: Record<ItemId, Item>
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user