Consolidate engine docs and naming
This commit is contained in:
@@ -9,4 +9,5 @@ PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Game Configuration
|
||||
DEFAULT_GAME_ENGINE=ink
|
||||
DEFAULT_WORLD_FILE=./data/worlds/example_world.yml
|
||||
|
||||
-511
@@ -1,511 +0,0 @@
|
||||
# Client Specification And Progress Report
|
||||
|
||||
This file is the single living technical specification, implementation checklist, and progress report for the web client. Usage instructions and changelog live in `README.md`.
|
||||
|
||||
## Product Goal
|
||||
|
||||
Build an AI-assisted interactive fiction client that feels like a carefully typeset illustrated novel rather than a chat window. The game server owns game state and narrative generation. The client renders incoming narrative as synchronized animated prose with optional speech, sound effects, music, image blocks, and persistent player options.
|
||||
|
||||
The production client must tolerate TTS being unavailable. The safe default TTS provider is `none`; a game, user preference, or explicit option can select another provider.
|
||||
|
||||
## Current Status
|
||||
|
||||
- Done: native ES module loader with dependency graph, module states, progress overlay, cache-busted development loading, and ordered async initialization.
|
||||
- Done: responsive book layout that scales page, font sizes, and word positions relative to page size.
|
||||
- Done: story parser/protocol bridge for Ink-style `#` tags, chapters, text blocks, Markdown emphasis, image blocks, sound effect cues, and music cues.
|
||||
- Done: SmartyPants punctuation, language-aware Hyphenopoly integration, and Knuth-Plass paragraph line breaking.
|
||||
- Done: paragraph rules for normal paragraphs, chapter-first paragraphs, textblock-first paragraphs, drop caps, and first-line indentation.
|
||||
- Done: sentence queue and playback coordinator for preparing text and TTS before synchronized playback.
|
||||
- Done: TTS providers for none, browser speech synthesis, Kokoro, ElevenLabs, and OpenAI, with status reporting in options.
|
||||
- Done: TTS cache keyed by request parameters rather than text alone.
|
||||
- Done: persisted speech enable state, provider, voice, speed, language, and volume preferences.
|
||||
- Done: top-bar and options controls for speech and speed synchronization after recent fixes.
|
||||
- Done: command input focus behavior and global typing redirection into the command input while a game is running.
|
||||
- Done: fast-forward by page click or space, including animation completion and TTS fade/stop.
|
||||
- Done: mouse cursor state reporting by process state.
|
||||
- Done: placeholder game API for new/load/save/running state.
|
||||
- Done: sound effect and music folders, sound effect playback, music playback, and music ducking during TTS.
|
||||
- Done: image markup is parsed, persisted in history, restored from save/history, and rendered as line-snapped page blocks.
|
||||
- Done: Ink engine integration with source compilation, engine config, metadata handoff, choice-mode turns, one-list choice UI, and keyboard choice letters.
|
||||
- Done: localized UI strings, game metadata language handoff for typography/hyphenation/TTS language, and German dialogue guillemet normalization.
|
||||
- Done: localized popup queue for intended endings, unrecoverable errors, achievements, and tutorial/player alerts.
|
||||
- Done: credits dialog and third-party license display.
|
||||
- Partial: save-game API restores story state and Ink state, but the broader save/storage model still needs hardening for all engines.
|
||||
- Pending: deeper automated tests for layout, playback timing, TTS provider switching, and media cue timing.
|
||||
|
||||
## Module System Specification
|
||||
|
||||
The client uses native browser ES modules. No bundler is required for the web client modules in `public/js/`.
|
||||
|
||||
Required module rules:
|
||||
|
||||
- Every app module extends `BaseModule`.
|
||||
- Every app module registers with `moduleRegistry`.
|
||||
- Every app module declares all required dependencies in its `dependencies` list.
|
||||
- The loader loads module scripts, resolves the dependency graph, initializes modules in dependency order, awaits async initialization, and only then hides the loading overlay.
|
||||
- Modules must rely on the loader for dependency readiness. Do not add fallback paths for missing dependencies inside modules.
|
||||
- Do not add fallback code that bypasses an authoritative module, service, parser, state store, or API to hide an architectural problem. If such a fallback already exists or seems tempting, stop and report the architectural mismatch before changing code.
|
||||
- Module states are `PENDING`, `LOADING`, `WAITING`, `INITIALIZING`, `FINISHED`, and `ERROR`.
|
||||
- Modules must report real state transitions. A module must not report `FINISHED` until its critical initialization is actually ready.
|
||||
- `setTimeout` must not be used to paper over dependency or async ordering bugs. It is acceptable inside isolated scheduling systems such as animation timing, debounce, throttle, or browser rendering workarounds when documented by context.
|
||||
|
||||
Core loader components:
|
||||
|
||||
- `loader.js`: dynamic import orchestration, dependency order, loading UI, cache-busted module URLs in development.
|
||||
- `module-registry.js`: module registration, dependency metadata, readiness promises.
|
||||
- `base-module.js`: shared lifecycle, state changes, event listener tracking, progress reporting.
|
||||
|
||||
The loader is deliberately the conductor, not the orchestra. Module-specific configuration, resource loading, and progress detail belong inside the module that owns the work.
|
||||
|
||||
## Current Module Responsibilities
|
||||
|
||||
- `markup-parser-module.js`: converts story text into text blocks, inline styled spans, image blocks, sound cues, and music cues.
|
||||
- `text-processor-module.js`: applies SmartyPants and Hyphenopoly according to active language.
|
||||
- `paragraph-layout-module.js`: measures text and computes Knuth-Plass layout.
|
||||
- `layout-renderer-module.js`: turns line-coordinate layout data into absolutely positioned page DOM with stable word positions and animation metadata.
|
||||
- `sentence-queue-module.js`: prepares speech/media readiness. It must not own page layout, image wrapping, or history rendering state.
|
||||
- `playback-coordinator-module.js`: starts synchronized text/audio playback in the right order.
|
||||
- `animation-queue-module.js`: schedules and fast-forwards visual text animation.
|
||||
- `audio-manager-module.js`: owns sound effects, music tracks, music ducking, volume application, and speech audio playback helpers.
|
||||
- `tts-factory-module.js`: selects provider, applies preferences, generates/preloads speech, caches speech data, and exposes unified TTS operations.
|
||||
- `tts-handler-module.js`: common TTS handler base.
|
||||
- `browser-tts-module.js`: Web Speech API provider.
|
||||
- `kokoro-tts-module.js`: Kokoro provider and loading bridge.
|
||||
- `elevenlabs-tts-module.js`: ElevenLabs provider.
|
||||
- `openai-tts-module.js`: OpenAI speech provider with fixed supported voices.
|
||||
- `persistence-manager-module.js`: browser preferences and durable client state.
|
||||
- `localization-module.js`: language state used by UI, hyphenation, and TTS selection.
|
||||
- `options-ui-module.js`: options modal, persisted controls, provider status displays.
|
||||
- `ui-controller-module.js`: top-bar commands, global input behavior, game API control wiring.
|
||||
- `ui-display-handler-module.js`: book page display, startup prompt, unified live/history rendering, line-coordinate scrolling, image placement, and media block dispatch.
|
||||
- `choice-display-module.js`: choice-mode rendering, keyboard-letter assignment, click/keyboard choice dispatch, and future choice-template hook.
|
||||
- `ui-input-handler-module.js`: command entry, history, fast-forward key handling.
|
||||
- `socket-client-module.js`: socket connection and game API request wrapper.
|
||||
- `game-loop-module.js`: high-level client/game flow.
|
||||
|
||||
## Text Layout Specification
|
||||
|
||||
The right page must look like typeset book text:
|
||||
|
||||
- Paragraphs are laid out by paragraph, not as one continuous text run.
|
||||
- Normal following paragraphs have a first-line indent.
|
||||
- There is no blank line between ordinary paragraphs.
|
||||
- A chapter marker creates a centered italic heading and makes the first following paragraph special.
|
||||
- A textblock/section marker creates one line of vertical separation and makes the first following paragraph special.
|
||||
- Special first paragraphs after chapter or textblock markers have no horizontal first-line indent.
|
||||
- Chapter-first paragraphs use a drop cap aligned to an exact multiple of the body line height. Current target is a two-line drop cap unless visual testing justifies three lines.
|
||||
- Lines are justified so line starts and line ends touch the intended measure. Final lines are not force-justified.
|
||||
- Hyphenation must be real language-aware hyphenation from Hyphenopoly, not a fallback-only emergency split.
|
||||
- Line breaking uses the Knuth-Plass algorithm over the full paragraph.
|
||||
- Punctuation and short marks should not visually break the measure; optical margin handling is desirable future polish.
|
||||
- The page must scale as a fixed-aspect book page. Font sizes and word positions scale with page size, preserving the composition when the window is resized.
|
||||
|
||||
## Right Page History And Scrolling Specification
|
||||
|
||||
The right page uses one virtual, line-addressed content pane. It must not behave like browser pagination and must not rely on native scrolling inside `#page_right`, `#story`, or story blocks.
|
||||
|
||||
Line model invariants:
|
||||
|
||||
- `#page_right` has a size relative to the browser window.
|
||||
- There is exactly one story line-height value.
|
||||
- The page height is divided into a fixed number of lines; currently `PAGE_LINE_COUNT = 25`.
|
||||
- `lineHeight = pageRightHeight / PAGE_LINE_COUNT`.
|
||||
- All rendered content has a height that is an exact multiple of line height, including margins, internal spacing, drop cap space, image vertical spacing, and section/chapter spacing.
|
||||
- All virtual content coordinates and pixel positions are derived mathematically from line coordinates.
|
||||
- Stored content does not change line numbers after creation.
|
||||
- Visible content is never inserted between already existing blocks; new live content is appended at the end of the virtual history.
|
||||
- Therefore cumulative pixel measurements from the browser DOM are not authoritative and cumulative line starts should not need updating after a block has been assigned coordinates.
|
||||
- In portrait-image cases, text and image blocks may occupy overlapping cumulative line ranges, but every block edge still lands on a line boundary.
|
||||
|
||||
Scroll positioning:
|
||||
|
||||
- Scrolling means translating the content pane vertically with an ease-in/ease-out animation.
|
||||
- Every finished scroll position must snap to the nearest position where page edges align with line edges.
|
||||
- Scrolling to the top means the top edge of the first line of the first block aligns with the top edge of the page.
|
||||
- Scrolling to the bottom means the bottom edge of the last line of the last rendered block aligns with the bottom edge of the page.
|
||||
- Scrolling to the bottom to insert new content uses the same bottom rule, but the new block is first added invisibly to block history, advancing the block counter and line history. The page scrolls to the resulting bottom position, then the block reveal animation starts.
|
||||
- If playback continues while the user is viewing older history, the view must first return to the live bottom insertion position before revealing new content.
|
||||
- If manual scrolling moves currently animating content out of focus, active text animation and TTS playback must be fast-forwarded through the same path used by page click/space, including TTS fade/stop.
|
||||
|
||||
Active line and active block model:
|
||||
|
||||
- The 41-block retention target is not pagination.
|
||||
- There is one active line representing the current view position.
|
||||
- If enough content exists above it, the active line is considered to be the last visible line of the page, line 25.
|
||||
- The block containing that active line is the active block.
|
||||
- The DOM should normally contain 20 blocks before the active block, the active block itself, and 20 blocks after the active block, when those blocks exist.
|
||||
- When normal scrolling shifts the active line into a different block, load one block in the direction of travel and unload one block from the opposite side.
|
||||
- This one-block exchange should happen as soon as the active block changes, not after the viewport reaches a DOM edge.
|
||||
- Mouse wheel, arrow key, and scrollbar interactions must drive this active-line model rather than loading page-sized chunks.
|
||||
|
||||
Random-position and scrollbar jumps:
|
||||
|
||||
- Scrolling to a random target first identifies the target line and target block.
|
||||
- If the target is reached by traversal, the one-block exchange model applies.
|
||||
- If the target is jumped to, the page first loads:
|
||||
- 20 blocks before the current/starting active block, as available,
|
||||
- the current/starting active block,
|
||||
- all blocks between the starting block and the target block,
|
||||
- the target block,
|
||||
- 20 blocks after the target block, as available.
|
||||
- The whole loaded range can then be traversed smoothly from the starting position to the target position.
|
||||
- The final target aligns so the bottom edge of the requested line aligns with the bottom edge of the page when enough content exists above it; otherwise it uses the top rule.
|
||||
- After the scroll finishes, blocks farther than the retained margin are unloaded.
|
||||
- If the required loaded range would exceed a sensible DOM budget, currently 150 blocks total, all visible page content fades out, the old DOM content is unloaded, the target block plus 20 blocks before and after it are loaded, and the page fades in at the target position.
|
||||
|
||||
Scrollbar behavior:
|
||||
|
||||
- The custom scrollbar represents virtual history position and history size in line coordinates, not native DOM scroll state.
|
||||
- Dragging the scrollbar thumb should move the thumb preview freely without scrolling content or loading history during the drag.
|
||||
- On pointer release, the target line/block is resolved, the required block range is loaded according to the random-position rules, and then the content scroll animation runs.
|
||||
- Scrollbar pointer events must not bubble into story fast-forward/continue handlers.
|
||||
|
||||
Processing order:
|
||||
|
||||
1. Parse block and inline story markup.
|
||||
2. Remove media markers from display and TTS text while keeping cue positions.
|
||||
3. Convert Markdown emphasis to inline style spans.
|
||||
4. Apply SmartyPants typographic punctuation.
|
||||
5. Apply Hyphenopoly for the active language.
|
||||
6. Measure words/spans.
|
||||
7. Run Knuth-Plass line breaking.
|
||||
8. Render stable positioned spans.
|
||||
9. Animate spans in sync with audio duration or estimated duration.
|
||||
|
||||
## Prototype Lessons To Preserve
|
||||
|
||||
The prototype in `prototype/` and the former `PROTOTYPE_ANALYSIS.md` proved the target text pipeline. Any future layout refactor should preserve these details:
|
||||
|
||||
- Hyphenopoly must emit pipe markers for hyphenation points through the `.hyphenatePipe` configuration.
|
||||
- The layout measurement function must treat the pipe marker as zero width.
|
||||
- `knuth-and-plass.js` must split text on spaces, punctuation separators, HTML tags, and pipe markers.
|
||||
- Pipe markers become penalty nodes, not visible text.
|
||||
- A visible hyphen is emitted only when the chosen line break uses a hyphenation penalty.
|
||||
- Text measurement should use the same real CSS font, size, and style that the book page uses.
|
||||
- Justification is applied through the Knuth-Plass break ratio by stretching or shrinking glue nodes.
|
||||
- Hyphenated syllables after a penalty must be rendered as one visual word group, preserving the prototype's no-overlap behavior.
|
||||
- The prototype used percentage-based positions so rendered text stayed proportional when the page scaled.
|
||||
|
||||
Known inherited implementation note: the old `linked-list.js` analysis identified `get last()` returning `this.last` instead of `this.tail`, which can recurse if used. The current implementation should be checked and corrected before future line-breaking refactors rely on `last`.
|
||||
|
||||
## Story Markup Specification
|
||||
|
||||
Canonical structural and media tags use Ink-style `#` syntax:
|
||||
|
||||
- Ink engines write native Ink tags such as `# chapter[Title]`, `# image[file.png](landscape)`, or `# music[track.ogg](crossfade loop)`.
|
||||
- inkjs exposes those tags without the leading `#`; the server parses them into `StoryTag` objects.
|
||||
- YAML and Zork narrative output use the same leading `#...` syntax, parsed by the server into `StoryTag` objects before the client sees them.
|
||||
- The browser protocol is structured `TurnResult` objects with structured tags and render blocks, not raw story markup.
|
||||
|
||||
Tag syntax:
|
||||
|
||||
```text
|
||||
#key
|
||||
#key[value]
|
||||
#key[value](options)
|
||||
```
|
||||
|
||||
The current parser accepts the bracket/parentheses form above and colon value tags such as `#key:x` or `#action:movement`.
|
||||
|
||||
Markdown emphasis:
|
||||
|
||||
```text
|
||||
*italic* or _italic_
|
||||
**bold** or __bold__
|
||||
***bold italic*** or ___bold italic___
|
||||
```
|
||||
|
||||
Right-page glossary notes:
|
||||
|
||||
```text
|
||||
The train stops at Eibenreith.
|
||||
#gloss[Eibenreith](A fictional alpine town in the Kaiserpunk setting.)
|
||||
```
|
||||
|
||||
The tag is scoped to the right-page paragraph/block it belongs to. The bracket value is the visible term; the parenthesized value is the note. The renderer marks every matching instance of that term in the same block. The tag is not displayed, not sent to TTS, and ignored by choices and command history. Escape literal Ink control characters in explanations as needed (`\|`, `\{`, `\}`).
|
||||
|
||||
Chapter:
|
||||
|
||||
```text
|
||||
#chapter[The Mysterious Mansion]
|
||||
|
||||
The first paragraph has a drop cap and no first-line indent.
|
||||
```
|
||||
|
||||
The heading is centered, italic, and uses the body font size. Following ordinary paragraphs return to normal first-line indentation.
|
||||
|
||||
Section or text block:
|
||||
|
||||
```text
|
||||
#section
|
||||
|
||||
The first paragraph is vertically separated from previous content and has no first-line indent.
|
||||
```
|
||||
|
||||
`#textblock` is an alias. Following ordinary paragraphs return to normal indentation.
|
||||
|
||||
Images:
|
||||
|
||||
```text
|
||||
#image[file-name.jpg](landscape)
|
||||
#image[file-name.jpg](portrait pause=2)
|
||||
#image[file-name.jpg](square delay=1.5)
|
||||
```
|
||||
|
||||
File names resolve relative to `public/images/`. `widescreen` is still accepted as an alias for `landscape`. Landscape and square images are centered and rendered near full page width with heights snapped to whole text lines. Portrait images float at half page width and following prose is narrowed for the number of lines the image covers. Image pauses accept the same timing style as music (`pause=2`, `delay=2`, `lead=2`, or `2s`); pauses are skippable with click/space and do not prevent the next TTS item from being prepared in the background.
|
||||
|
||||
Sound effects:
|
||||
|
||||
```text
|
||||
#sfx[squeaky-door.ogg]
|
||||
The old door opens into the dark.
|
||||
```
|
||||
|
||||
File names resolve relative to `public/sounds/`. The server parses the tag into a `StoryTag`; the tag is not displayed and is not sent to TTS.
|
||||
|
||||
Music:
|
||||
|
||||
```text
|
||||
#music[track.ogg](crossfade, loop, lead=4)
|
||||
```
|
||||
|
||||
File names resolve relative to `public/music/`. Modes:
|
||||
|
||||
- `queue`: wait until the current track ends.
|
||||
- `crossfade`: fade from the current track into the new track.
|
||||
- `cut`: stop the current track and start the new one immediately.
|
||||
- `loop`: repeat the track.
|
||||
- `once`: do not repeat the track.
|
||||
- `lead=<seconds>`: for block music, let music play alone before the following text/TTS paragraph starts.
|
||||
|
||||
For chapter openings, authors can place `#music[file](..., lead=N)` after `#chapter[...]` and before the first prose paragraph. The heading is rendered/spoken first, then music starts and plays alone for the lead duration, then the dropcapped paragraph continues. Music and image pauses are playback gates only; they must not stop the queue from preparing upcoming TTS in the background.
|
||||
|
||||
Sound effect options:
|
||||
|
||||
```text
|
||||
#sfx[church-bells.ogg](max=8 fade fade-duration=2)
|
||||
#sfx[steam-whistle.ogg](fade-after=4 fade-duration=1.5)
|
||||
```
|
||||
|
||||
Supported SFX options are `max=`, `duration=`, `max-duration=`, `limit=`, `stop-after=`, `fade-after=`, bare seconds such as `4s`, `mode=fade`, `mode=stop`, `fade`, `stop`, `cut`, and `fade-duration=`/`fade-time=`.
|
||||
|
||||
Choice tags:
|
||||
|
||||
```ink
|
||||
* [Open the compartment door]
|
||||
# letter[o]
|
||||
# action[examine]
|
||||
```
|
||||
|
||||
`#letter[x]`, `# letter[x]`, or `#key:x` reserves keyboard letter `X` for that choice. Explicit letters are assigned first; remaining visible choices receive `1` through `0`, then `A` through `Z` in screen order, skipping reserved letters. The current UI supports up to 36 visible choices and renders them in one list. `#action[name]` or `#action:name` is stored as the choice category and reserved for later template routing.
|
||||
|
||||
Future choice-template metadata should keep the same bracket tag syntax if implemented:
|
||||
|
||||
```text
|
||||
#action[movement]
|
||||
#optional
|
||||
#gated[noble]
|
||||
#sort[last]
|
||||
```
|
||||
|
||||
The older standalone `MARKUP_GUIDELINES.md` draft proposed colon tags, digit keys, grouping, shuffling, and parser-style reserved shortcuts. The active implementation deliberately does not use that syntax yet. Any future expansion should first extend the shared tag parser and `TurnResult.choices` contract so Ink, YAML, and Z-code all still emit the same structured choice objects.
|
||||
|
||||
Game-state and popup tags:
|
||||
|
||||
```text
|
||||
#score[You reached the quiet ending.]
|
||||
#error[The story ended unexpectedly.]
|
||||
#achievement[First Steps]
|
||||
#alert[Try examining objects before using them.]
|
||||
```
|
||||
|
||||
`#score[...]` marks an intended ending and is surfaced as `gameState.endState.type = intended` when the turn ends. `#error[...]` marks an unrecoverable ending and is surfaced as `gameState.endState.type = error`. The Ink engine emits a synthetic `#error[...]` when it runs out of content without an explicit `#score[...]`/`#error[...]`. `#achievement[...]` and `#alert[...]` show queued localized popups while play continues.
|
||||
|
||||
## TTS And Playback Specification
|
||||
|
||||
The playback system must keep text animation and audio synchronized.
|
||||
|
||||
- Complete sentences enter a preparation queue.
|
||||
- TTS generation/preload starts as soon as possible, including while previous prepared sentences are playing.
|
||||
- Text layout and TTS generation can be prepared in parallel.
|
||||
- Playback of a sentence starts only when the required audio duration is known, or when TTS is disabled/unavailable and an estimated duration has been calculated.
|
||||
- With measured TTS audio, animation duration follows the measured audio length.
|
||||
- With TTS `none` or unavailable providers, duration is estimated from text length and speed.
|
||||
- The speed slider is persisted and has normal speed at the center.
|
||||
- Provider-specific speed ranges must be converted from the app-level speed value:
|
||||
- OpenAI speech speed uses `1.0` as normal and supports a bounded multiplier.
|
||||
- ElevenLabs speed must be clamped to its accepted range.
|
||||
- Browser speech synthesis uses utterance rate.
|
||||
- Kokoro uses its provider-supported speed option.
|
||||
- `none` uses the same app-level speed to scale estimated animation duration.
|
||||
- Switching provider, voice, language, or speed during gameplay should apply to the next not-yet-generated sentence.
|
||||
- Kokoro is special: loading is expensive, so it should be loaded on startup only when selected and may require reload to switch into it.
|
||||
- Fast-forward completes visible animation and fades/stops active TTS playback so the next sentence can start earlier.
|
||||
- Music ducks to 70% of its configured volume while TTS playback is active, then fades back when the TTS playback queue is empty.
|
||||
|
||||
TTS cache keys must include:
|
||||
|
||||
- provider
|
||||
- voice
|
||||
- provider speed value
|
||||
- language
|
||||
- exact input string after markup/media removal
|
||||
|
||||
When all cache parameters match, cached audio should be played instead of regenerated.
|
||||
|
||||
OpenAI voices for the current speech endpoint are:
|
||||
|
||||
- `nova`
|
||||
- `shimmer`
|
||||
- `echo`
|
||||
- `onyx`
|
||||
- `fable`
|
||||
- `alloy`
|
||||
- `ash`
|
||||
- `sage`
|
||||
- `coral`
|
||||
|
||||
## Cursor And Interaction States
|
||||
|
||||
The mouse cursor, not the text insertion caret, indicates process state. The command input caret keeps its normal text-editing appearance.
|
||||
|
||||
Required states:
|
||||
|
||||
- ready for new command
|
||||
- command sent, waiting for game server answer
|
||||
- waiting silently for required TTS generation before animation can continue
|
||||
- audio and animation playing while another sentence is being generated
|
||||
- audio and animation playing while no further generation is needed
|
||||
|
||||
State changes should also be logged to the console during development.
|
||||
|
||||
Typing behavior:
|
||||
|
||||
- While a game is running, printable keyboard input should go to the command input regardless of the clicked element.
|
||||
- Enter sends the command when input is non-empty.
|
||||
- Space still inserts a space in the input, but if playback is active it also fast-forwards current playback.
|
||||
- Clicking on the book while playback is active fast-forwards current playback.
|
||||
|
||||
## Game API Specification
|
||||
|
||||
The client/server game API supports:
|
||||
|
||||
```text
|
||||
newGame()
|
||||
loadGame(slot)
|
||||
saveGame(slot)
|
||||
hasSaveGame(slot)
|
||||
getSaveGames()
|
||||
isGameRunning()
|
||||
```
|
||||
|
||||
`slot` is a positive integer from `1` to the number of save slots exposed by the UI.
|
||||
|
||||
Current placeholder behavior:
|
||||
|
||||
- `newGame()` starts the demo game and emits the introduction/current room description.
|
||||
- `saveGame(slot)` records a placeholder save for the current socket session.
|
||||
- `hasSaveGame(slot)` checks that session-local placeholder save.
|
||||
- `getSaveGames()` returns the saved slot numbers for the session.
|
||||
- `loadGame(slot)` requires a placeholder save and then starts the demo game like `newGame()`.
|
||||
- Saves do not persist across reloads yet.
|
||||
- `isGameRunning()` returns true after a game starts and until the session ends.
|
||||
- Turn results use `inputMode: 'end'` when no more input is accepted. End turns may include `gameState.endState` and/or `#score[...]`/`#error[...]` tags to distinguish intended endings from unrecoverable errors.
|
||||
|
||||
UI requirements before a game starts:
|
||||
|
||||
- Command input is hidden.
|
||||
- Right page shows the startup prompt.
|
||||
- `new game` is enabled.
|
||||
- `load` is enabled only when `hasSaveGame(slot)` is true.
|
||||
- `save` is disabled.
|
||||
|
||||
UI requirements after a game starts:
|
||||
|
||||
- Command input is visible and focused.
|
||||
- `save` is enabled.
|
||||
- `load` reflects save availability.
|
||||
- `new game` remains enabled and restarts the game.
|
||||
|
||||
## Server And World Model
|
||||
|
||||
The TypeScript server serves the web client and owns socket communication. The CLI and web modes use `GameRunner` and a YAML world file.
|
||||
|
||||
Current development world:
|
||||
|
||||
- Default file: `./data/worlds/example_world.yml`
|
||||
- Server-side command handling is currently mirrored for UI testing: entered commands are sent to the server and returned as narrative text through the socket response path.
|
||||
|
||||
Longer-term goal:
|
||||
|
||||
- Keep a deterministic world model for rooms, objects, actions, state changes, and validation.
|
||||
- Use the LLM to translate natural language player intent into game actions.
|
||||
- Use the LLM to render state changes as prose without allowing hallucinated state.
|
||||
|
||||
## Quality Rules
|
||||
|
||||
- Preserve existing module boundaries when changing code.
|
||||
- Prefer event-driven and Promise-based coordination over timing hacks.
|
||||
- Do not use loader fallbacks to hide bad dependency declarations.
|
||||
- Keep the book page visually stable under resizing.
|
||||
- Validate typography visually after layout changes.
|
||||
- Validate TTS timing with real provider playback and with TTS `none`.
|
||||
- Treat the ad-blocker console error `onpage-dialog.preload.js:121 Uncaught ReferenceError: browser is not defined` as unrelated noise.
|
||||
|
||||
## Progress Checklist
|
||||
|
||||
### Completed
|
||||
|
||||
- [x] Split monolithic prototype concepts into focused client modules.
|
||||
- [x] Added loader, registry, base module, states, dependency declarations, and ordered initialization.
|
||||
- [x] Added development cache busting and no-cache static file serving.
|
||||
- [x] Added socket-backed game API wrapper.
|
||||
- [x] Added manual game start flow for browser audio policies.
|
||||
- [x] Added SmartyPants support.
|
||||
- [x] Added Hyphenopoly support.
|
||||
- [x] Added Knuth-Plass paragraph layout.
|
||||
- [x] Fixed overfull words, incorrect spacing, and hyphenation integration regressions from the prototype migration.
|
||||
- [x] Added chapter heading and dropcap markup.
|
||||
- [x] Added section/textblock markup.
|
||||
- [x] Added Markdown emphasis parsing.
|
||||
- [x] Added right-page `#gloss[term](note)` annotations with clean TTS/plain-text handling.
|
||||
- [x] Added image markup parsing, line-snapped rendering, and history/save restoration.
|
||||
- [x] Added sound effect markup and playback.
|
||||
- [x] Added music markup, playback modes, loop/once, and lead-in.
|
||||
- [x] Added music ducking during TTS.
|
||||
- [x] Added TTS `none` mode.
|
||||
- [x] Added OpenAI TTS support and restricted OpenAI voice list.
|
||||
- [x] Added ElevenLabs speed clamping.
|
||||
- [x] Added persisted speed and speech state.
|
||||
- [x] Added speech toggle synchronization between top bar and options.
|
||||
- [x] Added volume controls for speech, music, and sound effects.
|
||||
- [x] Added command input focus and global typing behavior.
|
||||
- [x] Added command mirroring through the server path for UI testing.
|
||||
- [x] Added fast-forward for animation and TTS fade/stop.
|
||||
- [x] Added Ink source compilation and Ink engine server.
|
||||
- [x] Added choice-mode UI and keyboard letter assignment from `#letter[x]` and `#key:x`.
|
||||
- [x] Added localized ending, error, achievement, and alert popups from tag-channel events.
|
||||
- [x] Added credits/license dialog.
|
||||
- [x] Added line-addressed history scrolling model and moved the NOTE.md scrolling specification here.
|
||||
|
||||
### In Progress
|
||||
|
||||
- [ ] Keep validating provider-specific speed conversion for all TTS providers against real API behavior.
|
||||
- [ ] Tighten automated checks around top-bar/options state initialization after reload.
|
||||
- [ ] Improve automated visual regression coverage for page scaling, dropcap line-height alignment, and paragraph indentation.
|
||||
- [ ] Improve automated audio tests for music ducking, sound effect timing, and fast-forward fadeout.
|
||||
- [ ] Polish custom scrollbar dragging so the thumb moves freely during drag and commits the scroll target only on release.
|
||||
|
||||
### Pending
|
||||
|
||||
- [x] Implement image rendering for `#image[file](landscape)`, `#image[file](portrait)`, and `#image[file](square)`.
|
||||
- [ ] Replace placeholder save implementation with durable save files or server-side save storage.
|
||||
- [ ] Replace command mirroring with the full LLM/world-model command loop when typography/audio testing no longer needs mirroring.
|
||||
- [ ] Add optical margin alignment or punctuation protrusion support for line endings.
|
||||
- [ ] Add more provider readiness tests and richer diagnostics in options.
|
||||
- [ ] Add unit tests for `SentenceQueue`, markup parsing, TTS cache key generation, and game API methods.
|
||||
- [ ] Add browser integration tests for module loading, new-game startup, command input behavior, and playback controls.
|
||||
|
||||
## Consolidation Notes
|
||||
|
||||
The durable content from the former root and `references/` documents has been merged here or into `README.md`. The old reference files should remain untouched until the user approves cleanup, but they are no longer intended as the source of truth.
|
||||
@@ -14,16 +14,19 @@ npm run build
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The web app starts on `http://localhost:3000` when launched through `src/index.ts` or `dist/index.js`. The lower-level server module defaults to `3001` when started directly. Set `PORT` to choose a port; the server will try the next few ports if the requested one is already in use.
|
||||
`npm run dev` and `npm run start` use `DEFAULT_GAME_ENGINE` from `.env` to choose the active engine. Supported values are `ink`, `yaml`, and `zcode`. The engine-specific scripts remain available when you want to bypass the default.
|
||||
|
||||
Set `PORT` to choose a port; the server will try the next few ports if the requested one is already in use. Current engine defaults are YAML `3001`, Z-code `3002`, and Ink `3003` before port fallback.
|
||||
|
||||
## Commands
|
||||
|
||||
```powershell
|
||||
npm run dev # Start the web UI through ts-node/nodemon
|
||||
npm run start # Build/run the configured default engine from dist/
|
||||
npm run dev:ink # Start the Ink engine server, watch ink source, compile on restart
|
||||
npm run dev:yaml # Start the YAML engine server
|
||||
npm run dev:zork # Start the Z-code/Zork engine server
|
||||
npm run start # Run the compiled web server from dist/
|
||||
npm run dev:zcode # Start the Z-code engine server
|
||||
npm run start:ink # Build and run the compiled Ink engine server
|
||||
npm run build # Compile TypeScript
|
||||
npm run test # Run Jest tests
|
||||
npm run lint # Run ESLint on src/
|
||||
@@ -31,13 +34,14 @@ npm run start:cli # Run the CLI interface
|
||||
npm run dev:cli # Run the CLI interface through ts-node/nodemon
|
||||
```
|
||||
|
||||
Each game engine also has an inspect command for debugger work: `npm run dev:ink:inspect`, `npm run dev:yaml:inspect`, and `npm run dev:zork:inspect`.
|
||||
Each game engine also has `:debug` and `:inspect` variants. `:debug` enables engine-specific diagnostic logging. `:inspect` starts Node with the inspector and currently also enables that engine's debug flag, so it is the combined debug-plus-inspector mode.
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables are loaded from `.env`.
|
||||
|
||||
- `PORT`: preferred web server port.
|
||||
- `DEFAULT_GAME_ENGINE`: engine used by `npm run dev` and `npm run start`; one of `ink`, `yaml`, or `zcode`.
|
||||
- `DEFAULT_WORLD_FILE`: YAML world file to load. Defaults to `./data/worlds/example_world.yml`.
|
||||
- `OPENROUTER_API_KEY`: API key for LLM command interpretation.
|
||||
- `OPENROUTER_MODEL`: OpenRouter model name.
|
||||
@@ -57,7 +61,7 @@ The placeholder server API supports:
|
||||
- `getSaveGames()`
|
||||
- `isGameRunning()`
|
||||
|
||||
Save slots are positive integers. In the current placeholder implementation, save availability is per socket session and is lost on reload. Loading a save starts the demo content like `newGame()`.
|
||||
Save slots are positive integers. Save behavior is engine-specific: the Ink client/server path persists Ink state, client history, choices, media state, and playback position for browser save/load; YAML and Z-code persistence still need regression testing and cleanup.
|
||||
|
||||
## Web Client
|
||||
|
||||
@@ -97,7 +101,7 @@ The train stops at Eibenreith.
|
||||
|
||||
Glossary markup is a normal story tag scoped to the paragraph/block it is attached to. The UI finds every matching visible instance of the term in that right-page block and adds a hover/focus note. The tag itself is not displayed, is not sent to TTS, and is ignored by choices and command history. Avoid raw Ink control characters in the explanation; `|`, `{`, and `}` must be escaped in Ink as `\|`, `\{`, and `\}` if they are needed literally.
|
||||
|
||||
Canonical block/media/control tags use Ink-style `#` syntax. In Ink these are real Ink tags. In YAML and Zork narrative output, leading `#...` lines are parsed by the server into the same structured `StoryTag` objects before reaching the client. The browser only consumes structured `TurnResult` objects.
|
||||
Canonical block/media/control tags use Ink-style `#` syntax. In Ink these are real Ink tags. In YAML and Z-code narrative output, leading `#...` lines are parsed by the server into the same structured `StoryTag` objects before reaching the client. The browser only consumes structured `TurnResult` objects.
|
||||
|
||||
Tag format:
|
||||
|
||||
@@ -173,6 +177,10 @@ Game-state and player-message tags:
|
||||
|
||||
`#score[...]` marks an intended ending and opens a localized ending popup when the turn reaches `inputMode: end`. `#error[...]` marks an unrecoverable ending and opens an error popup. If an Ink story runs out of content without an explicit `#score[...]` or `#error[...]`, the Ink engine emits an `#error[...]` tag. `#achievement[...]` and `#alert[...]` open localized queued popups while the game continues.
|
||||
|
||||
## Architecture Documentation
|
||||
|
||||
`SPECIFICATION.md` is the canonical architecture and implementation specification. `TODO.md` is the canonical progress and remaining-work list. The former loose Ink and Z-code inclusion notes have been folded into those two files.
|
||||
|
||||
## Assets
|
||||
|
||||
- `public/sounds/`: sound effects referenced by `#sfx[file]` tags.
|
||||
@@ -215,7 +223,7 @@ The right page history is line-addressed rather than natively scrolled. The page
|
||||
|
||||
### 2026-05-14
|
||||
|
||||
- Consolidated usage, markup, and architecture documentation into `README.md` and `CLIENT_TODO.md`.
|
||||
- Consolidated usage, markup, and architecture documentation into `README.md` and `TODO.md`.
|
||||
- Added no-cache static serving and module URL cache busting so browser reloads pick up JS changes reliably during development.
|
||||
- Fixed module loader dependency ordering so modules are initialized only after their declared dependencies are ready.
|
||||
- Added the placeholder game API for `newGame`, `loadGame`, `saveGame`, `hasSaveGame`, `getSaveGames`, and `isGameRunning`.
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# AI Interactive Fiction Specification
|
||||
|
||||
This is the single architecture and behavior specification for the project. Usage and changelog live in `README.md`; actionable work items live in `TODO.md`; authoring conventions live in `MARKUP_GUIDELINES.md`.
|
||||
|
||||
## Product Goal
|
||||
|
||||
AI Interactive Fiction is a shared book-style web client plus interchangeable game engine servers. The client renders interactive fiction as animated, carefully typeset illustrated prose with optional speech, music, sound effects, images, choices, and command input. Game engines own game state and emit a shared structured protocol.
|
||||
|
||||
The production client must tolerate speech being unavailable. The safe TTS provider default is `none`; a game or player preference may select another provider.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
- `public/`: shared browser UI, assets, fonts, client modules, third-party browser libraries.
|
||||
- `src/`: TypeScript servers, shared protocol types, engine implementations, YAML world model, CLI support.
|
||||
- `config/engines/`: per-engine configuration files.
|
||||
- `data/ink-src/`: Ink source files.
|
||||
- `data/ink/`: compiled Ink JSON output.
|
||||
- `data/worlds/`: YAML world files.
|
||||
- `data/z-code/`: Z-machine story files such as `zork1.bin`.
|
||||
- `data/zcode-prompts/`: prompt templates used by the current LLM-mediated Z-code narrator.
|
||||
- `scripts/`: project utility scripts. Currently used: `check-node-version.js` and `run-engine.js`.
|
||||
- `templates/`: not present in the current repository and not used.
|
||||
|
||||
## Engine Selection And Commands
|
||||
|
||||
`DEFAULT_GAME_ENGINE` in `.env` selects the engine used by:
|
||||
|
||||
```text
|
||||
npm run dev
|
||||
npm run start
|
||||
```
|
||||
|
||||
Supported values are `ink`, `yaml`, and `zcode`.
|
||||
|
||||
Engine-specific commands bypass the default:
|
||||
|
||||
```text
|
||||
npm run dev:ink
|
||||
npm run dev:yaml
|
||||
npm run dev:zcode
|
||||
npm run start:ink
|
||||
npm run start:yaml
|
||||
npm run start:zcode
|
||||
```
|
||||
|
||||
`dev:*` runs TypeScript through `ts-node` and `nodemon`. `start:*` runs compiled JavaScript from `dist/` and builds first through `prestart:*`. `*:debug` enables the engine's debug environment flag. `*:inspect` starts Node inspector and currently also enables debug for that engine.
|
||||
|
||||
The CLI path is YAML-only and uses `src/index.ts --cli`. It is useful for testing the YAML `GameRunner` without the browser UI. The old `test-server-yaml.ts` is a legacy static/YAML harness and should be removed once no workflow depends on it.
|
||||
|
||||
## Shared Server Protocol
|
||||
|
||||
All engines communicate with the browser through Socket.IO and the same game API:
|
||||
|
||||
```text
|
||||
newGame()
|
||||
loadGame(slot)
|
||||
saveGame(slot)
|
||||
hasSaveGame(slot)
|
||||
getSaveGames()
|
||||
isGameRunning()
|
||||
chooseChoice(index)
|
||||
```
|
||||
|
||||
Line-input engines also use `playerCommand` for free text.
|
||||
|
||||
Every engine emits `TurnResult` objects:
|
||||
|
||||
```ts
|
||||
interface TurnResult {
|
||||
turnId: number;
|
||||
paragraphs: Array<{ text: string; tags?: StoryTag[] }>;
|
||||
choices: ChoiceResult[];
|
||||
inputMode: 'text' | 'choice' | 'end' | 'none';
|
||||
globalTags?: StoryTag[];
|
||||
gameState?: {
|
||||
score?: number;
|
||||
endState?: { type: 'intended' | 'error'; message?: string };
|
||||
};
|
||||
suggestions?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
The browser consumes structured `TurnResult` data only. YAML and Z-code servers must parse or synthesize the same tag objects that Ink exposes through native tags.
|
||||
|
||||
## Game Engines
|
||||
|
||||
### YAML Engine
|
||||
|
||||
- Config: `config/engines/yaml.json`
|
||||
- Server: `src/server-yaml.ts`
|
||||
- World model: `data/worlds/*.yml`
|
||||
- CLI entry: `src/index.ts --cli`
|
||||
|
||||
The YAML engine is no longer the architectural default; it is one engine beside Ink and Z-code. It uses `GameRunner`, `GameEngine`, and `YamlWorldParser`, emits `inputMode: 'text'`, and remains the best test bed for deterministic world-model plus LLM command interpretation.
|
||||
|
||||
### Ink Engine
|
||||
|
||||
- Config: `config/engines/ink.json`
|
||||
- Server: `src/server-ink.ts`
|
||||
- Engine: `src/engine/ink-engine.ts`
|
||||
- Source: `data/ink-src/eibenreith.ink` plus included chapter files.
|
||||
- Compiled output: `data/ink/eibenreith.ink.json`
|
||||
|
||||
The Ink server compiles source at startup using `inkjs/full`, then runs the compiled story with `inkjs`. Ink choices become `ChoiceResult` objects. Ink tags become shared `StoryTag` objects. Choice preview tags support `#key`, `#letter`, `#optional`, `#action`, `#gated`, and `#sort`.
|
||||
|
||||
Ink does not provide arbitrary string input as a native async primitive comparable to choices. Future text-input turns should be implemented through a tag such as `#input[name](prompt)`: the server returns `inputMode: 'text'`, the UI shows command input for one round, then the server stores the submitted string into an Ink variable and continues.
|
||||
|
||||
### Z-code Engine
|
||||
|
||||
- Config: `config/engines/zcode.json`
|
||||
- Server: `src/server-zcode.ts`
|
||||
- Engine: `src/engine/zcode-llm-engine.ts`
|
||||
- Story file: `data/z-code/zork1.bin` by default.
|
||||
- Prompt templates: `data/zcode-prompts/*.yml`
|
||||
|
||||
The engine name is Z-code. Zork I is only the current game file and prompt target. The current implementation runs a Z-machine story through `ifvms`, keeps Z-machine state authoritative, and uses an LLM to translate natural-language input into parser commands and rewrite raw Z-machine output into prose.
|
||||
|
||||
Future work should separate Z-code-generic logic from Zork-specific prompt content more clearly.
|
||||
|
||||
## Client Module System
|
||||
|
||||
The browser client uses native ES modules, no bundler. The loader imports modules, analyzes dependency declarations, initializes modules in dependency order, tracks state/progress, and hides the loading overlay only when initialization and progress exit animations are complete.
|
||||
|
||||
Rules:
|
||||
|
||||
- Every app module extends `BaseModule`.
|
||||
- Every app module registers with `moduleRegistry`.
|
||||
- Required dependencies must be listed in `dependencies`.
|
||||
- Modules should use authoritative dependencies instead of local fallbacks.
|
||||
- Do not add fallback paths to hide bad dependency declarations or ordering bugs.
|
||||
- `setTimeout` must not paper over initialization races. It is acceptable for animation, debounce, throttle, and browser rendering timing when locally justified.
|
||||
|
||||
Core modules:
|
||||
|
||||
- `loader.js`: module script loading, progress UI, dependency diagnostics.
|
||||
- `module-registry.js`: registration and readiness promises.
|
||||
- `base-module.js`: lifecycle, progress, state, event cleanup.
|
||||
|
||||
Primary client responsibilities:
|
||||
|
||||
- Text and typography: `text-processor`, `paragraph-layout`, `layout-renderer`.
|
||||
- Markup: `markup-parser`.
|
||||
- Queue/playback: `text-buffer`, `sentence-queue`, `playback-coordinator`, `animation-queue`.
|
||||
- Audio/TTS: `audio-manager`, `tts-factory`, provider modules.
|
||||
- UI: `ui-controller`, `ui-display-handler`, `ui-input-handler`, `choice-display`, `options-ui`, `ui-effects`.
|
||||
- Persistence/history: `persistence-manager`, `story-history`.
|
||||
- Networking: `socket-client`.
|
||||
|
||||
Known cleanup candidates: `debug-utils-module.js` is not loaded; `game-loop-module.js` still contains high-level glue from older architecture and should be audited before removal.
|
||||
|
||||
## Text Pipeline
|
||||
|
||||
Processing order:
|
||||
|
||||
1. Receive structured blocks and tags from a game engine.
|
||||
2. Parse inline story markup and remove media markers from display/TTS text.
|
||||
3. Apply Markdown emphasis.
|
||||
4. Apply locale-aware SmartyPants typography.
|
||||
5. Apply Hyphenopoly for the game metadata language.
|
||||
6. Measure text using the exact page font settings.
|
||||
7. Run Knuth-Plass line breaking.
|
||||
8. Render absolutely positioned words into the page line-coordinate model.
|
||||
9. Animate words in sync with measured TTS duration or estimated duration.
|
||||
|
||||
The external Knuth-Plass library should not be locally modified. Adaptation belongs in our modules.
|
||||
|
||||
## Right Page Layout And History
|
||||
|
||||
The right page is a virtual line-addressed content pane:
|
||||
|
||||
- `#page_right` does not use native scrolling.
|
||||
- Page height is divided into `PAGE_LINE_COUNT = 25`.
|
||||
- All block heights, margins, image spacing, and chapter/section spacing are exact line multiples.
|
||||
- Stored block positions are line coordinates, not pixels.
|
||||
- Window resize recalculates pixels from line coordinates.
|
||||
- New content appends at the live bottom.
|
||||
- Manual scrolling moves the active line and keeps a window of nearby blocks loaded.
|
||||
- The custom scrollbar represents virtual line history, not DOM scroll state.
|
||||
|
||||
Portrait images may overlap line ranges with text next to them, but edges must still land on line boundaries.
|
||||
|
||||
## Markup And Tags
|
||||
|
||||
Canonical tag syntax:
|
||||
|
||||
```text
|
||||
#key
|
||||
#key[value]
|
||||
#key[value](options)
|
||||
#key:value
|
||||
```
|
||||
|
||||
Supported story tags include:
|
||||
|
||||
- `#chapter[Title]`
|
||||
- `#section` / `#textblock`
|
||||
- `#image[file](landscape|portrait|square pause=2)`
|
||||
- `#sfx[file](max=8 fade fade-duration=2)`
|
||||
- `#music[file](crossfade loop lead=4)`
|
||||
- `#gloss[term](definition)`
|
||||
- `#score[...]`
|
||||
- `#error[...]`
|
||||
- `#achievement[...]`
|
||||
- `#alert[...]`
|
||||
|
||||
Choice tags:
|
||||
|
||||
- `#key:x` or `#key[x]`
|
||||
- `#letter[x]`
|
||||
- `#optional`
|
||||
- `#action[name]`
|
||||
|
||||
The active choice UI is one list. Explicit keys are reserved first, then remaining choices receive `1` through `0`, then `A` through `Z`.
|
||||
|
||||
Markdown emphasis:
|
||||
|
||||
```text
|
||||
*italic* or _italic_
|
||||
**bold** or __bold__
|
||||
***bold italic*** or ___bold italic___
|
||||
```
|
||||
|
||||
## Audio, TTS, And Media
|
||||
|
||||
TTS providers currently include `none`, Browser Speech, Kokoro, ElevenLabs, and OpenAI. Provider modules exist, but Browser Speech and Kokoro need focused validation before being considered production-ready.
|
||||
|
||||
TTS cache keys include provider, voice, provider speed value, language, and exact normalized TTS string. Fast-forward must accelerate visible animation and fade/stop active TTS without cancelling background generations unless the foreground block has been waiting long enough.
|
||||
|
||||
Music and sound effects are preloaded when requested. Music can queue, crossfade, cut, loop, play once, and lead into following text. Music ducks by a persisted percentage during TTS playback.
|
||||
|
||||
## Documentation Source Of Truth
|
||||
|
||||
- `README.md`: usage, commands, changelog, concise feature summary.
|
||||
- `SPECIFICATION.md`: architecture and behavior.
|
||||
- `TODO.md`: active status and backlog.
|
||||
- `MARKUP_GUIDELINES.md`: writing/authoring rules for story files.
|
||||
- `THIRD_PARTY_NOTICES.md` and `public/THIRD_PARTY_NOTICES.md`: license/credits material.
|
||||
@@ -0,0 +1,129 @@
|
||||
# TODO And Progress
|
||||
|
||||
This is the active implementation checklist. Architecture lives in `SPECIFICATION.md`; usage lives in `README.md`; authoring conventions live in `MARKUP_GUIDELINES.md`.
|
||||
|
||||
## Current Status
|
||||
|
||||
- The shared client is feature-rich enough for Ink gameplay: line-based book layout, animated text, TTS, music, sound effects, images, choices, glossary notes, save/load restoration, and localized UI are implemented.
|
||||
- The Ink engine is the current primary development engine.
|
||||
- The YAML engine and Z-code engine need regression testing after the Ink-heavy client changes.
|
||||
- Browser TTS and Kokoro provider modules exist but are not yet proven reliable.
|
||||
- The codebase still contains logging noise and older architecture fragments that need cleanup.
|
||||
|
||||
## Shared Client
|
||||
|
||||
### Completed
|
||||
|
||||
- [x] Native ES module loader, dependency graph, progress overlay, and ordered initialization.
|
||||
- [x] Responsive book layout that scales page, font sizes, and word positions relative to page size.
|
||||
- [x] SmartyPants, German guillemet normalization, Hyphenopoly, and Knuth-Plass layout.
|
||||
- [x] Paragraph/chapter/section/drop-cap rules.
|
||||
- [x] Markdown emphasis with `*` and `_` syntax.
|
||||
- [x] Right-page `#gloss[term](definition)` hover/focus notes.
|
||||
- [x] Image rendering for landscape, square, and portrait cases, with history/save restoration.
|
||||
- [x] Sound effect and music playback, including music lead-in, loop/once, and ducking.
|
||||
- [x] TTS `none`, OpenAI, ElevenLabs, Browser Speech, and Kokoro provider modules.
|
||||
- [x] TTS cache keys include provider, voice, speed, language, and exact normalized string.
|
||||
- [x] Persisted speech enable state, provider, voice, speed, language, and volume preferences.
|
||||
- [x] Fast-forward for text animation and active TTS fade/stop.
|
||||
- [x] Choice UI, explicit keys, automatic key assignment, optional-choice styling, click and keyboard selection.
|
||||
- [x] Localized popups for endings, errors, achievements, and alerts.
|
||||
- [x] Credits/license dialog.
|
||||
- [x] Line-addressed history scrolling model.
|
||||
- [x] Choice-return turns continue to the choice point when autoplay is off.
|
||||
|
||||
### In Progress
|
||||
|
||||
- [ ] Polish custom scrollbar dragging so the thumb moves freely during drag and commits the scroll target only on release.
|
||||
- [ ] Tighten automated checks around top-bar/options state initialization after reload.
|
||||
- [ ] Improve automated visual regression coverage for page scaling, drop caps, image wrapping, and paragraph indentation.
|
||||
- [ ] Improve automated audio tests for music ducking, sound effect timing, and fast-forward fadeout.
|
||||
- [ ] Validate provider-specific speed conversion for all TTS providers against real API behavior.
|
||||
|
||||
### Pending
|
||||
|
||||
- [ ] Add a logging module with levels/categories to reduce console output and improve runtime performance.
|
||||
- [ ] Show startup warnings/instructions when TTS APIs still need to be selected or configured.
|
||||
- [ ] Put production-ready default option values into code/config.
|
||||
- [ ] Get Browser TTS working reliably.
|
||||
- [ ] Get Kokoro.js TTS working for English-language games.
|
||||
- [ ] Get Kokoro.js TTS working for German-language games.
|
||||
- [ ] Add a TTS module for self-hosted or local OpenAI-compatible servers.
|
||||
- [ ] Test every documented `#tag` parameter and effect against parser, server, client rendering, playback, and save/load behavior.
|
||||
- [ ] Remove local file paths and diff-comments from third-party license markdown, refresh included third-party licenses/material, update external libraries where possible, and move any local modifications into our code.
|
||||
- [ ] Improve credits page layout with more window height, a larger notices markdown pane, and a Hollywood-style title scroll for creative credits.
|
||||
- [ ] Clean up unused modules, obsolete functions, legacy comments, and vestigial fragments from older architectures.
|
||||
- [ ] Add optical margin alignment/punctuation protrusion as typography polish if current hanging punctuation proves insufficient.
|
||||
|
||||
## Shared Server Architecture
|
||||
|
||||
### Completed
|
||||
|
||||
- [x] Shared `TurnResult` protocol used by all engines.
|
||||
- [x] Shared game API shape: `newGame`, `loadGame`, `saveGame`, `hasSaveGame`, `getSaveGames`, `isGameRunning`.
|
||||
- [x] Per-engine config files with metadata, locale, main game file, and asset paths.
|
||||
- [x] `.env` default engine selection for `npm run dev` and `npm run start`.
|
||||
- [x] Engine-specific dev/start/debug/inspect scripts.
|
||||
- [x] YAML server renamed to `server-yaml.ts` so it is no longer implied as the generic server.
|
||||
- [x] Z-code server/config/scripts use `zcode` naming; Zork is only the current story/prompt target.
|
||||
|
||||
### Pending
|
||||
|
||||
- [ ] Extract duplicated Express/Socket.IO/static-file/port-fallback setup into a shared server base.
|
||||
- [ ] Replace session-local placeholder saves with durable server-side or browser-coordinated saves where appropriate.
|
||||
- [ ] Clean up start scripts and add a Dockerfile for hosting the selected engine on Coolify.
|
||||
- [ ] Decide whether `src/index.ts` should remain as the YAML CLI entry or be replaced by clearer `cli-yaml.ts` and engine-specific launchers.
|
||||
- [ ] Remove `test-server-yaml.ts` if no current workflow depends on it.
|
||||
- [ ] Add logger configuration to scripts: `LOG_LEVEL`, `LOG_CATEGORIES`, and engine debug defaults.
|
||||
|
||||
## Ink Engine
|
||||
|
||||
### Completed
|
||||
|
||||
- [x] Ink source compilation through `inkjs/full`.
|
||||
- [x] Split Ink source files with a master include file.
|
||||
- [x] Ink metadata handoff to client.
|
||||
- [x] Ink choices converted to `ChoiceResult`.
|
||||
- [x] Ink tags converted to shared `StoryTag`.
|
||||
- [x] Choice preview tags for `#key`, `#letter`, `#optional`, and `#action`.
|
||||
- [x] Save/load of Ink state plus client history state.
|
||||
- [x] `#score`, `#error`, `#achievement`, and `#alert` tag behavior.
|
||||
- [x] `#gloss[term](definition)` support on right-page text.
|
||||
|
||||
### Pending
|
||||
|
||||
- [ ] Add text-input turns to Ink games, switching the UI to command input for one round and returning to choices afterward.
|
||||
- [ ] Add a full dynamic description of the created character to the score panel after the game intro.
|
||||
- [ ] Continue authoring and testing Eibenreith content.
|
||||
- [ ] Test all documented tag syntax inside real Ink source, including edge cases with includes and choice-local tags.
|
||||
|
||||
## YAML Engine
|
||||
|
||||
### Completed
|
||||
|
||||
- [x] Deterministic YAML world model and `GameRunner`.
|
||||
- [x] YAML CLI path for testing without browser UI.
|
||||
- [x] YAML web server emits `TurnResult` objects.
|
||||
|
||||
### Pending
|
||||
|
||||
- [ ] Test/debug the YAML engine after Ink-driven client changes.
|
||||
- [ ] Continue development of the YAML engine.
|
||||
- [ ] Replace command mirroring with the full LLM/world-model command loop when typography/audio testing no longer needs mirroring.
|
||||
- [ ] Validate YAML-generated `#` tags through the shared parser/protocol path.
|
||||
|
||||
## Z-code Engine
|
||||
|
||||
### Completed
|
||||
|
||||
- [x] Z-code naming for engine scripts/config/server.
|
||||
- [x] Current Zork I narrator implementation using `ifvms` plus OpenRouter prompt templates.
|
||||
- [x] Z-code engine emits shared `TurnResult` objects.
|
||||
|
||||
### Pending
|
||||
|
||||
- [ ] Test/debug the Z-code engine after Ink-driven client changes.
|
||||
- [ ] Finish the Z-code version: optimize prompt templates, choose the best LLM for the task, and test project memory behavior.
|
||||
- [ ] Separate Z-code-generic logic from Zork-specific prompt assumptions.
|
||||
- [ ] Validate save/restore of Z-machine state.
|
||||
- [ ] Merge this branch with `master` after YAML and Z-code regression testing.
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"engine": "zork",
|
||||
"engine": "zcode",
|
||||
"locale": "en_US",
|
||||
"paths": {
|
||||
"mainGameFile": "data/z-code/zork1.bin",
|
||||
"promptDir": "data/zork-prompts",
|
||||
"promptDir": "data/zcode-prompts",
|
||||
"music": "public/music",
|
||||
"sfx": "public/sounds",
|
||||
"images": "public/images"
|
||||
@@ -1,7 +1,7 @@
|
||||
# Z-Code Story Files
|
||||
|
||||
Place your Z-machine story files here. The Zork Narrator engine looks for
|
||||
`zork1.bin` by default. This can be overridden with the `ZORK_STORY_FILE`
|
||||
Place your Z-machine story files here. The Z-code narrator engine looks for
|
||||
`zork1.bin` by default. This can be overridden with the `ZCODE_STORY_FILE`
|
||||
environment variable.
|
||||
|
||||
## Obtaining Zork I
|
||||
@@ -18,13 +18,13 @@ You can obtain a legal copy via:
|
||||
archived distributions listed under the Infocom catalogue.
|
||||
|
||||
Once you have the file, rename it to `zork1.bin` and place it in this folder,
|
||||
or set `ZORK_STORY_FILE=./path/to/your/file` in your `.env`.
|
||||
or set `ZCODE_STORY_FILE=./path/to/your/file` in your `.env`.
|
||||
|
||||
## Supported Formats
|
||||
|
||||
The `ifvms` interpreter accepts:
|
||||
- `.z3`, `.z4`, `.z5`, `.z8` — raw Z-machine story files
|
||||
- `.zblorb` — Blorb-wrapped story files (may include sound resources)
|
||||
- `.z3`, `.z4`, `.z5`, `.z8` - raw Z-machine story files
|
||||
- `.zblorb` - Blorb-wrapped story files (may include sound resources)
|
||||
- Any file with the correct Z-machine header (the extension is ignored)
|
||||
|
||||
Zork I is a Z-machine version 3 (`.z3`) game.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Command Translator Prompt
|
||||
# Called for every player input. Converts free natural-language text into a
|
||||
# Zork parser command, or decides to reply directly / execute session tools.
|
||||
# Z-machine parser command, or decides to reply directly / execute session tools.
|
||||
# Expected output: a JSON object (see schema below).
|
||||
|
||||
system: |
|
||||
@@ -63,7 +63,7 @@ system: |
|
||||
- Use update_character for stable identity/body/personality updates.
|
||||
- Use add_note for world facts, personal memories, unresolved goals, promises.
|
||||
- Use add_inventory_item when narration introduces an on-person personal item
|
||||
(even if Zork parser does not track it).
|
||||
(even if Z-machine parser does not track it).
|
||||
- Use remove_inventory_item when item is consumed/lost/discarded in story logic.
|
||||
|
||||
Command policy:
|
||||
@@ -69,7 +69,7 @@ user_template: |
|
||||
|
||||
Raw Z-machine response:
|
||||
---
|
||||
{{zorkOutput}}
|
||||
{{zcodeOutput}}
|
||||
---
|
||||
|
||||
Decide now: accept and rewrite, or retry with a new command?
|
||||
@@ -11,7 +11,7 @@ system: |
|
||||
- Always narrate the player-character in second person: "you".
|
||||
- Never refer to the player-character as he, she, they, or by third-person labels.
|
||||
- Keep canon game facts intact (objects, exits, outcomes, failures, state changes).
|
||||
- Do not invent gameplay-critical facts that contradict Zork output.
|
||||
- Do not invent gameplay-critical facts that contradict Z-machine output.
|
||||
|
||||
Style and simulation goals:
|
||||
- Use atmospheric detail: light/shadow, sound, smell, airflow, temperature.
|
||||
@@ -35,7 +35,7 @@ system: |
|
||||
context to keep prose consistent.
|
||||
- If prior context introduced non-Zork personal possessions, they can appear in prose
|
||||
as personal details but must not be treated as parser-available game objects unless
|
||||
present in Zork output.
|
||||
present in Z-machine output.
|
||||
|
||||
Output constraints:
|
||||
- Return prose only. No JSON, no labels, no headings.
|
||||
@@ -71,7 +71,7 @@ user_template: |
|
||||
|
||||
Raw Z-machine output to rewrite:
|
||||
---
|
||||
{{zorkOutput}}
|
||||
{{zcodeOutput}}
|
||||
---
|
||||
|
||||
Rewrite the above as prose for the player now.
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
export type EngineName = 'yaml' | 'ink' | 'zork' | string;
|
||||
export type EngineName = 'yaml' | 'ink' | 'zcode' | string;
|
||||
export interface GameMetadata {
|
||||
title: string;
|
||||
author?: string;
|
||||
|
||||
Vendored
+1
-1
@@ -17,7 +17,7 @@ function fallbackConfig(engine) {
|
||||
paths: {
|
||||
mainGameFile: engine === 'ink'
|
||||
? 'data/ink/story.ink.json'
|
||||
: engine === 'zork'
|
||||
: engine === 'zcode'
|
||||
? 'data/z-code/zork1.bin'
|
||||
: 'data/worlds/example_world.yml',
|
||||
music: 'public/music',
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"game-config.js","sourceRoot":"","sources":["../../src/config/game-config.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"game-config.js","sourceRoot":"","sources":["../../src/config/game-config.ts"],"names":[],"mappings":"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"}
|
||||
@@ -1,19 +1,19 @@
|
||||
/**
|
||||
* Zork LLM Engine
|
||||
* Z-code LLM Engine
|
||||
*
|
||||
* Runs Zork I (or any Z-machine story file) as a headless subprocess via the
|
||||
* Runs a Z-machine story file as a headless subprocess via the
|
||||
* `ifvms` CLI, and wraps every I/O exchange with OpenRouter LLM calls that
|
||||
* translate free natural-language player input into parser commands and
|
||||
* re-voice the Z-machine's raw output as polished narrative prose.
|
||||
*
|
||||
* Configuration (environment variables):
|
||||
* ZORK_STORY_FILE – path to the .z5/.z8/.bin story file (default: ./data/z-code/zork1.bin)
|
||||
* ZORK_MAX_RETRIES – maximum command retry attempts per turn (default: 3)
|
||||
* ZORK_HISTORY_SIZE – player-facing outputs stored per room (default: 5)
|
||||
* OPENROUTER_API_KEY, OPENROUTER_MODEL – required
|
||||
* ZCODE_STORY_FILE - path to the .z5/.z8/.bin story file (default: ./data/z-code/zork1.bin)
|
||||
* ZCODE_MAX_RETRIES - maximum command retry attempts per turn (default: 3)
|
||||
* ZCODE_HISTORY_SIZE - player-facing outputs stored per room (default: 5)
|
||||
* OPENROUTER_API_KEY, OPENROUTER_MODEL - required
|
||||
*/
|
||||
import { TurnResult } from '../interfaces/turn-result';
|
||||
export interface ZorkSession {
|
||||
export interface ZcodeSession {
|
||||
characterDescription: string;
|
||||
notes: string[];
|
||||
recentParagraphs: string[];
|
||||
@@ -22,14 +22,14 @@ export interface ZorkSession {
|
||||
timeOfDay: string;
|
||||
weather: string;
|
||||
virtualInventory: string[];
|
||||
/** roomName → last N player-facing output strings */
|
||||
/** roomName -> last N player-facing output strings */
|
||||
roomHistory: Record<string, string[]>;
|
||||
currentRoom: string;
|
||||
running: boolean;
|
||||
}
|
||||
export type ZorkTurnResult = TurnResult;
|
||||
export declare class ZorkLlmEngine {
|
||||
private zork;
|
||||
export type ZcodeTurnResult = TurnResult;
|
||||
export declare class ZcodeLlmEngine {
|
||||
private zmachine;
|
||||
private session;
|
||||
private prompts;
|
||||
private llm;
|
||||
@@ -49,14 +49,14 @@ export declare class ZorkLlmEngine {
|
||||
private resolveFallbackModel;
|
||||
isRunning(): boolean;
|
||||
/**
|
||||
* Start a new game: launch Zork, generate the player character, rewrite the
|
||||
* Start a new game: launch the Z-machine story, generate the player character, rewrite the
|
||||
* intro text, and return the first TurnResult for the client.
|
||||
*/
|
||||
newGame(): Promise<ZorkTurnResult>;
|
||||
newGame(): Promise<ZcodeTurnResult>;
|
||||
/**
|
||||
* Process player free-text input. Returns the next TurnResult.
|
||||
*/
|
||||
processInput(userInput: string): Promise<ZorkTurnResult>;
|
||||
processInput(userInput: string): Promise<ZcodeTurnResult>;
|
||||
private runCommandPlan;
|
||||
/**
|
||||
* Save the current game state. Returns a JSON string suitable for storing
|
||||
@@ -66,7 +66,7 @@ export declare class ZorkLlmEngine {
|
||||
/**
|
||||
* Load a previously saved game. Returns the first TurnResult after restore.
|
||||
*/
|
||||
loadGame(savedJson: string): Promise<ZorkTurnResult>;
|
||||
loadGame(savedJson: string): Promise<ZcodeTurnResult>;
|
||||
private runSingleCommandLoop;
|
||||
private generateCharacter;
|
||||
private rewriteText;
|
||||
@@ -1,17 +1,17 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Zork LLM Engine
|
||||
* Z-code LLM Engine
|
||||
*
|
||||
* Runs Zork I (or any Z-machine story file) as a headless subprocess via the
|
||||
* Runs a Z-machine story file as a headless subprocess via the
|
||||
* `ifvms` CLI, and wraps every I/O exchange with OpenRouter LLM calls that
|
||||
* translate free natural-language player input into parser commands and
|
||||
* re-voice the Z-machine's raw output as polished narrative prose.
|
||||
*
|
||||
* Configuration (environment variables):
|
||||
* ZORK_STORY_FILE – path to the .z5/.z8/.bin story file (default: ./data/z-code/zork1.bin)
|
||||
* ZORK_MAX_RETRIES – maximum command retry attempts per turn (default: 3)
|
||||
* ZORK_HISTORY_SIZE – player-facing outputs stored per room (default: 5)
|
||||
* OPENROUTER_API_KEY, OPENROUTER_MODEL – required
|
||||
* ZCODE_STORY_FILE - path to the .z5/.z8/.bin story file (default: ./data/z-code/zork1.bin)
|
||||
* ZCODE_MAX_RETRIES - maximum command retry attempts per turn (default: 3)
|
||||
* ZCODE_HISTORY_SIZE - player-facing outputs stored per room (default: 5)
|
||||
* OPENROUTER_API_KEY, OPENROUTER_MODEL - required
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
@@ -50,7 +50,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ZorkLlmEngine = void 0;
|
||||
exports.ZcodeLlmEngine = void 0;
|
||||
const child_process_1 = require("child_process");
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
@@ -60,15 +60,15 @@ const axios_1 = __importDefault(require("axios"));
|
||||
const dotenv = __importStar(require("dotenv"));
|
||||
const turn_result_1 = require("../interfaces/turn-result");
|
||||
dotenv.config();
|
||||
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZORK_DEBUG ?? '');
|
||||
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZCODE_DEBUG ?? '');
|
||||
function debugLog(message, details) {
|
||||
if (!DEBUG_ENABLED)
|
||||
return;
|
||||
if (typeof details === 'undefined') {
|
||||
console.log(`[ZorkLlm:debug] ${message}`);
|
||||
console.log(`[ZcodeLlm:debug] ${message}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[ZorkLlm:debug] ${message}`, details);
|
||||
console.log(`[ZcodeLlm:debug] ${message}`, details);
|
||||
}
|
||||
function compactText(text, maxLength = 12000) {
|
||||
if (text.length <= maxLength)
|
||||
@@ -152,10 +152,10 @@ function isParserComplaint(output) {
|
||||
"there is no",
|
||||
].some(fragment => text.includes(fragment));
|
||||
}
|
||||
function formatExactReadOutput(command, zorkOutput) {
|
||||
function formatExactReadOutput(command, zcodeOutput) {
|
||||
const object = command.replace(/^READ\s+/i, '').trim().toLowerCase();
|
||||
const label = object ? `the ${object}` : 'it';
|
||||
const cleanedOutput = zorkOutput
|
||||
const cleanedOutput = zcodeOutput
|
||||
.split('\n')
|
||||
.filter((line, index) => index !== 0 || line.trim().toUpperCase() !== command.trim().toUpperCase())
|
||||
.join('\n')
|
||||
@@ -198,9 +198,9 @@ function evolveWeather(previous, turnCount) {
|
||||
return transitions[Math.floor(turnCount / 9) % transitions.length];
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// ZorkProcess – manages the ifvms zvm child process
|
||||
// ZcodeProcess – manages the ifvms zvm child process
|
||||
// ---------------------------------------------------------------------------
|
||||
class ZorkProcess {
|
||||
class ZcodeProcess {
|
||||
constructor() {
|
||||
this.proc = null;
|
||||
this.outputBuffer = '';
|
||||
@@ -279,7 +279,7 @@ class ZorkProcess {
|
||||
this.scheduleResolve();
|
||||
});
|
||||
}
|
||||
/** Debounced check: resolve when the buffer ends with Zork's '>' prompt. */
|
||||
/** Debounced check: resolve when the buffer ends with a Z-machine prompt. */
|
||||
scheduleResolve() {
|
||||
if (!/\n>\s*$/.test(this.outputBuffer))
|
||||
return;
|
||||
@@ -334,23 +334,23 @@ function renderTemplate(template, vars) {
|
||||
function logLlmError(scope, err) {
|
||||
if (axios_1.default.isAxiosError(err)) {
|
||||
const ax = err;
|
||||
console.error(`[ZorkLlm] ${scope} failed: ${ax.message}`);
|
||||
console.error(`[ZcodeLlm] ${scope} failed: ${ax.message}`);
|
||||
if (ax.response) {
|
||||
console.error(`[ZorkLlm] ${scope} status=${ax.response.status} data=`, ax.response.data);
|
||||
console.error(`[ZcodeLlm] ${scope} status=${ax.response.status} data=`, ax.response.data);
|
||||
if (ax.response.status === 404) {
|
||||
console.error('[ZorkLlm] Hint: OPENROUTER_MODEL is likely invalid or unavailable for your API key.');
|
||||
console.error('[ZcodeLlm] Hint: OPENROUTER_MODEL is likely invalid or unavailable for your API key.');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error(`[ZorkLlm] ${scope} failed:`, err);
|
||||
console.error(`[ZcodeLlm] ${scope} failed:`, err);
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// ZorkLlmEngine
|
||||
// ZcodeLlmEngine
|
||||
// ---------------------------------------------------------------------------
|
||||
class ZorkLlmEngine {
|
||||
class ZcodeLlmEngine {
|
||||
constructor(options = {}) {
|
||||
this.zork = new ZorkProcess();
|
||||
this.zmachine = new ZcodeProcess();
|
||||
this.session = null;
|
||||
this.resolvedFallbackModel = null;
|
||||
this.llmCallCounter = 0;
|
||||
@@ -360,10 +360,10 @@ class ZorkLlmEngine {
|
||||
if (!apiKey || !model) {
|
||||
throw new Error('Missing required environment variables: OPENROUTER_API_KEY and OPENROUTER_MODEL');
|
||||
}
|
||||
const replacement = ZorkLlmEngine.DEPRECATED_MODEL_REPLACEMENTS[model] ?? null;
|
||||
const replacement = ZcodeLlmEngine.DEPRECATED_MODEL_REPLACEMENTS[model] ?? null;
|
||||
if (replacement) {
|
||||
this.model = replacement;
|
||||
console.warn(`[ZorkLlm] Replacing deprecated model '${model}' with '${replacement}'.`);
|
||||
console.warn(`[ZcodeLlm] Replacing deprecated model '${model}' with '${replacement}'.`);
|
||||
}
|
||||
else {
|
||||
this.model = model;
|
||||
@@ -372,10 +372,10 @@ class ZorkLlmEngine {
|
||||
requestedModel: model,
|
||||
activeModel: this.model,
|
||||
});
|
||||
this.maxRetries = parseInt(process.env.ZORK_MAX_RETRIES ?? '3', 10);
|
||||
this.historySize = parseInt(process.env.ZORK_HISTORY_SIZE ?? '5', 10);
|
||||
this.storyPath = path.resolve(options.storyPath ?? process.env.ZORK_STORY_FILE ?? './data/z-code/zork1.bin');
|
||||
const promptDir = path.resolve(options.promptDir ?? './data/zork-prompts');
|
||||
this.maxRetries = parseInt(process.env.ZCODE_MAX_RETRIES ?? '3', 10);
|
||||
this.historySize = parseInt(process.env.ZCODE_HISTORY_SIZE ?? '5', 10);
|
||||
this.storyPath = path.resolve(options.storyPath ?? process.env.ZCODE_STORY_FILE ?? './data/z-code/zork1.bin');
|
||||
const promptDir = path.resolve(options.promptDir ?? './data/zcode-prompts');
|
||||
this.prompts = loadPrompts(promptDir);
|
||||
this.llm = axios_1.default.create({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
@@ -408,7 +408,7 @@ class ZorkLlmEngine {
|
||||
if (axios_1.default.isAxiosError(err) && err.response?.status === 404) {
|
||||
const fallbackModel = await this.resolveFallbackModel();
|
||||
this.model = fallbackModel;
|
||||
console.warn(`[ZorkLlm] Switching active model to '${fallbackModel}'.`);
|
||||
console.warn(`[ZcodeLlm] Switching active model to '${fallbackModel}'.`);
|
||||
const withFallbackModel = {
|
||||
...withReasoningDefaults(payload, fallbackModel),
|
||||
model: fallbackModel,
|
||||
@@ -478,23 +478,23 @@ class ZorkLlmEngine {
|
||||
}
|
||||
// ---- Public API -----------------------------------------------------------
|
||||
isRunning() {
|
||||
return this.session?.running === true && this.zork.isAlive();
|
||||
return this.session?.running === true && this.zmachine.isAlive();
|
||||
}
|
||||
/**
|
||||
* Start a new game: launch Zork, generate the player character, rewrite the
|
||||
* Start a new game: launch the Z-machine story, generate the player character, rewrite the
|
||||
* intro text, and return the first TurnResult for the client.
|
||||
*/
|
||||
async newGame() {
|
||||
// Kill any existing game
|
||||
if (this.zork.isAlive())
|
||||
this.zork.kill();
|
||||
if (this.zmachine.isAlive())
|
||||
this.zmachine.kill();
|
||||
this.nextTurnId = 1;
|
||||
if (!fs.existsSync(this.storyPath)) {
|
||||
throw new Error(`Story file not found: ${this.storyPath}\n` +
|
||||
'Place zork1.bin in ./data/z-code/ (see README in that folder).');
|
||||
}
|
||||
debugLog('launching Z-machine', { storyPath: this.storyPath });
|
||||
const rawIntro = await this.zork.launch(this.storyPath);
|
||||
const rawIntro = await this.zmachine.launch(this.storyPath);
|
||||
debugLog('Z-machine intro output', compactText(rawIntro));
|
||||
// Generate the player character before showing any text
|
||||
const characterDescription = await this.generateCharacter();
|
||||
@@ -555,7 +555,7 @@ class ZorkLlmEngine {
|
||||
for (const tool of cmdResponse.tools) {
|
||||
this.executeTool(tool);
|
||||
}
|
||||
// If the translator also supplied a Zork command, continue to game loop
|
||||
// If the translator also supplied a Z-machine command, continue to game loop
|
||||
if (!cmdResponse.command && !cmdResponse.commands?.length) {
|
||||
// Pure tool action — generate a brief acknowledgement via the rewriter
|
||||
const ack = await this.rewriteText(`(The narrator pauses. ${userInput})`);
|
||||
@@ -593,16 +593,16 @@ class ZorkLlmEngine {
|
||||
async saveGame() {
|
||||
if (!this.session)
|
||||
throw new Error('No active session to save');
|
||||
const tmpFile = path.join(os.tmpdir(), `zork-save-${Date.now()}.qzl`);
|
||||
const tmpFile = path.join(os.tmpdir(), `zcode-save-${Date.now()}.qzl`);
|
||||
try {
|
||||
// Ask Zork to save, supply the temp file path, and discard the output
|
||||
await this.zork.sendLine('SAVE');
|
||||
await this.zork.sendLine(tmpFile);
|
||||
let zorkSave = '';
|
||||
// Ask the Z-machine to save, supply the temp file path, and discard the output
|
||||
await this.zmachine.sendLine('SAVE');
|
||||
await this.zmachine.sendLine(tmpFile);
|
||||
let zcodeSave = '';
|
||||
if (fs.existsSync(tmpFile)) {
|
||||
zorkSave = fs.readFileSync(tmpFile).toString('base64');
|
||||
zcodeSave = fs.readFileSync(tmpFile).toString('base64');
|
||||
}
|
||||
return JSON.stringify({ session: this.session, zorkSave });
|
||||
return JSON.stringify({ session: this.session, zcodeSave });
|
||||
}
|
||||
finally {
|
||||
if (fs.existsSync(tmpFile))
|
||||
@@ -614,15 +614,15 @@ class ZorkLlmEngine {
|
||||
*/
|
||||
async loadGame(savedJson) {
|
||||
var _a, _b, _c, _d, _e, _f;
|
||||
const { session, zorkSave } = JSON.parse(savedJson);
|
||||
if (this.zork.isAlive())
|
||||
this.zork.kill();
|
||||
const tmpFile = path.join(os.tmpdir(), `zork-restore-${Date.now()}.qzl`);
|
||||
const { session, zcodeSave } = JSON.parse(savedJson);
|
||||
if (this.zmachine.isAlive())
|
||||
this.zmachine.kill();
|
||||
const tmpFile = path.join(os.tmpdir(), `zcode-restore-${Date.now()}.qzl`);
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, Buffer.from(zorkSave, 'base64'));
|
||||
await this.zork.launch(this.storyPath);
|
||||
await this.zork.sendLine('RESTORE');
|
||||
const restoreOutput = await this.zork.sendLine(tmpFile);
|
||||
fs.writeFileSync(tmpFile, Buffer.from(zcodeSave, 'base64'));
|
||||
await this.zmachine.launch(this.storyPath);
|
||||
await this.zmachine.sendLine('RESTORE');
|
||||
const restoreOutput = await this.zmachine.sendLine(tmpFile);
|
||||
this.session = { ...session, running: true };
|
||||
(_a = this.session).rawTranscript ?? (_a.rawTranscript = []);
|
||||
(_b = this.session).recentParagraphs ?? (_b.recentParagraphs = []);
|
||||
@@ -650,7 +650,7 @@ class ZorkLlmEngine {
|
||||
attempt,
|
||||
maxRetries: this.maxRetries,
|
||||
});
|
||||
const rawOutput = await this.zork.sendLine(command);
|
||||
const rawOutput = await this.zmachine.sendLine(command);
|
||||
lastOutput = rawOutput;
|
||||
this.appendRawTranscript(command, rawOutput);
|
||||
debugLog('received Z-machine output', {
|
||||
@@ -714,10 +714,10 @@ class ZorkLlmEngine {
|
||||
return 'You are a wary but curious explorer, driven more by persistence than bravery. You have come to the old house seeking answers, carrying a notebook of unfinished questions and a habit of checking every corner twice.';
|
||||
}
|
||||
}
|
||||
async rewriteText(zorkOutput) {
|
||||
async rewriteText(zcodeOutput) {
|
||||
const cfg = this.prompts.textRewriter;
|
||||
const vars = this.buildCommonVars();
|
||||
vars['zorkOutput'] = zorkOutput;
|
||||
vars['zcodeOutput'] = zcodeOutput;
|
||||
try {
|
||||
const response = await this.createCompletion({
|
||||
messages: [
|
||||
@@ -731,7 +731,7 @@ class ZorkLlmEngine {
|
||||
}
|
||||
catch (err) {
|
||||
logLlmError('rewriteText', err);
|
||||
return zorkOutput;
|
||||
return zcodeOutput;
|
||||
}
|
||||
}
|
||||
async translateCommand(userInput) {
|
||||
@@ -753,16 +753,16 @@ class ZorkLlmEngine {
|
||||
}
|
||||
catch (err) {
|
||||
logLlmError('translateCommand', err);
|
||||
// Fallback: pass input directly to Zork parser
|
||||
// Fallback: pass input directly to Z-machine parser
|
||||
return { type: 'command', command: userInput.toUpperCase() };
|
||||
}
|
||||
}
|
||||
async evaluateOutput(userIntent, commandTried, zorkOutput, attempt) {
|
||||
async evaluateOutput(userIntent, commandTried, zcodeOutput, attempt) {
|
||||
const cfg = this.prompts.outputEvaluator;
|
||||
const vars = this.buildCommonVars();
|
||||
vars['userIntent'] = userIntent;
|
||||
vars['commandTried'] = commandTried;
|
||||
vars['zorkOutput'] = zorkOutput;
|
||||
vars['zcodeOutput'] = zcodeOutput;
|
||||
vars['attempt'] = String(attempt);
|
||||
vars['maxAttempts'] = String(this.maxRetries);
|
||||
try {
|
||||
@@ -780,7 +780,7 @@ class ZorkLlmEngine {
|
||||
catch (err) {
|
||||
logLlmError('evaluateOutput', err);
|
||||
// Fallback: accept the raw output as-is
|
||||
return { decision: 'accept', text: zorkOutput };
|
||||
return { decision: 'accept', text: zcodeOutput };
|
||||
}
|
||||
}
|
||||
// ---- Session helpers -------------------------------------------------------
|
||||
@@ -968,7 +968,7 @@ class ZorkLlmEngine {
|
||||
};
|
||||
}
|
||||
buildTurnResult(text) {
|
||||
const alive = this.zork.isAlive();
|
||||
const alive = this.zmachine.isAlive();
|
||||
if (!alive && this.session)
|
||||
this.session.running = false;
|
||||
const paragraphs = (0, turn_result_1.textToParagraphs)(text);
|
||||
@@ -981,9 +981,9 @@ class ZorkLlmEngine {
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.ZorkLlmEngine = ZorkLlmEngine;
|
||||
ZorkLlmEngine.DEPRECATED_MODEL_REPLACEMENTS = {
|
||||
exports.ZcodeLlmEngine = ZcodeLlmEngine;
|
||||
ZcodeLlmEngine.DEPRECATED_MODEL_REPLACEMENTS = {
|
||||
'anthropic/claude-3-opus-20240229': 'openai/gpt-5.5',
|
||||
'openai/gpt-5.4-mini': 'openai/gpt-5.5',
|
||||
};
|
||||
//# sourceMappingURL=zork-llm-engine.js.map
|
||||
//# sourceMappingURL=zcode-llm-engine.js.map
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+5
-5
@@ -38,8 +38,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const dotenv = __importStar(require("dotenv"));
|
||||
const game_runner_1 = require("./cli/game-runner");
|
||||
// Import the server module and the startServer function for the web interface
|
||||
const server_1 = require("./server");
|
||||
// YAML CLI entry point. The web default is selected by scripts/run-engine.js.
|
||||
const server_yaml_1 = require("./server-yaml");
|
||||
const game_config_1 = require("./config/game-config");
|
||||
// Load environment variables
|
||||
console.log('Loading environment variables...');
|
||||
@@ -64,8 +64,8 @@ async function main() {
|
||||
const engineConfig = (0, game_config_1.loadGameConfig)(process.env.YAML_CONFIG_FILE || './config/engines/yaml.json', 'yaml');
|
||||
const worldFile = (0, game_config_1.projectPath)(process.env.DEFAULT_WORLD_FILE || engineConfig.paths.mainGameFile);
|
||||
console.log(`Using world file: ${worldFile}`);
|
||||
console.log(`OpenRouter API Key: ${process.env.OPENROUTER_API_KEY ? '✓ Found' : '✗ Missing'}`);
|
||||
console.log(`OpenRouter Model: ${process.env.OPENROUTER_MODEL || '✗ Not specified'}`);
|
||||
console.log(`OpenRouter API Key: ${process.env.OPENROUTER_API_KEY ? 'Found' : 'Missing'}`);
|
||||
console.log(`OpenRouter Model: ${process.env.OPENROUTER_MODEL || 'Not specified'}`);
|
||||
// Check if we should run in CLI mode
|
||||
const args = process.argv.slice(2);
|
||||
const cliMode = args.includes('--cli') || args.includes('-c');
|
||||
@@ -90,7 +90,7 @@ async function main() {
|
||||
const PORT_RANGE = 300;
|
||||
// Start the web server with port fallback
|
||||
console.log('Starting web server...');
|
||||
await (0, server_1.startServer)(PORT, PORT_RANGE);
|
||||
await (0, server_yaml_1.startServer)(PORT, PORT_RANGE);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+1
-1
@@ -305,4 +305,4 @@ if (require.main === module) {
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=server.js.map
|
||||
//# sourceMappingURL=server-yaml.js.map
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Z-code LLM Server
|
||||
*
|
||||
* Starts an Express + Socket.IO server that runs Zork I through the
|
||||
* ZcodeLlmEngine and serves the same shared client UI as the YAML engine.
|
||||
*
|
||||
* Usage:
|
||||
* npm run dev:zcode (development, with file watching)
|
||||
* npm run start:zcode (production, from compiled dist/)
|
||||
*
|
||||
* Environment variables:
|
||||
* PORT – HTTP port (default: 3002)
|
||||
* ZCODE_STORY_FILE – path to the story file (default: ./data/z-code/zork1.bin)
|
||||
* OPENROUTER_API_KEY, OPENROUTER_MODEL – required
|
||||
*/
|
||||
export {};
|
||||
+28
-28
@@ -1,17 +1,17 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Zork LLM Server
|
||||
* Z-code LLM Server
|
||||
*
|
||||
* Starts an Express + Socket.IO server that runs Zork I through the
|
||||
* ZorkLlmEngine and serves the same shared client UI as the YAML engine.
|
||||
* ZcodeLlmEngine and serves the same shared client UI as the YAML engine.
|
||||
*
|
||||
* Usage:
|
||||
* npm run dev:zork (development, with file watching)
|
||||
* npm run start:zork (production, from compiled dist/)
|
||||
* npm run dev:zcode (development, with file watching)
|
||||
* npm run start:zcode (production, from compiled dist/)
|
||||
*
|
||||
* Environment variables:
|
||||
* PORT – HTTP port (default: 3002)
|
||||
* ZORK_STORY_FILE – path to the story file (default: ./data/z-code/zork1.bin)
|
||||
* ZCODE_STORY_FILE – path to the story file (default: ./data/z-code/zork1.bin)
|
||||
* OPENROUTER_API_KEY, OPENROUTER_MODEL – required
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
@@ -57,7 +57,7 @@ const express_1 = __importDefault(require("express"));
|
||||
const socket_io_1 = require("socket.io");
|
||||
const dotenv = __importStar(require("dotenv"));
|
||||
const fs_1 = require("fs");
|
||||
const zork_llm_engine_1 = require("./engine/zork-llm-engine");
|
||||
const zcode_llm_engine_1 = require("./engine/zcode-llm-engine");
|
||||
const game_config_1 = require("./config/game-config");
|
||||
dotenv.config();
|
||||
const app = (0, express_1.default)();
|
||||
@@ -66,16 +66,16 @@ const io = new socket_io_1.Server(server);
|
||||
const DEFAULT_PORT = 3002;
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
|
||||
const PORT_RANGE = 300;
|
||||
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZORK_DEBUG ?? '');
|
||||
const engineConfig = (0, game_config_1.loadGameConfig)(process.env.ZORK_CONFIG_FILE || './config/engines/zork.json', 'zork');
|
||||
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZCODE_DEBUG ?? '');
|
||||
const engineConfig = (0, game_config_1.loadGameConfig)(process.env.ZCODE_CONFIG_FILE || './config/engines/zcode.json', 'zcode');
|
||||
function debugLog(message, details) {
|
||||
if (!DEBUG_ENABLED)
|
||||
return;
|
||||
if (typeof details === 'undefined') {
|
||||
console.log(`[zork:debug] ${message}`);
|
||||
console.log(`[zcode:debug] ${message}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[zork:debug] ${message}`, details);
|
||||
console.log(`[zcode:debug] ${message}`, details);
|
||||
}
|
||||
// Serve the same shared client UI
|
||||
app.use(express_1.default.static(path_1.default.join(__dirname, '../public'), {
|
||||
@@ -111,9 +111,9 @@ function normalizeSaveSlot(slot) {
|
||||
function getOrCreateEngine(socketId) {
|
||||
let engine = sessions.get(socketId);
|
||||
if (!engine) {
|
||||
engine = new zork_llm_engine_1.ZorkLlmEngine({
|
||||
storyPath: (0, game_config_1.projectPath)(process.env.ZORK_STORY_FILE || engineConfig.paths.mainGameFile),
|
||||
promptDir: (0, game_config_1.projectPath)(engineConfig.paths.promptDir || 'data/zork-prompts'),
|
||||
engine = new zcode_llm_engine_1.ZcodeLlmEngine({
|
||||
storyPath: (0, game_config_1.projectPath)(process.env.ZCODE_STORY_FILE || engineConfig.paths.mainGameFile),
|
||||
promptDir: (0, game_config_1.projectPath)(engineConfig.paths.promptDir || 'data/zcode-prompts'),
|
||||
});
|
||||
sessions.set(socketId, engine);
|
||||
}
|
||||
@@ -189,8 +189,8 @@ async function handleGameApi(socket, method, args) {
|
||||
}
|
||||
}
|
||||
function checkRuntimeConfiguration() {
|
||||
const storyPath = (0, game_config_1.projectPath)(process.env.ZORK_STORY_FILE ?? engineConfig.paths.mainGameFile);
|
||||
const promptDir = (0, game_config_1.projectPath)(engineConfig.paths.promptDir || 'data/zork-prompts');
|
||||
const storyPath = (0, game_config_1.projectPath)(process.env.ZCODE_STORY_FILE ?? engineConfig.paths.mainGameFile);
|
||||
const promptDir = (0, game_config_1.projectPath)(engineConfig.paths.promptDir || 'data/zcode-prompts');
|
||||
const promptFiles = [
|
||||
'character-generation.yml',
|
||||
'text-rewriter.yml',
|
||||
@@ -201,17 +201,17 @@ function checkRuntimeConfiguration() {
|
||||
.map((file) => path_1.default.join(promptDir, file))
|
||||
.filter((filePath) => !(0, fs_1.existsSync)(filePath));
|
||||
if (!process.env.OPENROUTER_API_KEY) {
|
||||
console.error('[zork] Missing OPENROUTER_API_KEY in environment.');
|
||||
console.error('[zcode] Missing OPENROUTER_API_KEY in environment.');
|
||||
}
|
||||
if (!process.env.OPENROUTER_MODEL) {
|
||||
console.error('[zork] Missing OPENROUTER_MODEL in environment.');
|
||||
console.error('[zcode] Missing OPENROUTER_MODEL in environment.');
|
||||
}
|
||||
if (!(0, fs_1.existsSync)(storyPath)) {
|
||||
console.error(`[zork] Story file missing: ${storyPath}`);
|
||||
console.error('[zork] Place zork1.bin in ./data/z-code/ or set ZORK_STORY_FILE.');
|
||||
console.error(`[zcode] Story file missing: ${storyPath}`);
|
||||
console.error('[zcode] Place zork1.bin in ./data/z-code/ or set ZCODE_STORY_FILE.');
|
||||
}
|
||||
if (missingPrompts.length > 0) {
|
||||
console.error('[zork] Missing prompt files:');
|
||||
console.error('[zcode] Missing prompt files:');
|
||||
for (const filePath of missingPrompts) {
|
||||
console.error(` - ${filePath}`);
|
||||
}
|
||||
@@ -225,7 +225,7 @@ function checkRuntimeConfiguration() {
|
||||
});
|
||||
}
|
||||
io.on('connection', (socket) => {
|
||||
console.log(`[zork] Client connected: ${socket.id}`);
|
||||
console.log(`[zcode] Client connected: ${socket.id}`);
|
||||
socket.emit('gameConfig', (0, game_config_1.clientGameConfig)(engineConfig));
|
||||
socket.on('gameApi', async (request, respond) => {
|
||||
try {
|
||||
@@ -235,7 +235,7 @@ io.on('connection', (socket) => {
|
||||
respond(result);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[zork] gameApi error:', error);
|
||||
console.error('[zcode] gameApi error:', error);
|
||||
if (typeof respond === 'function') {
|
||||
respond({
|
||||
success: false,
|
||||
@@ -266,14 +266,14 @@ io.on('connection', (socket) => {
|
||||
socket.emit('narrativeResponse', toClientTurn(turn));
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[zork] playerCommand error:', error);
|
||||
console.error('[zcode] playerCommand error:', error);
|
||||
socket.emit('error', {
|
||||
message: error instanceof Error ? error.message : 'An error occurred.',
|
||||
});
|
||||
}
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`[zork] Client disconnected: ${socket.id}`);
|
||||
console.log(`[zcode] Client disconnected: ${socket.id}`);
|
||||
sessions.delete(socket.id);
|
||||
saveSlots.delete(socket.id);
|
||||
});
|
||||
@@ -291,7 +291,7 @@ function ensureDirectories() {
|
||||
path_1.default.join(__dirname, '../public/sounds'),
|
||||
path_1.default.join(__dirname, '../public/fonts'),
|
||||
path_1.default.join(__dirname, '../data/z-code'),
|
||||
path_1.default.join(__dirname, '../data/zork-prompts'),
|
||||
path_1.default.join(__dirname, '../data/zcode-prompts'),
|
||||
];
|
||||
for (const dir of dirs) {
|
||||
if (!(0, fs_1.existsSync)(dir))
|
||||
@@ -319,7 +319,7 @@ async function startServer(initialPort, range) {
|
||||
server.removeAllListeners('error');
|
||||
server.removeAllListeners('listening');
|
||||
server.once('listening', () => {
|
||||
console.log(`[zork] Zork Narrator server running on http://localhost:${port}`);
|
||||
console.log(`[zcode] Z-code Narrator server running on http://localhost:${port}`);
|
||||
resolve();
|
||||
});
|
||||
server.once('error', (err) => {
|
||||
@@ -346,8 +346,8 @@ async function startServer(initialPort, range) {
|
||||
}
|
||||
if (require.main === module) {
|
||||
startServer(PORT, PORT_RANGE).catch((err) => {
|
||||
console.error('[zork] Failed to start:', err);
|
||||
console.error('[zcode] Failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=server-zork.js.map
|
||||
//# sourceMappingURL=server-zcode.js.map
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-16
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Zork LLM Server
|
||||
*
|
||||
* Starts an Express + Socket.IO server that runs Zork I through the
|
||||
* ZorkLlmEngine and serves the same shared client UI as the YAML engine.
|
||||
*
|
||||
* Usage:
|
||||
* npm run dev:zork (development, with file watching)
|
||||
* npm run start:zork (production, from compiled dist/)
|
||||
*
|
||||
* Environment variables:
|
||||
* PORT – HTTP port (default: 3002)
|
||||
* ZORK_STORY_FILE – path to the story file (default: ./data/z-code/zork1.bin)
|
||||
* OPENROUTER_API_KEY, OPENROUTER_MODEL – required
|
||||
*/
|
||||
export {};
|
||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -279,4 +279,4 @@ if (require.main === module) {
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=test-server.js.map
|
||||
//# sourceMappingURL=test-server-yaml.js.map
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
@@ -1,408 +0,0 @@
|
||||
# Multi-Engine Architecture & Ink Engine Integration Analysis
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the redesign of the server architecture to support **three interchangeable game engine servers**, all serving the **identical client UI** over a shared Socket.IO protocol. It also covers the Ink engine specifically, the unified tag-driven event pipeline that all engines must implement, and the required changes to the YAML/LLM engine to conform to the same protocol.
|
||||
|
||||
The three engines are:
|
||||
|
||||
| Engine | Entry point | `npm run` command | Story format |
|
||||
|---|---|---|---|
|
||||
| **YAML + LLM** | `src/server.ts` | `dev:yaml` | `.yml` world model + OpenRouter LLM |
|
||||
| **Ink** | `src/server-ink.ts` | `dev:ink` | Compiled `.ink.json` |
|
||||
| **Z-Code** | `src/server-zcode.ts` | `dev:zcode` | `.z5` / `.z8` / `.zblorb` (see `zcode_inclusion.md`) |
|
||||
|
||||
All three servers:
|
||||
- Serve the same `public/` directory (static files, no change).
|
||||
- Speak the same Socket.IO event protocol (see §3).
|
||||
- Are selectable by starting a different npm script; no client code changes needed.
|
||||
|
||||
---
|
||||
|
||||
## How the Prototype Worked
|
||||
|
||||
The prototype (`prototype/game.js`) ran **entirely in the browser**:
|
||||
|
||||
1. Fetched a compiled `.ink.json` file directly (e.g. `Herrenhaus.ink.json`).
|
||||
2. Created an `inkjs.Story` instance from the JSON.
|
||||
3. Called `story.Continue()` in a loop to collect paragraphs and `story.currentTags` for each.
|
||||
4. Dispatched choices via `story.ChooseChoiceIndex(index)`.
|
||||
5. Tags drove all media and layout:
|
||||
- `CHAPTER: Title` → drop-cap heading
|
||||
- `SEPARATOR` → ornamental divider
|
||||
- `AUDIO: src` / `AUDIOLOOP: src` → sound effects / music
|
||||
- `IMAGE: src` → inline image
|
||||
- `CLASS: name` → custom CSS on a paragraph
|
||||
- `CLEAR` / `RESTART` → wipe display
|
||||
- Choice tags `ACTION: examine` → sorted into categorised choice columns
|
||||
6. Save/load used `story.state.toJson()` / `story.state.LoadJson()` stored in `localStorage`.
|
||||
|
||||
The current server/client architecture replaces all of this with a Socket.IO loop: the server processes commands and emits `narrativeResponse`; the client renders.
|
||||
|
||||
---
|
||||
|
||||
## 1. The Unified Tag-Driven Event Pipeline
|
||||
|
||||
### 1.1 Core Principle
|
||||
|
||||
All text paragraph content emitted by any engine server contains **only prose text with inline Markdown and SmartyPants punctuation**. No media, layout, or structure information is embedded in the text strings. All such information is communicated exclusively via **structured tag objects** attached to paragraphs, choices, or the turn envelope.
|
||||
|
||||
This means:
|
||||
- The client's `markup-parser-module.js` no longer needs to detect and strip custom inline markup from text.
|
||||
- `text-processor-module.js` receives clean text and applies only SmartyPants + basic Markdown (bold, italic, inline code).
|
||||
- `audio-manager-module.js`, `ui-display-handler-module.js`, and any future media module listen for tag events dispatched alongside each paragraph.
|
||||
|
||||
### 1.2 Tag Syntax (Authoring Convention)
|
||||
|
||||
All engines author tags using the same `key[value]` bracket convention. For Ink this maps directly to native `# key[value]` tags. For YAML and Z-Code the server synthesises equivalent tag objects.
|
||||
|
||||
Ink choice text uses square brackets for its own display/control syntax, but choice-local tags still use the normal tag syntax on their own tag lines below the choice. The currently implemented parser accepts `# letter[o]` and `# action[examine]`; the earlier prototype-style `# key: value` notation is not part of the active syntax.
|
||||
|
||||
| Tag | Scope | Meaning |
|
||||
|---|---|---|
|
||||
| `music[filename]` | paragraph or global | Start looping music track |
|
||||
| `music[]` | paragraph | Stop music |
|
||||
| `sfx[filename]` | paragraph | Play one-shot sound effect |
|
||||
| `image[filename](caption)` | paragraph | Insert inline image |
|
||||
| `chapter[Title]` | paragraph | Begin chapter: heading + drop-cap on next paragraph |
|
||||
| `section` | paragraph | Ornamental section break (fleuron/divider) |
|
||||
| `class[name]` | paragraph | Add CSS class to this paragraph element |
|
||||
| `background[filename]` | paragraph | Change full-page background image |
|
||||
| `clear` | paragraph | Wipe all story text from the display |
|
||||
| `title[text]` | global (first turn) | Set document/story title |
|
||||
| `author[text]` | global (first turn) | Set byline |
|
||||
| `action[category]` | choice | Sort this choice into a named column |
|
||||
| `letter[x]` | choice | Reserve keyboard letter `x` for this choice |
|
||||
|
||||
### 1.3 Parsed Tag Object Shape
|
||||
|
||||
The server parses raw tags into structured objects before sending. The client never parses tag strings.
|
||||
|
||||
```ts
|
||||
interface StoryTag {
|
||||
key: string; // e.g. "music", "chapter", "action"
|
||||
value?: string; // e.g. "forest.mp3", "Part Two", "examine"
|
||||
param?: string; // optional second param, e.g. image caption
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 YAML Engine Rewrite for Tag Support
|
||||
|
||||
**Effort: Medium**
|
||||
|
||||
The current `TextAdventureEngine` + `GameRunner` + LLM pipeline does not emit any tag objects. To conform to the unified protocol it must be extended:
|
||||
|
||||
- **Room transitions**: When the player moves to a new room, the engine emits a `music[roomTrack]` tag if the room YAML defines a `music:` property, and a `background[image]` tag if it defines `background:`.
|
||||
- **Chapter/section**: When a new room is entered for the first time, optionally emit a `chapter[roomName]` tag to trigger a drop-cap opening paragraph.
|
||||
- **SFX**: Object interaction handlers in `game-engine.ts` can emit an `sfx[filename]` tag alongside the result text when the world YAML defines `sfx:` on an object's action.
|
||||
- **YAML world-model additions** (`world-model.ts`):
|
||||
- `Room`: add optional `music?: string`, `background?: string`, `entryTag?: string` fields.
|
||||
- `GameObject`: add optional `sfx?: Record<string, string>` (action → sound file) field.
|
||||
- **`GameRunner.processCommand()`**: collect tags generated during the action, include them in the turn result alongside the narrative text.
|
||||
- **LLM narrative text**: instruct the prompt to return only clean prose with Markdown. Strip any stray bracket-tag syntax from LLM output before sending.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Unified Socket.IO Protocol
|
||||
|
||||
### 2.1 Session Lifecycle Events (unchanged)
|
||||
|
||||
These work identically on all three servers:
|
||||
|
||||
| Direction | Event | Payload |
|
||||
|---|---|---|
|
||||
| client → server | `gameApi` | `{ method, args }` → `respond(result)` |
|
||||
| server → client | `narrativeResponse` | `TurnResult` |
|
||||
| server → client | `gameSaved` | `{ slot }` |
|
||||
| server → client | `gameLoaded` | `{ slot }` |
|
||||
| server → client | `error` | `{ message }` |
|
||||
|
||||
`gameApi` methods (all engines): `newGame`, `loadGame`, `saveGame`, `hasSaveGame`, `getSaveGames`, `isGameRunning`.
|
||||
|
||||
**New `gameApi` method (Ink + Z-Code only):**
|
||||
|
||||
| Method | Args | Description |
|
||||
|---|---|---|
|
||||
| `chooseChoice` | `[choiceIndex: number]` | Select a choice by index; server runs the next turn and emits `narrativeResponse` |
|
||||
|
||||
### 2.2 The `narrativeResponse` Event (Extended)
|
||||
|
||||
This is the core change. Previously `narrativeResponse` carried `{ text, gameState, suggestions }`. It is replaced with a richer `TurnResult` shape that all engines must produce:
|
||||
|
||||
```ts
|
||||
interface TurnResult {
|
||||
turnId: number; // Unique ascending id within the game session
|
||||
paragraphs: ParagraphResult[]; // Ordered list of story paragraphs this turn
|
||||
choices: ChoiceResult[]; // Available choices (empty in text-input mode)
|
||||
inputMode: 'choice' | 'text' | 'end';
|
||||
globalTags?: StoryTag[]; // Only on first turn: title, author, etc.
|
||||
gameState?: { // Optional, engines may omit unused fields
|
||||
currentRoomId?: string;
|
||||
score?: number;
|
||||
moves?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ParagraphResult {
|
||||
text: string; // Clean prose: Markdown inline + SmartyPants only
|
||||
tags: StoryTag[]; // Structural/media tags for this paragraph
|
||||
}
|
||||
|
||||
interface ChoiceResult {
|
||||
index: number; // Engine-specific choice index
|
||||
text: string; // Display text (SmartyPants applied)
|
||||
tags: StoryTag[]; // Per-choice tags, e.g. action[examine]
|
||||
category?: string; // Derived from action[...] tag for column grouping
|
||||
letter?: string; // Optional reserved keyboard letter, derived from letter[x]
|
||||
}
|
||||
```
|
||||
|
||||
The old flattened `{ text }` field is removed. Servers must emit `TurnResult` only.
|
||||
|
||||
### 2.3 `playerCommand` vs `chooseChoice`
|
||||
|
||||
- **YAML/LLM engine**: always uses `playerCommand` (free text). `inputMode` is always `'text'` unless the game ends.
|
||||
- **Ink engine**: uses `chooseChoice` when choices are available (`inputMode: 'choice'`), `playerCommand` if an external function expects text input (`inputMode: 'text'`).
|
||||
- **Z-Code engine**: uses `playerCommand` for standard line input (`inputMode: 'text'`), character input requests translate to `chooseChoice` with a synthetic choice list (see `zcode_inclusion.md`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Three-Server NPM Configuration
|
||||
|
||||
### 3.1 `package.json` additions
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"dev:yaml": "nodemon --watch src --ext ts,json --exec \"ts-node src/server.ts\"",
|
||||
"dev:ink": "nodemon --watch src --ext ts,json --exec \"ts-node src/server-ink.ts\"",
|
||||
"dev:zcode": "nodemon --watch src --ext ts,json --exec \"ts-node src/server-zcode.ts\"",
|
||||
"start:yaml": "node dist/server.js",
|
||||
"start:ink": "node dist/server-ink.js",
|
||||
"start:zcode": "node dist/server-zcode.js"
|
||||
}
|
||||
```
|
||||
|
||||
Each server file contains only the engine-specific `handleGameApi` switch and session management. The static file serving, port-finding logic, and Socket.IO setup are extracted to a shared `src/server-base.ts` that all three import.
|
||||
|
||||
### 3.2 Shared `server-base.ts`
|
||||
|
||||
```ts
|
||||
// src/server-base.ts
|
||||
export function createServer(handleGameApi: GameApiHandler): http.Server { ... }
|
||||
export function startServer(server: http.Server, port: number, range: number): Promise<void> { ... }
|
||||
```
|
||||
|
||||
Approximately 80 lines, extracted from the current `server.ts`. No functional change.
|
||||
|
||||
---
|
||||
|
||||
## 4. Ink Engine Server
|
||||
|
||||
### 4.1 `src/engine/ink-engine.ts`
|
||||
|
||||
**Effort: ~200 lines new**
|
||||
|
||||
- `npm install inkjs` (same library as in the prototype).
|
||||
- Load `.ink.json` file at `newGame()` / on construction.
|
||||
- `continueStory(): TurnResult`:
|
||||
1. Loop `story.canContinue`: call `story.Continue()`, parse `story.currentTags` into `StoryTag[]`, collect `ParagraphResult`.
|
||||
2. After loop: map `story.currentChoices` into `ChoiceResult[]`, derive `category` from `action[...]` tags.
|
||||
3. Set `inputMode`: `'choice'` if choices present, `'end'` if story finished, `'text'` otherwise (external function waiting).
|
||||
- `chooseChoice(index)`: `story.ChooseChoiceIndex(index)` then `continueStory()`.
|
||||
- `saveGame()`: `story.state.toJson()` → stored as string in session map.
|
||||
- `loadGame(json)`: `story.state.LoadJson(json)` then `continueStory()` to reconstruct current state display.
|
||||
|
||||
### 4.2 Tag Parser
|
||||
|
||||
```ts
|
||||
// src/utils/tag-parser.ts (~30 lines)
|
||||
export function parseTag(raw: string): StoryTag | null {
|
||||
// Matches: key[value](param) or key[value] or key
|
||||
const m = raw.match(/^(\w+)(?:\[([^\]]*)\])?(?:\(([^)]*)\))?$/);
|
||||
if (!m) return null;
|
||||
return { key: m[1], value: m[2], param: m[3] };
|
||||
}
|
||||
```
|
||||
|
||||
Used by all three engines.
|
||||
|
||||
### 4.3 `src/server-ink.ts`
|
||||
|
||||
**Effort: ~60 lines new**
|
||||
|
||||
```ts
|
||||
import { createServer, startServer } from './server-base';
|
||||
import { InkEngine } from './engine/ink-engine';
|
||||
|
||||
const sessions = new Map<string, InkEngine>();
|
||||
|
||||
async function handleGameApi(socket, method, args): Promise<object> {
|
||||
switch (method) {
|
||||
case 'newGame': { /* create InkEngine, continueStory(), emit narrativeResponse */ }
|
||||
case 'chooseChoice': { /* session.chooseChoice(args[0]), emit narrativeResponse */ }
|
||||
case 'saveGame': { /* session.saveGame() → slot map */ }
|
||||
// ... isGameRunning, hasSaveGame, loadGame, getSaveGames
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Client-Side Changes
|
||||
|
||||
### 5.0 Choice UI Architecture
|
||||
|
||||
The first Ink integration should ship with the simplest useful choice UI:
|
||||
|
||||
- Display all available choices in one ordered list.
|
||||
- Ignore choice grouping tags visually for now.
|
||||
- Assign every visible choice a keyboard letter.
|
||||
- Allow at most 26 visible choices (`A` through `Z`), which is enough for the current interaction model.
|
||||
- Choices with explicit `letter[x]` tags reserve that letter first.
|
||||
- Remaining choices receive letters in ascending screen order, skipping letters already reserved by tags.
|
||||
- The chosen letter is shown in the UI and pressing that letter selects the same choice as clicking it.
|
||||
- Letter matching is case-insensitive.
|
||||
|
||||
The architecture must still be ready for later choice templates. The choice renderer should normalize choices into a presentation model:
|
||||
|
||||
```ts
|
||||
interface ChoicePresentation {
|
||||
index: number;
|
||||
text: string;
|
||||
tags: StoryTag[];
|
||||
category?: string;
|
||||
letter: string;
|
||||
templateCell: string; // initially always "default"
|
||||
}
|
||||
```
|
||||
|
||||
The first implemented template has exactly one full-size cell named `default`. It receives every choice whose tags do not match a registered template cell. A later template can register cells such as `examine`, `ask`, `inventory`, or `reflect`, and route choices by `action[...]`, `category`, or a future `cell[...]` tag without changing the server protocol.
|
||||
|
||||
### 5.1 Tag Event Dispatch
|
||||
|
||||
**Modified: `game-loop-module.js`** (or `socket-client-module.js`)
|
||||
|
||||
On every `narrativeResponse`, after feeding text into the pipeline, iterate `paragraphs[i].tags` and dispatch a DOM `CustomEvent('story:tag', { detail: tag })` for each. Global tags dispatch once as `CustomEvent('story:global-tags', { detail: globalTags })`.
|
||||
|
||||
### 5.2 `audio-manager-module.js`
|
||||
|
||||
**Effort: ~20 lines added**
|
||||
|
||||
Replace the current ad-hoc inline-markup scan with a listener:
|
||||
```js
|
||||
document.addEventListener('story:tag', ({ detail: tag }) => {
|
||||
if (tag.key === 'music') tag.value ? this.playMusic(tag.value) : this.stopMusic();
|
||||
if (tag.key === 'sfx') this.playSfx(tag.value);
|
||||
});
|
||||
```
|
||||
|
||||
### 5.3 `ui-display-handler-module.js`
|
||||
|
||||
**Effort: ~50 lines added**
|
||||
|
||||
```js
|
||||
document.addEventListener('story:tag', ({ detail: tag }) => {
|
||||
if (tag.key === 'chapter') this.beginChapter(tag.value); // emit heading, set drop-cap flag
|
||||
if (tag.key === 'section') this.insertSectionBreak();
|
||||
if (tag.key === 'image') this.insertImage(tag.value, tag.param);
|
||||
if (tag.key === 'background') this.setBackground(tag.value);
|
||||
if (tag.key === 'clear') this.clearDisplay();
|
||||
if (tag.key === 'class') this.pendingClass = tag.value; // applied to next paragraph
|
||||
});
|
||||
```
|
||||
|
||||
### 5.4 `markup-parser-module.js`
|
||||
|
||||
**Effort: ~10 lines removed, ~10 lines changed**
|
||||
|
||||
Remove all tag-detection regex from the text stream. The module now only applies SmartyPants and Markdown inline formatting to the paragraph `text` field. No structural parsing of text content.
|
||||
|
||||
### 5.5 New: `choice-display-module.js`
|
||||
|
||||
**Effort: ~150 lines new**
|
||||
|
||||
Renders available choices from the canonical `TurnResult.choices` array. Registered in `module-registry.js`.
|
||||
|
||||
- Listens for a `story:choices` event (dispatched by `game-loop-module.js` from the `TurnResult.choices` array).
|
||||
- Uses a template object with one initial full-size `default` cell.
|
||||
- Assigns keyboard letters from `letter[x]` tags first, then fills remaining choices with `A` through `Z` in visible order.
|
||||
- On click/keypress: calls `socketClient.sendChoice(index)` → `gameApi { method: 'chooseChoice', args: [index] }`.
|
||||
- Future grouped or column layouts should be implemented by adding more template cells and routing rules, not by changing the server protocol.
|
||||
|
||||
### 5.6 `ui-input-handler-module.js`
|
||||
|
||||
**Effort: ~25 lines modified**
|
||||
|
||||
Extend `setInputAvailability()` to accept a mode:
|
||||
```js
|
||||
setMode(mode) { // 'text' | 'choice' | 'end'
|
||||
this.inputArea.style.display = mode === 'text' ? '' : 'none';
|
||||
document.dispatchEvent(new CustomEvent('story:input-mode', { detail: mode }));
|
||||
}
|
||||
```
|
||||
`choice-display-module.js` listens on `story:input-mode` to show/hide choice columns.
|
||||
|
||||
### 5.7 `game-loop-module.js`
|
||||
|
||||
**Effort: ~40 lines modified**
|
||||
|
||||
On `narrativeResponse`:
|
||||
1. Iterate `paragraphs` → feed each `text` into `text-buffer` pipeline → dispatch `story:tag` for each tag array.
|
||||
2. Dispatch `story:choices` with `choices` array.
|
||||
3. Call `uiInputHandler.setMode(inputMode)`.
|
||||
4. Update `gameState` from `result.gameState` if present.
|
||||
|
||||
---
|
||||
|
||||
## 6. What Does NOT Need to Change
|
||||
|
||||
| Component | Status |
|
||||
|---|---|
|
||||
| All TTS modules | Unchanged |
|
||||
| `text-buffer-module.js`, `sentence-queue-module.js`, `animation-queue-module.js` | Unchanged |
|
||||
| `paragraph-layout-module.js`, `layout-renderer-module.js` | Unchanged (drop-cap invoked by tag event) |
|
||||
| `persistence-manager-module.js` | Unchanged |
|
||||
| `options-ui-module.js` | Unchanged |
|
||||
| `playback-coordinator-module.js` | Unchanged |
|
||||
| `ui-controller-module.js` | Minor: reads `inputMode` from game state instead of deriving it |
|
||||
| Socket.IO infrastructure, `module-registry.js`, `loader.js` | Unchanged (new module registered) |
|
||||
| `public/index.html`, CSS | Unchanged (choice columns need ~30 lines new CSS) |
|
||||
|
||||
---
|
||||
|
||||
## 7. Effort Summary
|
||||
|
||||
| Area | Effort |
|
||||
|---|---|
|
||||
| `src/server-base.ts` — shared server setup | ~80 lines extracted |
|
||||
| `src/server-ink.ts` — Ink server entry | ~60 lines new |
|
||||
| `src/engine/ink-engine.ts` — Ink story runner | ~200 lines new |
|
||||
| `src/utils/tag-parser.ts` — tag parser | ~30 lines new |
|
||||
| `TurnResult` / `StoryTag` interfaces | ~30 lines new |
|
||||
| YAML world model additions (`world-model.ts`, `game-engine.ts`, `game-runner.ts`) | ~80 lines modified |
|
||||
| `game-loop-module.js` — turn result routing + tag dispatch | ~40 lines modified |
|
||||
| `audio-manager-module.js` — tag event listener | ~20 lines added |
|
||||
| `ui-display-handler-module.js` — tag event listener | ~50 lines added |
|
||||
| `markup-parser-module.js` — remove inline tag parsing | ~10 lines removed |
|
||||
| New `choice-display-module.js` | ~150 lines new |
|
||||
| `ui-input-handler-module.js` — mode switching | ~25 lines modified |
|
||||
| CSS — choice column layout | ~30 lines added |
|
||||
| `package.json` — new npm scripts | ~8 lines |
|
||||
|
||||
**Total: ~830 lines** across ~6 new files and ~8 modified files.
|
||||
|
||||
---
|
||||
|
||||
## 8. Recommended Implementation Order
|
||||
|
||||
1. Define `TurnResult` + `StoryTag` interfaces.
|
||||
2. Write `tag-parser.ts`; parse the single canonical tag shape `key[value](param)`.
|
||||
3. Update the client socket pipeline to consume canonical `TurnResult` objects only.
|
||||
4. Dispatch structured `story:tag`, `story:choices`, and `story:input-mode` events from the socket adapter.
|
||||
5. Add `choice-display-module.js` with the single-cell default template and letter assignment described in §5.0.
|
||||
6. Update `audio-manager-module.js` and `ui-display-handler-module.js` to listen for `story:tag` events by translating them into the current media/display paths.
|
||||
7. Convert Zork server emission from flattened text to canonical `TurnResult`.
|
||||
8. Convert YAML server introduction/responses to canonical `TurnResult`.
|
||||
9. Extract `server-base.ts`; verify YAML and Zork still work.
|
||||
10. Build `InkEngine` and `server-ink.ts`; smoke-test with a compiled `.ink.json`.
|
||||
11. Once all engines emit structured tags, remove structural tag parsing from `markup-parser-module.js`.
|
||||
+15
-19
@@ -6,43 +6,39 @@
|
||||
"scripts": {
|
||||
"check:node": "node scripts/check-node-version.js",
|
||||
"prestart": "npm run check:node",
|
||||
"start": "node dist/index.js",
|
||||
"prestart:web": "npm run check:node",
|
||||
"start:web": "node dist/index.js",
|
||||
"start": "node scripts/run-engine.js start",
|
||||
"prestart:cli": "npm run check:node",
|
||||
"start:cli": "node dist/index.js --cli",
|
||||
"predev": "npm run check:node",
|
||||
"dev": "nodemon --watch src --ext ts,json --exec \"ts-node src/index.ts\"",
|
||||
"dev": "node scripts/run-engine.js dev",
|
||||
"predev:yaml": "npm run check:node",
|
||||
"dev:yaml": "nodemon --watch src --watch data/worlds --watch config/engines/yaml.json --ext ts,json,yml --exec \"ts-node src/server.ts\"",
|
||||
"dev:yaml": "nodemon --watch src --watch data/worlds --watch config/engines/yaml.json --ext ts,json,yml --exec \"ts-node src/server-yaml.ts\"",
|
||||
"dev:yaml:debug": "powershell -NoProfile -Command \"$env:YAML_DEBUG='1'; npm run dev:yaml\"",
|
||||
"dev:yaml:inspect": "powershell -NoProfile -Command \"$env:YAML_DEBUG='1'; nodemon --watch src --watch data/worlds --watch config/engines/yaml.json --ext ts,json,yml --exec \\\"node --inspect=127.0.0.1:9230 -r ts-node/register src/server.ts\\\"\"",
|
||||
"predev:web": "npm run check:node",
|
||||
"dev:web": "nodemon --watch src --ext ts,json --exec \"ts-node src/index.ts\"",
|
||||
"dev:yaml:inspect": "powershell -NoProfile -Command \"$env:YAML_DEBUG='1'; nodemon --watch src --watch data/worlds --watch config/engines/yaml.json --ext ts,json,yml --exec \\\"node --inspect=127.0.0.1:9230 -r ts-node/register src/server-yaml.ts\\\"\"",
|
||||
"predev:cli": "npm run check:node",
|
||||
"dev:cli": "nodemon --watch src --ext ts,json --exec \"ts-node src/index.ts --cli\"",
|
||||
"predev:zork": "npm run check:node",
|
||||
"dev:zork": "nodemon --watch src --watch data/zork-prompts --watch config/engines/zork.json --ext ts,json,yml --exec \"ts-node src/server-zork.ts\"",
|
||||
"dev:zork:debug": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; npm run dev:zork\"",
|
||||
"dev:zork:inspect": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; nodemon --watch src --watch data/zork-prompts --watch config/engines/zork.json --ext ts,json,yml --exec \\\"node --inspect=127.0.0.1:9229 -r ts-node/register src/server-zork.ts\\\"\"",
|
||||
"predev:zcode": "npm run check:node",
|
||||
"dev:zcode": "nodemon --watch src --watch data/zcode-prompts --watch config/engines/zcode.json --ext ts,json,yml --exec \"ts-node src/server-zcode.ts\"",
|
||||
"dev:zcode:debug": "powershell -NoProfile -Command \"$env:ZCODE_DEBUG='1'; npm run dev:zcode\"",
|
||||
"dev:zcode:inspect": "powershell -NoProfile -Command \"$env:ZCODE_DEBUG='1'; nodemon --watch src --watch data/zcode-prompts --watch config/engines/zcode.json --ext ts,json,yml --exec \\\"node --inspect=127.0.0.1:9229 -r ts-node/register src/server-zcode.ts\\\"\"",
|
||||
"predev:ink": "npm run check:node",
|
||||
"dev:ink": "nodemon --watch src --watch data/ink-src --watch config/engines/ink.json --ext ts,json,ink --exec \"ts-node src/server-ink.ts\"",
|
||||
"dev:ink:debug": "powershell -NoProfile -Command \"$env:INK_DEBUG='1'; npm run dev:ink\"",
|
||||
"dev:ink:inspect": "powershell -NoProfile -Command \"$env:INK_DEBUG='1'; nodemon --watch src --watch data/ink-src --watch config/engines/ink.json --ext ts,json,ink --exec \\\"node --inspect=127.0.0.1:9231 -r ts-node/register src/server-ink.ts\\\"\"",
|
||||
"prestart:yaml": "npm run check:node && npm run build",
|
||||
"start:yaml": "node dist/server.js",
|
||||
"start:yaml": "node dist/server-yaml.js",
|
||||
"start:yaml:debug": "powershell -NoProfile -Command \"$env:YAML_DEBUG='1'; npm run start:yaml\"",
|
||||
"start:yaml:inspect": "powershell -NoProfile -Command \"$env:YAML_DEBUG='1'; node --inspect=127.0.0.1:9230 dist/server.js\"",
|
||||
"prestart:zork": "npm run check:node && npm run build",
|
||||
"start:zork": "node dist/server-zork.js",
|
||||
"start:zork:debug": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; npm run start:zork\"",
|
||||
"start:zork:inspect": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; node --inspect=127.0.0.1:9229 dist/server-zork.js\"",
|
||||
"start:yaml:inspect": "powershell -NoProfile -Command \"$env:YAML_DEBUG='1'; node --inspect=127.0.0.1:9230 dist/server-yaml.js\"",
|
||||
"prestart:zcode": "npm run check:node && npm run build",
|
||||
"start:zcode": "node dist/server-zcode.js",
|
||||
"start:zcode:debug": "powershell -NoProfile -Command \"$env:ZCODE_DEBUG='1'; npm run start:zcode\"",
|
||||
"start:zcode:inspect": "powershell -NoProfile -Command \"$env:ZCODE_DEBUG='1'; node --inspect=127.0.0.1:9229 dist/server-zcode.js\"",
|
||||
"prestart:ink": "npm run check:node && npm run build",
|
||||
"start:ink": "node dist/server-ink.js",
|
||||
"start:ink:debug": "powershell -NoProfile -Command \"$env:INK_DEBUG='1'; npm run start:ink\"",
|
||||
"start:ink:inspect": "powershell -NoProfile -Command \"$env:INK_DEBUG='1'; node --inspect=127.0.0.1:9231 dist/server-ink.js\"",
|
||||
"pretest-server": "npm run check:node",
|
||||
"test-server": "ts-node src/test-server.ts",
|
||||
"test-server": "ts-node src/test-server-yaml.ts",
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"lint": "eslint --ext .ts src/",
|
||||
|
||||
@@ -13,9 +13,9 @@ Image block markup:
|
||||
|
||||
Sizes:
|
||||
|
||||
- `landscape`/`widescreen`: 16:9, centered, near full page width, height snapped to whole line heights.
|
||||
- `landscape`/`widescreen`: 16:9, centered, near full page width, height snapped to whole line heights, with half a line of reserved space above and below.
|
||||
- `portrait`: 16:9, half page width, height snapped to whole line heights, with following prose flowing beside it.
|
||||
- `square`: 1:1, centered, near full page width, height snapped to whole line heights.
|
||||
- `square`: 1:1, centered, near full page width, height snapped to whole line heights, with half a line of reserved space above and below.
|
||||
|
||||
Images are inserted as story blocks, saved in browser history, restored on load/history scrolling, and revealed after the page scrolls to their line-snapped position. Optional `pause=`, `delay=`, `lead=`, or bare seconds such as `2s` delay the next spoken paragraph; the pause is skippable and does not block background TTS preparation.
|
||||
|
||||
|
||||
@@ -753,7 +753,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
if (this.lastContinueAt >= (sentence.playbackStartedAt || 0)) {
|
||||
return false;
|
||||
}
|
||||
if (this.sentenceQueue.length <= 1 && this.inputMode === 'choice') {
|
||||
if (this.inputMode === 'choice') {
|
||||
return false;
|
||||
}
|
||||
return this.sentenceQueue.length > 1;
|
||||
|
||||
@@ -700,18 +700,23 @@ class TTSFactoryModule extends BaseModule {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', 'preferred_handler', id);
|
||||
this.voice = persistenceManager.getPreference('tts', 'voice', this.voice || '');
|
||||
const providerVoice = persistenceManager.getPreference('tts', `${id}_voice`, '');
|
||||
const genericVoice = persistenceManager.getPreference('tts', 'voice', this.voice || '');
|
||||
this.voice = providerVoice || genericVoice || '';
|
||||
this.language = String(this.getModule('game-config')?.getLocale?.() || this.language || 'en_US').replace('_', '-').toLowerCase();
|
||||
this.speed = persistenceManager.getPreference('tts', 'speed', this.speed || 1.0);
|
||||
}
|
||||
|
||||
const handler = this.handlers[id];
|
||||
if (handler && typeof handler.setVoiceOptions === 'function') {
|
||||
handler.setVoiceOptions({
|
||||
voice: this.voice,
|
||||
const voiceOptions = {
|
||||
speed: this.speed,
|
||||
language: this.language
|
||||
});
|
||||
};
|
||||
if (this.voice) {
|
||||
voiceOptions.voice = this.voice;
|
||||
}
|
||||
handler.setVoiceOptions(voiceOptions);
|
||||
}
|
||||
|
||||
// Dispatch events
|
||||
|
||||
@@ -21,7 +21,7 @@ Supported playback flags:
|
||||
- `once`: play the track one time.
|
||||
- `lead=<seconds>`: for block music, let the music play alone for this many seconds before the next text/TTS paragraph starts.
|
||||
|
||||
Music volume is controlled by master volume and music volume in the options menu. While TTS is playing, music is ducked to 70% of its configured volume and restored when TTS playback is idle.
|
||||
Music volume is controlled by master volume and music volume in the options menu. While TTS is playing, music is ducked by the persisted ducking percentage and restored when TTS playback is idle.
|
||||
|
||||
The ducking amount is configurable in the options menu and can be disabled with the music-ducking mute toggle. Music state is saved with browser savegames when a track is active, including the current playback position, and restored with a fade-in on load.
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function loadDotEnv(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) continue;
|
||||
const [, key, rawValue] = match;
|
||||
if (process.env[key] != null) continue;
|
||||
process.env[key] = rawValue.replace(/^["']|["']$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
loadDotEnv(path.join(projectRoot, '.env'));
|
||||
|
||||
const mode = process.argv[2] || 'dev';
|
||||
const engine = String(process.env.DEFAULT_GAME_ENGINE || process.env.GAME_ENGINE || 'ink')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const allowedModes = new Set(['dev', 'start']);
|
||||
const allowedEngines = new Set(['ink', 'yaml', 'zcode']);
|
||||
|
||||
if (!allowedModes.has(mode)) {
|
||||
console.error(`Unsupported run mode "${mode}". Use "dev" or "start".`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!allowedEngines.has(engine)) {
|
||||
console.error(`Unsupported DEFAULT_GAME_ENGINE "${engine}". Use "ink", "yaml", or "zcode".`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const script = `${mode}:${engine}`;
|
||||
console.log(`[run-engine] DEFAULT_GAME_ENGINE=${engine}; running npm run ${script}`);
|
||||
|
||||
const result = spawnSync(npmCommand, ['run', script], {
|
||||
cwd: projectRoot,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
process.exit(result.status == null ? 1 : result.status);
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'path';
|
||||
import { existsSync, mkdirSync, readFileSync } from 'fs';
|
||||
|
||||
export type EngineName = 'yaml' | 'ink' | 'zork' | string;
|
||||
export type EngineName = 'yaml' | 'ink' | 'zcode' | string;
|
||||
|
||||
export interface GameMetadata {
|
||||
title: string;
|
||||
@@ -40,7 +40,7 @@ function fallbackConfig(engine: EngineName): GameEngineConfig {
|
||||
mainGameFile:
|
||||
engine === 'ink'
|
||||
? 'data/ink/story.ink.json'
|
||||
: engine === 'zork'
|
||||
: engine === 'zcode'
|
||||
? 'data/z-code/zork1.bin'
|
||||
: 'data/worlds/example_world.yml',
|
||||
music: 'public/music',
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* Zork LLM Engine
|
||||
* Z-code LLM Engine
|
||||
*
|
||||
* Runs Zork I (or any Z-machine story file) as a headless subprocess via the
|
||||
* Runs a Z-machine story file as a headless subprocess via the
|
||||
* `ifvms` CLI, and wraps every I/O exchange with OpenRouter LLM calls that
|
||||
* translate free natural-language player input into parser commands and
|
||||
* re-voice the Z-machine's raw output as polished narrative prose.
|
||||
*
|
||||
* Configuration (environment variables):
|
||||
* ZORK_STORY_FILE – path to the .z5/.z8/.bin story file (default: ./data/z-code/zork1.bin)
|
||||
* ZORK_MAX_RETRIES – maximum command retry attempts per turn (default: 3)
|
||||
* ZORK_HISTORY_SIZE – player-facing outputs stored per room (default: 5)
|
||||
* OPENROUTER_API_KEY, OPENROUTER_MODEL – required
|
||||
* ZCODE_STORY_FILE - path to the .z5/.z8/.bin story file (default: ./data/z-code/zork1.bin)
|
||||
* ZCODE_MAX_RETRIES - maximum command retry attempts per turn (default: 3)
|
||||
* ZCODE_HISTORY_SIZE - player-facing outputs stored per room (default: 5)
|
||||
* OPENROUTER_API_KEY, OPENROUTER_MODEL - required
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
@@ -27,15 +27,15 @@ import {
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZORK_DEBUG ?? '');
|
||||
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZCODE_DEBUG ?? '');
|
||||
|
||||
function debugLog(message: string, details?: unknown): void {
|
||||
if (!DEBUG_ENABLED) return;
|
||||
if (typeof details === 'undefined') {
|
||||
console.log(`[ZorkLlm:debug] ${message}`);
|
||||
console.log(`[ZcodeLlm:debug] ${message}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[ZorkLlm:debug] ${message}`, details);
|
||||
console.log(`[ZcodeLlm:debug] ${message}`, details);
|
||||
}
|
||||
|
||||
function compactText(text: string, maxLength = 12_000): string {
|
||||
@@ -80,7 +80,7 @@ function withReasoningDefaults(
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ZorkSession {
|
||||
export interface ZcodeSession {
|
||||
characterDescription: string;
|
||||
notes: string[];
|
||||
recentParagraphs: string[];
|
||||
@@ -89,20 +89,20 @@ export interface ZorkSession {
|
||||
timeOfDay: string;
|
||||
weather: string;
|
||||
virtualInventory: string[];
|
||||
/** roomName → last N player-facing output strings */
|
||||
/** roomName -> last N player-facing output strings */
|
||||
roomHistory: Record<string, string[]>;
|
||||
currentRoom: string;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export type ZorkTurnResult = TurnResult;
|
||||
export type ZcodeTurnResult = TurnResult;
|
||||
|
||||
interface PromptConfig {
|
||||
system: string;
|
||||
user_template: string;
|
||||
}
|
||||
|
||||
interface ZorkPrompts {
|
||||
interface ZcodePrompts {
|
||||
characterGeneration: PromptConfig;
|
||||
textRewriter: PromptConfig;
|
||||
commandTranslator: PromptConfig;
|
||||
@@ -184,10 +184,10 @@ function isParserComplaint(output: string): boolean {
|
||||
].some(fragment => text.includes(fragment));
|
||||
}
|
||||
|
||||
function formatExactReadOutput(command: string, zorkOutput: string): string {
|
||||
function formatExactReadOutput(command: string, zcodeOutput: string): string {
|
||||
const object = command.replace(/^READ\s+/i, '').trim().toLowerCase();
|
||||
const label = object ? `the ${object}` : 'it';
|
||||
const cleanedOutput = zorkOutput
|
||||
const cleanedOutput = zcodeOutput
|
||||
.split('\n')
|
||||
.filter((line, index) => index !== 0 || line.trim().toUpperCase() !== command.trim().toUpperCase())
|
||||
.join('\n')
|
||||
@@ -233,10 +233,10 @@ function evolveWeather(previous: string, turnCount: number): string {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ZorkProcess – manages the ifvms zvm child process
|
||||
// ZcodeProcess – manages the ifvms zvm child process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ZorkProcess {
|
||||
class ZcodeProcess {
|
||||
private proc: ChildProcess | null = null;
|
||||
private outputBuffer = '';
|
||||
private pendingResolve: ((text: string) => void) | null = null;
|
||||
@@ -326,7 +326,7 @@ class ZorkProcess {
|
||||
});
|
||||
}
|
||||
|
||||
/** Debounced check: resolve when the buffer ends with Zork's '>' prompt. */
|
||||
/** Debounced check: resolve when the buffer ends with a Z-machine prompt. */
|
||||
private scheduleResolve(): void {
|
||||
if (!/\n>\s*$/.test(this.outputBuffer)) return;
|
||||
|
||||
@@ -361,7 +361,7 @@ class ZorkProcess {
|
||||
// Prompt loader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadPrompts(promptDir: string): ZorkPrompts {
|
||||
function loadPrompts(promptDir: string): ZcodePrompts {
|
||||
function load(filename: string): PromptConfig {
|
||||
const filePath = path.join(promptDir, filename);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
@@ -384,32 +384,32 @@ function renderTemplate(template: string, vars: Record<string, string>): string
|
||||
function logLlmError(scope: string, err: unknown): void {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const ax = err as AxiosError;
|
||||
console.error(`[ZorkLlm] ${scope} failed: ${ax.message}`);
|
||||
console.error(`[ZcodeLlm] ${scope} failed: ${ax.message}`);
|
||||
if (ax.response) {
|
||||
console.error(
|
||||
`[ZorkLlm] ${scope} status=${ax.response.status} data=`,
|
||||
`[ZcodeLlm] ${scope} status=${ax.response.status} data=`,
|
||||
ax.response.data,
|
||||
);
|
||||
if (ax.response.status === 404) {
|
||||
console.error(
|
||||
'[ZorkLlm] Hint: OPENROUTER_MODEL is likely invalid or unavailable for your API key.',
|
||||
'[ZcodeLlm] Hint: OPENROUTER_MODEL is likely invalid or unavailable for your API key.',
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`[ZorkLlm] ${scope} failed:`, err);
|
||||
console.error(`[ZcodeLlm] ${scope} failed:`, err);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ZorkLlmEngine
|
||||
// ZcodeLlmEngine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ZorkLlmEngine {
|
||||
private zork = new ZorkProcess();
|
||||
private session: ZorkSession | null = null;
|
||||
private prompts: ZorkPrompts;
|
||||
export class ZcodeLlmEngine {
|
||||
private zmachine = new ZcodeProcess();
|
||||
private session: ZcodeSession | null = null;
|
||||
private prompts: ZcodePrompts;
|
||||
private llm: AxiosInstance;
|
||||
private model: string;
|
||||
private resolvedFallbackModel: string | null = null;
|
||||
@@ -433,11 +433,11 @@ export class ZorkLlmEngine {
|
||||
);
|
||||
}
|
||||
const replacement =
|
||||
ZorkLlmEngine.DEPRECATED_MODEL_REPLACEMENTS[model] ?? null;
|
||||
ZcodeLlmEngine.DEPRECATED_MODEL_REPLACEMENTS[model] ?? null;
|
||||
if (replacement) {
|
||||
this.model = replacement;
|
||||
console.warn(
|
||||
`[ZorkLlm] Replacing deprecated model '${model}' with '${replacement}'.`,
|
||||
`[ZcodeLlm] Replacing deprecated model '${model}' with '${replacement}'.`,
|
||||
);
|
||||
} else {
|
||||
this.model = model;
|
||||
@@ -446,13 +446,13 @@ export class ZorkLlmEngine {
|
||||
requestedModel: model,
|
||||
activeModel: this.model,
|
||||
});
|
||||
this.maxRetries = parseInt(process.env.ZORK_MAX_RETRIES ?? '3', 10);
|
||||
this.historySize = parseInt(process.env.ZORK_HISTORY_SIZE ?? '5', 10);
|
||||
this.maxRetries = parseInt(process.env.ZCODE_MAX_RETRIES ?? '3', 10);
|
||||
this.historySize = parseInt(process.env.ZCODE_HISTORY_SIZE ?? '5', 10);
|
||||
this.storyPath = path.resolve(
|
||||
options.storyPath ?? process.env.ZORK_STORY_FILE ?? './data/z-code/zork1.bin',
|
||||
options.storyPath ?? process.env.ZCODE_STORY_FILE ?? './data/z-code/zork1.bin',
|
||||
);
|
||||
|
||||
const promptDir = path.resolve(options.promptDir ?? './data/zork-prompts');
|
||||
const promptDir = path.resolve(options.promptDir ?? './data/zcode-prompts');
|
||||
this.prompts = loadPrompts(promptDir);
|
||||
|
||||
this.llm = axios.create({
|
||||
@@ -489,7 +489,7 @@ export class ZorkLlmEngine {
|
||||
const fallbackModel = await this.resolveFallbackModel();
|
||||
this.model = fallbackModel;
|
||||
console.warn(
|
||||
`[ZorkLlm] Switching active model to '${fallbackModel}'.`,
|
||||
`[ZcodeLlm] Switching active model to '${fallbackModel}'.`,
|
||||
);
|
||||
const withFallbackModel = {
|
||||
...withReasoningDefaults(payload, fallbackModel),
|
||||
@@ -571,16 +571,16 @@ export class ZorkLlmEngine {
|
||||
// ---- Public API -----------------------------------------------------------
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.session?.running === true && this.zork.isAlive();
|
||||
return this.session?.running === true && this.zmachine.isAlive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new game: launch Zork, generate the player character, rewrite the
|
||||
* Start a new game: launch the Z-machine story, generate the player character, rewrite the
|
||||
* intro text, and return the first TurnResult for the client.
|
||||
*/
|
||||
async newGame(): Promise<ZorkTurnResult> {
|
||||
async newGame(): Promise<ZcodeTurnResult> {
|
||||
// Kill any existing game
|
||||
if (this.zork.isAlive()) this.zork.kill();
|
||||
if (this.zmachine.isAlive()) this.zmachine.kill();
|
||||
this.nextTurnId = 1;
|
||||
|
||||
if (!fs.existsSync(this.storyPath)) {
|
||||
@@ -591,7 +591,7 @@ export class ZorkLlmEngine {
|
||||
}
|
||||
|
||||
debugLog('launching Z-machine', { storyPath: this.storyPath });
|
||||
const rawIntro = await this.zork.launch(this.storyPath);
|
||||
const rawIntro = await this.zmachine.launch(this.storyPath);
|
||||
debugLog('Z-machine intro output', compactText(rawIntro));
|
||||
|
||||
// Generate the player character before showing any text
|
||||
@@ -628,7 +628,7 @@ export class ZorkLlmEngine {
|
||||
/**
|
||||
* Process player free-text input. Returns the next TurnResult.
|
||||
*/
|
||||
async processInput(userInput: string): Promise<ZorkTurnResult> {
|
||||
async processInput(userInput: string): Promise<ZcodeTurnResult> {
|
||||
if (!this.session?.running) {
|
||||
throw new Error('No active game session');
|
||||
}
|
||||
@@ -661,7 +661,7 @@ export class ZorkLlmEngine {
|
||||
for (const tool of cmdResponse.tools) {
|
||||
this.executeTool(tool);
|
||||
}
|
||||
// If the translator also supplied a Zork command, continue to game loop
|
||||
// If the translator also supplied a Z-machine command, continue to game loop
|
||||
if (!cmdResponse.command && !cmdResponse.commands?.length) {
|
||||
// Pure tool action — generate a brief acknowledgement via the rewriter
|
||||
const ack = await this.rewriteText(
|
||||
@@ -692,7 +692,7 @@ export class ZorkLlmEngine {
|
||||
private async runCommandPlan(
|
||||
userInput: string,
|
||||
commands: string[],
|
||||
): Promise<ZorkTurnResult> {
|
||||
): Promise<ZcodeTurnResult> {
|
||||
const texts: string[] = [];
|
||||
for (const command of commands) {
|
||||
const text = await this.runSingleCommandLoop(userInput, command);
|
||||
@@ -711,18 +711,18 @@ export class ZorkLlmEngine {
|
||||
async saveGame(): Promise<string> {
|
||||
if (!this.session) throw new Error('No active session to save');
|
||||
|
||||
const tmpFile = path.join(os.tmpdir(), `zork-save-${Date.now()}.qzl`);
|
||||
const tmpFile = path.join(os.tmpdir(), `zcode-save-${Date.now()}.qzl`);
|
||||
try {
|
||||
// Ask Zork to save, supply the temp file path, and discard the output
|
||||
await this.zork.sendLine('SAVE');
|
||||
await this.zork.sendLine(tmpFile);
|
||||
// Ask the Z-machine to save, supply the temp file path, and discard the output
|
||||
await this.zmachine.sendLine('SAVE');
|
||||
await this.zmachine.sendLine(tmpFile);
|
||||
|
||||
let zorkSave = '';
|
||||
let zcodeSave = '';
|
||||
if (fs.existsSync(tmpFile)) {
|
||||
zorkSave = fs.readFileSync(tmpFile).toString('base64');
|
||||
zcodeSave = fs.readFileSync(tmpFile).toString('base64');
|
||||
}
|
||||
|
||||
return JSON.stringify({ session: this.session, zorkSave });
|
||||
return JSON.stringify({ session: this.session, zcodeSave });
|
||||
} finally {
|
||||
if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile);
|
||||
}
|
||||
@@ -731,21 +731,21 @@ export class ZorkLlmEngine {
|
||||
/**
|
||||
* Load a previously saved game. Returns the first TurnResult after restore.
|
||||
*/
|
||||
async loadGame(savedJson: string): Promise<ZorkTurnResult> {
|
||||
const { session, zorkSave } = JSON.parse(savedJson) as {
|
||||
session: ZorkSession;
|
||||
zorkSave: string;
|
||||
async loadGame(savedJson: string): Promise<ZcodeTurnResult> {
|
||||
const { session, zcodeSave } = JSON.parse(savedJson) as {
|
||||
session: ZcodeSession;
|
||||
zcodeSave: string;
|
||||
};
|
||||
|
||||
if (this.zork.isAlive()) this.zork.kill();
|
||||
if (this.zmachine.isAlive()) this.zmachine.kill();
|
||||
|
||||
const tmpFile = path.join(os.tmpdir(), `zork-restore-${Date.now()}.qzl`);
|
||||
const tmpFile = path.join(os.tmpdir(), `zcode-restore-${Date.now()}.qzl`);
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, Buffer.from(zorkSave, 'base64'));
|
||||
fs.writeFileSync(tmpFile, Buffer.from(zcodeSave, 'base64'));
|
||||
|
||||
await this.zork.launch(this.storyPath);
|
||||
await this.zork.sendLine('RESTORE');
|
||||
const restoreOutput = await this.zork.sendLine(tmpFile);
|
||||
await this.zmachine.launch(this.storyPath);
|
||||
await this.zmachine.sendLine('RESTORE');
|
||||
const restoreOutput = await this.zmachine.sendLine(tmpFile);
|
||||
|
||||
this.session = { ...session, running: true };
|
||||
this.session.rawTranscript ??= [];
|
||||
@@ -779,7 +779,7 @@ export class ZorkLlmEngine {
|
||||
attempt,
|
||||
maxRetries: this.maxRetries,
|
||||
});
|
||||
const rawOutput = await this.zork.sendLine(command);
|
||||
const rawOutput = await this.zmachine.sendLine(command);
|
||||
lastOutput = rawOutput;
|
||||
this.appendRawTranscript(command, rawOutput);
|
||||
debugLog('received Z-machine output', {
|
||||
@@ -856,10 +856,10 @@ export class ZorkLlmEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private async rewriteText(zorkOutput: string): Promise<string> {
|
||||
private async rewriteText(zcodeOutput: string): Promise<string> {
|
||||
const cfg = this.prompts.textRewriter;
|
||||
const vars = this.buildCommonVars();
|
||||
vars['zorkOutput'] = zorkOutput;
|
||||
vars['zcodeOutput'] = zcodeOutput;
|
||||
|
||||
try {
|
||||
const response = await this.createCompletion({
|
||||
@@ -873,7 +873,7 @@ export class ZorkLlmEngine {
|
||||
return getAssistantContent(response.data).trim();
|
||||
} catch (err) {
|
||||
logLlmError('rewriteText', err);
|
||||
return zorkOutput;
|
||||
return zcodeOutput;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -896,7 +896,7 @@ export class ZorkLlmEngine {
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
logLlmError('translateCommand', err);
|
||||
// Fallback: pass input directly to Zork parser
|
||||
// Fallback: pass input directly to Z-machine parser
|
||||
return { type: 'command', command: userInput.toUpperCase() };
|
||||
}
|
||||
}
|
||||
@@ -904,14 +904,14 @@ export class ZorkLlmEngine {
|
||||
private async evaluateOutput(
|
||||
userIntent: string,
|
||||
commandTried: string,
|
||||
zorkOutput: string,
|
||||
zcodeOutput: string,
|
||||
attempt: number,
|
||||
): Promise<EvaluatorResponse> {
|
||||
const cfg = this.prompts.outputEvaluator;
|
||||
const vars = this.buildCommonVars();
|
||||
vars['userIntent'] = userIntent;
|
||||
vars['commandTried'] = commandTried;
|
||||
vars['zorkOutput'] = zorkOutput;
|
||||
vars['zcodeOutput'] = zcodeOutput;
|
||||
vars['attempt'] = String(attempt);
|
||||
vars['maxAttempts'] = String(this.maxRetries);
|
||||
|
||||
@@ -929,7 +929,7 @@ export class ZorkLlmEngine {
|
||||
} catch (err) {
|
||||
logLlmError('evaluateOutput', err);
|
||||
// Fallback: accept the raw output as-is
|
||||
return { decision: 'accept', text: zorkOutput };
|
||||
return { decision: 'accept', text: zcodeOutput };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1145,8 +1145,8 @@ export class ZorkLlmEngine {
|
||||
};
|
||||
}
|
||||
|
||||
private buildTurnResult(text: string): ZorkTurnResult {
|
||||
const alive = this.zork.isAlive();
|
||||
private buildTurnResult(text: string): ZcodeTurnResult {
|
||||
const alive = this.zmachine.isAlive();
|
||||
if (!alive && this.session) this.session.running = false;
|
||||
const paragraphs = textToParagraphs(text);
|
||||
return {
|
||||
+4
-4
@@ -5,8 +5,8 @@
|
||||
import * as path from 'path';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { GameRunner } from './cli/game-runner';
|
||||
// Import the server module and the startServer function for the web interface
|
||||
import { startServer } from './server';
|
||||
// YAML CLI entry point. The web default is selected by scripts/run-engine.js.
|
||||
import { startServer } from './server-yaml';
|
||||
import { loadGameConfig, projectPath } from './config/game-config';
|
||||
|
||||
// Load environment variables
|
||||
@@ -35,8 +35,8 @@ async function main(): Promise<void> {
|
||||
);
|
||||
const worldFile = projectPath(process.env.DEFAULT_WORLD_FILE || engineConfig.paths.mainGameFile);
|
||||
console.log(`Using world file: ${worldFile}`);
|
||||
console.log(`OpenRouter API Key: ${process.env.OPENROUTER_API_KEY ? '✓ Found' : '✗ Missing'}`);
|
||||
console.log(`OpenRouter Model: ${process.env.OPENROUTER_MODEL || '✗ Not specified'}`);
|
||||
console.log(`OpenRouter API Key: ${process.env.OPENROUTER_API_KEY ? 'Found' : 'Missing'}`);
|
||||
console.log(`OpenRouter Model: ${process.env.OPENROUTER_MODEL || 'Not specified'}`);
|
||||
|
||||
// Check if we should run in CLI mode
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* Zork LLM Server
|
||||
* Z-code LLM Server
|
||||
*
|
||||
* Starts an Express + Socket.IO server that runs Zork I through the
|
||||
* ZorkLlmEngine and serves the same shared client UI as the YAML engine.
|
||||
* ZcodeLlmEngine and serves the same shared client UI as the YAML engine.
|
||||
*
|
||||
* Usage:
|
||||
* npm run dev:zork (development, with file watching)
|
||||
* npm run start:zork (production, from compiled dist/)
|
||||
* npm run dev:zcode (development, with file watching)
|
||||
* npm run start:zcode (production, from compiled dist/)
|
||||
*
|
||||
* Environment variables:
|
||||
* PORT – HTTP port (default: 3002)
|
||||
* ZORK_STORY_FILE – path to the story file (default: ./data/z-code/zork1.bin)
|
||||
* ZCODE_STORY_FILE – path to the story file (default: ./data/z-code/zork1.bin)
|
||||
* OPENROUTER_API_KEY, OPENROUTER_MODEL – required
|
||||
*/
|
||||
|
||||
@@ -20,7 +20,7 @@ import express from 'express';
|
||||
import { Server as SocketIOServer } from 'socket.io';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { existsSync, mkdirSync, copyFileSync } from 'fs';
|
||||
import { ZorkLlmEngine, ZorkTurnResult } from './engine/zork-llm-engine';
|
||||
import { ZcodeLlmEngine, ZcodeTurnResult } from './engine/zcode-llm-engine';
|
||||
import {
|
||||
clientGameConfig,
|
||||
ensureConfiguredAssetDirectories,
|
||||
@@ -37,19 +37,19 @@ const io = new SocketIOServer(server);
|
||||
const DEFAULT_PORT = 3002;
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
|
||||
const PORT_RANGE = 300;
|
||||
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZORK_DEBUG ?? '');
|
||||
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZCODE_DEBUG ?? '');
|
||||
const engineConfig = loadGameConfig(
|
||||
process.env.ZORK_CONFIG_FILE || './config/engines/zork.json',
|
||||
'zork',
|
||||
process.env.ZCODE_CONFIG_FILE || './config/engines/zcode.json',
|
||||
'zcode',
|
||||
);
|
||||
|
||||
function debugLog(message: string, details?: unknown): void {
|
||||
if (!DEBUG_ENABLED) return;
|
||||
if (typeof details === 'undefined') {
|
||||
console.log(`[zork:debug] ${message}`);
|
||||
console.log(`[zcode:debug] ${message}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[zork:debug] ${message}`, details);
|
||||
console.log(`[zcode:debug] ${message}`, details);
|
||||
}
|
||||
|
||||
// Serve the same shared client UI
|
||||
@@ -73,11 +73,11 @@ app.get('/api/game-config', (_req, res) => {
|
||||
});
|
||||
|
||||
// One engine instance per connected socket
|
||||
const sessions = new Map<string, ZorkLlmEngine>();
|
||||
const sessions = new Map<string, ZcodeLlmEngine>();
|
||||
// Save-game slot maps: socketId → Map<slotNumber, serialisedJson>
|
||||
const saveSlots = new Map<string, Map<number, string>>();
|
||||
|
||||
function toClientTurn(turn: ZorkTurnResult): ZorkTurnResult {
|
||||
function toClientTurn(turn: ZcodeTurnResult): ZcodeTurnResult {
|
||||
return {
|
||||
...turn,
|
||||
gameState: {
|
||||
@@ -93,12 +93,12 @@ function normalizeSaveSlot(slot: unknown): number {
|
||||
return Number.isInteger(n) && n > 0 ? n : 1;
|
||||
}
|
||||
|
||||
function getOrCreateEngine(socketId: string): ZorkLlmEngine {
|
||||
function getOrCreateEngine(socketId: string): ZcodeLlmEngine {
|
||||
let engine = sessions.get(socketId);
|
||||
if (!engine) {
|
||||
engine = new ZorkLlmEngine({
|
||||
storyPath: projectPath(process.env.ZORK_STORY_FILE || engineConfig.paths.mainGameFile),
|
||||
promptDir: projectPath(engineConfig.paths.promptDir || 'data/zork-prompts'),
|
||||
engine = new ZcodeLlmEngine({
|
||||
storyPath: projectPath(process.env.ZCODE_STORY_FILE || engineConfig.paths.mainGameFile),
|
||||
promptDir: projectPath(engineConfig.paths.promptDir || 'data/zcode-prompts'),
|
||||
});
|
||||
sessions.set(socketId, engine);
|
||||
}
|
||||
@@ -190,8 +190,8 @@ async function handleGameApi(
|
||||
}
|
||||
|
||||
function checkRuntimeConfiguration(): void {
|
||||
const storyPath = projectPath(process.env.ZORK_STORY_FILE ?? engineConfig.paths.mainGameFile);
|
||||
const promptDir = projectPath(engineConfig.paths.promptDir || 'data/zork-prompts');
|
||||
const storyPath = projectPath(process.env.ZCODE_STORY_FILE ?? engineConfig.paths.mainGameFile);
|
||||
const promptDir = projectPath(engineConfig.paths.promptDir || 'data/zcode-prompts');
|
||||
const promptFiles = [
|
||||
'character-generation.yml',
|
||||
'text-rewriter.yml',
|
||||
@@ -204,17 +204,17 @@ function checkRuntimeConfiguration(): void {
|
||||
.filter((filePath) => !existsSync(filePath));
|
||||
|
||||
if (!process.env.OPENROUTER_API_KEY) {
|
||||
console.error('[zork] Missing OPENROUTER_API_KEY in environment.');
|
||||
console.error('[zcode] Missing OPENROUTER_API_KEY in environment.');
|
||||
}
|
||||
if (!process.env.OPENROUTER_MODEL) {
|
||||
console.error('[zork] Missing OPENROUTER_MODEL in environment.');
|
||||
console.error('[zcode] Missing OPENROUTER_MODEL in environment.');
|
||||
}
|
||||
if (!existsSync(storyPath)) {
|
||||
console.error(`[zork] Story file missing: ${storyPath}`);
|
||||
console.error('[zork] Place zork1.bin in ./data/z-code/ or set ZORK_STORY_FILE.');
|
||||
console.error(`[zcode] Story file missing: ${storyPath}`);
|
||||
console.error('[zcode] Place zork1.bin in ./data/z-code/ or set ZCODE_STORY_FILE.');
|
||||
}
|
||||
if (missingPrompts.length > 0) {
|
||||
console.error('[zork] Missing prompt files:');
|
||||
console.error('[zcode] Missing prompt files:');
|
||||
for (const filePath of missingPrompts) {
|
||||
console.error(` - ${filePath}`);
|
||||
}
|
||||
@@ -230,7 +230,7 @@ function checkRuntimeConfiguration(): void {
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log(`[zork] Client connected: ${socket.id}`);
|
||||
console.log(`[zcode] Client connected: ${socket.id}`);
|
||||
socket.emit('gameConfig', clientGameConfig(engineConfig));
|
||||
|
||||
socket.on(
|
||||
@@ -248,7 +248,7 @@ io.on('connection', (socket) => {
|
||||
debugLog(`gameApi response to ${socket.id}`, result);
|
||||
if (typeof respond === 'function') respond(result);
|
||||
} catch (error) {
|
||||
console.error('[zork] gameApi error:', error);
|
||||
console.error('[zcode] gameApi error:', error);
|
||||
if (typeof respond === 'function') {
|
||||
respond({
|
||||
success: false,
|
||||
@@ -275,7 +275,7 @@ io.on('connection', (socket) => {
|
||||
debugLog(`playerCommand from ${socket.id}: ${input}`);
|
||||
|
||||
try {
|
||||
const turn: ZorkTurnResult = await engine.processInput(input);
|
||||
const turn: ZcodeTurnResult = await engine.processInput(input);
|
||||
debugLog(`narrativeResponse to ${socket.id}`, {
|
||||
inputMode: turn.inputMode,
|
||||
paragraphs: turn.paragraphs.length,
|
||||
@@ -283,7 +283,7 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
socket.emit('narrativeResponse', toClientTurn(turn));
|
||||
} catch (error) {
|
||||
console.error('[zork] playerCommand error:', error);
|
||||
console.error('[zcode] playerCommand error:', error);
|
||||
socket.emit('error', {
|
||||
message:
|
||||
error instanceof Error ? error.message : 'An error occurred.',
|
||||
@@ -293,7 +293,7 @@ io.on('connection', (socket) => {
|
||||
);
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`[zork] Client disconnected: ${socket.id}`);
|
||||
console.log(`[zcode] Client disconnected: ${socket.id}`);
|
||||
sessions.delete(socket.id);
|
||||
saveSlots.delete(socket.id);
|
||||
});
|
||||
@@ -313,7 +313,7 @@ function ensureDirectories(): void {
|
||||
path.join(__dirname, '../public/sounds'),
|
||||
path.join(__dirname, '../public/fonts'),
|
||||
path.join(__dirname, '../data/z-code'),
|
||||
path.join(__dirname, '../data/zork-prompts'),
|
||||
path.join(__dirname, '../data/zcode-prompts'),
|
||||
];
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
@@ -340,7 +340,7 @@ async function startServer(initialPort: number, range: number): Promise<void> {
|
||||
server.removeAllListeners('listening');
|
||||
server.once('listening', () => {
|
||||
console.log(
|
||||
`[zork] Zork Narrator server running on http://localhost:${port}`,
|
||||
`[zcode] Z-code Narrator server running on http://localhost:${port}`,
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
@@ -369,7 +369,7 @@ async function startServer(initialPort: number, range: number): Promise<void> {
|
||||
|
||||
if (require.main === module) {
|
||||
startServer(PORT, PORT_RANGE).catch((err) => {
|
||||
console.error('[zork] Failed to start:', err);
|
||||
console.error('[zcode] Failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,674 +0,0 @@
|
||||
# Z-Code Engine Integration Analysis
|
||||
|
||||
## Overview
|
||||
|
||||
This document analyses what would be required to add a **Z-Code engine server** (`src/server-zcode.ts`) to the multi-engine architecture described in `ink_inclusion.md`. The Z-Code server would allow classic and modern Inform-compiled `.z5`, `.z8`, and `.zblorb` story files to run in the same book-style UI, over the same unified Socket.IO protocol.
|
||||
|
||||
---
|
||||
|
||||
## 1. What Is the Z-Machine?
|
||||
|
||||
The Z-machine is a virtual machine created by Infocom in 1979 for running interactive fiction. Versions 1–8 exist; almost all modern Inform 6/7-compiled games target version 5 or 8. Key properties:
|
||||
|
||||
- **Text-based I/O** via a simple line-input / text-output model.
|
||||
- **Multi-window layout**: a fixed "status bar" window (room name + score/moves) plus a scrolling story window.
|
||||
- **Formatting**: bold, italic, fixed-pitch, colours (optional).
|
||||
- **Sound** (v5+): simple beeps in older games; sampled audio via Blorb archives in modern ones.
|
||||
- **Graphics** (v6 only): a rarely-used version with arbitrary bitmap graphics windows. Almost no modern games use v6.
|
||||
- **UNDO**: built-in opcode; many games support multiple undo levels.
|
||||
- **Save/restore**: binary save files (Quetzal format).
|
||||
|
||||
The Z-machine does **not** have a tag system like Ink. All structural and media information must be inferred from the Glk/GlkOte output stream.
|
||||
|
||||
---
|
||||
|
||||
## 2. Available JavaScript Z-Machine Interpreters
|
||||
|
||||
### 2.1 `ifvms.js` (Recommended)
|
||||
|
||||
- **Repo**: [github.com/curiousdannii/ifvms.js](https://github.com/curiousdannii/ifvms.js)
|
||||
- **npm**: `npm install ifvms` (package name: `ifvms`)
|
||||
- **License**: MIT
|
||||
- **Versions supported**: Z-machine v1–8 (full), Glulx (not yet)
|
||||
- **Architecture**: JIT disassembler/compiler targeting JS. Generates an AST from Z-machine bytecode, then emits JS. Uses the **GlkOte** I/O protocol (JSON update objects).
|
||||
- **Node.js support**: Yes — used by Parchment, also ships a terminal CLI (`zvm story.z5`).
|
||||
- **Active maintenance**: Yes; last commit 10 months ago fixing edge cases in the spec.
|
||||
- **Used by**: Parchment (`iplayif.com`), Lectrote desktop interpreter.
|
||||
|
||||
### 2.2 `Bocfel` via `Emglken` (WebAssembly)
|
||||
|
||||
- **Repo**: [github.com/garglk/garglk](https://github.com/garglk/garglk) (Bocfel) + [curiousdannii/emglken](https://github.com/curiousdannii/emglken)
|
||||
- **Architecture**: Bocfel is a C interpreter compiled to WebAssembly via Emscripten. More complete Z-machine support (all versions, including V6 graphics to some extent), but WASM adds ~2 MB overhead and Node.js integration is more complex.
|
||||
- **Recommendation**: Overkill for this use case. `ifvms.js` is simpler to integrate in Node.js.
|
||||
|
||||
### 2.3 `Quixe`
|
||||
|
||||
- **Repo**: [github.com/erkyrath/quixe](https://github.com/erkyrath/quixe)
|
||||
- Implements **Glulx** (the successor to Z-machine), not Z-machine. Not directly applicable unless you want to run Glulx games.
|
||||
|
||||
**Verdict**: Use `ifvms.js` for Z-machine. If Glulx support is wanted later, add Quixe as a fourth engine type.
|
||||
|
||||
---
|
||||
|
||||
## 3. The GlkOte Protocol — The Key to Integration
|
||||
|
||||
Both `ifvms.js` and the browser Parchment front-end communicate via the **GlkOte JSON protocol**. Understanding this is essential because it is the seam where we integrate with our own pipeline.
|
||||
|
||||
GlkOte sends structured JSON **content updates** from the interpreter to the display layer:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "update",
|
||||
"windows": [
|
||||
{ "id": 1, "type": "grid", "gridheight": 1, "gridwidth": 80 },
|
||||
{ "id": 2, "type": "buffer", "rock": 201 }
|
||||
],
|
||||
"content": [
|
||||
{
|
||||
"id": 2,
|
||||
"text": [
|
||||
{ "content": [{ "style": "normal", "text": "You are in a dark room." }] },
|
||||
{ "content": [{ "style": "em", "text": "Something moves." }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"lines": [
|
||||
{ "line": 0, "content": [{ "style": "normal", "text": "Dark Room Score: 12 Moves: 7" }] }
|
||||
]
|
||||
}
|
||||
],
|
||||
"input": [
|
||||
{ "id": 2, "type": "line", "gen": 5 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The interpreter **waits** after sending an update. The display sends back input events:
|
||||
|
||||
```json
|
||||
{ "type": "line", "window": 2, "value": "go north", "gen": 5 }
|
||||
```
|
||||
|
||||
This is already very close to our `TurnResult` shape. **Our Z-Code server translates GlkOte updates into `TurnResult` objects**, forwarding them to the client via Socket.IO.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture: Z-Code Server
|
||||
|
||||
### 4.1 `src/engine/zcode-engine.ts`
|
||||
|
||||
**Effort: ~300 lines new — the hardest piece**
|
||||
|
||||
The engine runs `ifvms.js` in a Node.js worker thread (or the main thread with async I/O), intercepts GlkOte updates, and translates them:
|
||||
|
||||
```ts
|
||||
import { ZMachine } from 'ifvms'; // ifvms npm package
|
||||
|
||||
class ZCodeEngine {
|
||||
private vm: ZMachine;
|
||||
private pendingResolve: ((input: GlkInput) => void) | null = null;
|
||||
private statusLine: string = '';
|
||||
|
||||
async newGame(storyPath: string): Promise<TurnResult> { ... }
|
||||
async sendCommand(text: string): Promise<TurnResult> { ... }
|
||||
async sendCharInput(charCode: number): Promise<TurnResult> { ... }
|
||||
async undo(): Promise<TurnResult> { ... }
|
||||
|
||||
private onGlkUpdate(update: GlkUpdate): TurnResult { ... } // The translator
|
||||
}
|
||||
```
|
||||
|
||||
The `ifvms.js` Glk layer is designed to be replaceable. Instead of plugging in the browser GlkOte display, we plug in a **custom Glk backend** that captures output into our `TurnResult` structure.
|
||||
|
||||
### 4.2 GlkOte Update → `TurnResult` Translation
|
||||
|
||||
This is the core of the Z-Code server. The translation rules:
|
||||
|
||||
| GlkOte concept | `TurnResult` mapping |
|
||||
|---|---|
|
||||
| Window 2 (buffer) text spans | `paragraphs[]` — one entry per `text[]` item |
|
||||
| `style: "header"` or bold span at paragraph start | `tags: [{ key: 'chapter', value: text }]` |
|
||||
| Window 1 (grid/status bar) line 0 | `gameState.statusLine`, parsed for room/score/moves |
|
||||
| `input: [{ type: 'line' }]` | `inputMode: 'text'` |
|
||||
| `input: [{ type: 'char' }]` | `inputMode: 'char'` (see §5.4) |
|
||||
| No input (game waiting for timer/end) | `inputMode: 'end'` |
|
||||
| Sound channel open (Blorb) | `tags: [{ key: 'sfx', value: soundId }]` |
|
||||
| Background colour set | `tags: [{ key: 'background', value: cssColor }]` |
|
||||
|
||||
### 4.3 `src/server-zcode.ts`
|
||||
|
||||
**Effort: ~70 lines new**
|
||||
|
||||
Identical structure to `server-ink.ts`. `handleGameApi` creates a `ZCodeEngine` per socket session, delegates `newGame` / `saveGame` / `loadGame` to it, and also handles:
|
||||
- `playerCommand` → `engine.sendCommand(text)` → emit `narrativeResponse`
|
||||
- `chooseChoice` → `engine.sendCharInput(charCode)` for char-input mode (see §5.4)
|
||||
- `undo` (new optional API method) → `engine.undo()`
|
||||
|
||||
---
|
||||
|
||||
## 5. What Works Easily
|
||||
|
||||
### 5.1 Text Output (Buffer Window)
|
||||
|
||||
**Effort: Low** — maps directly to `paragraphs[]`. The GlkOte buffer window content arrives as an array of styled text spans per paragraph, which translates cleanly into `{ text, tags }` pairs.
|
||||
|
||||
Inline styling (`style: 'em'`, `style: 'strong'`) maps to Markdown `_..._` and `**...**` in the text field, or to an inline HTML wrapper. SmartyPants is applied server-side.
|
||||
|
||||
### 5.2 Line Input (Standard Command Mode)
|
||||
|
||||
**Effort: None** — already matches the existing `playerCommand` event and `inputMode: 'text'`. The Z-machine's line input request (`input: [{ type: 'line' }]`) maps directly.
|
||||
|
||||
### 5.3 Save and Restore
|
||||
|
||||
**Effort: Low** — `ifvms.js` handles Quetzal-format save files internally via its Glk Dialog layer. We intercept Glk file-open/write calls and redirect to Node.js `Buffer` objects stored in the session slot map (`Map<number, Buffer>`). No Quetzal parsing needed on our side.
|
||||
|
||||
### 5.4 Character Input (Menu Mode)
|
||||
|
||||
**Effort: Medium**
|
||||
|
||||
Some Z-machine games use `read_char` for yes/no prompts, menu selections, or "press any key" pauses. When GlkOte reports `input: [{ type: 'char' }]`, we set `inputMode: 'char'`.
|
||||
|
||||
The client already has a `'choice'` mode concept. For char input we can synthesise a minimal choice list:
|
||||
- For yes/no prompts: `choices: [{ index: 89, text: 'Yes' }, { index: 78, text: 'No' }]`
|
||||
- For "press any key": `choices: [{ index: 32, text: 'Continue' }]`
|
||||
|
||||
Detecting *which* char input a game expects requires heuristics (checking the game's current output context). A simpler fallback: always show a freetext input accepting single characters, passed as `engine.sendCharInput(text.charCodeAt(0))`.
|
||||
|
||||
### 5.5 UNDO
|
||||
|
||||
**Effort: Low**
|
||||
|
||||
The Z-machine `save_undo` / `restore_undo` opcodes are handled internally by `ifvms.js` when the game calls them. We can also expose an explicit `undo` API method in `handleGameApi` that the client's toolbar restart button can call. The game then emits a new turn result showing the result of undoing.
|
||||
|
||||
### 5.6 Sound Effects (V5+ with Blorb)
|
||||
|
||||
**Effort: Medium**
|
||||
|
||||
Blorb archives embed sounds alongside the story file. `ifvms.js` (via GlkOte's Blorb support) signals sound playback via `glk_schannel_play`. We intercept these Glk sound calls and translate them to `sfx[soundId]` tags in the turn result. The client's `audio-manager-module.js` then fetches and plays the audio. Sound files from the Blorb archive would need to be extracted to `public/sounds/` at server startup.
|
||||
|
||||
### 5.7 Text Styling (Bold, Italic, Fixed-Pitch)
|
||||
|
||||
**Effort: Low**
|
||||
|
||||
GlkOte styles (`em`, `strong`, `fixed`, `preformatted`) map to Markdown or HTML in the paragraph text field:
|
||||
- `em` → `_text_`
|
||||
- `strong` / `header` → `**text**`
|
||||
- `fixed` / `preformatted` → `` `text` `` or `<code>text</code>`
|
||||
|
||||
The existing `text-processor-module.js` already handles Markdown inline formatting.
|
||||
|
||||
---
|
||||
|
||||
## 6. What Is Difficult or Unsupported
|
||||
|
||||
### 6.1 Status Bar / Split Windows
|
||||
|
||||
**Effort: Medium — biggest conceptual mismatch**
|
||||
|
||||
The Z-machine has a top "status bar" (window 1, type grid) showing room name and score/moves. Our UI currently has no equivalent space.
|
||||
|
||||
Options:
|
||||
1. **Parse and emit as `gameState`**: Extract room name, score, and move count from the grid window content and send them as `gameState.statusLine` in `TurnResult`. The client `ui-controller-module.js` would display them in the existing toolbar area (room label, score). **This works for standard Inform games.**
|
||||
2. **Ignore entirely**: Many games function fine without a visible status bar.
|
||||
3. **Add a status bar element**: Add a `<div id="status-bar">` to `index.html` and update it from `gameState`. Medium CSS/JS effort.
|
||||
|
||||
The grid window parsing is fragile for non-standard layouts, but for Inform 6/7 games it is reliably structured as `Room Name Score: N Moves: M`.
|
||||
|
||||
### 6.2 Multiple Text Windows
|
||||
|
||||
**Effort: High — not practical for most games**
|
||||
|
||||
A few Z-machine v5+ games use multiple buffer windows (e.g. `Border Zone` with split-screen displays). Our UI has a single story column. Supporting arbitrary multi-window layout would require significant UI restructuring and is not recommended. These games represent a small minority.
|
||||
|
||||
### 6.3 V6 Graphics Windows
|
||||
|
||||
**Effort: Very High — not recommended**
|
||||
|
||||
Z-machine version 6 (`Shogun`, `Journey`, `Arthur`) uses arbitrary graphics windows with bitmap drawing commands. `ifvms.js` has limited V6 support. The GlkOte graphics API (pixel buffer, image blitting) has no mapping to our canvas-less UI. V6 games should be considered **out of scope**.
|
||||
|
||||
### 6.4 Colours
|
||||
|
||||
**Effort: Low–Medium**
|
||||
|
||||
Z-machine games can set foreground/background colours (16 named colours + true colour in V5+). GlkOte reports these as CSS-compatible strings. We can translate `background-colour` changes to `background[#rrggbb]` tags, but mapping arbitrary text colours to our typography-focused style is aesthetically problematic. Recommended: honour background colour changes, ignore foreground colour (always use theme typography).
|
||||
|
||||
### 6.5 Fixed-Width / Pre-formatted Text
|
||||
|
||||
**Effort: Medium**
|
||||
|
||||
Some Z-machine games use fixed-pitch text for ASCII-art maps, inventory tables, or status displays (e.g. `Anchorhead`'s newspaper). These arrive in `style: 'fixed'` or `style: 'preformatted'` spans. The current book-layout renderer with Knuth–Plass line breaking is incompatible with fixed-width layout. Options:
|
||||
- Render fixed spans as `<pre>` elements, outside the book column layout.
|
||||
- Tag the paragraph with `class[fixed]` and use monospace CSS.
|
||||
|
||||
Neither option will look as good as a native terminal display, but both are functional.
|
||||
|
||||
### 6.6 Timed Input
|
||||
|
||||
**Effort: High**
|
||||
|
||||
The Z-machine supports timed line input: the interpreter can interrupt an in-progress input request after N/10 seconds to fire a timer routine. This is used by games like `Border Zone` for real-time events. `ifvms.js` supports this, but over Socket.IO it would require sending a "timer tick" event from server to client and receiving it mid-input. This is complex and rarely needed. **Not recommended for initial implementation.**
|
||||
|
||||
### 6.7 Mouse Input (V5+, V6)
|
||||
|
||||
**Effort: High**
|
||||
|
||||
Mouse clicks are used by very few V5 games and extensively by V6 games. V5 mouse support maps to a grid window coordinate — not applicable in our single-column layout. **Out of scope.**
|
||||
|
||||
### 6.8 Hyperlinks (Extended Glk)
|
||||
|
||||
**Effort: Medium**
|
||||
|
||||
Some modern Glk-aware Z-machine games (compiled with extensions) use hyperlinks in the story text. GlkOte reports these as spans with `href` or `hyperlink` values. We could translate them to `[text](choice://N)` Markdown links that the client renders as clickable choices. Nice to have, but not essential.
|
||||
|
||||
### 6.9 Transcript / Recording
|
||||
|
||||
**Effort: Low**
|
||||
|
||||
The Z-machine's `script_on` / `script_off` opcodes open a transcript file via Glk. We can intercept these and append to a server-side text file per session. Straightforward but low priority.
|
||||
|
||||
---
|
||||
|
||||
## 7. The Paragraph Boundary Problem
|
||||
|
||||
**This is the most subtle technical challenge.**
|
||||
|
||||
The Z-machine does not have an explicit "paragraph" concept. Games print text character by character (or string by string) via `print` opcodes. GlkOte batches output between input requests into `content[].text[]` arrays, where each array element is one `glk_put_string` call. A single "paragraph" may be split across many such calls.
|
||||
|
||||
The rule we apply for translation:
|
||||
- A `\n` newline within output → end current paragraph, start new.
|
||||
- Two consecutive `\n` → additional visual space (paragraph break).
|
||||
- No trailing `\n` → append to current paragraph buffer.
|
||||
|
||||
This is exactly what `text-buffer-module.js` already does on the client for the LLM narrative stream. Applying the same logic server-side before building `ParagraphResult[]` works correctly for standard Inform games.
|
||||
|
||||
Edge cases:
|
||||
- Games that use `glk_put_char('\n')` to create specific whitespace (e.g. centred headings) may produce unexpected splits. These are rare.
|
||||
- The status bar (window 1 grid) output does not follow this rule and must be handled separately (see §6.1).
|
||||
|
||||
---
|
||||
|
||||
## 8. Save/Restore Deep Dive
|
||||
|
||||
`ifvms.js` implements Glk file operations via a pluggable `Dialog` object. We replace the default browser-localStorage `Dialog` with a Node.js implementation that stores Quetzal save data in `Buffer` objects:
|
||||
|
||||
```ts
|
||||
const customDialog = {
|
||||
open: (usage, mode, rock, callback) => { /* return file reference */ },
|
||||
read: (ref, callback) => callback(saveSlots.get(currentSlot)), // Buffer
|
||||
write: (ref, data, callback) => { saveSlots.set(currentSlot, data); callback(); },
|
||||
};
|
||||
```
|
||||
|
||||
This is ~50 lines and gives us full save/restore with no changes to `ifvms.js`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Effort Summary
|
||||
|
||||
| Area | Effort | Notes |
|
||||
|---|---|---|
|
||||
| `src/engine/zcode-engine.ts` — GlkOte translator | **~300 lines new** | Hardest piece |
|
||||
| Custom Glk Dialog (save/restore) | **~50 lines new** | Replaces localStorage |
|
||||
| `src/server-zcode.ts` — server entry | **~70 lines new** | Same pattern as ink server |
|
||||
| Blorb sound extraction utility | **~60 lines new** | Run at startup |
|
||||
| Status bar parsing + `gameState` | **~30 lines** | In `zcode-engine.ts` |
|
||||
| Client: `story:char-input` mode handling | **~20 lines** | Extend `choice-display-module.js` |
|
||||
| CSS: fixed-pitch paragraph style | **~15 lines** | `style.css` |
|
||||
| **`ifvms.js` npm dependency** | `npm install ifvms` | |
|
||||
|
||||
**Total new Z-Code specific code: ~530 lines** across 4 new files. All client-side changes reuse the infrastructure built for the Ink engine (tag events, choice display, input mode switching).
|
||||
|
||||
---
|
||||
|
||||
## 10. Feature Support Matrix
|
||||
|
||||
| Z-Machine Feature | Support Level | Notes |
|
||||
|---|---|---|
|
||||
| Text output (buffer window) | ✅ Full | Core functionality |
|
||||
| Line input | ✅ Full | Standard command prompt |
|
||||
| Character input | ⚠️ Partial | Synthesised choice list or single-char text field |
|
||||
| UNDO | ✅ Full | Via Z-machine internal opcode |
|
||||
| Save / Restore | ✅ Full | Custom Glk Dialog with server-side Buffers |
|
||||
| Status bar (room/score) | ⚠️ Partial | Parsed and shown in toolbar, not as a true grid window |
|
||||
| Text styles (bold/italic/fixed) | ⚠️ Partial | Mapped to Markdown/CSS; fixed-width not book-formatted |
|
||||
| Colours | ⚠️ Partial | Background colour only; foreground ignored |
|
||||
| Sound effects (Blorb v5+) | ⚠️ Partial | Requires Blorb extraction at startup |
|
||||
| Simple beeps (v3) | ❌ Ignored | No concept in our audio system |
|
||||
| Multiple buffer windows | ❌ Not supported | UI has single story column |
|
||||
| V6 graphics | ❌ Out of scope | No canvas rendering |
|
||||
| Timed input | ❌ Not supported | Complex; rarely needed |
|
||||
| Mouse input | ❌ Out of scope | No pointing concept in UI |
|
||||
| Hyperlinks | ⚠️ Optional | Could map to clickable choice spans |
|
||||
| Transcript | ⚠️ Optional | Server-side file append |
|
||||
|
||||
---
|
||||
|
||||
## 11. Recommended Implementation Order
|
||||
|
||||
1. `npm install ifvms`
|
||||
2. Write `ZCodeEngine` with a minimal custom Glk backend, returning raw GlkOte updates.
|
||||
3. Implement the GlkOte → `TurnResult` translator for buffer window only (ignoring status bar and styles).
|
||||
4. Build `server-zcode.ts`; test with a simple `.z5` file (e.g. `Zork I`) via `playerCommand`.
|
||||
5. Add Quetzal save/restore via custom Dialog.
|
||||
6. Add status bar parsing → `gameState`.
|
||||
7. Add Blorb sound extraction + `sfx` tag emission.
|
||||
8. Add fixed-pitch / bold text style mapping.
|
||||
9. Handle char input mode (yes/no, press-any-key).
|
||||
10. Test with a range of Inform 6 and Inform 7 games.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Section 2: LLM-Enhanced Zork Engine ("Zork Narrator")
|
||||
|
||||
### 2.1 Concept
|
||||
|
||||
This engine runs a specific Z-machine game — Zork I — inside `ifvms.js` as a headless backend, while an LLM acts as a narrative layer between the Z-machine and the player. The player never sees raw Z-machine output; they read a continuously re-voiced prose adaptation of it. Conversely, the player never types parser commands; they write in natural language, and the LLM translates their intent into the terse syntax Zork's parser understands.
|
||||
|
||||
The central premise is that **Zork's world state is authoritative** — only the Z-machine determines what actually exists, what has been taken, what doors are unlocked — while **everything the player reads and writes is mediated by an LLM** that maintains a consistent narrative voice, a distinct player character, and persistent memory across the session.
|
||||
|
||||
This engine is a separate npm server (`dev:zork`) that shares the same client UI over the same Socket.IO protocol. No client changes are required.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Architecture Overview
|
||||
|
||||
```
|
||||
Player ──(free text)──► [Command Translator LLM]
|
||||
│
|
||||
┌──────────┴───────────┐
|
||||
│ tool call │ Zork command
|
||||
▼ ▼
|
||||
[Session Manager] [Z-Machine (ifvms.js)]
|
||||
(char, notes) │
|
||||
raw Zork output
|
||||
│
|
||||
▼
|
||||
[Output Evaluator LLM]
|
||||
┌─────────┴──────────┐
|
||||
│ retry │ accept
|
||||
▼ ▼
|
||||
new command [Text Rewriter LLM]
|
||||
│
|
||||
prose output
|
||||
▼
|
||||
Player
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Component Inventory
|
||||
|
||||
| Component | File | Role |
|
||||
|---|---|---|
|
||||
| Z-machine subprocess | `src/engine/zork-llm-engine.ts` → `ZorkProcess` | Runs `zork1.bin` via `ifvms` CLI; captures text I/O |
|
||||
| Session state | `ZorkSession` (in-memory) | Character description, notes, room history |
|
||||
| LLM client | Inline in engine (axios + OpenRouter) | Four distinct prompt invocations per turn |
|
||||
| Prompt files | `data/zork-prompts/*.yml` | YAML templates; easily refined without code changes |
|
||||
| Engine | `ZorkLlmEngine` | Orchestrates the three-step loop |
|
||||
| Server | `src/server-zork.ts` | Express + Socket.IO, same pattern as YAML server |
|
||||
| Story file | `data/z-code/zork1.bin` | Provided by the operator; not in version control |
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Session State
|
||||
|
||||
The engine maintains one `ZorkSession` object per connected socket:
|
||||
|
||||
```ts
|
||||
interface ZorkSession {
|
||||
characterDescription: string; // Generated at game start; LLM can update
|
||||
notes: string[]; // Persistent notes the LLM adds/removes
|
||||
roomHistory: Record<string, string[]>; // roomName → up to 5 recent player-facing outputs
|
||||
currentRoom: string; // Latest known room name
|
||||
running: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
When saved, the session is serialised to JSON and stored in a server-side slot map (same pattern as the YAML engine). The Zork process state is saved by sending the `SAVE` command to the running Z-machine and capturing the resulting Quetzal binary, stored as Base64 inside the session JSON.
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Prompt Files
|
||||
|
||||
All four prompts live in `data/zork-prompts/` as YAML files with two fields:
|
||||
|
||||
- `system` — the system message, used verbatim.
|
||||
- `user_template` — the user message, with `{{variable}}` placeholders substituted at call time.
|
||||
|
||||
Available variables in every prompt:
|
||||
|
||||
| Variable | Contents |
|
||||
|---|---|
|
||||
| `{{characterDescription}}` | Current player character prose |
|
||||
| `{{notes}}` | Numbered list of persistent notes, or "(none)" |
|
||||
| `{{roomHistory}}` | Up to 5 most recent player-facing outputs for the current room |
|
||||
| `{{currentRoom}}` | Current room name |
|
||||
|
||||
#### 2.5.1 `character-generation.yml`
|
||||
|
||||
Called once at the start of a new game, before any Z-machine output is processed.
|
||||
|
||||
- **Input**: none (no user message template needed; the system message is the full prompt).
|
||||
- **Expected output**: A single block of vivid prose (300–500 words) describing the player character — name, personality, history, voice, quirks, motivations.
|
||||
- **Used in**: All subsequent prompts as `{{characterDescription}}`.
|
||||
|
||||
#### 2.5.2 `text-rewriter.yml`
|
||||
|
||||
Called only for the game's **opening text** (before the player has made any input) and for any Z-machine output produced when re-entering a previously visited room that has no recent history yet.
|
||||
|
||||
Additional template variables:
|
||||
|
||||
| Variable | Contents |
|
||||
|---|---|
|
||||
| `{{zorkOutput}}` | Raw Z-machine text |
|
||||
|
||||
- **Expected output**: Plain prose. No JSON. No Z-machine parser vocabulary. Written in second person (or first if the character's voice demands it), in the established narrative style.
|
||||
|
||||
#### 2.5.3 `command-translator.yml`
|
||||
|
||||
Called each time the player submits input. Translates free natural-language text into a Zork parser command — or decides that no game command is appropriate.
|
||||
|
||||
Additional template variables:
|
||||
|
||||
| Variable | Contents |
|
||||
|---|---|
|
||||
| `{{userInput}}` | Raw player input |
|
||||
|
||||
- **Expected output**: A JSON object with one of these shapes:
|
||||
|
||||
```jsonc
|
||||
// Player input maps to a Zork command
|
||||
{ "type": "command", "command": "open mailbox" }
|
||||
|
||||
// Player input has no in-game equivalent; reply narratively
|
||||
{ "type": "reply", "text": "You pause and take a steadying breath..." }
|
||||
|
||||
// LLM wants to update session state; may also include a command
|
||||
{
|
||||
"type": "tools",
|
||||
"tools": [
|
||||
{ "name": "add_note", "args": { "note": "Player is afraid of the dark." } },
|
||||
{ "name": "update_character", "args": { "description": "Updated character prose..." } },
|
||||
{ "name": "remove_note", "args": { "index": 2 } }
|
||||
],
|
||||
"command": "examine lantern" // optional — also try this command
|
||||
}
|
||||
```
|
||||
|
||||
The `command-translator` prompt must include the full list of available tools with descriptions, so that the LLM knows what actions it can take independently of the Z-machine.
|
||||
|
||||
#### 2.5.4 `output-evaluator.yml`
|
||||
|
||||
Called after each Z-machine response. Decides whether to accept and rewrite the output, or to discard it and try a different command.
|
||||
|
||||
Additional template variables:
|
||||
|
||||
| Variable | Contents |
|
||||
|---|---|
|
||||
| `{{userIntent}}` | Original player input (natural language) |
|
||||
| `{{commandTried}}` | The Zork command that was sent |
|
||||
| `{{zorkOutput}}` | Raw Z-machine text |
|
||||
| `{{attempt}}` | Current retry attempt number (1-based) |
|
||||
| `{{maxAttempts}}` | Maximum allowed retry attempts |
|
||||
|
||||
- **Expected output**: A JSON object with one of two shapes:
|
||||
|
||||
```jsonc
|
||||
// Accept: rewrite the output for the player
|
||||
{
|
||||
"decision": "accept",
|
||||
"text": "The heavy lid of the mailbox swings open..."
|
||||
}
|
||||
|
||||
// Retry: discard this output, try a different command instead
|
||||
{
|
||||
"decision": "retry",
|
||||
"command": "open mailbox with hands"
|
||||
}
|
||||
```
|
||||
|
||||
When the evaluator returns `retry`, the engine sends the new command to the Z-machine and calls the evaluator again on the result. If the maximum number of retries is reached without acceptance, the engine falls back to rewriting the last Z-machine output regardless.
|
||||
|
||||
**The evaluator should return `retry` when:**
|
||||
- The Z-machine says it does not understand the command ("I don't understand that" / "That's not a verb I recognise").
|
||||
- The Z-machine says the action is not possible in a way that suggests a different phrasing would work ("You can't go that way" when the player clearly wants to go somewhere).
|
||||
- The output is mechanically correct but logically inconsistent with the established narrative.
|
||||
|
||||
**The evaluator should return `accept` when:**
|
||||
- The Z-machine performed an observable world-state change (picked up an object, moved to a new room, unlocked something).
|
||||
- The action failed in a meaningful, story-relevant way ("The troll blocks your path").
|
||||
- The maximum retry count has been reached (soft-forced accept on the last attempt).
|
||||
|
||||
---
|
||||
|
||||
### 2.6 The Game Loop in Detail
|
||||
|
||||
#### Start-Up
|
||||
|
||||
1. Spawn the Z-machine subprocess with `zork1.bin`.
|
||||
2. Collect all output until the first `>` input prompt.
|
||||
3. Call `character-generation` LLM → store result as `session.characterDescription`.
|
||||
4. Call `text-rewriter` LLM with the Zork intro text → send rewritten output to client as `TurnResult`.
|
||||
5. Set `inputMode: 'text'`; await player input.
|
||||
|
||||
#### Per-Turn Loop
|
||||
|
||||
```
|
||||
user input
|
||||
│
|
||||
▼
|
||||
command-translator LLM
|
||||
│
|
||||
├─── type: 'reply' → send LLM text to client; await next input (no Zork involved)
|
||||
│
|
||||
├─── type: 'tools' → execute tool actions (update character / add or remove notes)
|
||||
│ │ then, if a command is included, fall through to ↓
|
||||
│ └─────────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
└─── type: 'command' ──────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
send command to Z-machine
|
||||
│
|
||||
▼
|
||||
raw Zork output
|
||||
│
|
||||
▼
|
||||
extract room name → update currentRoom → update roomHistory
|
||||
│
|
||||
▼
|
||||
output-evaluator LLM (attempt N of maxRetries)
|
||||
│
|
||||
├─── decision: 'retry' AND attempt < maxRetries
|
||||
│ │
|
||||
│ └──→ send new command to Z-machine (loop back)
|
||||
│
|
||||
└─── decision: 'accept' (or maxRetries exceeded)
|
||||
│
|
||||
▼
|
||||
store accepted text in roomHistory[currentRoom]
|
||||
│
|
||||
▼
|
||||
send TurnResult to client; await next input
|
||||
```
|
||||
|
||||
#### Room History
|
||||
|
||||
Each accepted player-facing output is stored in `session.roomHistory[roomName]`. Only the five most recent entries per room are kept (older entries are dropped). This rolling history is injected into all subsequent LLM prompts via `{{roomHistory}}`, ensuring that descriptions added to a room on previous visits can be referenced again when the player returns.
|
||||
|
||||
Room names are extracted from Z-machine output using the status bar (window 1 in the GlkOte protocol), which Zork reliably populates with the current room name. As a fallback, the first line of each Z-machine response is used if it matches the pattern for a room title (capitalised, fewer than 60 characters, no trailing punctuation).
|
||||
|
||||
---
|
||||
|
||||
### 2.7 LLM Tool Actions
|
||||
|
||||
The `command-translator` prompt informs the LLM of three tool actions it can invoke instead of — or in addition to — a Zork command:
|
||||
|
||||
| Tool | Arguments | Effect |
|
||||
|---|---|---|
|
||||
| `update_character` | `description: string` | Replaces `session.characterDescription` with new prose |
|
||||
| `add_note` | `note: string` | Appends a note to `session.notes` |
|
||||
| `remove_note` | `index: number` | Removes the note at the given zero-based index |
|
||||
|
||||
These tools exist to handle player inputs that have no in-game equivalent but *do* have a character-state equivalent. Examples:
|
||||
|
||||
- Player writes: *"I decide I'm no longer scared of trolls after that encounter."* → LLM calls `update_character` to amend the character's personality note, then replies narratively.
|
||||
- Player writes: *"Remember that the sword glows near enemies."* → LLM calls `add_note` with that fact so it appears in all future evaluator and rewriter prompts.
|
||||
- Player writes: *"Forget about that note about the axe."* → LLM calls `remove_note` referencing the relevant index.
|
||||
|
||||
---
|
||||
|
||||
### 2.8 Configuration
|
||||
|
||||
All runtime configuration is provided via environment variables (`.env`):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `OPENROUTER_API_KEY` | — | Required |
|
||||
| `OPENROUTER_MODEL` | — | Required (e.g. `anthropic/claude-3-5-sonnet`) |
|
||||
| `ZORK_STORY_FILE` | `./data/z-code/zork1.bin` | Path to the Z-machine story file |
|
||||
| `ZORK_MAX_RETRIES` | `3` | Maximum retry attempts before forced accept |
|
||||
| `ZORK_HISTORY_SIZE` | `5` | Number of past outputs stored per room |
|
||||
| `PORT` | `3002` | HTTP port for the Zork server |
|
||||
|
||||
---
|
||||
|
||||
### 2.9 Save and Restore
|
||||
|
||||
Saving a game involves two steps:
|
||||
|
||||
1. Send the `SAVE` command to the running Z-machine subprocess; respond to its filename prompt with a temporary file path; read the resulting Quetzal binary and encode it as Base64.
|
||||
2. Serialise `ZorkSession` (character, notes, room history, current room) to JSON. Embed the Base64 Quetzal data as `zorkSave`.
|
||||
|
||||
Loading reverses this: decode and write the Quetzal file to a temp path, start a fresh Z-machine process, send `RESTORE` and provide that path, then run `continueUntilPrompt()` to reach the restored state.
|
||||
|
||||
---
|
||||
|
||||
### 2.10 Effort Estimate
|
||||
|
||||
| Component | Effort |
|
||||
|---|---|
|
||||
| `ZorkProcess` class (subprocess management) | ~100 lines |
|
||||
| `ZorkLlmEngine` class (session + loop logic) | ~280 lines |
|
||||
| `src/server-zork.ts` | ~80 lines |
|
||||
| 4 × YAML prompt files | ~200 lines total |
|
||||
| `package.json` script additions | 4 lines |
|
||||
| **Total** | **~660 lines** |
|
||||
|
||||
No client-side changes are required. The engine reuses `axios` and `js-yaml`, which are already in the project's dependency tree. The only new dependency is `ifvms`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Recommended Test Games
|
||||
|
||||
| Game | Format | Tests |
|
||||
|---|---|---|
|
||||
| `Zork I` | z3 | Basic text, status bar, save/restore |
|
||||
| `Anchorhead` | z8 | Fixed-width text, complex parser |
|
||||
| `Lost Pig` | z8 | Modern Inform 6, clean output |
|
||||
| `Counterfeit Monkey` | zblorb | Blorb, sounds, complex Inform 7 |
|
||||
| `Anchorhead` (Glulx re-release) | — | Out of scope (Glulx, not Z-machine) |
|
||||
| Any V6 game | z6 | Expect graceful failure / fallback |
|
||||
Reference in New Issue
Block a user