33 Commits

Author SHA1 Message Date
Georg beac5a2be3 Fix stale restore after game restart 2026-05-20 22:27:36 +02:00
Georg 8258ea2321 Update TTS providers and story markup 2026-05-20 22:13:31 +02:00
Georg b911c40d89 Stabilize TTS voice reload and reconnect logging 2026-05-19 17:08:48 +02:00
Georg df5933c194 Fix autosave resume choice restoration 2026-05-19 15:48:15 +02:00
Georg 9111dedaa2 Archive prototype outside main tree 2026-05-19 14:39:09 +02:00
Georg ebc8e1c7df Add Ink session recovery and Coolify Docker support 2026-05-19 13:14:46 +02:00
Georg dbcb8f4284 Consolidate engine docs and naming 2026-05-19 11:09:37 +02:00
Georg 121b174f2c Add glossary hover presentation 2026-05-19 07:34:52 +02:00
Georg 751ac5f62b Stabilize playback state and cursor feedback 2026-05-18 20:57:20 +02:00
Georg 6e908037fb Preload media assets and refine process cursors 2026-05-18 11:15:39 +02:00
Georg 4f6300042c Fix portrait image flow and drop-cap spacing 2026-05-18 03:08:23 +02:00
Georg d7bb175167 Checkpoint current UI and ink integration state 2026-05-18 02:46:02 +02:00
Georg 2c54498ee2 Document markup and improve choice tags 2026-05-17 15:52:41 +02:00
Georg c2fb27b6b8 Tighten detailed book page calibration 2026-05-16 22:19:12 +02:00
Georg e1a5d5809d Adapt book skin to detailed artwork 2026-05-16 22:06:56 +02:00
Georg f8911f6fc8 Keep live appends out of history reflow 2026-05-16 21:46:44 +02:00
Georg e368d252ad Refine line-based story scrolling 2026-05-16 21:40:36 +02:00
Georg b9ae7f71c5 Checkpoint line-grid renderer state 2026-05-16 15:57:03 +02:00
Georg fe33e4f0ab Checkpoint before line-grid scrolling refactor 2026-05-16 13:44:03 +02:00
Georg 42582352d6 Add storage-backed story history 2026-05-15 21:58:30 +02:00
Georg f2e786d5bc Add ink integration UI and media playback 2026-05-15 21:23:46 +02:00
Georg 44dc64f830 Add Ink integration notes 2026-05-15 08:11:35 +02:00
Georg 6faee20268 Add Zork engine integration work 2026-05-15 07:55:05 +02:00
Georg b8fe8535aa Fix story page scrolling and ellipsis spacing 2026-05-15 07:35:27 +02:00
Georg 74be77b267 Updated animations. 2026-05-14 23:19:06 +02:00
Georg 9a6bb009f2 Fixed Ducking. Refined UI. 2026-05-14 23:18:30 +02:00
Georg b5829ed773 Remove consolidated reference documentation 2026-05-14 21:18:54 +02:00
Georg 873049f7e6 Checkpoint current interactive fiction state 2026-05-14 21:17:43 +02:00
Georg c745efd1d2 Latest state before reworking with cluade 4.6. 2026-02-12 22:44:44 +00:00
Georg b1387f4833 Fixed kokoro loading process. 2025-04-07 06:51:45 +00:00
Georg 0842cbfefc Cleaned persistence manager, updated ui-options connectivity 2025-04-06 19:35:05 +00:00
Georg 0ab639fd25 Refactored modules and updated loader. 2025-04-06 18:35:04 +00:00
Georg fc693ae695 Fix Kokoro TTS integration issues: Remove API key requirement and ensure system-specific options display correctly 2025-04-05 22:06:22 +00:00
534 changed files with 956559 additions and 15749 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(wc:*)",
"Bash(git -C /workspaces/ai.interactive.fiction log --oneline -15)"
]
}
}
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
.git
.env
.env.*
!.env.example
npm-debug.log*
coverage
.nyc_output
data/ink-src/eibenreith.old.ink
+2 -1
View File
@@ -1,6 +1,7 @@
# OpenRouter API Configuration
OPENROUTER_API_KEY=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
OPENROUTER_MODEL=anthropic/claude-3-opus-20240229
OPENROUTER_MODEL=openai/gpt-5.5
OPENROUTER_REASONING_EFFORT=none
# Application Configuration
PORT=3001
+4 -1
View File
@@ -1,10 +1,13 @@
# OpenRouter API Configuration
OPENROUTER_API_KEY=your_openrouter_api_key_here
OPENROUTER_MODEL=your_selected_model_here
OPENROUTER_MODEL=openai/gpt-5.5
# GPT-5 reasoning tokens can consume short completion budgets; keep narration calls direct by default.
OPENROUTER_REASONING_EFFORT=none
# Application Configuration
PORT=3000
NODE_ENV=development
# Game Configuration
DEFAULT_GAME_ENGINE=ink
DEFAULT_WORLD_FILE=./data/worlds/example_world.yml
+5
View File
@@ -2,3 +2,8 @@ node_modules
# windsurf rules
.windsurfrules
# local inspection / generated scratch artifacts
.tmp/
*.orig
*.bkp
+1
View File
@@ -0,0 +1 @@
22
+29
View File
@@ -0,0 +1,29 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV DEFAULT_GAME_ENGINE=ink
ENV PORT=3000
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
COPY --from=build /app/public ./public
COPY --from=build /app/config ./config
COPY --from=build /app/data ./data
COPY --from=build /app/scripts ./scripts
EXPOSE 3000
CMD ["node", "dist/server-ink.js"]
+120
View File
@@ -0,0 +1,120 @@
# Markup Guidelines
This file documents author-facing Ink tag conventions. The active parser normalizes tags into structured `StoryTag` objects before they reach the UI.
## Implemented Tag Forms
Use bracket tags for titles, filenames, and longer text:
```ink
#chapter[Eibenreith]
#image[statue.png](square)
#music[Kaiserpunk Waltz.mp3](crossfade, loop, lead=8)
#sfx[church-bells.ogg](max=8, fade)
#score[You reached an ending.]
#achievement[First Steps]
#alert[Try examining the room.]
```
Use colon tags for short identifiers, categories, and choice keys:
```ink
#action:movement
#key:l
#sort:last
#gated:noble
```
Bare flags are accepted as tags with no value:
```ink
#optional
```
## Right-Page Glossary Notes
Glossary notes are story tags scoped to the paragraph/block they belong to. They affect only the right-page story rendering, never choice text or command history.
```ink
The conductor points toward Eibenreith.
#gloss[Eibenreith](A fictional alpine town in the Kaiserpunk setting.)
```
The bracket value is the visible term to find. The parenthesized value is the note shown on hover/focus. The renderer marks every matching instance of the term in the same right-page block. The tag is not displayed and is not sent to TTS. Avoid raw Ink control characters in the explanation; `|`, `{`, and `}` must be escaped in Ink as `\|`, `\{`, and `\}` if they are needed literally.
## TTS Reading Instructions
TTS instruction tags are story tags scoped to the paragraph/block they belong to. They are not rendered, and they are only sent to TTS providers that support per-request reading instructions. Currently this means OpenAI with `gpt-4o-mini-tts`.
```ink
„Ich habe nichts gesehen“, sagt Viktor.
#tts[Read softly, with controlled unease.]
```
The default form omits a provider and is the preferred authoring style. Providers that support instructions may consume it; providers that do not support instructions silently ignore it. Provider-specific instructions are only needed when two providers should receive different direction, or when an instruction must be hidden from all but one provider. They use the tag parameter position:
```ink
„Ich habe nichts gesehen“, sagt Viktor.
#tts[openai](Read softly, with controlled unease.)
```
The shorthand `#tts-openai[...]` is also accepted. `#tts(...)` is equivalent to providerless `#tts[...]` if parentheses read better in a local context. `tts-1` and `tts-1-hd` ignore these instructions because the OpenAI speech endpoint only supports the `instructions` request parameter for `gpt-4o-mini-tts`.
Keep instructions short and describe performance rather than content. OpenAI's TTS guide recommends using `gpt-4o-mini-tts` when you need controllable delivery; useful instruction targets include tone, emotional range, intonation, speaking speed, accent, impressions, and whispering. Good examples:
```ink
#tts[Speak with restrained concern and a slower pace.]
#tts[Whisper the line with controlled urgency.]
#tts-openai[Use a dry, formal tone; avoid melodrama.]
```
Avoid repeating the full dialogue in the instruction. Put the words to be spoken in the story text, and use `#tts` only to describe how the provider should read that block.
## Choice Metadata
Choice tags are placed on the Ink choice they belong to:
```ink
* [__Schaue__: Aus dem Fenster.]
#action:orientation
#key:l
```
Implemented choice metadata:
- `#key:x`: reserves keyboard key `X` for the choice.
- `#letter[x]`: older equivalent for reserving keyboard key `X`.
- `#action:group` or `#action[group]`: assigns the choice to an invisible action group.
The current UI renders all choices in one visible list. Choices are first grouped by `#action` in the order each new action group appears in the authored choice list. Choices inside each group are randomized. Choices without `#action` form one final unlabelled group shown after all tagged groups. Explicit keys are assigned before automatic keys; choices without explicit keys receive `1` through `0`, then `A` through `Z` in final visible order while skipping explicit keys. `#optional` choices are displayed italic. Grouping columns, `#gated[...]`, and `#sort[...]` are documented authoring conventions or future metadata, not fully implemented UI behavior yet.
## Popup And End-State Tags
These tags may appear as Ink global tags, paragraph tags, or empty tag-only lines. They are dispatched through the same tag channel as media tags.
```ink
#score[You reached the quiet ending.]
#error[The story ended unexpectedly.]
#achievement[First Steps]
#alert[Try examining objects before using them.]
```
- `#score[...]`: intended ending. When the turn reaches `inputMode: end`, the UI shows a localized ending popup with the tag value as the optional message.
- `#error[...]`: unrecoverable ending. The UI shows a localized error popup with the tag value as the optional message. The Ink engine emits this automatically if Ink runs out of content without an explicit `#score[...]` or `#error[...]`.
- `#achievement[...]`: queued localized achievement popup while the game continues.
- `#alert[...]`: queued localized player hint/tutorial popup while the game continues.
## Existing Media And Structure Tags
```ink
#chapter[Title]
#section
#textblock
#image[filename.png](landscape)
#image[filename.png](portrait pause=2)
#image[filename.png](square lead=1.5)
#music[track.mp3](crossfade, loop, lead=4)
#sfx[file.ogg](max=8 fade fade-duration=2)
```
Asset filenames resolve relative to the configured image, music, and sound folders.
+253 -23
View File
@@ -1,38 +1,268 @@
# AI Interactive Fiction
A modern take on classic text adventures that combines traditional world modeling with Large Language Models (LLMs) to create natural language interactive fiction experiences.
AI Interactive Fiction is a web and CLI text adventure prototype that combines a deterministic world model with LLM-assisted command interpretation and narrative output. The web client presents the story as an animated, novel-like book page with synchronized text animation, optional TTS, music, and sound effects.
## Project Overview
## Quick Start
This application reimagines the classic text adventure game genre by replacing the traditional parser with an LLM. The system consists of:
Use Node.js 22 LTS for development. The project accepts Node >= 18.17, but current development has been done on Node 22.
1. **World Model**: A traditional game engine that manages rooms, objects, actions, and game state - similar to old-school Infocom games.
```powershell
nvm install 22
nvm use 22
npm install
npm run build
npm run dev
```
2. **LLM Interface**: An AI layer that processes natural language input from players and translates it into actions the game engine can understand.
`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.
3. **Narrative Generation**: The LLM converts the world state changes into rich, contextual prose for the player.
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.
## Key Features
## Commands
- **Natural Language Understanding**: Players can express their intent in plain language without worrying about specific command syntax.
- **Rich Narrative**: Dynamic descriptions that adapt to the current game state and player history.
- **Consistent World Model**: The underlying game engine enforces world rules to prevent hallucinations or inconsistencies.
- **Modular Design**: Easily swap between different world models, including YAML-based custom worlds or integrations with classic Z-machine games.
```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: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/
npm run start:cli # Run the CLI interface
npm run dev:cli # Run the CLI interface through ts-node/nodemon
```
## How It Works
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.
1. Player enters natural language input
2. LLM analyzes input and translates it into game actions
3. Game engine processes valid actions and updates the game state
4. LLM receives the state change information and generates narrative prose
5. Player receives the beautifully written response
## Docker / Coolify Ink Deployment
## Technical Structure
The included `Dockerfile` builds and serves the Ink engine only. Coolify can use the repository Dockerfile directly.
- YAML-based world definition (rooms, objects, actions)
- OpenRouter API integration for accessing suitable LLMs
- Modular design allowing for Z-machine integration in the future
Set the Coolify environment variables from `coolify.env.example`; at minimum:
## Getting Started
```text
NODE_ENV=production
DEFAULT_GAME_ENGINE=ink
PORT=3000
INK_CONFIG_FILE=./config/engines/ink.json
```
[Installation and running instructions will be added here]
The container compiles TypeScript during image build and compiles the configured Ink source to JSON when the server starts.
## 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.
TTS provider settings are configured in the browser options menu and persisted in browser storage. Providers currently include `none`, browser speech synthesis, Kokoro, ElevenLabs, OpenAI, and local OpenAI-compatible servers. Production should not assume a universal TTS default; the game or player state selects the active mode, and `none` is the safe fallback.
## Starting A Game
The web client no longer starts the game automatically. Browsers require a user gesture before audio playback, so the right page initially shows a start prompt and the command input is hidden. Use `new game` or `load` in the top bar to start.
The placeholder server API supports:
- `newGame()`
- `loadGame(slot)`
- `saveGame(slot)`
- `hasSaveGame(slot)`
- `getSaveGames()`
- `isGameRunning()`
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
The browser app is built from native ES modules in `public/js/`. The loader dynamically imports modules, applies a cache-busting query string during development, resolves declared dependencies, and awaits module initialization in dependency order before the UI becomes usable.
Major modules:
- `module-registry.js`, `base-module.js`, `loader.js`: module lifecycle, dependency graph, progress overlay, state reporting.
- `text-processor-module.js`, `paragraph-layout-module.js`, `layout-renderer-module.js`: SmartyPants, language-aware hyphenation, Knuth-Plass line breaking, DOM rendering.
- `markup-parser-module.js`: story markup fallback for chapters, sections, Markdown emphasis, right-page glossary notes, images, SFX, and music.
- `sentence-queue-module.js`, `playback-coordinator-module.js`, `animation-queue-module.js`: sentence preparation, synchronized playback, timing, fast-forward.
- `tts-factory-module.js` plus provider modules: TTS provider selection, voice settings, speed mapping, caching, and playback.
- `audio-manager-module.js`: master, speech, music, and sound effect volume, music playback, sound effects, and music ducking.
- `ui-controller-module.js`, `ui-display-handler-module.js`, `ui-input-handler-module.js`, `options-ui-module.js`: book UI, command input, options, top-bar controls, and game API calls.
- `choice-display-module.js`: choice-mode UI, click selection, keyboard-letter assignment, and future choice-template routing.
The static server sends no-cache headers for local development so stale ES modules do not mask changes. If the browser console shows `onpage-dialog.preload.js:121 Uncaught ReferenceError: browser is not defined`, ignore it; that comes from the installed ad blocker, not this project.
## Story Markup
Plain paragraphs are rendered paragraph by paragraph. Normal following paragraphs are horizontally indented and do not get a blank line between them. Special block markers change the treatment of the next paragraph.
Inline 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.)
```
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.
TTS reading instructions:
```text
„Ich habe nichts gesehen“, sagt Viktor.
#tts[Read softly, with controlled unease.]
```
`#tts[...]` is scoped to the paragraph/block it is attached to and is sent only to providers that support per-request reading instructions. This providerless form is the normal authoring style; `#tts(...)` is equivalent if parentheses read better. Provider-specific forms are also accepted for overrides, for example `#tts[openai](Read softly.)` or `#tts-openai[Read softly.]`. Currently only OpenAI `gpt-4o-mini-tts` consumes the instruction.
Write TTS instructions as concise performance direction: tone, emotion, intonation, pace, accent, or whispering/singing style. Keep the spoken words in the paragraph itself and use the tag only to guide delivery.
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:
```text
#key
#key[value]
#key[value](options)
#key:value
```
For Ink choices, put choice-local tags under the choice they belong to. Explicit keyboard letters are supported with `# letter[x]`, `#letter[x]`, or the colon form `#key:x`; the client reserves those keys first, then assigns the remaining visible choices from `1` through `0`, then `A` through `Z` in visible order. `#optional` renders the choice in italic. `# action[name]` or `#action:name` assigns an invisible action group: group order follows the first appearance of each action tag in the authored list, entries inside each group are randomized, and choices without an action tag are grouped last.
Chapter:
```text
#chapter[The Mysterious Mansion]
The first paragraph uses a drop cap and no first-line indent.
Following paragraphs use the normal paragraph indent.
```
The heading is centered, italic, and uses the same text face as the body. The first paragraph after a chapter marker is unindented and receives the drop cap treatment.
Section or text block:
```text
#section
The first paragraph starts a separated block without horizontal indent.
The following paragraph returns to the normal indent.
```
`#textblock` is treated the same way. The first paragraph after the marker is separated from previous content by one line of vertical space.
Images are story blocks:
```text
#image[mansion-rain.jpg](landscape)
#image[portrait-letter.jpg](portrait pause=2)
#image[seal.png](square lead=1.5)
```
Image file names are relative to `public/images/`. `landscape`/`widescreen` and `square` images are centered, near full page width, and line-snapped. `portrait` images sit beside prose at half page width. Image pauses (`pause=`, `delay=`, `lead=`, or a bare `2s`) are skippable and do not block background TTS preparation.
Sound effects are story tags:
```text
#sfx[squeaky-door.ogg]
#sfx[church-bells.ogg](max=8 fade fade-duration=2)
The door opens and the hall exhales.
```
The tag is parsed by the server into a `StoryTag` object. Sound effect paths are relative to `public/sounds/`. Optional parameters can limit playback (`max=`, `duration=`, `stop-after=`, `fade-after=`), choose the end mode (`fade` or `stop`/`cut`), and set `fade-duration=`.
Music can be placed as a block:
```text
#music[rain-theme.ogg](crossfade, loop, lead=4)
```
Music paths are relative to `public/music/`. Supported modes are `queue`, `crossfade`, and `cut`. Use `loop` or `once` to control repetition. `lead=<seconds>` delays the following text/TTS paragraph so the music can play alone before narration continues. To place that pause between a chapter heading and the dropcapped first paragraph, put the music tag after the chapter tag and before the first prose paragraph; TTS generation for the next spoken paragraph continues during the lead pause.
Game-state and player-message tags:
```text
#score[You found the quiet ending.]
#error[Ink story ended without an explicit ending tag.]
#achievement[First Steps]
#alert[Try examining objects before using them.]
```
`#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.
- `public/music/`: background music referenced by `#music[file](...)` tags.
- `public/images/`: story images referenced by `#image[file](...)`.
- `public/fonts/`: font assets used by the book UI.
Keep third-party assets licensed for local redistribution, and document source and license in the folder README or alongside the file.
## Typography And Playback Behavior
The renderer is designed to behave like a scaled static book page. The page keeps its aspect ratio, and text sizes and word positions scale relative to the page instead of reflowing unpredictably at small browser sizes.
Text processing order:
1. Parse story markup and remove non-display media markers.
2. Apply Markdown emphasis spans and right-page glossary annotations.
3. Run SmartyPants for typographic punctuation.
4. Apply Hyphenopoly for the selected language.
5. Calculate line breaks with the Knuth-Plass algorithm.
6. Render absolutely positioned word spans and animate them in sync with audio or estimated duration.
When real TTS audio is available, animation duration is driven by measured audio length. With TTS disabled or unavailable, duration is estimated from text length and the persisted speed setting.
Fast-forwarding by page click or space completes the active animation and fades/stops current TTS playback so queued content can proceed.
The right page history is line-addressed rather than natively scrolled. The page has a fixed line count, all block heights snap to whole lines, and the custom scrollbar represents virtual history line position. The DOM keeps a moving window of history blocks around the active line instead of paginating the story.
## Changelog
### 2026-05-17
- Added Ink engine support with source compilation, engine config files, game metadata, locale-driven UI text, choice mode, keyboard choice letters, and one-list choice rendering.
- Added line-addressed right-page history, save/load reconstruction, image restoration, custom scrollbar plumbing, and virtual block-window rendering.
- Added story image rendering for landscape, portrait, and square images, including line-snapped sizing and portrait text exclusion.
- Added localized popups for endings, errors, achievements, and alerts through the tag channel.
- Added credits and third-party license UI.
- Added per-volume mute toggles and configurable music ducking amount.
- Added German typography handling for dialogue guillemets based on game metadata language.
### 2026-05-14
- 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`.
- Changed the web UI to require a manual game start before showing the command input, which keeps browser audio autoplay restrictions manageable.
- Implemented story markup for chapters, text blocks, Markdown emphasis, image placeholders, sound effects, and music cues.
- Added music block parameters for playback mode, loop/once behavior, and lead-in delay.
- Added sound and music asset folders and playback plumbing for sound effects and background music.
- Added music ducking while TTS is active.
- Reworked book typography around Knuth-Plass line breaking, Hyphenopoly hyphenation, SmartyPants, paragraph indentation rules, drop caps, and responsive page scaling.
- Reworked TTS provider behavior, speed mapping, persistence, caching keys, top-bar/options synchronization, and OpenAI voice validation.
- Added development notes for ignoring the unrelated ad-blocker console error.
### Earlier Prototype Work
- Established the original animated fiction prototype with inkjs, SmartyPants, Hyphenopoly, Knuth-Plass line breaking, custom animation scheduling, save/load concepts, and media tags.
- Split the client from a monolithic prototype into focused modules for text processing, layout, animation, audio, persistence, TTS, and UI control.
+254
View File
@@ -0,0 +1,254 @@
# 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)
```
The Ink engine additionally supports browser-owned session recovery:
```text
resumeGame(savedInkState)
exportGameState()
```
`exportGameState()` returns the current Ink state without creating a server-side save slot. The client stores that state with story history, choices, input mode, and media state in IndexedDB. `resumeGame(savedInkState)` rehydrates a fresh server-side InkEngine after a socket reconnect or browser reload without emitting duplicate narrative. This keeps durable player-specific state client-side for hosted multi-client Ink deployments.
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`.
The server keeps only ephemeral per-socket InkEngine instances. Browser IndexedDB owns durable Ink saves and the current autosave. If the socket reconnects or the page reloads, the browser sends the autosaved Ink state to `resumeGame()` and restores rendered history locally.
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)`
- `#tts[instruction]`
- `#tts(instruction)`
- `#tts[provider](instruction)` / `#tts-openai[instruction]`
- `#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`.
Before key assignment, choices are ordered by invisible `#action` groups. The first appearance of each action group in the authored list determines group order. Choices inside each group are randomized for presentation. Choices without an action group form one final group shown last. Group labels are not displayed.
TTS instruction tags are paragraph/block metadata. They are ignored by renderers and by providers that do not support per-request reading instructions. Providerless `#tts[...]` and `#tts(...)` are the default authoring forms; provider-specific forms are optional filters for provider overrides. OpenAI consumes matching instructions only for `gpt-4o-mini-tts`, where they are sent as the Speech API `instructions` field. Instructions should describe delivery, such as tone, emotion, intonation, pace, accent, whispering, humming, or singing style.
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, OpenAI, and local OpenAI-compatible servers. 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.
+47
View File
@@ -0,0 +1,47 @@
# Third-Party Library Audit
Date: 2026-05-17
## Summary
The project currently uses the expected browser-side typography/story libraries plus additional runtime packages:
- inkjs
- SmartyPants.js
- Hyphenopoly
- Knuth-Plass line breaking support (`knuth-and-plass.js`, `linebreak.js`, `linked-list.js`)
- Kokoro JS browser bundle
- Server/runtime npm packages: Express, Socket.IO, OpenAI SDK, Axios, cors, dotenv, js-yaml, ifvms
- EB Garamond font files
## Browser-vendored files
| Component | Files | Upstream/latest check | Local status |
| --- | --- | --- | --- |
| SmartyPants.js | `public/js/smartypants.js` | Local header says `smartypants.js 0.0.6`; npm `smartypants` latest is `0.2.2`. The old `smartypants.js` package name is unpublished from npm. | Not byte-identical to npm `smartypants` 0.0.5, 0.0.9, or 0.2.2. Treat as modified/older vendor code. |
| Hyphenopoly browser files | `public/js/Hyphenopoly.js`, `public/js/Hyphenopoly_Loader.js`, `public/js/hyphenopoly.module.js`, `public/js/patterns/*.wasm` | Browser header says `5.2.0-beta.1`; npm dependency is `6.0.0`; npm latest is `6.1.0`. | `Hyphenopoly.js` is effectively 5.2.0-beta.1 after line-ending normalization. `Hyphenopoly_Loader.js` has a small local/prototype difference in `H.hide`. Browser copy is older than package/latest. |
| Knuth-Plass adapter | `public/js/knuth-and-plass.js` | No authoritative upstream identified from headers or npm metadata. | Modified from the prototype copy and currently application-owned adapter code. |
| Line breaking support | `public/js/linebreak.js`, `public/js/linked-list.js` | No authoritative upstream identified from headers. Not the npm `linebreak` package 1.1.0. | Identical to prototype copies. `linked-list.js` still has a suspicious `get last() { return this.last; }` accessor inherited from the prototype. |
| Kokoro JS browser bundle | `public/js/kokoro-js.js` | npm `kokoro-js` latest is `1.2.1`; installed is `1.2.0`. | Byte-identical to `kokoro-js@1.2.0/dist/kokoro.web.js`; not latest. |
## Direct runtime npm packages
| Package | Installed | Latest checked | License | Status |
| --- | --- | --- | --- | --- |
| `inkjs` | 2.4.0 | 2.4.0 | MIT | Current. |
| `hyphenopoly` | 6.0.0 | 6.1.0 | MIT | Not latest. Browser vendored files are older than this dependency. |
| `kokoro-js` | 1.2.0 | 1.2.1 | Apache-2.0 | Not latest. |
| `ifvms` | 1.1.6 | 1.1.6 | MIT | Current. |
| `openai` | 4.91.0 | 6.38.0 | Apache-2.0 | Not latest major. |
| `socket.io` | 4.8.1 | 4.8.3 | MIT | Not latest patch. |
| `express` | 5.1.0 | 5.2.1 | MIT | Not latest patch. |
| `axios` | 1.8.4 | 1.16.1 | MIT | Not latest. |
| `cors` | 2.8.5 | 2.8.6 | MIT | Not latest patch. |
| `dotenv` | 16.4.7 | 17.4.2 | BSD-2-Clause | Not latest major. |
| `js-yaml` | 4.1.0 | 4.1.1 | MIT | Not latest patch. |
## Notices
The UI-readable license and credit notice is `public/THIRD_PARTY_NOTICES.md`.
The root `THIRD_PARTY_NOTICES.md` points to that served file so the repository has an obvious project-level notice entry.
+7
View File
@@ -0,0 +1,7 @@
# Third-Party Notices
The browser-visible third-party notices and license text live at:
`public/THIRD_PARTY_NOTICES.md`
That file is served by the game UI and is the source used by the in-game credits dialog.
+111 -88
View File
@@ -1,106 +1,129 @@
# Module System Refactoring TODO
# TODO And Progress
## High Priority (Critical Architectural Issues)
This is the active implementation checklist. Architecture lives in `SPECIFICATION.md`; usage lives in `README.md`; authoring conventions live in `MARKUP_GUIDELINES.md`.
### 1. Asynchronous Flow Control Improvements
- [ ] Remove all `setTimeout` calls used for synchronization in modules
- [X] Replace timeout in `browser-tts-handler.js` with proper Promise handling for voice loading
- [X] Eliminate race condition in `tts-player.js` that uses a hard-coded 1000ms timeout
- [ ] Remove all `setTimeout` calls in `ui-controller.js` for UI updates
- [ ] Implement proper Promise-based flow control in all modules
- [ ] Update `kokoro-handler.js` to correctly handle loading events
- [ ] Ensure all `async/await` patterns follow best practices
- [ ] Fix race conditions in module loading sequences
## Current Status
### 2. Module State Management
- [ ] Fix premature reporting of `FINISHED` state
- [ ] Ensure `tts-player.js` properly waits for Kokoro loading before reporting FINISHED
- [ ] Add proper state checks in all modules before reporting FINISHED
- [ ] Implement proper state transition reporting
- [ ] Update modules to use event system for reporting state transitions
- [ ] Add better error handling during module initialization
- 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.
### 3. Module Dependencies & Loading
- [ ] Fix missing dependency declarations
- [ ] Update `ui-controller.js` to properly declare its TTS dependency
- [ ] Ensure all modules correctly specify all dependencies
- [ ] Remove dependency availability checks within modules
- [ ] Remove conditional checks like `if (!this.ttsHandler)` in `ui-controller.js`
- [ ] Rely on the module loader for dependency management
## Shared Client
## Medium Priority (Functionality & Implementation Issues)
### Completed
### 4. TTS Handler Implementation
- [ ] Implement missing `tts-handler.js` file content
- [ ] Create proper implementation with consistent interface
- [ ] Ensure it uses proper event-based communication
- [ ] Fix inconsistent event usage across TTS handlers
- [ ] Replace direct callbacks with event system
- [ ] Standardize event names and parameters
- [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, local OpenAI-compatible, 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.
### 5. Animation Queue Enhancements
- [ ] Implement proper queue control mechanisms
- [ ] Add pause/resume functionality
- [ ] Implement more robust animation timing
- [ ] Add priority management for animations
### In Progress
### 6. UI Controller Cleanup
- [ ] Fix duplicate methods in UI Controller
- [ ] Deduplicate code for creating UI elements
- [ ] Consolidate event handling functions
- [ ] Remove redundant `ModuleEvent` class implementation
- [ ] Use the shared implementation from `base-module.js`
- [ ] 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.
### 7. Kokoro Loading Implementation
- [ ] Implement proper `requestIdleCallback` for Kokoro loading
- [ ] Follow the pattern described in the specification
- [ ] Add progress reporting during Kokoro loading
- [ ] Fix event handling for loading completion
### Pending
## Lower Priority (Refinements & Optimizations)
- [ ] 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.
- [x] 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.
### 8. Code Quality & Consistency
- [ ] Standardize module registration pattern
- [ ] Ensure all modules follow the same pattern
- [ ] Fix inconsistencies in export approaches
- [ ] Improve module progress reporting
- [ ] Make progress reporting more granular
- [ ] Add more descriptive status messages
## Shared Server Architecture
### 9. Error Handling Improvements
- [ ] Add better error recovery mechanisms
- [ ] Implement fallbacks for critical failures
- [ ] Add user-friendly error messages
- [ ] Improve error logging
- [ ] Add structured error reporting
- [ ] Implement debugging tools
### Completed
### 10. Performance Optimizations
- [ ] Optimize module loading sequence
- [ ] Prioritize critical modules
- [ ] Defer non-essential loading
- [ ] Improve resource utilization
- [ ] Minimize memory footprint
- [ ] Reduce CPU usage during animations
- [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.
## Documentation & Testing
### Pending
### 11. Documentation
- [ ] Add JSDoc comments to all public methods
- [ ] Create architectural documentation
- [ ] Document module dependencies
- [ ] Explain event system
- [ ] Add example usage for modules
- [ ] 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.
### 12. Testing
- [ ] Create unit tests for modules
- [ ] Implement integration tests for module system
- [ ] Add browser compatibility tests
## Ink Engine
## Future Enhancements
### Completed
### 13. New Features
- [ ] Add module versioning support
- [ ] Implement module hot-reloading
- [ ] Create plugin system for extending modules
- [ ] Add internationalization support for UI
- [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.
+20
View File
@@ -0,0 +1,20 @@
{
"engine": "ink",
"locale": "de_DE",
"paths": {
"mainGameFile": "data/ink/eibenreith.ink.json",
"inkSource": "data/ink-src/eibenreith.ink",
"inkCompiled": "data/ink/eibenreith.ink.json",
"music": "public/music",
"sfx": "public/sounds",
"images": "public/images"
},
"metadata": {
"title": "Eibenreith",
"author": "Georg Tomitsch",
"subtitle": "Ein Kaiserpunk Abenteuer",
"version": "0.1.0",
"language": "de_DE",
"copyright": "© 2026 Bad Tools Studio"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"engine": "yaml",
"locale": "en_US",
"paths": {
"mainGameFile": "data/worlds/example_world.yml",
"music": "public/music",
"sfx": "public/sounds",
"images": "public/images"
},
"metadata": {
"title": "The Mysterious Mansion",
"author": "AI Interactive Fiction",
"subtitle": "An open-world text adventure",
"version": "1.0.0",
"language": "en_US",
"copyright": "Prototype content for local development."
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"engine": "zcode",
"locale": "en_US",
"paths": {
"mainGameFile": "data/z-code/zork1.bin",
"promptDir": "data/zcode-prompts",
"music": "public/music",
"sfx": "public/sounds",
"images": "public/images"
},
"metadata": {
"title": "Zork I",
"author": "Infocom",
"subtitle": "A narrated Z-code adventure",
"version": "1.0.0",
"language": "en_US",
"copyright": "Use only with a legally supplied Z-code story file."
}
}
+11
View File
@@ -0,0 +1,11 @@
# Coolify environment for the Ink-only web deployment.
# Set these in Coolify's environment variable UI, not in a committed .env file.
NODE_ENV=production
DEFAULT_GAME_ENGINE=ink
PORT=3000
INK_CONFIG_FILE=./config/engines/ink.json
# Optional server-side LLM variables are only needed by non-Ink engines.
# OPENROUTER_API_KEY=
# OPENROUTER_MODEL=
+110
View File
@@ -0,0 +1,110 @@
// Eibenreith.ink
// Main index file for the German intro of the choice-based horror text game.
// This file contains global state, global functions, the initial divert, INCLUDE statements,
// and an index of important knots. Chapter content lives in separate .ink files.
// -----------------------------------------------------------------------------
// INCLUDES
// -----------------------------------------------------------------------------
INCLUDE eibenreith_01_zug.ink
INCLUDE eibenreith_02_bahnhof.ink
INCLUDE eibenreith_03_graben.ink
INCLUDE eibenreith_04_dorf.ink
// -----------------------------------------------------------------------------
// GLOBAL STATE
// -----------------------------------------------------------------------------
VAR birth_class = "unset"
VAR title_part = ""
VAR given_names = ""
VAR common_name = ""
VAR surname = ""
VAR religion_stance = "unset"
VAR supernatural_belief = "unset"
VAR supernatural_senses = "unset"
VAR body_detail = "unset"
VAR hair_colour = "unset"
VAR hairstyle = "unset"
VAR complexion_detail = "unset"
VAR face_detail = "unset"
VAR outfit_detail = "unset"
VAR baggage_style = "unset"
VAR viktor_relation = "unset"
VAR tut_choice_intro = false
VAR tut_optional_intro = false
VAR tut_character_intro = false
VAR tut_dialog_intro = false
VAR tut_manners_intro = false
VAR tut_gated_intro = false
VAR lover = 0
VAR sapphic = 0
VAR detective = 0
VAR careless = 0
VAR eccentric = 0
VAR class_confidence = 0
VAR medium_reputation = 0
VAR court_loyalty = 0
VAR viktor_trust = 0
VAR viktor_suspicion = 0
VAR supernatural_exposure = 0
// -----------------------------------------------------------------------------
// GLOBAL FUNCTIONS
// -----------------------------------------------------------------------------
// Add global Ink functions here when needed.
// -----------------------------------------------------------------------------
// KNOT INDEX
// -----------------------------------------------------------------------------
// eibenreith_01_zug.ink
// intro_train
// train_compartment
// next_compartment_definition
// compartment_room
// compartment_letter
// look_out_window
// observe_viktor
// define_class_and_name
// choose_name_noble
// choose_surname_noble
// choose_name_middle
// choose_surname_middle
// choose_name_working
// choose_surname_working
// assemble_full_name
// define_religion_and_supernatural
// define_appearance
// first_viktor_exchange
// viktor_class_noble
// viktor_class_middle
// viktor_class_working
// viktor_mission_briefing
// eibenreith_02_bahnhof.ink
// railway_station
// station_platform_options
// station_baggage
// eibenreith_03_graben.ink
// coach_journey
// coach_road_options
// coach_after_road_options
// eibenreith_04_dorf.ink
// village_arrival_options
// village_exit_puzzle
// -----------------------------------------------------------------------------
// ENTRY POINT
// -----------------------------------------------------------------------------
-> intro_train
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
// eibenreith_02_bahnhof.ink
// Kapitel: Der Bahnhof.
// Enthält Stations-Erkundung, Gepäckwahl und erstes Manierenpuzzle.
=== railway_station ===
Die Station ist klein genug, dass der Zug kurz verlegen wirkt, als er dort hält. #chapter[Der Bahnhof] #image[muerzzuschlag.png](portrait)
Ein Gepäckträger mit einer zu großen Kappe eilt über den Bahnsteig. Eine Frau mit Korb tritt vor dem Dampf zurück wie vor einem Tier. Irgendwo jenseits des Stationsgebäudes stampft ein Kutschpferd im gefrorenen Schlamm. Das Schild gibt dem Ort einen Namen, den du im Fahrplan gesehen hast und an den du dich nicht mit Zuneigung erinnern wirst.
-> station_platform_options
=== station_platform_options ===
{not tut_optional_intro:
#alert[Manche Wahlen sind Erkundungen. Sie öffnen Beobachtungen, Stimmungen oder Hinweise, ohne dich meist sofort auf einen unwiderruflichen Schritt festzulegen.]
~ tut_optional_intro = true
}
* [__Schaue__: Auf das Stationsschild.] #action:orientation #optional #key:l
Der Ortsname auf dem Schild ist mit schwarzer Farbe auf hellem Grund gemalt, zweckmäßig, kaiserlich, ohne jede Rücksicht auf den Eindruck, den er auf Ankommende macht. Die Buchstaben sehen aus, als hätten sie nie vorgehabt, in einem Salon ausgesprochen zu werden.
-> station_platform_options
* [__Höre__: Auf den Bahnsteig.] #action:orientation #optional
Unter dem Zischen der Lokomotive liegen kleinere Geräusche: ein Koffer, der auf Holz abgesetzt wird; das kurze Räuspern eines Beamten; Pferdehufe im gefrorenen Schlamm; eine Frau, die ein Gebet beginnt und beim zweiten Wort wieder verschluckt.
-> station_platform_options
* [__Untersuche__: Die Wartenden.] #action:orientation #optional
Niemand starrt offen. Das wäre grob. Stattdessen entstehen kleine Leerstellen in den Bewegungen der Leute: ein Blick, der zu spät weiterwandert; ein Schritt, der seine Richtung ändert; ein Gespräch, das plötzlich nur noch aus Endungen besteht.
-> station_platform_options
* [__Überblicke__: Dein Gepäck.] #action:object
-> station_baggage
=== station_baggage ===
Dein Gepäck wird in Etappen ausgeladen.
* [__Überblicke__: Eine disziplinierte amtliche Zusammenstellung.] #action:object
~ baggage_style = "official"
~ detective += 1
Zuerst kommt ein nüchterner Reisekoffer mit vom Gebrauch stumpfen Messingecken, dann eine Aktenmappe, dann eine Hutschachtel, dann der schmale schwarze Kasten, dessen Inhalt sowohl einen Priester als auch einen Taschenspieler in Verlegenheit bringen würde, falls einer von beiden ihn ohne Phantasie durchsuchte.
* [__Überblicke__: Das Gepäck einer eleganten Dame.] #action:object
~ baggage_style = "elegant"
~ class_confidence += 1
Zuerst kommt ein großer Koffer aus dunklem Leder, dann ein kleinerer für Wäsche, dann eine runde Hutschachtel, ein Reise-Necessaire und ein Ridikül, das du zu nahe bei der Hand behältst, als dass ein Gepäckträger seine Bedeutung missverstehen dürfte.
* [__Überblicke__: Das Gepäck einer Darstellerin.] #action:object
~ baggage_style = "performer"
~ medium_reputation += 1
Zuerst kommt ein respektabler Koffer, dann eine Hutschachtel, dann ein Reisekasten mit Handschuhen, Schleiern, Bändern, Visitenkarten und kleinen Gegenständen, mit denen man ein Zimmer überreden kann, an Kräfte zu glauben, die längst anwesend sind.
* [__Überblicke__: Eine praktische Auswahl, die zu viel Vorbereitung verrät.] #action:object
~ baggage_style = "practical"
~ detective += 1
Zuerst kommt ein abgenützter, an den Ecken verstärkter Koffer, dann eine Ledertasche mit Notizheften, Bleistiften, gefalteten Karten, Ersatzhandschuhen, einer Handlampe und genug kleinen Notwendigkeiten, um jeden zu beleidigen, der Frauen lieber dekorativ hat.
* [__Überblicke__: Ein übertriebener Haufen, der jede Tarnung erschwert.] #action:object
~ baggage_style = "excessive"
~ careless += 1
Zuerst kommt ein Koffer, dann ein zweiter, dann eine Hutschachtel, dann eine Reisedecke, dann ein Toilettenkasten, dann der schmale schwarze Kasten, dann ein kleineres Paket, von dem du vergessen hattest, dass es das Packen überlebt hat. Am Ende sieht selbst Viktor einen Augenblick lang zahlenmäßig unterlegen aus.
-
Viktor überwacht die Umladung mit knapper Höflichkeit. Er trägt nicht wie ein Diener. Er weist an wie ein Mann, der vorgibt, nicht zu befehlen.
Die kleine Szene vor dem Waggon ist harmlos genug, um gefährlich zu sein. Ein Gepäckträger wartet mit geneigtem Kopf. Der Kutscher steht einige Schritte entfernt. Viktor ist nah genug, um dir beim Aussteigen die Hand zu reichen, aber nicht so nah, dass er es ohne dein stilles Einverständnis täte. Drei Männer, drei Stände, drei verschiedene Arten von Nützlichkeit.
Was hier geschieht, wird niemand in einem Bericht erwähnen. Gerade deshalb wird es behalten.
{
- baggage_style == "practical":
Weil dein Gepäck nach Vorbereitung aussieht, wirkt die nüchterne Anweisung an einen Gepäckträger weniger wie Anmaßung und mehr wie Gewohnheit.
- baggage_style == "excessive":
Weil dein Gepäck zu zahlreich ist, wird schon vor dem ersten Wort sichtbar, dass jemand in deiner Nähe arbeiten muss.
- baggage_style == "performer":
Der schmale schwarze Kasten zieht Viktors Blick eine Spur länger auf sich als die übrigen Stücke.
- else:
Das Gepäck gibt den Männern genug zu tun, um ihnen ihre Rollen zu erklären.
}
{not tut_manners_intro:
#alert[In Gesellschaft entscheidet oft nicht nur, was du tust, sondern wann und vor wem. Höflichkeit, Rang und Timing können ebenso viel verraten wie ein Geständnis.]
~ tut_manners_intro = true
}
{not tut_gated_intro:
{
- birth_class == "noble":
#alert[Manche Möglichkeiten erkennst du nur, weil deine Herkunft, dein Glaube oder deine bisherigen Entscheidungen sie dir öffnen. Der hervorgehobene Hinweis nach dem Mittelpunkt zeigt, wodurch sie möglich wurde.]
~ tut_gated_intro = true
- birth_class == "working":
#alert[Manche Möglichkeiten erkennst du nur, weil deine Herkunft, dein Glaube oder deine bisherigen Entscheidungen sie dir öffnen. Der hervorgehobene Hinweis nach dem Mittelpunkt zeigt, wodurch sie möglich wurde.]
~ tut_gated_intro = true
}
}
* {birth_class == "noble"} [__Warte__ · **Adel**: Bis Viktor seine Hand anbietet.] #action:social #gated:noble #key:z
#manners:excellent
~ class_confidence += 2
~ court_loyalty += 1
Du wartest einen Atemzug, bis Viktor seine Hand anbietet, und nimmst sie dann, als wäre dies keine Hilfe, sondern die Ordnung der Welt.
Du gibst ihm nicht dein Gewicht. Nur deine Hand. Genau genug, dass er dienen darf, ohne Diener zu werden. Der Gepäckträger senkt den Blick ein wenig tiefer. Der Kutscher sieht, was er sehen muss: eine Dame, die ihren Rang nicht beweist, weil Beweise für Leute ohne Rang sind.
* [__Nicke__: Viktor zu und überlasse dem Gepäckträger das Gepäck.] #action:social
#manners:good
~ viktor_trust += 1
Du nimmst Viktors angebotene Hand knapp und sicher, dankst ihm mit einem Nicken und lässt den Gepäckträger das Gepäck nehmen.
Es ist gutes Benehmen ohne Prunk: nicht zu vertraut gegenüber Viktor, nicht zu freundlich gegenüber dem Gepäckträger, nicht so kalt, dass es nach Unsicherheit riecht. Mittelstand könnte dies lernen. Adel könnte es billigen. Dienstboten würden erkennen, dass du ihre Arbeit nicht mit Herablassung verwechselst.
* [__Bitte den Gepäckträger__: „Zuerst den kleineren Kasten, wenn ich bitten darf.“] #action:social
#route:detective
#manners:practical
~ detective += 1
Du steigst selbst aus, bevor Viktor sich entscheiden kann, und bittest den Gepäckträger sachlich, zuerst den kleineren Kasten zu nehmen.
Das ist nicht ganz falsch, aber auch nicht ganz richtig. Viktor bemerkt die kleine Missachtung der erwarteten Form. Der Gepäckträger gehorcht erleichtert, weil klare Anweisungen leichter zu tragen sind als feine Ungewissheit. Der Kutscher ordnet dich eher der Nützlichkeit als dem Rang zu.
* [__Warte__: Einen Augenblick zu lange, bevor du Viktors Hand nimmst.] #action:social
#route:lover
#manners:provocative
~ lover += 1
~ viktor_suspicion += 1
Du lässt Viktor zu lange mit ausgestreckter Hand warten und lächelst erst dann, als hättest du ihn absichtlich geprüft.
Es ist fast ein Fauxpas, gerettet durch Anmut und die Tatsache, dass Männer Demütigungen leichter verzeihen, wenn sie sich wie Aufmerksamkeit anfühlen. Viktor hilft dir hinunter. Seine Hand bleibt vollkommen korrekt. Sein Blick nicht ganz.
* [__Greife__: Selbst nach einem Koffer.] #action:object
#route:careless
#manners:awkward
~ careless += 1
Du entschuldigst dich beim Gepäckträger dafür, dass deine Sachen Mühe machen, und greifst selbst nach einem Koffer.
Der Gepäckträger erstarrt, als hättest du ihm eine philosophische Frage gestellt. Viktor tritt sofort dazwischen, höflich genug, um die Rettung wie Zufall aussehen zu lassen. Du hast gegen keine Moral verstoßen, nur gegen die unsichtbare Arbeitsteilung, auf der diese kleine Welt ruht.
* {birth_class == "working"} [__Nimm__ · **Unterschicht**: Dem Gepäckträger beinahe den Koffer ab.] #action:object #gated:working #key:t
#manners:fauxpas
~ class_confidence -= 1
~ careless += 1
Du springst hinunter, bevor jemand dir helfen kann, und nimmst dem Gepäckträger beinahe den Koffer aus der Hand.
Für eine Sekunde bist du schneller als deine Verkleidung. Der Gepäckträger hält fest, Viktor greift nach deinem Ellbogen, der Kutscher sieht weg, weil Wegsehen manchmal die höflichste Form von Zeugenschaft ist. Es ist kein Unglück. Nur ein Riss, klein genug, um ihn mit Haltung zu schließen.
-
Die Kutsche aus Hohenreith wartet jenseits des Stationshofes: dunkelgrüner Lack, schwarze Räder, das gräfliche Wappen dezent auf der Tür, zwei Pferde bereits unruhig im Geschirr. Der Kutscher nimmt den Hut ab, als er dich sieht. Nicht zu tief. Tief genug für Rang, nicht tief genug für Ehrfurcht. #sfx[horse-neigh.ogg]
„Gnädiges Fräulein? Herr Sekretär?“
{birth_class == "noble":
Man hat ihm genug gesagt, um dich einzuordnen. Das ist eine Höflichkeit. Es ist auch eine Warnung.
- else:
Er zögert bei dir um das kleinste Maß. Das Zögern ist keine Unhöflichkeit. Es ist Berechnung. Erste Klasse, Hofschreiben, kein Titel außer Fräulein, und ein Mann neben dir, der aussieht, als hätte er Menschen für weniger verhaften lassen als Starren.
}
Viktor antwortet, bevor du es kannst.
„Vom Jagdhaus Hohenreith?“
„Jawohl, Herr Sekretär. Der Weg ist befahrbar. Wenn der Nebel nicht dichter wird, sollten wir Eibenreith vor Einbruch der Dunkelheit erreichen.“
Das Wort tritt ohne Zeremonie in die Luft.
Eibenreith.
Nicht Hohenreith, der Name, der in sauberer Hand auf der Einladung steht. Eibenreith: das Dorf darunter. Ein kleinerer Name. Älter im Mund. Ein Name mit Wurzeln statt Briefpapier.
-> coach_journey
+209
View File
@@ -0,0 +1,209 @@
// eibenreith_03_graben.ink
// Kapitel: Der Graben.
// Enthält Kutschfahrt, optionale Grabenbeobachtungen und Statue/Viktor-Reaktion.
=== coach_journey ===
Die Kutsche lässt die Station hinter sich und damit das letzte leicht erkennbare Zeichen der Monarchie. #chapter[Der Graben] #music[Kaiserpunk Jodler.mp3](crossfade, loop, lead=4)
Zuerst folgt der Weg einem Tal, in dem Telegraphendraht ihm noch Gesellschaft leistet und der Fluss in einem hellen, steinigen Bett läuft. Sägewerke, umzäunte Wiesen und Bauernhäuser erscheinen und verschwinden hinter Fichtenbeständen. Die Berge steigen nicht auf einmal. Sie rücken zuständigkeitsweise vor. Ein bewaldeter Hang beansprucht den linken Himmel, dann schließt eine graue Wand aus Kalk den Norden, dann sammelt sich im Osten ein weiterer Rücken, bis selbst die Wolken in Dienst getreten scheinen.
Der Kutscher nennt Orte, wenn Viktor fragt, doch die Namen sind örtlich und praktisch, gedacht für Männer, die wissen, welche Brücke bei Hochwasser nachgibt und welcher Hof störrische Pferde hält. Irgendwo hinter den sichtbaren Rücken, sagt er, liegt der große weiße Rücken des Hochschwab. Nach Osten, jenseits von Wald und Pass, hält die Hohe Veitsch ihr eigenes Wetter. Er sagt das nicht wie ein Führer, sondern wie ein Mann, der Nachbarn erklärt, die vielleicht guter Laune sind und vielleicht nicht.
Das Haupttal verengt sich.
Der Weg biegt davon in einen Seitengraben, und die Veränderung ist augenblicklich. Der Klang ändert sich. Die Räder klingen nicht mehr gegen offene Entfernung, sondern mahlen zwischen Böschungen, Wurzeln und nassem Stein. Die Luft riecht nach Lauberde, Harz und kaltem Wasser. Eiben erscheinen zwischen den Fichten in dunkler, unwahrscheinlicher Geduld, ihre Nadeln zu schwarz für den Nachmittag.
„Eibenreither Graben“, sagt der Kutscher und bekreuzigt sich so rasch, dass die Geste auch einem Schlagloch gegolten haben könnte.
Viktor bemerkt es. Natürlich bemerkt er es.
„Schlechter Weg?“, fragt er.
„Alter Weg“, sagt der Kutscher.
Eine Weile spricht niemand.
-> coach_road_options
=== coach_road_options ===
* [__Berühre__: Das kalte Kutschenfenster.] #action:object #optional
Das Glas ist kälter, als es im Inneren der Kutsche sein dürfte. Feuchtigkeit sammelt sich an deinem Handschuh und verschwindet sofort wieder, als hätte sie es sich anders überlegt. Draußen streifen Zweige so nah vorbei, dass sie die Scheibe beinahe mit Nägeln prüfen.
-> coach_road_options
* [__Höre__: Auf die Räder im Graben.] #action:orientation #optional
Das Geräusch der Räder hat sich verändert. Auf der offenen Straße war es ein Rhythmus; hier ist es ein Mahlen, ein Zählen, ein wiederholtes Bestehen gegen Stein und Wurzel. Der Weg klingt nicht befahren. Er klingt benutzt.
-> coach_road_options
* [__Untersuche__: Viktors Reaktion.] #action:orientation #optional
Viktor betrachtet nicht die Landschaft. Er betrachtet ihre Möglichkeiten: Engstellen, Böschungen, tote Winkel, die Entfernung bis zum Kutscher, die Frage, wie rasch man aus einer Kutsche steigt, wenn die Straße selbst dagegen ist.
-> coach_road_options
* [__Warte__: In der schaukelnden Kutsche.] #action:social #key:z
-> coach_after_road_options
=== coach_after_road_options ===
Du beobachtest die Bäume.
Es gibt Wälder, die zu Geschichten einladen, weil sie hübsch sind, und Wälder, die Geschichten zurückweisen, weil das, was dort geschah, keine Zeugen brauchte. Dieser gehört zur zweiten Art. Seine Stämme stehen eng, nicht wild, sondern mit der Haltung einer Menge, die Platz macht für etwas, das vor langer Zeit durch sie getragen wurde. Der Schnee in den Mulden ist nicht rein. Er hat Nadeln gesammelt, Rinde und einen gelblichen Fleck dort, wo Wasser von unten aufgestiegen ist.
An einem Hang oberhalb des Weges, halb vom Unterholz verschluckt, erblickst du Stein.
Ein Wegheiligtum vielleicht. Ein Grenzzeichen. Eine Figur. Die Kutsche rollt schon vorbei, bevor deine Augen sich auf ihre Form einigen können. Für einen Augenblick bleibt der Eindruck eines Frauenkopfes zurück, geneigt nicht im Gebet, sondern im Lauschen. #image[statue.png](square)
{
- supernatural_senses == "genuine":
Dein Nacken zieht sich zusammen.
Nicht Furcht. Wiedererkennen wäre schlimmer.
- supernatural_senses == "ambiguous":
Dein Nacken zieht sich zusammen.
Nicht Furcht. Wiedererkennen wäre schlimmer.
- supernatural_senses == "repressed":
Dein Nacken zieht sich zusammen.
Nicht Furcht. Wiedererkennen wäre schlimmer.
- else:
Du sagst dir, dass alter Stein, durch bewegte Zweige gesehen, zu allem wird, wozu der Geist feig genug ist.
}
Viktor wendet sich leicht demselben Hang zu.
„Haben Sie etwas gesehen?“
* [__Antworte__: „Vielleicht eine Frau im Wald. Oder ein Stein, der eine sein wollte.“] #action:conversation
#route:eccentric
#hint:statue
~ eccentric += 1
~ viktor_suspicion += 1
„Vielleicht eine Frau im Wald. Oder ein Stein, der eine sein wollte.“
Er betrachtet die vorbeiziehenden Bäume.
„Ein Wegheiligtum?“
** [__Antworte__: „Wenn es ein Heiligtum ist, so ist es seit langem nicht mehr geliebt worden.“] #action:conversation
„Wenn es ein Heiligtum ist, so ist es seit langem nicht mehr geliebt worden.“
„Sie sprechen, als bemerkten Steine Vernachlässigung.“
Soldaten bemerken Vernachlässigung ebenfalls. Sein Schweigen gesteht genug zu.
Er antwortet nicht.
** [__Antworte__: „Nein. Heiligtümer wenden sich den Gläubigen zu. Dieses Ding lauschte seitwärts.“] #action:conversation
~ supernatural_exposure += 1
„Nein. Heiligtümer wenden sich den Gläubigen zu. Dieses Ding lauschte seitwärts.“
Viktors Hand ruht am Halteriemen der Kutsche, still und bereit.
--
* [__Antworte__: „Ein Grenz- oder Wegzeichen. Ich wüsste gern, wohin dieser Pfad führt.“] #action:conversation
#route:detective
#hint:statue
~ detective += 1
~ viktor_trust += 1
„Ein Grenz- oder Wegzeichen. Ich wüsste gern, wohin dieser Pfad führt.“
„Sie haben einen Pfad gesehen?“
** [__Antworte__: „Nicht deutlich. Genug, um später danach zu fragen.“] #action:conversation
„Nicht deutlich. Genug, um später danach zu fragen.“
Viktor blickt durch das kleine rückwärtige Fenster. Die Biegung hat den Hang bereits ausgelöscht.
„Fragen Sie vorsichtig. Orte, die man nicht erwähnt, sind oft aufschlussreicher als jene, die man empfiehlt.“
** [__Antworte__: „Nur die Andeutung eines Pfades. Wenn er existiert, erhält jemand seine Abwesenheit aufrecht.“] #action:conversation
#route:detective
~ detective += 1
„Nur die Andeutung eines Pfades. Wenn er existiert, erhält jemand seine Abwesenheit aufrecht.“
„Sie lassen Abwesenheiten kostspielig klingen.“
Das sind sie meistens; Abwesenheit ist teuer, wenn jemand sie pflegt.
--
* [__Antworte__: „Nur Bäume. Die Art, bei der man für Herren mit Revolvern dankbar wird.“] #action:conversation
#route:careless
~ careless += 1
~ viktor_relation = "dependence"
„Nur Bäume. Die Art, bei der man für Herren mit Revolvern dankbar wird.“
Sein Ausdruck verdunkelt sich um einen amtlichen Grad.
„Ein Revolver ist ein schlechtes Werkzeug gegen Bäume.“
** [__Antworte__: „Dann werde ich mich darauf verlassen, dass Ihre Unterhaltung sie einschüchtert.“] #action:conversation
„Dann werde ich mich darauf verlassen, dass Ihre Unterhaltung sie einschüchtert.“
Der Kutscher tut, als höre er nichts. Seine Schultern jedoch hören alles.
** [__Antworte__: „Wie bedauerlich. Sie wirkten so berufsmäßig beruhigend.“] #action:conversation
#route:lover
~ lover += 1
„Wie bedauerlich. Sie wirkten so berufsmäßig beruhigend.“
„Ich bevorzuge Feinde, die sich zu erkennen geben.“
--
* [__Frage Viktor__: „Würden Sie mir glauben, wenn ich sagte, ich hätte etwas gesehen?“] #action:conversation
#route:lover
~ lover += 1
~ viktor_suspicion += 1
„Würden Sie mir glauben, wenn ich sagte, ich hätte etwas gesehen?“
„Das hinge davon ab, welchen Vorteil Sie sich von der Antwort versprechen.“
** [__Antworte__: „Herr Nowak. Sie verletzen mich.“] #action:conversation
„Herr Nowak. Sie verletzen mich.“
„Noch nicht.“
Es ist das Erste, was er an diesem Tag gesagt hat, das beinahe wie ein Flirt klingt, wenn auch vielleicht nur deshalb, weil Gefahr ein Talent dafür hat, wärmere Kleider zu borgen.
** [__Weise Viktor an__: „Beobachten Sie den Hang, nicht meine Absichten. Eines von beidem könnte nützlich sein.“] #action:social
~ viktor_trust += 1
„Dann beobachten Sie den Hang, nicht meine Absichten. Eines von beidem könnte nützlich sein.“
Er gehorcht, ohne zuzugeben, dass er es getan hat.
--
* [__Antworte__: „Nein.“] #action:conversation
#route:sapphic
~ sapphic += 1
„Nein.“
Die Verneinung kommt zu rasch, und ihr hört es beide.
Du denkst nicht mehr an den Stein. Du denkst an die junge Frau, die irgendwo vor euch wartet: die Tochter des Grafen, der Grund, der sorgsam nicht im Memorandum steht, die Fremde, deren Haushalt dich unter einem Titel herbeigerufen hat, der zugleich lächerlich und nützlich ist.
** [__Antworte__: „Es war nur Schatten.“] #action:conversation
„Es war nur Schatten.“
Wenn dieser Ort Frauen in Stein hält, denkst du, was tut er dann mit ihnen in den Häusern?
** [__Antworte__: „Oder, falls ich etwas sah, ziehe ich es vor, es mir nicht erklären zu lassen, bevor ich verstehe, warum es von Bedeutung ist.“] #action:conversation
#route:detective
~ detective += 1
„Oder, falls ich etwas sah, ziehe ich es vor, es mir nicht erklären zu lassen, bevor ich verstehe, warum es von Bedeutung ist.“
Wenn dieser Ort Frauen in Stein hält, denkst du, was tut er dann mit ihnen in den Häusern?
--
-
Der Graben öffnet sich widerwillig.
-> village_arrival_options
+162
View File
@@ -0,0 +1,162 @@
// eibenreith_04_dorf.ink
// Kapitel: Eibenreith.
// Enthält Dorfankunft, optionale Dorfbeobachtungen, Ausstiegs-Manierenpuzzle und Schluss des Intros.
=== village_arrival_options ===
Zuerst kommt der Geruch von Rauch. Dann ein Dach, niedrig und dunkel vom Wetter. Dann ein zweites. Dann ein Kirchturm, nicht hoch, nicht anmutig, sondern breitschultrig und blass vor dem Hang dahinter. Seine Mauern wirken älter als das Dorf um sie her und weniger sicher ihres Sieges. Die Fenster sind klein. Die Kirchhofmauer hält die Straße auf Abstand, als bräuchten die Toten Schutz vor den Lebenden oder die Lebenden vor etwas anderem. #chapter[Eibenreith] #sfx[church-bells.ogg](max=8, fade) #image[eibenreith.png](landscape)
Eibenreith erscheint nicht, wie ein Dorf auf einem Bild erscheint, auf einmal und zur Bewunderung geordnet, sondern in Bruchstücken.
Eine Frau mit einem dunklen Kopftuch hält mit einem Eimer in der Hand inne. Ein Bub hört auf, Gänse zu treiben, und lässt sie um seine Stiefel klagen. Zwei Männer vor einem Schuppen beenden im selben Augenblick ihr Gespräch, ohne einander anzusehen. Vorhänge rühren sich an Fenstern, hinter denen niemand zugibt zu stehen. Ein Schmiedeschild bewegt sich leicht in Luft, die du nicht fühlen kannst. Wasser läuft irgendwo unter Brettern, unter Stein, unter der Straße selbst, schnell, kalt und verborgen.
Die Häuser sind nicht arm, nicht eigentlich. Viele sind fest, weißgekalkt, geschindelt, erhalten mit der störrischen Anständigkeit von Menschen, die reparieren, was sie nicht ersetzen können. Und doch stört etwas in ihrer Anordnung das Auge. Sie wenden sich der Kirche zu, aber nicht ganz. Sie halten die Straße, aber lehnen sich von ihr weg. Sie lassen zwischen Hof, Zaun und Holzstoß schmale Durchgänge, in denen sich Schatten zu früh sammelt.
Die Kutsche wird langsamer.
Niemand läuft herbei, um sie zu begrüßen.
Niemand muss das. Die Nachricht ist bereits ins Dorf eingetreten, auf Wegen schneller als Bahn, Telegraph oder kaiserliches Siegel.
Du sitzt sehr gerade, während Eibenreith dich zum ersten Mal betrachtet.
- (dorfbeobachtung)
* [__Schaue__: In die Gesichter am Straßenrand.] #action:orientation #optional #key:l
Die Gesichter verschwinden nicht, wenn du hinsiehst. Sie verändern nur ihre Begründung: Eine Frau prüft plötzlich ihren Eimer. Ein Bub entdeckt die Gänse neu. Ein Mann tut, als habe er schon immer zum Kirchtor gesehen. Das Dorf besitzt keine Bühne, aber jeder hier kennt seinen Auftritt.
-> dorfbeobachtung
* [__Höre__: Auf das Wasser unter der Straße.] #action:orientation #optional
Unter den Rädern, unter Brettern und Steinen, unter der höflichen Behauptung einer Dorfstraße läuft Wasser. Es klingt nicht tief, aber schnell. Als hätte der Ort einen zweiten Atem, einen kalten, verborgenen, der nicht durch menschliche Münder geht.
-> dorfbeobachtung
* [__Untersuche__: Die Kirche.] #action:orientation #optional
Der Turm ist nicht schlank genug, um in den Himmel zu zeigen. Er steht da wie eine Faust. Die kleinen Fenster geben wenig preis, und die Mauer des Kirchhofs wirkt weniger wie Einfriedung als wie eine alte Gewohnheit, sich gegen etwas zu stemmen.
{
- religion_stance == "devout_catholic":
Gerade das stört dich: nicht der Mangel an Schönheit, sondern der Mangel an Frieden.
- religion_stance == "josephinian_sceptic":
Du siehst weniger Andacht als Institution: Stein, Besitz, Grenze, Verwaltung der Furcht.
- else:
Die Kirche sieht nicht aus, als habe sie den älteren Dingen im Tal widersprochen. Eher, als habe sie gelernt, über ihnen zu stehen.
}
-> dorfbeobachtung
* [__Warte__: Bis die Kutsche hält.] #action:social #key:z
-> village_exit_puzzle
=== village_exit_puzzle ===
Der Kutscher hält vor dem Wirtshaus oder vielleicht nur vor dem Gebäude, das in einem besseren Dorf eines gewesen wäre. Ein Knecht aus dem Dorf tritt aus dem Schatten des Tors. Viktor öffnet die Kutschentür von innen nicht sofort; der Kutscher steigt ab, um den Schlag zu öffnen. Der Knecht sieht auf dein Gepäck, dann auf deine Handschuhe, dann auf Viktor.
Wieder stellt die Welt eine Frage, ohne sie auszusprechen: Wer darf dir helfen, wer muss dir helfen, und wem erlaubst du, dabei wichtig zu wirken?
* {birth_class == "noble"} [__Warte__ · **Adel**: Bis der Kutscher den Schlag öffnet und Viktor zuerst aussteigt.] #action:social #gated:noble #key:z
#manners:excellent
~ class_confidence += 2
Du wartest, bis der Kutscher den Schlag öffnet und Viktor zuerst aussteigt; erst dann reichst du Viktor die behandschuhte Hand.
Es geschieht langsam genug, dass alle Beteiligten ihre Rolle finden. Der Kutscher ist Dienst, Viktor ist Begleitung, der Knecht ist noch nicht wichtig genug, um dich zu berühren. Dein Fuß erreicht den Boden, als hätte die Straße sich dafür bereitgehalten.
* [__Nicke__: Dem Kutscher knapp zu, nachdem Viktor dir geholfen hat.] #action:social
#manners:good
~ viktor_trust += 1
Du lässt Viktor aussteigen, nimmst seine Hand beim Abtreten und dankst dem Kutscher erst danach mit einem knappen Blick.
Der Ablauf ist korrekt genug, um keine Geschichte zu erzeugen. In einem Dorf, das von Geschichten lebt, ist das ein kleiner Sieg.
* [__Weise den Knecht an__: „Zuerst den kleineren Kasten.“] #action:social
#route:detective
#manners:practical
~ detective += 1
Du gibst dem Knecht eine klare Anweisung, welches Gepäck zuerst abgeladen werden soll, bevor er danach fragen kann.
Er gehorcht sofort. Viktor registriert die Zweckmäßigkeit. Der Kutscher registriert die Ungewöhnlichkeit. Eine Dame, die Gepäckreihenfolgen kennt, ist entweder sehr erfahren, sehr nervös oder beides.
* [__Lächle__: Dem Kutscher zu freundlich zu.] #action:social
#route:lover
#manners:too_warm
~ lover += 1
Du bietest dem Kutscher ein sichtbares Lächeln und ein zu freundliches „Danke“ an.
Der Mann senkt den Blick, verwirrt und geschmeichelt. Viktor wird stiller. Freundlichkeit über Standesgrenzen hinweg kann Güte sein, Taktik oder Unachtsamkeit. Auf dem Dorf wird niemand lange brauchen, eine vierte Möglichkeit zu erfinden.
* [__Steige aus__: Zu früh, ehe alle Rollen verteilt sind.] #action:movement
#route:careless
#manners:awkward
~ careless += 1
~ viktor_relation = "dependence"
Du steigst zu früh aus, trittst beinahe in den Straßenschlamm und fängst dich an Viktors Arm.
Er hält dich ohne sichtbare Anstrengung fest. Für einen Augenblick sieht das Dorf genau das, was es am liebsten sieht: eine Dame, gerettet durch einen Mann. Es ist lächerlich nützlich und nützlich lächerlich.
* {birth_class == "working"} [__Steige aus__ · **Unterschicht**: Allein, bevor jemand dir helfen kann.] #action:movement #gated:working
#manners:fauxpas
~ class_confidence -= 1
Du steigst allein aus, nimmst deinen Rock hoch genug, um den Schlamm zu sehen, und sagst dem Knecht, er solle mit dem schweren Koffer vorsichtig sein.
Es ist praktisch, schnell und völlig falsch. Nicht, weil du unrecht hast, sondern weil du recht hast wie jemand, der selbst schon getragen hat. Der Knecht erkennt es. Viktor auch.
-
Neben dir senkt Viktor die Stimme.
„Vergessen Sie nicht: In Hohenreith wird jede Höflichkeit etwas bedeuten. Hier wird es jedes Schweigen tun.“
* [__Antworte__: „Dann werden wir bereits empfangen.“] #action:conversation
#route:detective
~ detective += 1
„Dann werden wir bereits empfangen.“
„Ja“, sagt er. „Und geprüft.“
* [__Antworte__: „Sie lassen es klingen, als stünde das Dorf über dem Grafen.“] #action:conversation
#route:eccentric
~ eccentric += 1
„Sie lassen es klingen, als stünde das Dorf über dem Grafen.“
„Nein“, sagt Viktor. „Nur, als hätte es vielleicht mehr als einen überlebt.“
* [__Antworte__: „Wie glücklich, dass ich mehrere Arten des Schweigens eingepackt habe.“] #action:conversation
#route:lover
~ lover += 1
„Wie glücklich, dass ich mehrere Arten des Schweigens eingepackt habe.“
Sein Mund bewegt sich beinahe. „Verwenden Sie zuerst das schlichteste.“
* [__Antworte__: „Ich mag es nicht, von Leuten beobachtet zu werden, die sich nicht vorstellen.“] #action:conversation
#route:careless
~ careless += 1
„Ich mag es nicht, von Leuten beobachtet zu werden, die sich nicht vorstellen.“
„Das“, sagt er, „wird sich heute kaum bessern.“
* [__Antworte__: „Wenn Amalia ihr ganzes Leben unter diesem Blick gelebt hat, beginne ich zu verstehen, weshalb man nach Geistern sandte.“] #action:conversation
#route:sapphic
~ sapphic += 1
„Wenn Amalia ihr ganzes Leben unter diesem Blick gelebt hat, beginne ich zu verstehen, weshalb man nach Geistern sandte.“
Viktor sieht dich an, doch welche Antwort er auch erwägt, er behält sie hinter den Zähnen.
-
Die Pferde ziehen die Kutsche an der Kirchhofmauer vorbei. Darüber, auf dem alten Putz neben dem Tor, blickt eine verblasste gemalte Frau unter einem abblätternden blauen Mantel herab. Ihre Hände sind zum Gebet gefaltet. Ihre Augen, vom Wetter beschädigt, zeigen nicht mehr in dieselbe Richtung.
{
- religion_stance == "devout_catholic":
Für einen Atemzug stört dich nicht, dass das Bild alt ist. Es stört dich, dass es nicht mehr ganz heilig wirkt.
- religion_stance == "josephinian_sceptic":
Für einen Atemzug wirkt das Bild weniger wie Andacht als wie Verwaltung: ein aufgemaltes Siegel über etwas, das man nicht fortschaffen konnte.
- religion_stance == "wounded_catholic":
Für einen Atemzug trifft dich das gemalte Gesicht an einer Stelle, die du lieber Schuld als Erinnerung nennen würdest.
- else:
Für einen Atemzug, als die Räder über ein verborgenes Wasserrinnsal fahren, wirkt das gemalte Gesicht weniger wie die Heilige Mutter als wie eine Maske, die etwas aufgesetzt wurde, das länger gewartet hatte.
}
Dann fährt die Kutsche in das eigentliche Dorf hinein, und die Straße biegt zu der unsichtbaren Höhe, auf der Jagdhaus Hohenreith über Eibenreith unter seinem neueren Namen steht.
#score[Du hast Eibenreith erreicht.]
-> END
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+31 -6
View File
@@ -2,10 +2,24 @@ title: The Mysterious Mansion
author: AI Interactive Fiction
version: 1.0.0
introduction: |
You find yourself standing outside an old, abandoned mansion on a hill.
Rain patters gently on the gravel path leading to the front door.
A strange letter in your pocket invited you here, but you can't remember who sent it.
Perhaps the answers lie within...
#chapter[The Mysterious Mansion]
#music[Dark Jodler.mp3](lead=10)
The last thing you remember is the letter: heavy paper, black wax, your name written in a hand you almost recognized.
It asked you to come after dusk, alone, and promised that the house would answer what the sender could not.
Now you stand beyond the wrought iron gate, with rain cooling your face and the hill rising before you.
At its crest waits the old Victorian mansion, every dark window turned toward the path as if the building has been expecting you.
The gate gives under your hand with no protest, though its ironwork is wet enough to shine black.
Gravel shifts beneath your boots as you pass between the pillars, and the garden closes behind you with the soft finality of a curtain.
Halfway up the path, you stop and listen.
The rain has thinned to a whisper, but the house answers with other sounds: timber settling, gutters ticking, and something deep inside the walls that might be machinery or breath.
For a heartbeat you think the mansion is about to speak ... but only the wind moves through the ivy.
It drags the leaves across the brickwork in slow strokes, as if wiping dust from an old name.
# Room definitions
rooms:
@@ -13,9 +27,20 @@ rooms:
front_yard:
name: Front Yard
description: |
You stand on a gravel path leading to an imposing Victorian mansion.
The rain has softened to a drizzle, and moonlight peeks through gaps in the clouds.
You follow the gravel path up the hill.
The rain softens to a drizzle, and moonlight peeks through gaps in the clouds.
Ancient oak trees frame the property, their branches swaying in the gentle breeze.
At the top of three worn stone steps, the mansion's front door waits under a sagging porch roof.
The porch boards are swollen with rain, each one bending under your weight before it remembers its shape.
A brass knocker hangs at eye level, polished bright at the edges where countless hands have touched it and left no warmth behind.
The letter in your pocket presses against your ribs.
You remember the last line now: come before the clocks learn your name.
Somewhere above you, behind a blind upper window, a pale shape passes from left to right and is gone.
You tell yourself it was a reflection, then look back at the path and find no light behind you bright enough to make one.
The house waits.
#sfx[squeaky-door.ogg]
When you reach for the handle, it turns before your fingers touch it, and the door opens with a long, complaining squeak.
exits:
- direction: north
targetRoomId: entrance_hall
+30
View File
@@ -0,0 +1,30 @@
# Z-Code Story Files
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
The Zork I story file (`zork1.bin`, also distributed as `ZORK1.DAT` or as a
`.z3` or `.z5` file) is copyrighted by Infocom / Activision. It is not
included in this repository.
You can obtain a legal copy via:
- The **Zork Trilogy** on GOG.com or Steam (includes the original data files).
- The [Internet Archive](https://archive.org/details/Zork_I_The_Great_Underground_Empire_1980_Infocom)
hosts a playable version in-browser; the original data files are part of some
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 `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)
- Any file with the correct Z-machine header (the extension is ignored)
Zork I is a Z-machine version 3 (`.z3`) game.
Binary file not shown.
@@ -0,0 +1,44 @@
# Character Generation Prompt
# Called once at game start to create a unique player character.
# No user_template is needed — the system message IS the full prompt.
# Expected output: 300-500 words of vivid character description prose. No JSON.
system: |
You are creating the canonical player-character profile for:
Zork I: The Great Underground Empire.
Hard requirements:
- Always write in second person and refer to the protagonist as "you".
- Never call the protagonist "he", "she", "they", or by a third-person noun.
- The character is from an Earth-like 1980s setting blended with Zork lore.
- The character is NOT an American treasure hunter.
- Tone: vivid, concrete, grounded, literary, and emotionally specific.
- Give the character one primary sensitive sense and make it easy for later
narration to use that sense.
Generate a complete persona that includes:
- Random full name.
- Gender, nationality, race, age.
- Skin color, eye color, hair color, body size, body build.
- Personal style, hairstyle.
- Tattoos (optional), piercings (optional), scars (optional).
- Distinctive standout trait (at least one clearly unusual detail).
- One dominant sense (sight, hearing, smell, taste, touch) that is most sensitive.
- Exactly three sentences of backstory.
- Personality, likes, dislikes, hopes, fears, worldview.
- Clothing and accessories worn on body, including underlayers where relevant.
- Do NOT list bags, tools, or equipment.
- Seed one or two concrete memory hooks that can later be triggered by places,
smells, sounds, architecture, darkness, weather, or treasure.
Output format (strict):
- First line must start exactly with: Welcome to the game
- On that same line include the full official title: Zork I: The Great Underground Empire
- Second line must start exactly with: You are
- Continue with the full persona in flowing prose.
- Do not output any extra headings, metadata, bullet points, or explanations.
Ensure the generated profile is specific enough to support memory continuity,
body-description requests, mood shifts, and character-consistent narration later.
user_template: ""
+112
View File
@@ -0,0 +1,112 @@
# Command Translator Prompt
# Called for every player input. Converts free natural-language text into a
# Z-machine parser command, or decides to reply directly / execute session tools.
# Expected output: a JSON object (see schema below).
system: |
You are the command-intent router for a literary Zork I engine.
Hard rules:
- Keep player-character continuity in second person ("you").
- If user asks for personal life/body/memory detail not present in context,
reply directly from the established character profile instead of sending a
parser command to Zork.
- If the player changes or adds stable identity, personality, mood, memory,
clothing, body, or backstory facts, use update_character or add_note so future
narration remembers it.
- If newly invented personal possessions are implied, add them to virtual inventory.
Choose one response mode:
MODE A — command
Use for one parser action.
JSON:
{ "type": "command", "command": "OPEN MAILBOX" }
MODE B — commands
Use when the user asks for multiple sequential actions in one input.
Example: "Take and read the pamphlet" -> TAKE PAMPHLET, READ PAMPHLET.
JSON:
{ "type": "commands", "commands": ["TAKE PAMPHLET", "READ PAMPHLET"] }
MODE C — reply
Use when no meaningful parser action exists.
Give a brief in-world response and guide back to actionable input only if the
player seems blocked. For body, clothing, identity, mood, memory, or "who am I"
questions, answer in second-person prose from the character profile.
JSON:
{ "type": "reply", "text": "..." }
MODE D — tools
Use tools when memory/state should be persisted, optionally with command(s).
JSON shape:
{
"type": "tools",
"tools": [ ... ],
"command": "OPTIONAL_SINGLE_COMMAND",
"commands": ["OPTIONAL", "MULTI", "COMMANDS"]
}
Available tools:
- update_character
args: { "description": string }
- add_note
args: { "note": string }
- remove_note
args: { "index": number }
- add_inventory_item
args: { "item": string }
- remove_inventory_item
args: { "item": string }
Tool usage policy:
- 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 Z-machine parser does not track it).
- Use remove_inventory_item when item is consumed/lost/discarded in story logic.
Command policy:
- Use terse Zork-style imperatives, uppercase preferred.
- Split compound natural language requests into ordered commands when needed.
- Avoid impossible commands when a helpful reply is better.
- Do not translate "who am I", "describe me", "look at myself", or body/clothing
inspection into parser commands; answer as the narrator using MODE C unless
the input also contains a concrete world action.
- When the player asks what a leaflet/pamphlet/paper says, use READ LEAFLET.
- When the player asks to take and read something from the mailbox, use
TAKE LEAFLET followed by READ LEAFLET, not TAKE MAILBOX or READ MAILBOX.
- When the player asks to look inside the mailbox, use LOOK IN MAILBOX.
- If the player complains that readable text was not shown, route to READ LEAFLET
when the recent context includes a leaflet/pamphlet/paper.
Output only valid JSON in exactly one mode.
user_template: |
Player character:
{{characterDescription}}
Narrator's notes (index 0, 1, 2…):
{{notes}}
Character-side virtual inventory:
{{virtualInventory}}
Narrator simulation state:
{{narratorState}}
Current location: {{currentRoom}}
What the player has seen here recently:
{{roomHistory}}
Most recent narrative paragraphs across scenes (up to 10, newest last):
{{recentNarrative}}
Recent raw parser transcript for factual anchoring:
{{rawTranscript}}
Player's input:
"{{userInput}}"
Respond with the appropriate JSON now.
+76
View File
@@ -0,0 +1,76 @@
# Output Evaluator Prompt
# Called after each Z-machine response. Decides whether to accept the output
# and rewrite it for the player, or to discard it and retry with a new command.
# Expected output: a JSON object (see schema below).
system: |
You are the quality gate between parser output and literary narration.
Decide whether to accept parser output or retry with a better command.
Retry when:
- parser error / unknown verb / malformed command,
- a clearer command likely achieves user intent,
- and attempt is not the final one.
Accept when:
- any meaningful world response occurred (including meaningful failure),
- or this is the final attempt.
If accepting, output vivid prose that:
- always refers to protagonist as "you" (never he/she/they),
- preserves parser facts,
- preserves written/readable text exactly when the command reads an object,
- uses the narrator simulation state for time/weather continuity,
- uses atmosphere and sensory detail, especially the character's sensitive sense,
- may include required preparatory body movement if it does not change game state,
- may include fitting internal monologue, direct speech, or a triggered memory,
- aligns with established character, notes, virtual inventory, and recent narrative.
Keep output concrete and scene-rooted.
Do not recommend commands, list possible next actions, or end with "If you want...".
Do not say the parser failed to provide text when the raw Z-machine response contains
the text being read.
Output JSON only:
- Accept:
{ "decision": "accept", "text": "..." }
- Retry:
{ "decision": "retry", "command": "..." }
user_template: |
Player character:
{{characterDescription}}
Narrator's notes:
{{notes}}
Character-side virtual inventory:
{{virtualInventory}}
Narrator simulation state:
{{narratorState}}
Current location: {{currentRoom}}
What the player has seen here recently:
{{roomHistory}}
Most recent narrative paragraphs across scenes (up to 10, newest last):
{{recentNarrative}}
Recent raw parser transcript for factual anchoring:
{{rawTranscript}}
---
Original player intent: "{{userIntent}}"
Command tried: {{commandTried}}
Attempt: {{attempt}} of {{maxAttempts}}
Raw Z-machine response:
---
{{zcodeOutput}}
---
Decide now: accept and rewrite, or retry with a new command?
Respond with the appropriate JSON.
+77
View File
@@ -0,0 +1,77 @@
# Text Rewriter Prompt
# Called for the game's opening text, and for re-entry into rooms that have
# no prior player-facing history yet.
# Expected output: polished prose. No JSON.
system: |
You are the narrative layer for Zork I: The Great Underground Empire.
Rewrite raw Z-machine output into immersive prose while preserving game facts.
Core stance:
- 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 Z-machine output.
Style and simulation goals:
- Use atmospheric detail: light/shadow, sound, smell, airflow, temperature.
- Use the supplied narrator simulation state for day/night and weather continuity;
let it influence outside scenes and thresholds, and mention it only when it
naturally changes the felt scene.
- Make physical actions visceral when movement/exertion occurs.
- Let the character's personality, sensitive sense, hopes, fears, and worldview
color word choice, interpretation, internal monologue, and occasional direct
speech.
- Occasionally weave memory flashes from established backstory/notes when context fits.
- If describing the body, describe only what "you" can perceive directly and your
immediate thoughts about those details.
- Add incidental preparatory body movement when it would be required to perform
an action, as long as it does not change Zork's authoritative game state.
- Use Zork lore as texture, rumor, architecture, old names, or cultural memory,
but never as a new solvable fact unless the raw parser output establishes it.
Continuity policy:
- Use character profile, notes, virtual inventory, room history, and recent narrative
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 Z-machine output.
Output constraints:
- Return prose only. No JSON, no labels, no headings.
- Prefer short paragraphs (2-5 sentences each).
- Preserve parser intent while replacing parser phrasing with natural narration.
- Do not recommend commands, list possible actions, or end with "If you want...".
- Do not apologize or mention missing information unless the raw Z-machine output
explicitly says that information is unavailable.
- When raw output contains written text from a sign, leaflet, book, label, inscription,
or other readable object, preserve the exact wording verbatim inside the prose.
user_template: |
The player character:
{{characterDescription}}
Narrator's notes about the story so far:
{{notes}}
Character-side virtual inventory (can exist even if Zork does not track it):
{{virtualInventory}}
Narrator simulation state:
{{narratorState}}
What the player has seen in this location before (most recent last):
{{roomHistory}}
Most recent narrative paragraphs across scenes (up to 10, newest last):
{{recentNarrative}}
Recent raw parser transcript for factual anchoring:
{{rawTranscript}}
Raw Z-machine output to rewrite:
---
{{zcodeOutput}}
---
Rewrite the above as prose for the player now.
+39
View File
@@ -0,0 +1,39 @@
export type EngineName = 'yaml' | 'ink' | 'zcode' | string;
export interface GameMetadata {
title: string;
author?: string;
subtitle?: string;
version?: string;
copyright?: string;
language?: string;
}
export interface GamePaths {
mainGameFile: string;
inkSource?: string;
inkCompiled?: string;
promptDir?: string;
music?: string;
sfx?: string;
images?: string;
[key: string]: string | undefined;
}
export interface GameEngineConfig {
engine: EngineName;
locale: 'en_US' | 'de_DE' | string;
paths: GamePaths;
metadata: GameMetadata;
}
export declare function projectPath(relativeOrAbsolutePath: string): string;
export declare function loadGameConfig(configPath: string, engine: EngineName): GameEngineConfig;
export declare function ensureConfiguredAssetDirectories(config: GameEngineConfig): void;
export declare function clientGameConfig(config: GameEngineConfig): {
engine: string;
locale: string;
metadata: GameMetadata;
assets: {
music: string;
sfx: string;
sounds: string;
images: string;
};
};
+96
View File
@@ -0,0 +1,96 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.projectPath = projectPath;
exports.loadGameConfig = loadGameConfig;
exports.ensureConfiguredAssetDirectories = ensureConfiguredAssetDirectories;
exports.clientGameConfig = clientGameConfig;
const path_1 = __importDefault(require("path"));
const fs_1 = require("fs");
const PROJECT_ROOT = path_1.default.resolve(__dirname, '../..');
function fallbackConfig(engine) {
return {
engine,
locale: 'en_US',
paths: {
mainGameFile: engine === 'ink'
? 'data/ink/story.ink.json'
: engine === 'zcode'
? 'data/z-code/zork1.bin'
: 'data/worlds/example_world.yml',
music: 'public/music',
sfx: 'public/sounds',
images: 'public/images',
},
metadata: {
title: 'AI Interactive Fiction',
author: 'Generative AI',
subtitle: 'An open-world text adventure',
version: '1.0.0',
copyright: '',
language: 'en_US',
},
};
}
function projectPath(relativeOrAbsolutePath) {
return path_1.default.isAbsolute(relativeOrAbsolutePath)
? relativeOrAbsolutePath
: path_1.default.resolve(PROJECT_ROOT, relativeOrAbsolutePath);
}
function loadGameConfig(configPath, engine) {
const absolutePath = projectPath(configPath);
if (!(0, fs_1.existsSync)(absolutePath)) {
console.warn(`[config] Missing ${absolutePath}; using ${engine} defaults.`);
return fallbackConfig(engine);
}
const parsed = JSON.parse((0, fs_1.readFileSync)(absolutePath, 'utf8'));
const fallback = fallbackConfig(engine);
return {
engine: parsed.engine ?? fallback.engine,
locale: parsed.locale ?? fallback.locale,
paths: {
...fallback.paths,
...(parsed.paths ?? {}),
},
metadata: {
...fallback.metadata,
...(parsed.metadata ?? {}),
language: parsed.metadata?.language ?? parsed.locale ?? fallback.metadata.language,
},
};
}
function ensureConfiguredAssetDirectories(config) {
const directories = [
config.paths.music,
config.paths.sfx,
config.paths.images,
config.paths.inkSource ? path_1.default.dirname(config.paths.inkSource) : undefined,
config.paths.inkCompiled ? path_1.default.dirname(config.paths.inkCompiled) : undefined,
config.paths.mainGameFile ? path_1.default.dirname(config.paths.mainGameFile) : undefined,
config.paths.promptDir,
];
for (const directory of directories) {
if (!directory)
continue;
const absolutePath = projectPath(directory);
if (!(0, fs_1.existsSync)(absolutePath)) {
(0, fs_1.mkdirSync)(absolutePath, { recursive: true });
}
}
}
function clientGameConfig(config) {
return {
engine: config.engine,
locale: config.locale,
metadata: config.metadata,
assets: {
music: '/music/',
sfx: '/sounds/',
sounds: '/sounds/',
images: '/images/',
},
};
}
//# sourceMappingURL=game-config.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"game-config.js","sourceRoot":"","sources":["../../src/config/game-config.ts"],"names":[],"mappings":"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"}
+33
View File
@@ -0,0 +1,33 @@
import { TurnResult } from '../interfaces/turn-result';
export interface InkCompileResult {
sourcePath: string;
outputPath: string;
warningCount: number;
}
export declare function compileInkSource(sourcePath: string, outputPath: string): InkCompileResult;
export declare class InkEngine {
private readonly storyPath;
private story;
private nextTurnId;
private storyJson;
private readonly choicePreviewTagKeys;
constructor(storyPath: string);
isRunning(): boolean;
newGame(): TurnResult;
chooseChoice(choiceIndex: number): TurnResult;
saveGame(): string;
resumeGame(savedState: string): void;
loadGame(savedState: string): TurnResult;
private restoreState;
private loadStory;
private continueStory;
private isParagraphScopedTag;
private reassignTrailingGlossTags;
private normalizeGlossMatchText;
private getChoiceTags;
private extractChoicePreviewTags;
private resolveInkPath;
private findNamedInkChild;
private getInkContainerMap;
private isNamedContainerMap;
}
+359
View File
@@ -0,0 +1,359 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.InkEngine = void 0;
exports.compileInkSource = compileInkSource;
const fs_1 = require("fs");
const path_1 = __importDefault(require("path"));
const inkjs_1 = require("inkjs");
const tag_parser_1 = require("../utils/tag-parser");
const { Compiler } = require('inkjs/full');
function compileInkSource(sourcePath, outputPath) {
const resolvedSource = path_1.default.resolve(sourcePath);
const resolvedOutput = path_1.default.resolve(outputPath);
if (!(0, fs_1.existsSync)(resolvedSource)) {
throw new Error(`Ink source file not found: ${resolvedSource}`);
}
const warnings = [];
const errors = [];
const source = (0, fs_1.readFileSync)(resolvedSource, 'utf8').replace(/^\uFEFF/, '');
const sourceDir = path_1.default.dirname(resolvedSource);
const fileHandler = {
ResolveInkFilename: (filename) => path_1.default.isAbsolute(filename) ? filename : path_1.default.resolve(sourceDir, filename),
LoadInkFileContents: (filename) => (0, fs_1.readFileSync)(path_1.default.isAbsolute(filename) ? filename : path_1.default.resolve(sourceDir, filename), 'utf8')
.replace(/^\uFEFF/, ''),
};
const compiler = new Compiler(source, {
sourceFilename: resolvedSource,
fileHandler,
errorHandler: (message, type) => {
if (type === 1) {
warnings.push(message);
}
else {
errors.push(message);
}
},
});
const story = compiler.Compile();
if (!story || errors.length > 0) {
throw new Error(`Ink compilation failed:\n${errors.join('\n')}`);
}
if (warnings.length > 0) {
warnings.forEach((warning) => console.warn(`[ink] ${warning}`));
}
(0, fs_1.mkdirSync)(path_1.default.dirname(resolvedOutput), { recursive: true });
(0, fs_1.writeFileSync)(resolvedOutput, story.ToJson(), 'utf8');
return {
sourcePath: resolvedSource,
outputPath: resolvedOutput,
warningCount: warnings.length,
};
}
class InkEngine {
constructor(storyPath) {
this.storyPath = storyPath;
this.story = null;
this.nextTurnId = 1;
this.storyJson = null;
this.choicePreviewTagKeys = new Set(['action', 'key', 'letter', 'optional', 'gated', 'sort']);
}
isRunning() {
if (!this.story)
return false;
return this.story.canContinue || this.story.currentChoices.length > 0;
}
newGame() {
this.story = this.loadStory();
this.nextTurnId = 1;
return this.continueStory();
}
chooseChoice(choiceIndex) {
if (!this.story) {
throw new Error('No active Ink story');
}
const choice = this.story.currentChoices.find((item) => item.index === choiceIndex);
if (!choice) {
throw new Error(`Ink choice ${choiceIndex} is not available`);
}
this.story.ChooseChoiceIndex(choice.index);
return this.continueStory();
}
saveGame() {
if (!this.story) {
throw new Error('No active Ink story to save');
}
return JSON.stringify({
inkState: this.story.state.toJson(),
nextTurnId: this.nextTurnId,
});
}
resumeGame(savedState) {
this.restoreState(savedState);
}
loadGame(savedState) {
this.restoreState(savedState);
return this.continueStory();
}
restoreState(savedState) {
this.story = this.loadStory();
let inkState = savedState;
try {
const parsed = JSON.parse(savedState);
if (parsed && typeof parsed.inkState === 'string') {
inkState = parsed.inkState;
if (Number.isInteger(parsed.nextTurnId)) {
this.nextTurnId = Math.max(1, parsed.nextTurnId);
}
}
}
catch {
// Backward compatibility with raw Ink state JSON.
}
this.story.state.LoadJson(inkState);
}
loadStory() {
const resolvedPath = path_1.default.resolve(this.storyPath);
if (!(0, fs_1.existsSync)(resolvedPath)) {
throw new Error(`Ink story file not found: ${resolvedPath}`);
}
this.storyJson = JSON.parse((0, fs_1.readFileSync)(resolvedPath, 'utf8'));
return new inkjs_1.Story(this.storyJson);
}
continueStory() {
if (!this.story) {
throw new Error('No active Ink story');
}
const paragraphs = [];
const globalTags = [];
const turnTags = [];
let pendingParagraphTags = [];
while (this.story.canContinue) {
const rawText = this.story.Continue();
const text = String(rawText || '').trim();
const tags = (0, tag_parser_1.parseTags)(this.story.currentTags || []);
turnTags.push(...tags);
tags
.filter((tag) => tag.key === 'title' || tag.key === 'author')
.forEach((tag) => globalTags.push(tag));
if (text) {
const paragraphTags = this.reassignTrailingGlossTags(text, [...pendingParagraphTags, ...tags], paragraphs);
pendingParagraphTags = [];
paragraphs.push({ text, tags: paragraphTags });
}
else {
const paragraphTags = this.reassignTrailingGlossTags('', tags, paragraphs);
paragraphTags.forEach((tag) => {
if (this.isParagraphScopedTag(tag)) {
pendingParagraphTags.push(tag);
}
else {
globalTags.push(tag);
}
});
}
}
if (pendingParagraphTags.length > 0) {
globalTags.push(...pendingParagraphTags);
pendingParagraphTags = [];
}
const choices = this.story.currentChoices.map((choice) => {
const tags = this.getChoiceTags(choice);
const category = (0, tag_parser_1.getTagValue)(tags, 'action');
const letter = (0, tag_parser_1.getTagValue)(tags, 'letter') || (0, tag_parser_1.getTagValue)(tags, 'key');
return {
index: choice.index,
text: String(choice.text || '').trim(),
tags,
category,
letter,
};
});
const inputMode = choices.length > 0 ? 'choice' : 'end';
const gameState = {};
if (inputMode === 'end') {
const errorTag = turnTags.find((tag) => tag.key === 'error');
const scoreTag = turnTags.find((tag) => tag.key === 'score');
if (!errorTag && !scoreTag) {
const message = 'Ink story ended without an explicit #score ending tag.';
const generatedErrorTag = { key: 'error', value: message };
globalTags.push(generatedErrorTag);
turnTags.push(generatedErrorTag);
}
const finalErrorTag = turnTags.find((tag) => tag.key === 'error');
const finalScoreTag = turnTags.find((tag) => tag.key === 'score');
if (finalErrorTag) {
gameState.endState = {
type: 'error',
message: finalErrorTag.value || finalErrorTag.param,
};
}
else if (finalScoreTag) {
const numericScore = Number(finalScoreTag?.value);
if (Number.isFinite(numericScore)) {
gameState.score = numericScore;
}
gameState.endState = {
type: 'intended',
message: finalScoreTag.value || finalScoreTag.param,
};
}
}
return {
turnId: this.nextTurnId++,
paragraphs,
choices,
inputMode,
globalTags: globalTags.length > 0 ? globalTags : undefined,
gameState: Object.keys(gameState).length > 0 ? gameState : undefined,
};
}
isParagraphScopedTag(tag) {
const key = String(tag?.key || '').toLowerCase();
return ['chapter', 'heading', 'section', 'textblock', 'image', 'music', 'sfx', 'sound', 'audio', 'gloss', 'tts']
.includes(key) || key.startsWith('tts-');
}
reassignTrailingGlossTags(text, tags, paragraphs) {
if (!Array.isArray(tags) || tags.length === 0)
return [];
const previous = paragraphs.length > 0 ? paragraphs[paragraphs.length - 1] : null;
if (!previous)
return tags;
const currentText = this.normalizeGlossMatchText(text);
const previousText = this.normalizeGlossMatchText(previous.text);
const remainingTags = [];
tags.forEach((tag) => {
if (tag.key === 'tts' || tag.key.startsWith('tts-')) {
if (!currentText) {
previous.tags.push(tag);
}
else {
remainingTags.push(tag);
}
return;
}
if (tag.key !== 'gloss') {
remainingTags.push(tag);
return;
}
const term = this.normalizeGlossMatchText(tag.value || '');
if (!term) {
remainingTags.push(tag);
return;
}
const matchesCurrent = currentText.includes(term);
const matchesPrevious = previousText.includes(term);
if (!matchesCurrent && matchesPrevious) {
previous.tags.push(tag);
}
else {
remainingTags.push(tag);
}
});
return remainingTags;
}
normalizeGlossMatchText(value) {
return String(value || '')
.normalize('NFC')
.toLocaleLowerCase('de-DE')
.replace(/[.,;:!?()[\]{}"'„“”‚‘’»«]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
getChoiceTags(choice) {
const directTags = (0, tag_parser_1.parseTags)(choice?.tags || []);
const previewTags = this.extractChoicePreviewTags(choice);
const merged = new Map();
[...previewTags, ...directTags].forEach((tag) => {
merged.set(`${tag.key}:${tag.value || ''}:${tag.param || ''}`, tag);
});
return Array.from(merged.values());
}
extractChoicePreviewTags(choice) {
const pathString = String(choice?.pathStringOnChoice || choice?.targetPath?.toString?.() || '').trim();
if (!pathString || !this.storyJson)
return [];
const container = this.resolveInkPath(pathString);
if (!Array.isArray(container))
return [];
const tags = [];
for (let index = 0; index < container.length; index += 1) {
const token = container[index];
if (typeof token === 'string' && token.replace(/^\^/, '').trim() === '')
continue;
if (token === '\n')
continue;
if (token !== '#')
break;
const rawParts = [];
index += 1;
while (index < container.length && container[index] !== '/#') {
const part = container[index];
if (typeof part === 'string') {
rawParts.push(part.replace(/^\^/, ''));
}
index += 1;
}
const tag = (0, tag_parser_1.parseTags)([rawParts.join('').trim()])[0];
if (tag && this.choicePreviewTagKeys.has(tag.key)) {
tags.push(tag);
}
}
return tags;
}
resolveInkPath(pathString) {
const parts = pathString.split('.').filter(Boolean);
let node = this.storyJson?.root;
for (const part of parts) {
if (!node)
return null;
if (Array.isArray(node) && /^\d+$/.test(part)) {
node = node[Number(part)];
}
else if (Array.isArray(node)) {
node = this.findNamedInkChild(node, part);
}
else if (this.isNamedContainerMap(node) && part in node) {
node = node[part];
}
else {
return null;
}
}
return node;
}
findNamedInkChild(container, part) {
for (let index = container.length - 1; index >= 0; index -= 1) {
const item = container[index];
if (this.isNamedContainerMap(item) && part in item) {
return item[part];
}
if (!Array.isArray(item))
continue;
const namedMap = this.getInkContainerMap(item);
if (namedMap?.['#n'] === part) {
return item;
}
if (namedMap && part in namedMap) {
return namedMap[part];
}
}
return null;
}
getInkContainerMap(container) {
for (let index = container.length - 1; index >= 0; index -= 1) {
const item = container[index];
if (this.isNamedContainerMap(item)) {
return item;
}
}
return null;
}
isNamedContainerMap(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
}
exports.InkEngine = InkEngine;
//# sourceMappingURL=ink-engine.js.map
+1
View File
File diff suppressed because one or more lines are too long
+84
View File
@@ -0,0 +1,84 @@
/**
* Z-code LLM Engine
*
* 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):
* 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 ZcodeSession {
characterDescription: string;
notes: string[];
recentParagraphs: string[];
rawTranscript: string[];
turnCount: number;
timeOfDay: string;
weather: string;
virtualInventory: string[];
/** roomName -> last N player-facing output strings */
roomHistory: Record<string, string[]>;
currentRoom: string;
running: boolean;
}
export type ZcodeTurnResult = TurnResult;
export declare class ZcodeLlmEngine {
private zmachine;
private session;
private prompts;
private llm;
private model;
private resolvedFallbackModel;
private llmCallCounter;
private maxRetries;
private historySize;
private nextTurnId;
private storyPath;
private static readonly DEPRECATED_MODEL_REPLACEMENTS;
constructor(options?: {
storyPath?: string;
promptDir?: string;
});
private createCompletion;
private resolveFallbackModel;
isRunning(): boolean;
/**
* 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<ZcodeTurnResult>;
/**
* Process player free-text input. Returns the next TurnResult.
*/
processInput(userInput: string): Promise<ZcodeTurnResult>;
private runCommandPlan;
/**
* Save the current game state. Returns a JSON string suitable for storing
* in the socket's save-game slot map.
*/
saveGame(): Promise<string>;
/**
* Load a previously saved game. Returns the first TurnResult after restore.
*/
loadGame(savedJson: string): Promise<ZcodeTurnResult>;
private runSingleCommandLoop;
private generateCharacter;
private rewriteText;
private translateCommand;
private evaluateOutput;
private executeTool;
private appendRecentParagraph;
private extractCommands;
private appendRawTranscript;
private advanceNarratorState;
private getDeterministicCommandPlan;
private appendRoomHistory;
private buildCommonVars;
private buildTurnResult;
}
+989
View File
@@ -0,0 +1,989 @@
"use strict";
/**
* Z-code LLM Engine
*
* 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):
* 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;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ZcodeLlmEngine = void 0;
const child_process_1 = require("child_process");
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const os = __importStar(require("os"));
const yaml = __importStar(require("js-yaml"));
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.ZCODE_DEBUG ?? '');
function debugLog(message, details) {
if (!DEBUG_ENABLED)
return;
if (typeof details === 'undefined') {
console.log(`[ZcodeLlm:debug] ${message}`);
return;
}
console.log(`[ZcodeLlm:debug] ${message}`, details);
}
function compactText(text, maxLength = 12000) {
if (text.length <= maxLength)
return text;
return `${text.slice(0, maxLength)}\n...[truncated ${text.length - maxLength} chars]`;
}
function getAssistantContent(data) {
const content = data?.choices?.[0]?.message?.content;
if (typeof content === 'string')
return content;
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === 'string')
return part;
if (typeof part?.text === 'string')
return part.text;
if (typeof part?.content === 'string')
return part.content;
return '';
})
.join('')
.trim();
}
throw new Error(`LLM response did not contain assistant text: ${compactText(JSON.stringify(data))}`);
}
function withReasoningDefaults(payload, model) {
if (payload.reasoning || !/\bgpt-5/i.test(model))
return payload;
return {
...payload,
reasoning: {
effort: process.env.OPENROUTER_REASONING_EFFORT ?? 'none',
exclude: true,
},
};
}
// ---------------------------------------------------------------------------
// Utility: strip ANSI escape sequences
// ---------------------------------------------------------------------------
function stripAnsi(s) {
// eslint-disable-next-line no-control-regex
return s.replace(/\x1B\[[0-9;]*[mGKHFJA-Z]/g, '');
}
// ---------------------------------------------------------------------------
// Utility: extract the current room name from Z-machine output
// ---------------------------------------------------------------------------
function extractRoomName(output) {
const lines = output
.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0);
if (lines.length === 0)
return null;
const first = lines[0];
// Room name heuristics: short, starts with capital, no sentence-ending punctuation
if (first.length < 65 &&
/^[A-Z]/.test(first) &&
!/[.!?]$/.test(first) &&
!/^(You |I |It |There |The [a-z])/.test(first)) {
return first;
}
return null;
}
function isReadCommand(command) {
return /^READ\b/i.test(command.trim());
}
function isParserComplaint(output) {
const text = output.toLowerCase();
return [
"i don't know the word",
"i don't understand",
"that's not a verb",
"you can't see any",
"you don't have",
"you aren't carrying",
"what do you want to",
"what do you want to read",
"what do you want to take",
"which do you mean",
"there is no",
].some(fragment => text.includes(fragment));
}
function formatExactReadOutput(command, zcodeOutput) {
const object = command.replace(/^READ\s+/i, '').trim().toLowerCase();
const label = object ? `the ${object}` : 'it';
const cleanedOutput = zcodeOutput
.split('\n')
.filter((line, index) => index !== 0 || line.trim().toUpperCase() !== command.trim().toUpperCase())
.join('\n')
.trim();
return `You read ${label}.\n\n${cleanedOutput}`;
}
function pickInitialWeather() {
const options = [
'cool, unsettled air under a low grey sky',
'a dry bright afternoon with thin wind moving through the grass',
'misty weather with damp earth-smell clinging to everything outside',
'a mild overcast day, quiet enough that small sounds carry',
];
return options[Math.floor(Math.random() * options.length)];
}
function timeOfDayForTurn(turnCount) {
const phases = [
'late morning',
'early afternoon',
'late afternoon',
'dusk',
'early evening',
'night',
'deep night',
'pre-dawn',
'morning',
];
return phases[Math.floor(turnCount / 12) % phases.length];
}
function evolveWeather(previous, turnCount) {
if (turnCount > 0 && turnCount % 9 !== 0)
return previous;
const transitions = [
'the air has cooled and carries a faint mineral dampness',
'the wind has shifted, restless but not yet stormy',
'the light has thinned behind a veil of cloud',
'the weather holds steady, quiet and watchful',
'a trace of moisture gathers in the air',
];
return transitions[Math.floor(turnCount / 9) % transitions.length];
}
// ---------------------------------------------------------------------------
// ZcodeProcess manages the ifvms zvm child process
// ---------------------------------------------------------------------------
class ZcodeProcess {
constructor() {
this.proc = null;
this.outputBuffer = '';
this.pendingResolve = null;
this.debounceTimer = null;
}
/** Start the Z-machine with the given story file, return the opening text. */
async launch(storyPath) {
const zvm = this.locateZvm();
this.proc = (0, child_process_1.spawn)(zvm, [storyPath], {
stdio: ['pipe', 'pipe', 'pipe'],
shell: true,
cwd: process.cwd(),
});
this.proc.stdout.on('data', (chunk) => {
this.outputBuffer += stripAnsi(chunk.toString());
this.scheduleResolve();
});
this.proc.stderr.on('data', (chunk) => {
// Log but don't throw ifvms may emit warnings on stderr
console.warn('[zvm]', chunk.toString().trim());
});
this.proc.on('exit', () => {
// If the process exits while we are waiting for output, resolve immediately
if (this.pendingResolve) {
const resolver = this.pendingResolve;
this.pendingResolve = null;
resolver(this.outputBuffer.trim());
this.outputBuffer = '';
}
this.proc = null;
});
return this.waitForPrompt();
}
/** Send a line of input and return all output until the next prompt. */
async sendLine(text) {
if (!this.proc)
throw new Error('Z-machine process is not running');
this.outputBuffer = '';
this.proc.stdin.write(text + '\n');
return this.waitForPrompt();
}
isAlive() {
return this.proc !== null && !this.proc.killed;
}
kill() {
if (this.proc) {
this.proc.kill();
this.proc = null;
}
}
// ---- private ----
waitForPrompt() {
return new Promise((resolve) => {
// Wrap to allow debounce timer to cancel a previous waiter safely
const wrapped = (text) => resolve(text);
this.pendingResolve = wrapped;
// Safety timeout: if no prompt detected after 15 s, resolve with what we have
const safety = setTimeout(() => {
if (this.pendingResolve === wrapped) {
this.pendingResolve = null;
const text = this.outputBuffer.trim();
this.outputBuffer = '';
resolve(text);
}
}, 15000);
// Ensure the safety timeout does not keep Node alive indefinitely
if (safety.unref)
safety.unref();
// Override so debounce also cancels the safety timer
this.pendingResolve = (text) => {
clearTimeout(safety);
resolve(text);
};
// Data may already be buffered
this.scheduleResolve();
});
}
/** Debounced check: resolve when the buffer ends with a Z-machine prompt. */
scheduleResolve() {
if (!/\n>\s*$/.test(this.outputBuffer))
return;
if (this.debounceTimer)
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
if (!this.pendingResolve)
return;
const text = this.outputBuffer.replace(/\n>\s*$/, '').trim();
this.outputBuffer = '';
const resolver = this.pendingResolve;
this.pendingResolve = null;
resolver(text);
}, 80);
}
locateZvm() {
const binDir = path.join(process.cwd(), 'node_modules', '.bin');
const candidates = process.platform === 'win32'
? ['zvm.cmd', 'zvm.ps1', 'zvm']
: ['zvm'];
for (const name of candidates) {
const full = path.join(binDir, name);
if (fs.existsSync(full))
return full;
}
// Fall through to shell PATH lookup (works if ifvms is installed globally)
return 'zvm';
}
}
// ---------------------------------------------------------------------------
// Prompt loader
// ---------------------------------------------------------------------------
function loadPrompts(promptDir) {
function load(filename) {
const filePath = path.join(promptDir, filename);
if (!fs.existsSync(filePath)) {
throw new Error(`Prompt file not found: ${filePath}`);
}
return yaml.load(fs.readFileSync(filePath, 'utf8'));
}
return {
characterGeneration: load('character-generation.yml'),
textRewriter: load('text-rewriter.yml'),
commandTranslator: load('command-translator.yml'),
outputEvaluator: load('output-evaluator.yml'),
};
}
function renderTemplate(template, vars) {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? '');
}
function logLlmError(scope, err) {
if (axios_1.default.isAxiosError(err)) {
const ax = err;
console.error(`[ZcodeLlm] ${scope} failed: ${ax.message}`);
if (ax.response) {
console.error(`[ZcodeLlm] ${scope} status=${ax.response.status} data=`, ax.response.data);
if (ax.response.status === 404) {
console.error('[ZcodeLlm] Hint: OPENROUTER_MODEL is likely invalid or unavailable for your API key.');
}
}
return;
}
console.error(`[ZcodeLlm] ${scope} failed:`, err);
}
// ---------------------------------------------------------------------------
// ZcodeLlmEngine
// ---------------------------------------------------------------------------
class ZcodeLlmEngine {
constructor(options = {}) {
this.zmachine = new ZcodeProcess();
this.session = null;
this.resolvedFallbackModel = null;
this.llmCallCounter = 0;
this.nextTurnId = 1;
const apiKey = process.env.OPENROUTER_API_KEY;
const model = process.env.OPENROUTER_MODEL;
if (!apiKey || !model) {
throw new Error('Missing required environment variables: OPENROUTER_API_KEY and OPENROUTER_MODEL');
}
const replacement = ZcodeLlmEngine.DEPRECATED_MODEL_REPLACEMENTS[model] ?? null;
if (replacement) {
this.model = replacement;
console.warn(`[ZcodeLlm] Replacing deprecated model '${model}' with '${replacement}'.`);
}
else {
this.model = model;
}
debugLog('active LLM model configured', {
requestedModel: model,
activeModel: this.model,
});
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',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
});
}
async createCompletion(payload) {
const withConfiguredModel = {
...withReasoningDefaults(payload, this.model),
model: this.model,
};
const callId = ++this.llmCallCounter;
debugLog(`LLM call #${callId} request`, {
model: this.model,
payload: compactText(JSON.stringify(withConfiguredModel, null, 2)),
});
try {
const response = await this.llm.post('/chat/completions', withConfiguredModel);
debugLog(`LLM call #${callId} response`, {
model: this.model,
status: response.status,
data: compactText(JSON.stringify(response.data, null, 2)),
});
return response;
}
catch (err) {
if (axios_1.default.isAxiosError(err) && err.response?.status === 404) {
const fallbackModel = await this.resolveFallbackModel();
this.model = fallbackModel;
console.warn(`[ZcodeLlm] Switching active model to '${fallbackModel}'.`);
const withFallbackModel = {
...withReasoningDefaults(payload, fallbackModel),
model: fallbackModel,
};
debugLog(`LLM call #${callId} fallback request`, {
model: fallbackModel,
payload: compactText(JSON.stringify(withFallbackModel, null, 2)),
});
const fallbackResponse = await this.llm.post('/chat/completions', withFallbackModel);
debugLog(`LLM call #${callId} fallback response`, {
model: fallbackModel,
status: fallbackResponse.status,
data: compactText(JSON.stringify(fallbackResponse.data, null, 2)),
});
return fallbackResponse;
}
debugLog(`LLM call #${callId} error`, {
message: err instanceof Error ? err.message : String(err),
});
throw err;
}
}
async resolveFallbackModel() {
if (this.resolvedFallbackModel)
return this.resolvedFallbackModel;
const preferred = [
process.env.OPENROUTER_FALLBACK_MODEL,
'openai/gpt-5.5',
'openai/gpt-5.4',
'openai/gpt-5.4-mini',
'openai/gpt-5.4-nano',
'openai/gpt-5.3-chat',
'~anthropic/claude-sonnet-latest',
'~anthropic/claude-opus-latest',
'anthropic/claude-sonnet-4.6',
'anthropic/claude-sonnet-4',
'openai/gpt-4o-mini',
].filter((v) => Boolean(v && v.trim()));
try {
const response = await this.llm.get('/models');
const ids = new Set(Array.isArray(response.data?.data)
? response.data.data
.map((m) => (typeof m?.id === 'string' ? m.id : null))
.filter((id) => Boolean(id))
: []);
debugLog('OpenRouter model list fetched for fallback resolution', {
preferred,
availableCount: ids.size,
});
for (const candidate of preferred) {
if (ids.has(candidate)) {
this.resolvedFallbackModel = candidate;
return candidate;
}
}
const firstAvailable = response.data?.data?.[0]?.id;
if (typeof firstAvailable === 'string' && firstAvailable.length > 0) {
this.resolvedFallbackModel = firstAvailable;
return firstAvailable;
}
}
catch (err) {
logLlmError('resolveFallbackModel', err);
}
this.resolvedFallbackModel = 'openai/gpt-4o-mini';
return this.resolvedFallbackModel;
}
// ---- Public API -----------------------------------------------------------
isRunning() {
return this.session?.running === true && this.zmachine.isAlive();
}
/**
* 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.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.zmachine.launch(this.storyPath);
debugLog('Z-machine intro output', compactText(rawIntro));
// Generate the player character before showing any text
const characterDescription = await this.generateCharacter();
this.session = {
characterDescription,
notes: [],
roomHistory: {},
currentRoom: extractRoomName(rawIntro) ?? 'Unknown Location',
recentParagraphs: [],
rawTranscript: [`[intro]\n${rawIntro}`],
turnCount: 0,
timeOfDay: timeOfDayForTurn(0),
weather: pickInitialWeather(),
virtualInventory: [],
running: true,
};
// Rewrite the opening text with the character's narrative voice
debugLog('session initialized', {
currentRoom: this.session.currentRoom,
characterDescription,
timeOfDay: this.session.timeOfDay,
weather: this.session.weather,
});
const introText = await this.rewriteText(rawIntro);
this.appendRecentParagraph(introText);
this.appendRoomHistory(this.session.currentRoom, introText);
return this.buildTurnResult(introText);
}
/**
* Process player free-text input. Returns the next TurnResult.
*/
async processInput(userInput) {
if (!this.session?.running) {
throw new Error('No active game session');
}
debugLog('processInput start', {
userInput,
currentRoom: this.session.currentRoom,
turnCount: this.session.turnCount,
timeOfDay: this.session.timeOfDay,
weather: this.session.weather,
notes: this.session.notes,
virtualInventory: this.session.virtualInventory,
});
this.advanceNarratorState();
const deterministicCommands = this.getDeterministicCommandPlan(userInput);
if (deterministicCommands.length > 0) {
debugLog('deterministic command plan selected', {
userInput,
commands: deterministicCommands,
});
return this.runCommandPlan(userInput, deterministicCommands);
}
const cmdResponse = await this.translateCommand(userInput);
debugLog('command translator parsed response', cmdResponse);
// Execute any tool calls first
if (cmdResponse.type === 'tools') {
for (const tool of cmdResponse.tools) {
this.executeTool(tool);
}
// 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})`);
this.appendRecentParagraph(ack);
return this.buildTurnResult(ack);
}
}
if (cmdResponse.type === 'reply') {
this.appendRecentParagraph(cmdResponse.text);
return this.buildTurnResult(cmdResponse.text);
}
const commands = this.extractCommands(cmdResponse);
if (commands.length === 0) {
const fallback = await this.rewriteText("You hesitate, uncertain what action to take.");
this.appendRecentParagraph(fallback);
return this.buildTurnResult(fallback);
}
return this.runCommandPlan(userInput, commands);
}
async runCommandPlan(userInput, commands) {
const texts = [];
for (const command of commands) {
const text = await this.runSingleCommandLoop(userInput, command);
texts.push(text);
if (!this.isRunning())
break;
}
const combined = texts.join('\n\n');
return this.buildTurnResult(combined);
}
/**
* Save the current game state. Returns a JSON string suitable for storing
* in the socket's save-game slot map.
*/
async saveGame() {
if (!this.session)
throw new Error('No active session to save');
const tmpFile = path.join(os.tmpdir(), `zcode-save-${Date.now()}.qzl`);
try {
// 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)) {
zcodeSave = fs.readFileSync(tmpFile).toString('base64');
}
return JSON.stringify({ session: this.session, zcodeSave });
}
finally {
if (fs.existsSync(tmpFile))
fs.unlinkSync(tmpFile);
}
}
/**
* Load a previously saved game. Returns the first TurnResult after restore.
*/
async loadGame(savedJson) {
var _a, _b, _c, _d, _e, _f;
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(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 = []);
(_c = this.session).virtualInventory ?? (_c.virtualInventory = []);
(_d = this.session).turnCount ?? (_d.turnCount = 0);
(_e = this.session).timeOfDay ?? (_e.timeOfDay = timeOfDayForTurn(this.session.turnCount));
(_f = this.session).weather ?? (_f.weather = pickInitialWeather());
const text = await this.rewriteText(restoreOutput);
this.appendRecentParagraph(text);
return this.buildTurnResult(text);
}
finally {
if (fs.existsSync(tmpFile))
fs.unlinkSync(tmpFile);
}
}
// ---- Core game loop -------------------------------------------------------
async runSingleCommandLoop(userIntent, firstCommand) {
let command = firstCommand;
let lastOutput = '';
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
debugLog('sending Z-machine command', {
userIntent,
command,
attempt,
maxRetries: this.maxRetries,
});
const rawOutput = await this.zmachine.sendLine(command);
lastOutput = rawOutput;
this.appendRawTranscript(command, rawOutput);
debugLog('received Z-machine output', {
command,
attempt,
output: compactText(rawOutput),
});
const newRoom = extractRoomName(rawOutput);
if (newRoom) {
this.session.currentRoom = newRoom;
debugLog('current room updated', newRoom);
}
if (isReadCommand(command) && !isParserComplaint(rawOutput)) {
const exactText = formatExactReadOutput(command, rawOutput);
debugLog('accepted exact READ output without LLM paraphrase', {
command,
text: compactText(exactText),
});
this.appendRecentParagraph(exactText);
this.appendRoomHistory(this.session.currentRoom, exactText);
return exactText;
}
const evalResponse = await this.evaluateOutput(userIntent, command, rawOutput, attempt);
debugLog('output evaluator decision', evalResponse);
if (evalResponse.decision === 'accept') {
this.appendRecentParagraph(evalResponse.text);
this.appendRoomHistory(this.session.currentRoom, evalResponse.text);
return evalResponse.text;
}
// Retry with the LLM-suggested command
if (attempt < this.maxRetries) {
debugLog('retrying with evaluator command', {
previousCommand: command,
nextCommand: evalResponse.command,
});
command = evalResponse.command;
}
}
// Max retries exceeded — force a rewrite of the last output
const fallbackText = await this.rewriteText(lastOutput);
this.appendRecentParagraph(fallbackText);
this.appendRoomHistory(this.session.currentRoom, fallbackText);
return fallbackText;
}
// ---- LLM calls ------------------------------------------------------------
async generateCharacter() {
const cfg = this.prompts.characterGeneration;
try {
const response = await this.createCompletion({
messages: [
{ role: 'system', content: cfg.system },
{ role: 'user', content: 'Create the player character now.' },
],
temperature: 0.9,
max_tokens: 600,
});
return getAssistantContent(response.data).trim();
}
catch (err) {
logLlmError('generateCharacter', err);
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(zcodeOutput) {
const cfg = this.prompts.textRewriter;
const vars = this.buildCommonVars();
vars['zcodeOutput'] = zcodeOutput;
try {
const response = await this.createCompletion({
messages: [
{ role: 'system', content: cfg.system },
{ role: 'user', content: renderTemplate(cfg.user_template, vars) },
],
temperature: 0.75,
max_tokens: 800,
});
return getAssistantContent(response.data).trim();
}
catch (err) {
logLlmError('rewriteText', err);
return zcodeOutput;
}
}
async translateCommand(userInput) {
const cfg = this.prompts.commandTranslator;
const vars = this.buildCommonVars();
vars['userInput'] = userInput;
try {
const response = await this.createCompletion({
messages: [
{ role: 'system', content: cfg.system },
{ role: 'user', content: renderTemplate(cfg.user_template, vars) },
],
temperature: 0.2,
max_tokens: 300,
response_format: { type: 'json_object' },
});
const parsed = JSON.parse(getAssistantContent(response.data));
return parsed;
}
catch (err) {
logLlmError('translateCommand', err);
// Fallback: pass input directly to Z-machine parser
return { type: 'command', command: userInput.toUpperCase() };
}
}
async evaluateOutput(userIntent, commandTried, zcodeOutput, attempt) {
const cfg = this.prompts.outputEvaluator;
const vars = this.buildCommonVars();
vars['userIntent'] = userIntent;
vars['commandTried'] = commandTried;
vars['zcodeOutput'] = zcodeOutput;
vars['attempt'] = String(attempt);
vars['maxAttempts'] = String(this.maxRetries);
try {
const response = await this.createCompletion({
messages: [
{ role: 'system', content: cfg.system },
{ role: 'user', content: renderTemplate(cfg.user_template, vars) },
],
temperature: 0.3,
max_tokens: 500,
response_format: { type: 'json_object' },
});
return JSON.parse(getAssistantContent(response.data));
}
catch (err) {
logLlmError('evaluateOutput', err);
// Fallback: accept the raw output as-is
return { decision: 'accept', text: zcodeOutput };
}
}
// ---- Session helpers -------------------------------------------------------
executeTool(tool) {
if (!this.session)
return;
debugLog('executing tool call', tool);
switch (tool.name) {
case 'update_character':
if (typeof tool.args['description'] === 'string') {
this.session.characterDescription = tool.args['description'];
debugLog('tool updated character', this.session.characterDescription);
}
break;
case 'add_note':
if (typeof tool.args['note'] === 'string') {
this.session.notes.push(tool.args['note']);
debugLog('tool added note', {
note: tool.args['note'],
notes: this.session.notes,
});
}
break;
case 'remove_note': {
const idx = Number(tool.args['index']);
if (Number.isInteger(idx) &&
idx >= 0 &&
idx < this.session.notes.length) {
this.session.notes.splice(idx, 1);
debugLog('tool removed note', {
index: idx,
notes: this.session.notes,
});
}
break;
}
case 'add_inventory_item': {
const item = String(tool.args['item'] ?? '').trim();
if (!item)
break;
const exists = this.session.virtualInventory.some((it) => it.toLowerCase() === item.toLowerCase());
if (!exists)
this.session.virtualInventory.push(item);
debugLog('tool added inventory item', {
item,
virtualInventory: this.session.virtualInventory,
});
break;
}
case 'remove_inventory_item': {
const item = String(tool.args['item'] ?? '').trim();
if (!item)
break;
this.session.virtualInventory = this.session.virtualInventory.filter((it) => it.toLowerCase() !== item.toLowerCase());
debugLog('tool removed inventory item', {
item,
virtualInventory: this.session.virtualInventory,
});
break;
}
}
}
appendRecentParagraph(text) {
if (!this.session)
return;
const trimmed = text.trim();
if (!trimmed)
return;
this.session.recentParagraphs.push(trimmed);
if (this.session.recentParagraphs.length > 10) {
this.session.recentParagraphs.splice(0, this.session.recentParagraphs.length - 10);
}
}
extractCommands(cmdResponse) {
const list = [];
if (cmdResponse.type === 'command') {
list.push(cmdResponse.command);
}
else if (cmdResponse.type === 'commands') {
list.push(...cmdResponse.commands);
}
else if (cmdResponse.type === 'tools') {
if (cmdResponse.command)
list.push(cmdResponse.command);
if (Array.isArray(cmdResponse.commands))
list.push(...cmdResponse.commands);
}
return list
.map((c) => String(c).trim())
.filter(Boolean)
.map((c) => c.toUpperCase());
}
appendRawTranscript(command, output) {
if (!this.session)
return;
this.session.rawTranscript.push([`> ${command}`, output.trim()].filter(Boolean).join('\n'));
if (this.session.rawTranscript.length > 12) {
this.session.rawTranscript.splice(0, this.session.rawTranscript.length - 12);
}
}
advanceNarratorState() {
if (!this.session)
return;
this.session.turnCount += 1;
this.session.timeOfDay = timeOfDayForTurn(this.session.turnCount);
this.session.weather = evolveWeather(this.session.weather, this.session.turnCount);
debugLog('narrator state advanced', {
turnCount: this.session.turnCount,
timeOfDay: this.session.timeOfDay,
weather: this.session.weather,
});
}
getDeterministicCommandPlan(userInput) {
const normalized = userInput.toLowerCase();
const context = [
this.session?.currentRoom ?? '',
this.session?.recentParagraphs.join('\n') ?? '',
Object.values(this.session?.roomHistory ?? {}).flat().join('\n'),
].join('\n').toLowerCase();
const mentionsLeaflet = /\b(leaflet|pamphlet|brochure|paper|it|this)\b/.test(normalized);
const contextHasLeaflet = /\b(leaflet|pamphlet|brochure)\b/.test(context);
const mentionsMailbox = /\bmail\s*box|mailbox\b/.test(normalized);
const asksToRead = /\bread\b/.test(normalized) ||
/\bwhat (does|did|do).*say\b/.test(normalized) ||
/\btell me what it says\b/.test(normalized) ||
/\byou did not tell me\b/.test(normalized);
const asksToTake = /\b(take|get|grab|pick up|pluck)\b/.test(normalized);
const asksToOpen = /\bopen\b/.test(normalized);
const asksToLookIn = /\blook (in|inside|into)\b/.test(normalized) || /\binside\b/.test(normalized);
if (mentionsMailbox && asksToOpen && asksToLookIn) {
return ['OPEN MAILBOX', 'LOOK IN MAILBOX'];
}
if (mentionsMailbox && asksToOpen) {
return ['OPEN MAILBOX'];
}
if (asksToRead && (mentionsLeaflet || mentionsMailbox || contextHasLeaflet)) {
if (asksToTake || mentionsMailbox) {
return ['TAKE LEAFLET', 'READ LEAFLET'];
}
return ['READ LEAFLET'];
}
if (asksToTake && (mentionsLeaflet || (mentionsMailbox && contextHasLeaflet))) {
return ['TAKE LEAFLET'];
}
return [];
}
appendRoomHistory(room, text) {
if (!this.session)
return;
const history = this.session.roomHistory[room] ?? [];
history.push(text);
if (history.length > this.historySize) {
history.splice(0, history.length - this.historySize);
}
this.session.roomHistory[room] = history;
}
buildCommonVars() {
const s = this.session;
const notes = s.notes.length > 0
? s.notes.map((n, i) => `${i + 1}. ${n}`).join('\n')
: '(none)';
const virtualInventory = s.virtualInventory.length > 0
? s.virtualInventory.map((n, i) => `${i + 1}. ${n}`).join('\n')
: '(none)';
const recentNarrative = s.recentParagraphs.length > 0
? s.recentParagraphs.join('\n\n---\n\n')
: '(none)';
const rawTranscript = s.rawTranscript.length > 0
? s.rawTranscript.join('\n\n---\n\n')
: '(none)';
const history = (s.roomHistory[s.currentRoom] ?? []).join('\n\n---\n\n');
return {
characterDescription: s.characterDescription,
notes,
virtualInventory,
recentNarrative,
rawTranscript,
roomHistory: history || '(no prior visits)',
currentRoom: s.currentRoom,
narratorState: [
`Turn count: ${s.turnCount}`,
`Time of day: ${s.timeOfDay}`,
`Outside weather drift: ${s.weather}`,
].join('\n'),
};
}
buildTurnResult(text) {
const alive = this.zmachine.isAlive();
if (!alive && this.session)
this.session.running = false;
const paragraphs = (0, turn_result_1.textToParagraphs)(text);
return {
turnId: this.nextTurnId++,
paragraphs,
choices: [],
inputMode: alive ? 'text' : 'end',
gameState: { statusLine: this.session?.currentRoom },
};
}
}
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=zcode-llm-engine.js.map
File diff suppressed because one or more lines are too long
+10 -8
View File
@@ -38,8 +38,9 @@ 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...');
try {
@@ -59,11 +60,12 @@ async function main() {
console.log('=== AI Interactive Fiction ===');
console.log('A modern take on classic text adventures with LLM-powered interactions');
console.log('');
// Get the world file path from environment variables or use default
const worldFile = process.env.DEFAULT_WORLD_FILE || './data/worlds/example_world.yml';
// Get the world file path from the YAML engine config, with environment override.
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');
@@ -85,10 +87,10 @@ async function main() {
// Get port configuration
const DEFAULT_PORT = 3000;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10;
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) {
+1 -1
View File
@@ -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":";AAAA;;GAEG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAGH,+CAAiC;AACjC,mDAA+C;AAC/C,8EAA8E;AAC9E,+CAA4C;AAC5C,sDAAmE;AAEnE,6BAA6B;AAC7B,OAAO,CAAC,GAAG,CAAC,kCAAkC,CAAC,CAAC;AAChD,IAAI,CAAC;IACH,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;IAC/B,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QACjB,OAAO,CAAC,KAAK,CAAC,0BAA0B,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;IAC1D,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,GAAG,CAAC,2CAA2C,CAAC,CAAC;IAC3D,CAAC;AACH,CAAC;AAAC,OAAO,KAAK,EAAE,CAAC;IACf,OAAO,CAAC,KAAK,CAAC,6BAA6B,EAAE,KAAK,CAAC,CAAC;AACtD,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,IAAI,CAAC;QACH,OAAO,CAAC,GAAG,CAAC,gCAAgC,CAAC,CAAC;QAC9C,OAAO,CAAC,GAAG,CAAC,wEAAwE,CAAC,CAAC;QACtF,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAEhB,kFAAkF;QAClF,MAAM,YAAY,GAAG,IAAA,4BAAc,EACjC,OAAO,CAAC,GAAG,CAAC,gBAAgB,IAAI,4BAA4B,EAC5D,MAAM,CACP,CAAC;QACF,MAAM,SAAS,GAAG,IAAA,yBAAW,EAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,IAAI,YAAY,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;QACjG,OAAO,CAAC,GAAG,CAAC,qBAAqB,SAAS,EAAE,CAAC,CAAC;QAC9C,OAAO,CAAC,GAAG,CAAC,uBAAuB,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC;QAC3F,OAAO,CAAC,GAAG,CAAC,qBAAqB,OAAO,CAAC,GAAG,CAAC,gBAAgB,IAAI,eAAe,EAAE,CAAC,CAAC;QAEpF,qCAAqC;QACrC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACnC,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QAE9D,IAAI,OAAO,EAAE,CAAC;YACZ,WAAW;YACX,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAC;YAEvC,oCAAoC;YACpC,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAC;YACvC,MAAM,UAAU,GAAG,IAAI,wBAAU,EAAE,CAAC;YAEpC,OAAO,CAAC,GAAG,CAAC,sBAAsB,CAAC,CAAC;YACpC,MAAM,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;YAEvC,qBAAqB;YACrB,OAAO,CAAC,GAAG,CAAC,sBAAsB,CAAC,CAAC;YACpC,MAAM,UAAU,CAAC,KAAK,EAAE,CAAC;QAC3B,CAAC;aAAM,CAAC;YACN,sEAAsE;YACtE,OAAO,CAAC,GAAG,CAAC,mCAAmC,CAAC,CAAC;YAEjD,yBAAyB;YACzB,MAAM,YAAY,GAAG,IAAI,CAAC;YAC1B,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC;YAC1E,MAAM,UAAU,GAAG,GAAG,CAAC;YAEvB,0CAA0C;YAC1C,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;YACtC,MAAM,IAAA,yBAAW,EAAC,IAAI,EAAE,UAAU,CAAC,CAAC;QACtC,CAAC;IACH,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CAAC,kBAAkB,EAAE,KAAK,CAAC,CAAC;QACzC,IAAI,KAAK,YAAY,KAAK,EAAE,CAAC;YAC3B,OAAO,CAAC,KAAK,CAAC,aAAa,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;YACzC,OAAO,CAAC,KAAK,CAAC,gBAAgB,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;YAC/C,OAAO,CAAC,KAAK,CAAC,cAAc,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;QAC7C,CAAC;QACD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;AACH,CAAC;AAED,wBAAwB;AACxB,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAC;AACvC,IAAI,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,0BAA0B,EAAE,KAAK,CAAC,CAAC;IACjD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
+36
View File
@@ -0,0 +1,36 @@
export type InputMode = 'text' | 'choice' | 'end';
export interface StoryTag {
key: string;
value?: string;
param?: string;
}
export interface ParagraphResult {
text: string;
tags: StoryTag[];
}
export interface ChoiceResult {
index: number;
text: string;
tags: StoryTag[];
category?: string;
letter?: string;
}
export interface TurnResult {
turnId: number;
paragraphs: ParagraphResult[];
choices: ChoiceResult[];
inputMode: InputMode;
globalTags?: StoryTag[];
gameState?: {
currentRoomId?: string;
score?: number;
moves?: number;
statusLine?: string;
endState?: {
type: 'intended' | 'error';
message?: string;
};
};
suggestions?: string[];
}
export declare function textToParagraphs(text: string, tags?: StoryTag[]): ParagraphResult[];
+36
View File
@@ -0,0 +1,36 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.textToParagraphs = textToParagraphs;
/**
* Shared engine-to-client turn protocol.
*/
const tag_parser_1 = require("../utils/tag-parser");
function textToParagraphs(text, tags = []) {
return String(text || '')
.replace(/\r\n?/g, '\n')
.split(/\n{2,}/)
.map((paragraph) => paragraph.trim())
.filter(Boolean)
.map((paragraph) => {
const lines = paragraph.split('\n');
const paragraphTags = [...tags];
const textLines = [];
let tagPrefixOpen = true;
for (const line of lines) {
const trimmed = line.trim();
const maybeTag = tagPrefixOpen && trimmed.startsWith('#') ? (0, tag_parser_1.parseTag)(trimmed) : null;
if (maybeTag) {
paragraphTags.push(maybeTag);
}
else {
tagPrefixOpen = false;
textLines.push(line);
}
}
return {
text: textLines.join('\n').trim(),
tags: paragraphTags,
};
});
}
//# sourceMappingURL=turn-result.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"turn-result.js","sourceRoot":"","sources":["../../src/interfaces/turn-result.ts"],"names":[],"mappings":";;AA6CA,4CA6BC;AA1ED;;GAEG;AACH,oDAA+C;AA0C/C,SAAgB,gBAAgB,CAAC,IAAY,EAAE,OAAmB,EAAE;IAClE,OAAO,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;SACtB,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC;SACvB,KAAK,CAAC,QAAQ,CAAC;SACf,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;SACpC,MAAM,CAAC,OAAO,CAAC;SACf,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE;QACjB,MAAM,KAAK,GAAG,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACpC,MAAM,aAAa,GAAe,CAAC,GAAG,IAAI,CAAC,CAAC;QAC5C,MAAM,SAAS,GAAa,EAAE,CAAC;QAC/B,IAAI,aAAa,GAAG,IAAI,CAAC;QAEzB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;YAC5B,MAAM,QAAQ,GAAG,aAAa,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAA,qBAAQ,EAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;YAErF,IAAI,QAAQ,EAAE,CAAC;gBACb,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YAC/B,CAAC;iBAAM,CAAC;gBACN,aAAa,GAAG,KAAK,CAAC;gBACtB,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACvB,CAAC;QACH,CAAC;QAED,OAAO;YACL,IAAI,EAAE,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE;YACjC,IAAI,EAAE,aAAa;SACpB,CAAC;IACJ,CAAC,CAAC,CAAC;AACP,CAAC"}
+13
View File
@@ -0,0 +1,13 @@
/**
* Ink Engine Server
*
* Serves the shared client UI and runs a compiled Ink JSON story through the
* unified TurnResult socket protocol.
*/
import http from 'http';
import { Server as SocketIOServer } from 'socket.io';
declare const app: import("express-serve-static-core").Express;
declare const server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
declare const io: SocketIOServer<import("socket.io").DefaultEventsMap, import("socket.io").DefaultEventsMap, import("socket.io").DefaultEventsMap, any>;
export declare function startServer(initialPort: number, range: number): Promise<void>;
export { app, server, io };
+307
View File
@@ -0,0 +1,307 @@
"use strict";
/**
* Ink Engine Server
*
* Serves the shared client UI and runs a compiled Ink JSON story through the
* unified TurnResult socket protocol.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.io = exports.server = exports.app = void 0;
exports.startServer = startServer;
const path_1 = __importDefault(require("path"));
const http_1 = __importDefault(require("http"));
const express_1 = __importDefault(require("express"));
const socket_io_1 = require("socket.io");
const dotenv = __importStar(require("dotenv"));
const fs_1 = require("fs");
const ink_engine_1 = require("./engine/ink-engine");
const game_config_1 = require("./config/game-config");
dotenv.config();
const app = (0, express_1.default)();
exports.app = app;
const server = http_1.default.createServer(app);
exports.server = server;
const io = new socket_io_1.Server(server);
exports.io = io;
const DEFAULT_PORT = 3003;
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
const PORT_RANGE = 300;
const engineConfig = (0, game_config_1.loadGameConfig)(process.env.INK_CONFIG_FILE || './config/engines/ink.json', 'ink');
app.use(express_1.default.static(path_1.default.join(__dirname, '../public'), {
etag: false,
lastModified: false,
setHeaders: (res) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
},
}));
app.get('/api/game-config', (_req, res) => {
res.json((0, game_config_1.clientGameConfig)(engineConfig));
});
const sessions = new Map();
const saveSlots = new Map();
function normalizeSaveSlot(slot) {
const n = Number(slot);
return Number.isInteger(n) && n > 0 ? n : 1;
}
function getStoryPath() {
return (0, game_config_1.projectPath)(process.env.INK_STORY_FILE ||
engineConfig.paths.inkCompiled ||
engineConfig.paths.mainGameFile);
}
function getSourcePath() {
return (0, game_config_1.projectPath)(process.env.INK_SOURCE_FILE || engineConfig.paths.inkSource || '');
}
function compileConfiguredStory() {
const sourcePath = getSourcePath();
const outputPath = getStoryPath();
const result = (0, ink_engine_1.compileInkSource)(sourcePath, outputPath);
console.log(`[ink] Compiled ${result.sourcePath} -> ${result.outputPath}` +
(result.warningCount > 0 ? ` (${result.warningCount} warnings)` : ''));
}
function getSlots(socketId) {
let slots = saveSlots.get(socketId);
if (!slots) {
slots = new Map();
saveSlots.set(socketId, slots);
}
return slots;
}
function getOrCreateEngine(socketId) {
let engine = sessions.get(socketId);
if (!engine) {
engine = new ink_engine_1.InkEngine(getStoryPath());
sessions.set(socketId, engine);
}
return engine;
}
function withClientRequestId(turn, requestId) {
const id = Number(requestId || 0);
return Number.isInteger(id) && id > 0
? { ...turn, clientRequestId: id }
: turn;
}
async function handleGameApi(socket, method, args, requestId) {
const slots = getSlots(socket.id);
switch (method) {
case 'newGame':
case 'newGame()': {
const engine = new ink_engine_1.InkEngine(getStoryPath());
sessions.set(socket.id, engine);
socket.emit('narrativeResponse', withClientRequestId(engine.newGame(), requestId));
return {
success: true,
result: true,
running: true,
canLoad: slots.size > 0,
savedState: engine.saveGame(),
};
}
case 'chooseChoice':
case 'chooseChoice()': {
const engine = sessions.get(socket.id);
if (!engine?.isRunning()) {
return { success: false, error: 'game_not_running', result: false };
}
const choiceIndex = Number(args[0]);
if (!Number.isInteger(choiceIndex)) {
return { success: false, error: 'invalid_choice', result: false };
}
socket.emit('narrativeResponse', withClientRequestId(engine.chooseChoice(choiceIndex), requestId));
return { success: true, result: true };
}
case 'loadGame':
case 'loadGame()': {
const slot = normalizeSaveSlot(args[0]);
const browserSave = typeof args[1] === 'string' ? args[1] : null;
if (!browserSave && !slots.has(slot)) {
return { success: false, error: 'missing_save', result: false };
}
const engine = getOrCreateEngine(socket.id);
socket.emit('narrativeResponse', withClientRequestId(engine.loadGame(browserSave || slots.get(slot)), requestId));
socket.emit('gameLoaded', { slot, clientRequestId: requestId });
return { success: true, result: true, running: true, slot };
}
case 'resumeGame':
case 'resumeGame()': {
const browserSave = typeof args[0] === 'string' ? args[0] : null;
if (!browserSave) {
return { success: false, error: 'missing_state', result: false };
}
const engine = new ink_engine_1.InkEngine(getStoryPath());
engine.resumeGame(browserSave);
sessions.set(socket.id, engine);
return { success: true, result: true, running: engine.isRunning() };
}
case 'exportGameState':
case 'exportGameState()': {
const engine = sessions.get(socket.id);
if (!engine?.isRunning()) {
return { success: false, error: 'game_not_running', result: false };
}
return { success: true, result: true, savedState: engine.saveGame() };
}
case 'saveGame':
case 'saveGame()': {
const engine = sessions.get(socket.id);
if (!engine?.isRunning()) {
return { success: false, error: 'game_not_running', result: false };
}
const slot = normalizeSaveSlot(args[0]);
const savedState = engine.saveGame();
slots.set(slot, savedState);
socket.emit('gameSaved', { slot });
return { success: true, result: true, slot, savedState };
}
case 'hasSaveGame':
case 'hasSaveGame()': {
const slot = normalizeSaveSlot(args[0]);
return { success: true, result: slots.has(slot), slot };
}
case 'getSaveGames':
case 'getSaveGames()':
return { success: true, result: Array.from(slots.keys()).sort((a, b) => a - b) };
case 'isGameRunning':
case 'isGameRunning()':
return { success: true, result: sessions.get(socket.id)?.isRunning() ?? false };
default:
return { success: false, error: `unknown_method:${method}` };
}
}
io.on('connection', (socket) => {
console.log(`[ink] Client connected: ${socket.id}`);
socket.emit('gameConfig', (0, game_config_1.clientGameConfig)(engineConfig));
socket.on('gameApi', async (request, respond) => {
try {
const result = await handleGameApi(socket, String(request?.method ?? ''), Array.isArray(request?.args) ? request.args : [], Number.isInteger(Number(request?.requestId)) && Number(request?.requestId) > 0
? Number(request?.requestId)
: undefined);
if (typeof respond === 'function')
respond(result);
}
catch (error) {
console.error('[ink] gameApi error:', error);
if (typeof respond === 'function') {
respond({
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
});
socket.on('disconnect', () => {
console.log(`[ink] Client disconnected: ${socket.id}`);
sessions.delete(socket.id);
saveSlots.delete(socket.id);
});
});
function ensureDirectories() {
const dirs = [
path_1.default.join(__dirname, '../public'),
path_1.default.join(__dirname, '../public/js'),
path_1.default.join(__dirname, '../public/css'),
path_1.default.join(__dirname, '../public/images'),
path_1.default.join(__dirname, '../public/music'),
path_1.default.join(__dirname, '../public/sounds'),
path_1.default.join(__dirname, '../public/fonts'),
];
for (const dir of dirs) {
if (!(0, fs_1.existsSync)(dir))
(0, fs_1.mkdirSync)(dir, { recursive: true });
}
(0, game_config_1.ensureConfiguredAssetDirectories)(engineConfig);
}
function ensureKokoroJs() {
const source = path_1.default.join(__dirname, '../node_modules/kokoro-js/dist/index.js');
const destination = path_1.default.join(__dirname, '../public/js/kokoro-js.js');
if ((0, fs_1.existsSync)(source) && !(0, fs_1.existsSync)(destination)) {
(0, fs_1.copyFileSync)(source, destination);
}
}
async function startServer(initialPort, range) {
ensureDirectories();
try {
ensureKokoroJs();
}
catch { /* optional */ }
compileConfiguredStory();
if (!(0, fs_1.existsSync)(getStoryPath())) {
console.error(`[ink] Story file missing: ${getStoryPath()}`);
console.error('[ink] Set INK_SOURCE_FILE or configure paths.inkSource in config/engines/ink.json.');
}
let port = initialPort;
while (port < initialPort + range) {
try {
await new Promise((resolve, reject) => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
server.once('listening', () => {
console.log(`[ink] Ink server running on http://localhost:${port}`);
resolve();
});
server.once('error', (error) => {
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') {
console.log(`Port ${port} unavailable (${error.code}), trying ${port + 1}...`);
server.close();
port++;
reject();
}
else {
reject(error);
}
});
server.listen(port);
});
return;
}
catch {
if (port >= initialPort + range - 1) {
throw new Error(`Failed to start server on ports ${initialPort} to ${initialPort + range - 1}`);
}
}
}
}
if (require.main === module) {
startServer(PORT, PORT_RANGE).catch((error) => {
console.error('[ink] Failed to start:', error);
process.exit(1);
});
}
//# sourceMappingURL=server-ink.js.map
+1
View File
File diff suppressed because one or more lines are too long
View File
+142 -79
View File
@@ -49,6 +49,8 @@ const socket_io_1 = require("socket.io");
const dotenv = __importStar(require("dotenv"));
const game_runner_1 = require("./cli/game-runner");
const fs_1 = require("fs");
const turn_result_1 = require("./interfaces/turn-result");
const game_config_1 = require("./config/game-config");
// Load environment variables
dotenv.config();
// Create Express application
@@ -61,35 +63,136 @@ exports.io = io;
// Get port from environment variables or use default
const DEFAULT_PORT = 3001;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
// Serve static files from the public directory
app.use(express_1.default.static(path_1.default.join(__dirname, '../public')));
const PORT_RANGE = 300; // Try enough ports to skip OS-excluded ranges.
const engineConfig = (0, game_config_1.loadGameConfig)(process.env.YAML_CONFIG_FILE || './config/engines/yaml.json', 'yaml');
// Serve static files from the public directory. During local development the
// browser must not keep stale ES modules, otherwise UI fixes appear to do
// nothing until a hard cache clear.
app.use(express_1.default.static(path_1.default.join(__dirname, '../public'), {
etag: false,
lastModified: false,
setHeaders: (res) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
}));
app.get('/api/game-config', (_req, res) => {
res.json((0, game_config_1.clientGameConfig)(engineConfig));
});
// Set up game sessions
const gameSessions = new Map();
const nextTurnIds = new Map();
function nextTurnId(socketId) {
const current = nextTurnIds.get(socketId) || 1;
nextTurnIds.set(socketId, current + 1);
return current;
}
function createTextTurn(socketId, text, gameState = {}, suggestions) {
const paragraphs = (0, turn_result_1.textToParagraphs)(text);
return {
turnId: nextTurnId(socketId),
paragraphs,
choices: [],
inputMode: 'text',
gameState,
suggestions,
};
}
function normalizeSaveSlot(slot) {
const value = Number(slot);
return Number.isInteger(value) && value > 0 ? value : 1;
}
function withClientRequestId(turn, requestId) {
const id = Number(requestId || 0);
return Number.isInteger(id) && id > 0
? { ...turn, clientRequestId: id }
: turn;
}
async function startDemoGameForSocket(socket, requestId) {
nextTurnIds.set(socket.id, 1);
const gameRunner = new game_runner_1.GameRunner();
const worldFile = (0, game_config_1.projectPath)(process.env.DEFAULT_WORLD_FILE || engineConfig.paths.mainGameFile);
await gameRunner.initialize(worldFile);
gameSessions.set(socket.id, gameRunner);
const gameState = gameRunner.getGameState();
const paragraphs = [
...(0, turn_result_1.textToParagraphs)(gameState.world.introduction),
...(0, turn_result_1.textToParagraphs)(gameRunner.getCurrentRoomDescription()),
];
socket.emit('narrativeResponse', withClientRequestId({
turnId: nextTurnId(socket.id),
paragraphs,
choices: [],
inputMode: 'text',
gameState: {
currentRoomId: gameState.currentRoomId,
},
}, requestId));
return gameRunner;
}
async function handleGameApi(socket, method, args = [], requestId) {
const saveGames = socket.data.saveGames || new Map();
socket.data.saveGames = saveGames;
switch (method) {
case 'newGame':
case 'newGame()':
await startDemoGameForSocket(socket, requestId);
return { success: true, result: true, running: true, canLoad: saveGames.size > 0 };
case 'loadGame':
case 'loadGame()': {
const slot = normalizeSaveSlot(args[0]);
if (!saveGames.has(slot)) {
return { success: false, error: 'missing_save', result: false };
}
await startDemoGameForSocket(socket, requestId);
socket.emit('gameLoaded', { slot, clientRequestId: requestId });
return { success: true, result: true, running: true, slot };
}
case 'saveGame':
case 'saveGame()': {
const gameRunner = gameSessions.get(socket.id);
if (!gameRunner) {
return { success: false, error: 'game_not_running', result: false };
}
const slot = normalizeSaveSlot(args[0]);
saveGames.set(slot, gameRunner.getGameState());
socket.emit('gameSaved', { slot });
return { success: true, result: true, slot };
}
case 'hasSaveGame':
case 'hasSaveGame()': {
const slot = normalizeSaveSlot(args[0]);
return { success: true, result: saveGames.has(slot), slot };
}
case 'getSaveGames':
case 'getSaveGames()':
return { success: true, result: Array.from(saveGames.keys()).sort((a, b) => a - b) };
case 'isGameRunning':
case 'isGameRunning()':
return { success: true, result: gameSessions.has(socket.id) };
default:
return { success: false, error: `unknown_method:${method}` };
}
}
// Handle socket connections
io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);
// Start a new game
socket.on('startGame', async () => {
socket.emit('gameConfig', (0, game_config_1.clientGameConfig)(engineConfig));
socket.data.saveGames = new Map();
socket.on('gameApi', async (request, respond) => {
try {
// Initialize game runner
const gameRunner = new game_runner_1.GameRunner();
const worldFile = process.env.DEFAULT_WORLD_FILE || './data/worlds/example_world.yml';
// Initialize the game
await gameRunner.initialize(worldFile);
// Store game session
gameSessions.set(socket.id, gameRunner);
// Send introduction to client
const gameState = gameRunner.getGameState();
socket.emit('gameIntroduction', {
introduction: gameState.world.introduction,
initialRoomDescription: gameRunner.getCurrentRoomDescription(),
currentRoomId: gameState.currentRoomId
});
const requestId = Number(request?.requestId || 0);
const response = await handleGameApi(socket, String(request?.method || ''), Array.isArray(request?.args) ? request.args : [], Number.isInteger(requestId) && requestId > 0 ? requestId : undefined);
if (typeof respond === 'function') {
respond(response);
}
}
catch (error) {
console.error('Error starting game:', error);
socket.emit('error', { message: 'Failed to start game. Please try again.' });
console.error('Game API error:', error);
if (typeof respond === 'function') {
respond({ success: false, error: error instanceof Error ? error.message : String(error) });
}
}
});
// Process player command
@@ -100,65 +203,18 @@ io.on('connection', (socket) => {
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
return;
}
// Process command and get response
const response = await gameRunner.processCommand(data.command);
// Send narrative response to client
socket.emit('narrativeResponse', {
text: response,
gameState: {
currentRoomId: gameRunner.getGameState().currentRoomId
},
suggestions: gameRunner.getSuggestions()
});
const command = String(data?.command || '').trim();
// During typography and animation work, mirror the command back through
// the real socket path so the UI pipeline can be tested end to end.
socket.emit('narrativeResponse', createTextTurn(socket.id, command, {
currentRoomId: gameRunner.getGameState().currentRoomId
}, gameRunner.getSuggestions()));
}
catch (error) {
console.error('Error processing command:', error);
socket.emit('error', { message: 'Failed to process command. Please try again.' });
}
});
// Save game state
socket.on('saveGame', () => {
try {
const gameRunner = gameSessions.get(socket.id);
if (!gameRunner) {
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
return;
}
// Store save data in session
socket.data.savedGame = gameRunner.getGameState();
socket.emit('gameSaved');
}
catch (error) {
console.error('Error saving game:', error);
socket.emit('error', { message: 'Failed to save game. Please try again.' });
}
});
// Load game state
socket.on('loadGame', () => {
try {
const gameRunner = gameSessions.get(socket.id);
if (!gameRunner) {
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
return;
}
// Check if there's a saved game
if (!socket.data.savedGame) {
socket.emit('error', { message: 'No saved game found.' });
return;
}
// Load saved game
gameRunner.loadGameState(socket.data.savedGame);
// Send current state to client
socket.emit('gameLoaded', {
currentRoomDescription: gameRunner.getCurrentRoomDescription(),
currentRoomId: gameRunner.getGameState().currentRoomId
});
}
catch (error) {
console.error('Error loading game:', error);
socket.emit('error', { message: 'Failed to load game. Please try again.' });
}
});
// Handle disconnection
socket.on('disconnect', () => {
console.log(`Client disconnected: ${socket.id}`);
@@ -166,6 +222,7 @@ io.on('connection', (socket) => {
if (gameSessions.has(socket.id)) {
gameSessions.delete(socket.id);
}
nextTurnIds.delete(socket.id);
});
});
// Ensure required asset folders exist
@@ -175,6 +232,8 @@ function ensureDirectories() {
path_1.default.join(__dirname, '../public/js'),
path_1.default.join(__dirname, '../public/css'),
path_1.default.join(__dirname, '../public/images'),
path_1.default.join(__dirname, '../public/music'),
path_1.default.join(__dirname, '../public/sounds'),
path_1.default.join(__dirname, '../public/fonts')
];
for (const dir of dirs) {
@@ -182,6 +241,7 @@ function ensureDirectories() {
(0, fs_1.mkdirSync)(dir, { recursive: true });
}
}
(0, game_config_1.ensureConfiguredAssetDirectories)(engineConfig);
}
// Copy kokoro-js library from node_modules if not already present
function ensureKokoroJs() {
@@ -210,14 +270,16 @@ async function startServer(initialPort, range) {
}
// Try to start the server on the current port
await new Promise((resolve, reject) => {
server.listen(currentPort, () => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
server.once('listening', () => {
console.log(`AI Interactive Fiction web server running on http://localhost:${currentPort}`);
resolve();
});
server.on('error', (error) => {
server.once('error', (error) => {
// If port is in use, try next port
if (error.code === 'EADDRINUSE') {
console.log(`Port ${currentPort} is in use, trying next port...`);
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') {
console.log(`Port ${currentPort} is unavailable (${error.code}), trying next port...`);
server.close();
currentPort++;
reject();
@@ -228,6 +290,7 @@ async function startServer(initialPort, range) {
reject(error);
}
});
server.listen(currentPort);
});
// If we reach here, server started successfully
return;
@@ -249,4 +312,4 @@ if (require.main === module) {
process.exit(1);
});
}
//# sourceMappingURL=server.js.map
//# sourceMappingURL=server-yaml.js.map
+1
View File
File diff suppressed because one or more lines are too long
+16
View File
@@ -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 {};
+361
View File
@@ -0,0 +1,361 @@
"use strict";
/**
* 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
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const path_1 = __importDefault(require("path"));
const http_1 = __importDefault(require("http"));
const express_1 = __importDefault(require("express"));
const socket_io_1 = require("socket.io");
const dotenv = __importStar(require("dotenv"));
const fs_1 = require("fs");
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)();
const server = http_1.default.createServer(app);
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.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(`[zcode:debug] ${message}`);
return;
}
console.log(`[zcode:debug] ${message}`, details);
}
// Serve the same shared client UI
app.use(express_1.default.static(path_1.default.join(__dirname, '../public'), {
etag: false,
lastModified: false,
setHeaders: (res) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
},
}));
app.get('/api/game-config', (_req, res) => {
res.json((0, game_config_1.clientGameConfig)(engineConfig));
});
// One engine instance per connected socket
const sessions = new Map();
// Save-game slot maps: socketId → Map<slotNumber, serialisedJson>
const saveSlots = new Map();
function toClientTurn(turn) {
return {
...turn,
gameState: {
...turn.gameState,
currentRoomId: turn.gameState?.statusLine,
statusLine: turn.gameState?.statusLine,
},
};
}
function normalizeSaveSlot(slot) {
const n = Number(slot);
return Number.isInteger(n) && n > 0 ? n : 1;
}
function getOrCreateEngine(socketId) {
let engine = sessions.get(socketId);
if (!engine) {
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);
}
return engine;
}
function getSlots(socketId) {
let slots = saveSlots.get(socketId);
if (!slots) {
slots = new Map();
saveSlots.set(socketId, slots);
}
return slots;
}
function withClientRequestId(turn, requestId) {
const id = Number(requestId || 0);
return Number.isInteger(id) && id > 0
? { ...turn, clientRequestId: id }
: turn;
}
async function handleGameApi(socket, method, args, requestId) {
const slots = getSlots(socket.id);
debugLog(`gameApi request from ${socket.id}: ${method}`, { args });
switch (method) {
case 'newGame':
case 'newGame()': {
const engine = getOrCreateEngine(socket.id);
const turn = await engine.newGame();
socket.emit('narrativeResponse', withClientRequestId(toClientTurn(turn), requestId));
return {
success: true,
result: true,
running: true,
canLoad: slots.size > 0,
};
}
case 'loadGame':
case 'loadGame()': {
const slot = normalizeSaveSlot(args[0]);
if (!slots.has(slot)) {
return { success: false, error: 'missing_save', result: false };
}
const engine = getOrCreateEngine(socket.id);
const turn = await engine.loadGame(slots.get(slot));
socket.emit('narrativeResponse', withClientRequestId(toClientTurn(turn), requestId));
socket.emit('gameLoaded', { slot, clientRequestId: requestId });
return { success: true, result: true, running: true, slot };
}
case 'saveGame':
case 'saveGame()': {
const engine = sessions.get(socket.id);
if (!engine?.isRunning()) {
return { success: false, error: 'game_not_running', result: false };
}
const slot = normalizeSaveSlot(args[0]);
const savedJson = await engine.saveGame();
slots.set(slot, savedJson);
socket.emit('gameSaved', { slot });
return { success: true, result: true, slot };
}
case 'hasSaveGame':
case 'hasSaveGame()': {
const slot = normalizeSaveSlot(args[0]);
return { success: true, result: slots.has(slot), slot };
}
case 'getSaveGames':
case 'getSaveGames()':
return {
success: true,
result: Array.from(slots.keys()).sort((a, b) => a - b),
};
case 'isGameRunning':
case 'isGameRunning()':
return {
success: true,
result: sessions.get(socket.id)?.isRunning() ?? false,
};
default:
return { success: false, error: `unknown_method:${method}` };
}
}
function checkRuntimeConfiguration() {
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',
'command-translator.yml',
'output-evaluator.yml',
];
const missingPrompts = promptFiles
.map((file) => path_1.default.join(promptDir, file))
.filter((filePath) => !(0, fs_1.existsSync)(filePath));
if (!process.env.OPENROUTER_API_KEY) {
console.error('[zcode] Missing OPENROUTER_API_KEY in environment.');
}
if (!process.env.OPENROUTER_MODEL) {
console.error('[zcode] Missing OPENROUTER_MODEL in environment.');
}
if (!(0, fs_1.existsSync)(storyPath)) {
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('[zcode] Missing prompt files:');
for (const filePath of missingPrompts) {
console.error(` - ${filePath}`);
}
}
debugLog('runtime configuration', {
storyPath,
promptDir,
debug: DEBUG_ENABLED,
hasApiKey: Boolean(process.env.OPENROUTER_API_KEY),
model: process.env.OPENROUTER_MODEL ?? null,
});
}
io.on('connection', (socket) => {
console.log(`[zcode] Client connected: ${socket.id}`);
socket.emit('gameConfig', (0, game_config_1.clientGameConfig)(engineConfig));
socket.on('gameApi', async (request, respond) => {
try {
const result = await handleGameApi(socket, String(request?.method ?? ''), Array.isArray(request?.args) ? request.args : [], Number.isInteger(Number(request?.requestId)) && Number(request?.requestId) > 0
? Number(request?.requestId)
: undefined);
debugLog(`gameApi response to ${socket.id}`, result);
if (typeof respond === 'function')
respond(result);
}
catch (error) {
console.error('[zcode] gameApi error:', error);
if (typeof respond === 'function') {
respond({
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
});
socket.on('playerCommand', async (data) => {
const engine = sessions.get(socket.id);
if (!engine?.isRunning()) {
socket.emit('error', {
message: 'No active game. Start or load a game first.',
});
return;
}
const input = String(data?.command ?? '').trim();
if (!input)
return;
debugLog(`playerCommand from ${socket.id}: ${input}`);
try {
const turn = await engine.processInput(input);
debugLog(`narrativeResponse to ${socket.id}`, {
inputMode: turn.inputMode,
paragraphs: turn.paragraphs.length,
statusLine: turn.gameState?.statusLine,
});
socket.emit('narrativeResponse', toClientTurn(turn));
}
catch (error) {
console.error('[zcode] playerCommand error:', error);
socket.emit('error', {
message: error instanceof Error ? error.message : 'An error occurred.',
});
}
});
socket.on('disconnect', () => {
console.log(`[zcode] Client disconnected: ${socket.id}`);
sessions.delete(socket.id);
saveSlots.delete(socket.id);
});
});
// ---------------------------------------------------------------------------
// Startup helpers
// ---------------------------------------------------------------------------
function ensureDirectories() {
const dirs = [
path_1.default.join(__dirname, '../public'),
path_1.default.join(__dirname, '../public/js'),
path_1.default.join(__dirname, '../public/css'),
path_1.default.join(__dirname, '../public/images'),
path_1.default.join(__dirname, '../public/music'),
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/zcode-prompts'),
];
for (const dir of dirs) {
if (!(0, fs_1.existsSync)(dir))
(0, fs_1.mkdirSync)(dir, { recursive: true });
}
(0, game_config_1.ensureConfiguredAssetDirectories)(engineConfig);
}
function ensureKokoroJs() {
const src = path_1.default.join(__dirname, '../node_modules/kokoro-js/dist/index.js');
const dst = path_1.default.join(__dirname, '../public/js/kokoro-js.js');
if ((0, fs_1.existsSync)(src) && !(0, fs_1.existsSync)(dst))
(0, fs_1.copyFileSync)(src, dst);
}
async function startServer(initialPort, range) {
ensureDirectories();
try {
ensureKokoroJs();
}
catch { /* optional */ }
checkRuntimeConfiguration();
let port = initialPort;
while (port < initialPort + range) {
try {
await new Promise((resolve, reject) => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
server.once('listening', () => {
console.log(`[zcode] Z-code Narrator server running on http://localhost:${port}`);
resolve();
});
server.once('error', (err) => {
if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
console.log(`Port ${port} unavailable (${err.code}), trying ${port + 1}...`);
server.close();
port++;
reject();
}
else {
reject(err);
}
});
server.listen(port);
});
return;
}
catch {
if (port >= initialPort + range - 1) {
throw new Error(`Failed to start server on ports ${initialPort}${initialPort + range - 1}`);
}
}
}
}
if (require.main === module) {
startServer(PORT, PORT_RANGE).catch((err) => {
console.error('[zcode] Failed to start:', err);
process.exit(1);
});
}
//# sourceMappingURL=server-zcode.js.map
+1
View File
File diff suppressed because one or more lines are too long
-1
View File
File diff suppressed because one or more lines are too long
+107 -22
View File
@@ -47,6 +47,7 @@ const http_1 = __importDefault(require("http"));
const socket_io_1 = require("socket.io");
const dotenv = __importStar(require("dotenv"));
const fs_1 = require("fs");
const turn_result_1 = require("./interfaces/turn-result");
// Load environment variables
dotenv.config();
// Create Express application
@@ -59,9 +60,18 @@ exports.io = io;
// Get port from environment variables or use default
const DEFAULT_PORT = 3001;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
// Serve static files from the public directory
app.use(express_1.default.static(path_1.default.join(__dirname, '../public')));
const PORT_RANGE = 300; // Try enough ports to skip OS-excluded ranges.
// Serve static files from the public directory. Keep browser modules uncached
// during local development so fixes are visible without a hard cache clear.
app.use(express_1.default.static(path_1.default.join(__dirname, '../public'), {
etag: false,
lastModified: false,
setHeaders: (res) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
}));
// Test paragraphs to send to the client
const TEST_PARAGRAPHS = [
"You stand at the entrance of a mysterious cave. The air is cool and damp, carrying the scent of earth and ancient stone. Shadows dance on the walls as your torch flickers in the gentle breeze.",
@@ -72,31 +82,101 @@ const TEST_PARAGRAPHS = [
io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);
let currentParagraphIndex = 0;
// Start a new game
socket.on('startGame', async () => {
let gameRunning = false;
let nextTurnId = 1;
const saveGames = new Set();
const startDemoGame = () => {
gameRunning = true;
nextTurnId = 1;
currentParagraphIndex = 0;
socket.emit('narrativeResponse', {
turnId: nextTurnId++,
paragraphs: [
...(0, turn_result_1.textToParagraphs)("#chapter[Interactive Fiction Test]\n\nWelcome to the Interactive Fiction Test. This is a simplified version that sends predefined paragraphs instead of using an LLM."),
...(0, turn_result_1.textToParagraphs)(TEST_PARAGRAPHS[0]),
],
choices: [],
inputMode: 'text',
gameState: {
currentRoomId: 'test-room',
},
});
};
const normalizeSaveSlot = (slot) => {
const value = Number(slot);
return Number.isInteger(value) && value > 0 ? value : 1;
};
socket.on('gameApi', (request, respond) => {
try {
console.log('Starting test game session');
// Send introduction to client
socket.emit('gameIntroduction', {
introduction: "Welcome to the Interactive Fiction Test. This is a simplified version that sends predefined paragraphs instead of using an LLM.",
initialRoomDescription: TEST_PARAGRAPHS[0],
currentRoomId: "test-room"
});
const method = String(request?.method || '');
const args = Array.isArray(request?.args) ? request.args : [];
let response;
switch (method) {
case 'newGame':
case 'newGame()':
startDemoGame();
response = { success: true, result: true, running: true, canLoad: saveGames.size > 0 };
break;
case 'loadGame':
case 'loadGame()': {
const slot = normalizeSaveSlot(args[0]);
if (!saveGames.has(slot)) {
response = { success: false, error: 'missing_save', result: false };
break;
}
startDemoGame();
socket.emit('gameLoaded', { slot });
response = { success: true, result: true, running: true, slot };
break;
}
case 'saveGame':
case 'saveGame()': {
if (!gameRunning) {
response = { success: false, error: 'game_not_running', result: false };
break;
}
const slot = normalizeSaveSlot(args[0]);
saveGames.add(slot);
socket.emit('gameSaved', { slot });
response = { success: true, result: true, slot };
break;
}
case 'hasSaveGame':
case 'hasSaveGame()': {
const slot = normalizeSaveSlot(args[0]);
response = { success: true, result: saveGames.has(slot), slot };
break;
}
case 'getSaveGames':
case 'getSaveGames()':
response = { success: true, result: Array.from(saveGames).sort((a, b) => a - b) };
break;
case 'isGameRunning':
case 'isGameRunning()':
response = { success: true, result: gameRunning };
break;
default:
response = { success: false, error: `unknown_method:${method}` };
}
if (typeof respond === 'function')
respond(response);
}
catch (error) {
console.error('Error starting game:', error);
socket.emit('error', { message: 'Failed to start game. Please try again.' });
if (typeof respond === 'function') {
respond({ success: false, error: error instanceof Error ? error.message : String(error) });
}
}
});
// Process player command
socket.on('playerCommand', async (data) => {
try {
console.log(`Received command: ${data.command}`);
// Move to the next paragraph
currentParagraphIndex = (currentParagraphIndex + 1) % TEST_PARAGRAPHS.length;
// Send narrative response to client
socket.emit('narrativeResponse', {
text: TEST_PARAGRAPHS[currentParagraphIndex],
turnId: nextTurnId++,
paragraphs: (0, turn_result_1.textToParagraphs)(String(data.command || '')),
choices: [],
inputMode: 'text',
gameState: {
currentRoomId: "test-room"
},
@@ -120,6 +200,8 @@ function ensureDirectories() {
path_1.default.join(__dirname, '../public/js'),
path_1.default.join(__dirname, '../public/css'),
path_1.default.join(__dirname, '../public/images'),
path_1.default.join(__dirname, '../public/music'),
path_1.default.join(__dirname, '../public/sounds'),
path_1.default.join(__dirname, '../public/fonts')
];
for (const dir of dirs) {
@@ -155,15 +237,17 @@ async function startServer(initialPort, range) {
}
// Try to start the server on the current port
await new Promise((resolve, reject) => {
server.listen(currentPort, () => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
server.once('listening', () => {
console.log(`AI Interactive Fiction TEST SERVER running on http://localhost:${currentPort}`);
console.log('This server is sending predefined test paragraphs instead of using an LLM');
resolve();
});
server.on('error', (error) => {
server.once('error', (error) => {
// If port is in use, try next port
if (error.code === 'EADDRINUSE') {
console.log(`Port ${currentPort} is in use, trying next port...`);
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') {
console.log(`Port ${currentPort} is unavailable (${error.code}), trying next port...`);
server.close();
currentPort++;
reject();
@@ -174,6 +258,7 @@ async function startServer(initialPort, range) {
reject(error);
}
});
server.listen(currentPort);
});
// If we reach here, server started successfully
return;
@@ -194,4 +279,4 @@ if (require.main === module) {
process.exit(1);
});
}
//# sourceMappingURL=test-server.js.map
//# sourceMappingURL=test-server-yaml.js.map
+1
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"test-server.js","sourceRoot":"","sources":["../src/test-server.ts"],"names":[],"mappings":"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"}
+4
View File
@@ -0,0 +1,4 @@
import type { StoryTag } from '../interfaces/turn-result';
export declare function parseTag(raw: string): StoryTag | null;
export declare function parseTags(rawTags: unknown[] | undefined): StoryTag[];
export declare function getTagValue(tags: StoryTag[], key: string): string | undefined;
+53
View File
@@ -0,0 +1,53 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseTag = parseTag;
exports.parseTags = parseTags;
exports.getTagValue = getTagValue;
const LEGACY_TAG_ALIASES = {
audio: 'sfx',
audioloop: 'music',
separator: 'section',
};
function normalizeKey(key) {
const normalized = key.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
return LEGACY_TAG_ALIASES[normalized] || normalized;
}
function parseTag(raw) {
const text = String(raw || '').trim().replace(/^#\s*/, '');
if (!text)
return null;
const bracketMatch = text.match(/^([A-Za-z][\w-]*)(?:\[([^\]]*)\])?(?:\(([^)]*)\))?$/);
if (bracketMatch) {
const tag = { key: normalizeKey(bracketMatch[1]) };
if (typeof bracketMatch[2] !== 'undefined')
tag.value = bracketMatch[2].trim();
if (typeof bracketMatch[3] !== 'undefined')
tag.param = bracketMatch[3].trim();
return tag;
}
const colonMatch = text.match(/^([A-Za-z][\w-]*)\s*:\s*(.*?)\s*(?:\(([^)]*)\))?$/);
if (colonMatch) {
const tag = { key: normalizeKey(colonMatch[1]) };
tag.value = colonMatch[2].trim();
if (typeof colonMatch[3] !== 'undefined')
tag.param = colonMatch[3].trim();
return tag;
}
const bareMatch = text.match(/^[A-Za-z][\w-]*$/);
if (bareMatch) {
return { key: normalizeKey(text) };
}
return null;
}
function parseTags(rawTags) {
if (!Array.isArray(rawTags))
return [];
return rawTags
.map((raw) => parseTag(String(raw ?? '')))
.filter((tag) => Boolean(tag));
}
function getTagValue(tags, key) {
const normalizedKey = normalizeKey(key);
return tags.find((tag) => tag.key === normalizedKey)?.value;
}
//# sourceMappingURL=tag-parser.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"tag-parser.js","sourceRoot":"","sources":["../../src/utils/tag-parser.ts"],"names":[],"mappings":";;AAaA,4BA0BC;AAED,8BAMC;AAED,kCAGC;AAlDD,MAAM,kBAAkB,GAA2B;IACjD,KAAK,EAAE,KAAK;IACZ,SAAS,EAAE,OAAO;IAClB,SAAS,EAAE,SAAS;CACrB,CAAC;AAEF,SAAS,YAAY,CAAC,GAAW;IAC/B,MAAM,UAAU,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC,CAAC;IAC1E,OAAO,kBAAkB,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC;AACtD,CAAC;AAED,SAAgB,QAAQ,CAAC,GAAW;IAClC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IAC3D,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IAEvB,MAAM,YAAY,GAAG,IAAI,CAAC,KAAK,CAAC,qDAAqD,CAAC,CAAC;IACvF,IAAI,YAAY,EAAE,CAAC;QACjB,MAAM,GAAG,GAAa,EAAE,GAAG,EAAE,YAAY,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7D,IAAI,OAAO,YAAY,CAAC,CAAC,CAAC,KAAK,WAAW;YAAE,GAAG,CAAC,KAAK,GAAG,YAAY,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC/E,IAAI,OAAO,YAAY,CAAC,CAAC,CAAC,KAAK,WAAW;YAAE,GAAG,CAAC,KAAK,GAAG,YAAY,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC/E,OAAO,GAAG,CAAC;IACb,CAAC;IAED,MAAM,UAAU,GAAG,IAAI,CAAC,KAAK,CAAC,mDAAmD,CAAC,CAAC;IACnF,IAAI,UAAU,EAAE,CAAC;QACf,MAAM,GAAG,GAAa,EAAE,GAAG,EAAE,YAAY,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3D,GAAG,CAAC,KAAK,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QACjC,IAAI,OAAO,UAAU,CAAC,CAAC,CAAC,KAAK,WAAW;YAAE,GAAG,CAAC,KAAK,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC3E,OAAO,GAAG,CAAC;IACb,CAAC;IAED,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAC;IACjD,IAAI,SAAS,EAAE,CAAC;QACd,OAAO,EAAE,GAAG,EAAE,YAAY,CAAC,IAAI,CAAC,EAAE,CAAC;IACrC,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAgB,SAAS,CAAC,OAA8B;IACtD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,CAAC;IAEvC,OAAO,OAAO;SACX,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,CAAC;SACzC,MAAM,CAAC,CAAC,GAAG,EAAmB,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;AACpD,CAAC;AAED,SAAgB,WAAW,CAAC,IAAgB,EAAE,GAAW;IACvD,MAAM,aAAa,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC;IACxC,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,GAAG,KAAK,aAAa,CAAC,EAAE,KAAK,CAAC;AAC9D,CAAC"}
+152 -17
View File
@@ -14,8 +14,11 @@
"dotenv": "^16.4.7",
"express": "^5.1.0",
"hyphenopoly": "^6.0.0",
"ifvms": "^1.1.6",
"inkjs": "^2.4.0",
"js-yaml": "^4.1.0",
"kokoro-js": "^1.2.0",
"marked": "^15.0.12",
"openai": "^4.91.0",
"socket.io": "^4.8.1"
},
@@ -32,6 +35,9 @@
"ts-jest": "^29.3.1",
"ts-node": "^10.9.2",
"typescript": "^5.8.2"
},
"engines": {
"node": ">=18.17"
}
},
"node_modules/@ampproject/remapping": {
@@ -2314,7 +2320,6 @@
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
"integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"type-fest": "^0.21.3"
@@ -2330,7 +2335,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -2340,7 +2344,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -2707,7 +2710,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -3031,6 +3033,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dedent": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz",
@@ -3192,7 +3203,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/encodeurl": {
@@ -3913,7 +3923,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
@@ -4088,7 +4097,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -4154,6 +4162,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/glkote-term": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/glkote-term/-/glkote-term-0.4.4.tgz",
"integrity": "sha512-5l2t4QC9Pr4DgMz/OBGojgaAZJ3p0yf+e8pIYuz63kT0gBaHqsAuASYWQVqSkj60v6nUxKYJRzE0GQucf9PDxg==",
"license": "MIT",
"dependencies": {
"ansi-escapes": "^4.0.0"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -4343,6 +4360,89 @@
"node": ">=0.10.0"
}
},
"node_modules/ifvms": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/ifvms/-/ifvms-1.1.6.tgz",
"integrity": "sha512-4OPV23gHu/YsyqcUuV4oqVBkicz6KsFdwKyMQkaUeN6nvv4maGcYA5qgjDse/iEdvsqSijLHRbx5VuM0zuXEMQ==",
"license": "MIT",
"dependencies": {
"glkote-term": "^0.4.0",
"mute-stream": "0.0.8",
"yargs": "^15.0.1"
},
"bin": {
"zvm": "bin/zvm.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/ifvms/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/ifvms/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ifvms/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/ifvms/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ifvms/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4435,6 +4535,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/inkjs": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/inkjs/-/inkjs-2.4.0.tgz",
"integrity": "sha512-EoPCYESIbMtfI8SqEDZCJwn+A5is0QozMLw250iic1ReJCgZpRKIezWj0VqgRUzAx0f3MmEbsUjY/ILe2815JQ==",
"license": "MIT",
"bin": {
"inkjs-compiler": "bin/inkjs-compiler.js"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -4494,7 +4603,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5396,7 +5504,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
@@ -5481,6 +5588,18 @@
"tmpl": "1.0.5"
}
},
"node_modules/marked": {
"version": "15.0.12",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5628,6 +5747,12 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mute-stream": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
"license": "ISC"
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -5968,7 +6093,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
@@ -5981,7 +6105,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
@@ -5997,7 +6120,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -6048,7 +6170,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6358,12 +6479,17 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -6542,6 +6668,12 @@
"node": ">= 18"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -6983,7 +7115,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -6998,7 +7129,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -7319,7 +7449,6 @@
"version": "0.21.3",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
@@ -7501,6 +7630,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+40 -6
View File
@@ -4,18 +4,49 @@
"description": "A modern take on classic text adventures that combines traditional world modeling with Large Language Models (LLMs) to create natural language interactive fiction experiences.",
"main": "index.js",
"scripts": {
"start": "node dist/index.js",
"start:web": "node dist/index.js",
"check:node": "node scripts/check-node-version.js",
"prestart": "npm run check:node",
"start": "node scripts/run-engine.js start",
"prestart:cli": "npm run check:node",
"start:cli": "node dist/index.js --cli",
"dev": "nodemon --watch 'src/**' --ext 'ts,json' --exec 'ts-node src/index.ts'",
"dev:web": "nodemon --watch 'src/**' --ext 'ts,json' --exec 'ts-node src/index.ts'",
"dev:cli": "nodemon --watch 'src/**' --ext 'ts,json' --exec 'ts-node src/index.ts --cli'",
"test-server": "ts-node src/test-server.ts",
"predev": "npm run check:node",
"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-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-yaml.ts\\\"\"",
"predev:cli": "npm run check:node",
"dev:cli": "nodemon --watch src --ext ts,json --exec \"ts-node src/index.ts --cli\"",
"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-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-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-yaml.ts",
"build": "tsc",
"test": "jest",
"lint": "eslint --ext .ts src/",
"lint:fix": "eslint --ext .ts src/ --fix"
},
"engines": {
"node": ">=18.17"
},
"keywords": [],
"author": "",
"license": "ISC",
@@ -40,8 +71,11 @@
"dotenv": "^16.4.7",
"express": "^5.1.0",
"hyphenopoly": "^6.0.0",
"ifvms": "^1.1.6",
"inkjs": "^2.4.0",
"js-yaml": "^4.1.0",
"kokoro-js": "^1.2.0",
"marked": "^15.0.12",
"openai": "^4.91.0",
"socket.io": "^4.8.1"
}
+88
View File
@@ -0,0 +1,88 @@
# Third-Party Notices
This application includes or interfaces with the following third-party libraries, tools, fonts, and services.
## Browser-vendored libraries
| Component | Local files | Local version | Latest checked | License | Status |
| --- | --- | --- | --- | --- | --- |
| SmartyPants.js | `public/js/smartypants.js` | Header says 0.0.6 | npm `smartypants` 0.2.2 | BSD-3-Clause | Local file is not identical to npm `smartypants` 0.0.5, 0.0.9, or 0.2.2. It appears to be an older browser bundle with local or unreleased changes. |
| Hyphenopoly | `public/js/Hyphenopoly.js`, `public/js/Hyphenopoly_Loader.js`, `public/js/hyphenopoly.module.js`, `public/js/patterns/*.wasm` | Browser file header says 5.2.0-beta.1 | npm `hyphenopoly` 6.1.0 | MIT | `Hyphenopoly.js` matches the 5.2.0-beta.1 npm file after line-ending normalization. Loader differs by a small local/prototype change. Package dependency is 6.0.0, so the browser vendored copy is older than both the installed package and latest npm. |
| Knuth-Plass line breaking adapter | `public/js/knuth-and-plass.js` | No upstream version header | Unknown | Unknown/inherited prototype code | Local file differs from the prototype file and is application-adapted. Exact upstream could not be identified from file headers or npm metadata. |
| Line breaking support | `public/js/linebreak.js`, `public/js/linked-list.js` | No upstream version header | Unknown | Unknown/inherited prototype code | Files are identical to the prototype copies. Exact upstream could not be identified from file headers. |
| Kokoro JS browser bundle | `public/js/kokoro-js.js` | 1.2.0 | npm `kokoro-js` 1.2.1 | Apache-2.0 | Local file is byte-identical to `kokoro-js` 1.2.0 `dist/kokoro.web.js`; not latest. |
| Marked browser bundle | `public/js/vendor/marked.esm.js` | 15.0.12 | npm `marked` 15.0.12 | MIT | Local file is copied from the installed `marked` package. |
| EB Garamond 12 | `public/fonts/EBGaramond12/**` | Local EB Garamond 12 distribution | https://github.com/octaviopardo/EBGaramond12 | SIL OFL 1.1 | Active UI/book font family. Includes regular, italic, bold, bold italic, semibold, variable fonts, and OpenType features including `smcp`, `c2sc`, `case`, ligatures, oldstyle figures, proportional figures, swashes, and stylistic sets. |
| EB Garamond Initials | `public/fonts/EB-Garamond-Initials/**` | EB Garamond 0.016 initials font | https://github.com/georgd/EB-Garamond | SIL OFL 1.1 | Active drop-cap font. The Fill1 and Fill2 sibling fonts are layer fonts for ornament/letter separation; the combined Initials font is used for single-glyph drop caps. |
## Direct npm runtime dependencies
| Package | Installed | Latest checked | License | Credit |
| --- | --- | --- | --- | --- |
| `inkjs` | 2.4.0 | 2.4.0 | MIT | Yannick Lohse; based on ink by Inkle |
| `hyphenopoly` | 6.0.0 | 6.1.0 | MIT | Mathias Nater |
| `kokoro-js` | 1.2.0 | 1.2.1 | Apache-2.0 | hexgrad, Xenova |
| `ifvms` | 1.1.6 | 1.1.6 | MIT | Dannii Willis |
| `openai` | 4.91.0 | 6.38.0 | Apache-2.0 | OpenAI |
| `socket.io` | 4.8.1 | 4.8.3 | MIT | Socket.IO contributors |
| `express` | 5.1.0 | 5.2.1 | MIT | Express contributors |
| `axios` | 1.8.4 | 1.16.1 | MIT | Axios contributors |
| `marked` | 15.0.12 | 15.0.12 | MIT | marked contributors |
| `cors` | 2.8.5 | 2.8.6 | MIT | Troy Goode |
| `dotenv` | 16.4.7 | 17.4.2 | BSD-2-Clause | dotenv contributors |
| `js-yaml` | 4.1.0 | 4.1.1 | MIT | Vladimir Zapparov and contributors |
## Fonts and services
| Component | Use | License/Credit |
| --- | --- | --- |
| EB Garamond 12 | UI and book text font | SIL Open Font License 1.1; Georg Duffner, Octavio Pardo |
| EB Garamond Initials | Drop-cap font | SIL Open Font License 1.1; Georg Duffner |
| OpenAI / ChatGPT / Codex / GPT-image-2 | Coding assistance, writing assistance, generated images | OpenAI |
| Claude Code | Coding assistance | Anthropic |
| Suno | Music generation | Suno |
## License Texts
### MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of software and associated documentation files licensed under the MIT License, to deal in the software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the software, and to permit persons to whom the software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
### BSD 2-Clause License
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
### BSD 3-Clause License
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
### Apache License 2.0
Licensed under the Apache License, Version 2.0. You may not use files licensed under the Apache License except in compliance with the License. You may obtain a copy of the License at:
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the Apache License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
### SIL Open Font License 1.1
EB Garamond 12 and EB Garamond Initials are distributed under the SIL Open Font License, Version 1.1. The license permits use, study, modification and redistribution of the font, with reserved font name restrictions and the requirement that derivative fonts remain under the same license.
Full license: https://openfontlicense.org/
Local copies of the OFL text for the active EB Garamond files are included at `public/fonts/EBGaramond12/OFL.txt` and `public/fonts/EB-Garamond-Initials/EBGaramond-0.016/COPYING`.
+1191 -226
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,93 @@
Copyright (c) 2010-2013 Georg Duffner (http://www.georgduffner.at)
All "EB Garamond" Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,38 @@
EB Garamond 0.016 (2014-04-07)
===============================
* License
- No reserved font name any more
* New Features:
- [12-It] fina for e.fina (more shall come)
- [12-It] ss07: h and k with alternate accent placement above the stem
- [12-Re] Smallcaps now dont enable ss20 any longer
- OS/2 Optical Size settings
- Change the naming scheme for the small-caps-fonts: they now relate to the preferred family “EB Garamond SC”, hence they are named “EBGaramondSCXX-Style” where XX is the design size and Style is Regular (Italic, Bold, … once they exist).
* New and redrawn Glyphs:
- [08-Re] Half-ring modifiers
- [12-Re] Redraw esh (with Siva Kalyan)
- [12-Re] Regional identifiers 1F1E6 ­— 1F1FF (Tim Larson)
- [12-Re] Arrows and mathematical symbols (Tim Larson): arrowdbldown, arrowdblleft, arrowdblright, arrowdblup, gradient, product, uni210E, uni214B, uni219E — uni21A2, uni21DA — uni21DD, uni2210, uni2B45, uni2B46, uniFFFD
- [12-Re] e less round
- [12-Re] exclam more delicate
- [12-Re] Missing glyphs in Latin Extended C and D (Capillatus)
- [12-Re] uni1DC4 (more shall come)
- [12-Re] find a latin chi
- [12-Re] ditto mark
- [12-It] Fully redraw the small-caps
- [12-It] Redraw the Euro
- [12-It] Redraw the asterisk
* Fixes:
- Caron and alternate caron position on l.sc, dcaron.sc and tcaron.sc
- Lots of kerning and spacing
- Lots of anchors freshly positioned
- Fix f-ligatures for German locale
- c2sc + German umlauts
- extended IPA small-caps are petite-caps now
EB Garamond 0.015d (2013-06-28) and older
=========================================
Please have a look at the git sources.
@@ -0,0 +1,36 @@
# EB Garamond
## Claude Garamonts designs go opensource.
This project aims at providing a free version of the Garamond types, based on the Designs of the Berner specimen from 1592.
In the end, the fonts shall cover extended latin, greek and cyrillic scripts in different styles (regular, italic, bold, bolditalic) and design sizes. There are also fonts containing initials based on those found in a 16th century french bible print. The fonts make heavy use of opentype features for specialities like small caps or different number styles as well as for imitating renaissance typography.
For the use with Xe- and LuaLaTeX Im working on a configuration for mycrotype. For the use on the web via @fontface, the make-script produces eot and woff files which can be found in the web section. But be aware that they are not subset but contain the whole fonts, which might result in undesirably big files. Webfont hosters like googlefonts or fontsquirrel might provide better solutions.
## Fonts in this repository:
- EBGaramond12-Regular: Regular font for design size 12pt
- EBGaramond12-Italic: Italic font for design size 12pt
- EBGaramond12-Bold: Bold font for design size 12pt (very rough/unusable; not included in releases)
- EBGaramond08-Regular: Regular font for design size 8pt
- EBGaramond08-Italic: Italic font for design size 8pt (very rough spacing!)
- EBGaramond12-SC: Smallcaps font for programs that ignore opentype features (12pt)
- EBGaramond12-AllSC: All smallcaps font for programs that ignore opentype features
- EBGaramond08-SC: Smallcaps font for programs that ignore opentype features (8pt)
- EBGaramond-Initials: Initials
- EBGaramond-InitialsF1: Background (the ornament) of initials
- EBGaramond-InitialsF2: Foreground (the letter) of initials
- EBGaramond-Lettrines: Workbench for Initials fonts (not included in releases)
This is a work in progress, so expect bugs! The qualitiy of the fonts still varies widely! You can see every fonts current state in its *-Glyphs.pdf file in the specimen section.
## Mirrors:
Due to Github deciding not to provide a download area any more, this project resides in two mirrored repositories on Github (https://github.com/georgd/EB-Garamond) and Bitbucket (https://bitbucket.org/georgd/eb-garamond).
- Downloadable zip-files are at https://bitbucket.org/georgd/eb-garamond/downloads
- The issue tracker continues to live at https://github.com/georgd/EB-Garamond/issues
- Forks and pull requests should be possible on both platforms
For more infos please visit http://www.georgduffner.at/ebgaramond/
@@ -0,0 +1,21 @@
Short note about using XeLaTeX and LuaLatex with EB Garamond via fontspec.
EB Garamond fonts are loaded through fontspec with
\setmainfont{EB Garamond}
This will load the appropriate fonts for the actual sizes, currently EBGaramond08* for sizes up to 10pt and EBGaramond12* from 10.1pt onwards.
If you want to stay with one design size, you have to load its regular font and switch off optical sizes:
\setmainfont[OpticalSize=0]{EB Garamond 12 Regular}
If you wish to use a specific font file, e.g. EB Garamond Italic:
\setmainfont{EB Garamond 12 Italic}
N.B. It is preferable to use the +c2sc and +scmp features for Small Caps, rather than the SC or ALLSC font files.
\newfontfamily{\smallcaps}[RawFeature={+c2sc,+scmp}]{EB Garamond}
The specimens preamble may be a good guide too.
@@ -0,0 +1,25 @@
<HTML>
<HEAD>
<TITLE>FFonts - Redirect</TITLE>
<meta http-equiv="refresh" content="1;url=https://www.ffonts.net">
<script language="javascript">
<!--
//location.replace("https://www.ffonts.net");
-->
</script>
</HEAD>
<BODY >
<CENTER>
<TABLE WIDTH="400" HEIGHT="100%" BORDER="0" CELLPADDING="0" CELLSPACING="0">
<TR><TD WIDTH="100%" HEIGHT="20" ALIGN="CENTER" VALIGN="LEFT" BGCOLOR="#FFFFFF"><FONT FACE="Arial, Sans Serif, Verdana" SIZE=3 COLOR="#000000"><B>Redirect</B></FONT></TD></TR>
<TR><TD WIDTH="100%" HEIGHT="300" ALIGN="CENTER" VALIGN="LEFT" BGCOLOR="#000000"><BR><BR><BR>
<FONT FACE="Arial, Sans Serif, Verdana" SIZE=3 COLOR="#FFFFFF">If this page does not automatically redirect you, click below to go to the FFonts homepage</FONT>
<BR><BR>
<FONT FACE="Arial, Sans Serif, Verdana" SIZE=3 COLOR="#FFFFFF"><A HREF="https://www.ffonts.net" TARGET="_top">http://www.ffonts.net</A>
</TD></TR>
</TD></TR>
</TABLE>
</CENTER>
</BODY>
</HTML>
@@ -0,0 +1,5 @@
Download Free fonts from FFonts:
https://www.ffonts.net
Free Fonts Donwload
@@ -0,0 +1,44 @@
Installing fonts is quick and simple. Once fonts are installed, they are available to yours programs.
The font packages you download from the www.ffonts.net is in compressed .zip files to reduce file size and make downloading faster.
If you have downloaded a font that is saved in .zip format, you can "unzip" it by double-clicking the icon for the font and following the instructions on the screen.
INSTALLING MORE THAN 1000 FONTS ONTO YOUR COMPUTER CAN CAUSE A REDUCTION IN SPEED.
WE RECOMMEND THAT YOU LIMIT YOURSELF TO A NUMBER LESS THAN 1000 (400-500).
Installing new fonts
How to install a font under Windows? Download Font
Click on the "Download" button, save the font file on your hard disk.
Under Windows Vista : Select the font files (.ttf, .otf or .fon) then Right-click > Install
Under any version of Windows : Place the font files (.ttf, .otf or .fon) into the Fonts folder, usually C:\Windows\Fonts or C:\WINNT\Fonts
(can be reached as well by the Start Menu > Control Panel > Appearance and Themes > Fonts).
Tip : if you punctually need a font, you don't need to install it. Just double-click on the .ttf file, and while the preview window is opened you can use it in most of the programs you'll launch (apart from a few exceptions like OpenOffice).
How to install a font under Mac OS ? Download Font
Click on the "Download" button, save the font file on your hard disk.
Under Mac OS X 10.3 or above (including the FontBook) : Double-click the font file > "Install font" button at the bottom of the preview.
Under any version of Mac OS X : Put the files into /Library/Fonts (for all users) or into /Users/Your_username/Library/Fonts (for you only).
Under Mac OS 9 or earlier : Download the font files (.ttf or .otf),Then drag the fonts suitcases into the System folder. The system will propose you to add them to the Fonts folder.
How to install a font under Linux ? Download Font
Click on the "Download" button, save the font file on your hard disk.
Copy the font files (.ttf or .otf) to fonts:/// in the File manager.
Notes
* To select more than one font to add, in step 6, hold down the CTRL key, and then click each of the fonts you want to add.
* You can also drag OpenType, TrueType, Type 1, and raster fonts from another location to add them to the Fonts folder. This works only if the font is not already in the Fonts folder.
* To add fonts from a network drive without using disk space on your computer, clear the Copy fonts to Fonts folder check box in the Add Fonts dialog box. This is available only when you install OpenType, TrueType, or raster fonts using the Install New Font option on the File menu.
@@ -0,0 +1,20 @@
<HTML>
<HEAD>
<TITLE>WhatFontIs - Redirect</TITLE>
<meta http-equiv="refresh" content="15;url=https://www.WhatFontIs.com">
<script language="javascript">
<!--
//location.replace("https://www.WhatFontIs.com");
-->
</script>
</HEAD>
<BODY >
<CENTER>
<A HREF="https://www.WhatFontIs.com/?utm_source=ff" TARGET="_top"><img src="https://www.whatfontis.com/sponsored/v1.png" border="0" alt="www.WhatFontIs.com"></A>
</TABLE>
</CENTER>
</BODY>
</HTML>
@@ -0,0 +1,93 @@
Copyright (c) 2010-2013 Georg Duffner (http://www.georgduffner.at)
All "EB Garamond" Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,38 @@
EB Garamond 0.016 (2014-04-07)
===============================
* License
- No reserved font name any more
* New Features:
- [12-It] fina for e.fina (more shall come)
- [12-It] ss07: h and k with alternate accent placement above the stem
- [12-Re] Smallcaps now dont enable ss20 any longer
- OS/2 Optical Size settings
- Change the naming scheme for the small-caps-fonts: they now relate to the preferred family “EB Garamond SC”, hence they are named “EBGaramondSCXX-Style” where XX is the design size and Style is Regular (Italic, Bold, … once they exist).
* New and redrawn Glyphs:
- [08-Re] Half-ring modifiers
- [12-Re] Redraw esh (with Siva Kalyan)
- [12-Re] Regional identifiers 1F1E6 ­— 1F1FF (Tim Larson)
- [12-Re] Arrows and mathematical symbols (Tim Larson): arrowdbldown, arrowdblleft, arrowdblright, arrowdblup, gradient, product, uni210E, uni214B, uni219E — uni21A2, uni21DA — uni21DD, uni2210, uni2B45, uni2B46, uniFFFD
- [12-Re] e less round
- [12-Re] exclam more delicate
- [12-Re] Missing glyphs in Latin Extended C and D (Capillatus)
- [12-Re] uni1DC4 (more shall come)
- [12-Re] find a latin chi
- [12-Re] ditto mark
- [12-It] Fully redraw the small-caps
- [12-It] Redraw the Euro
- [12-It] Redraw the asterisk
* Fixes:
- Caron and alternate caron position on l.sc, dcaron.sc and tcaron.sc
- Lots of kerning and spacing
- Lots of anchors freshly positioned
- Fix f-ligatures for German locale
- c2sc + German umlauts
- extended IPA small-caps are petite-caps now
EB Garamond 0.015d (2013-06-28) and older
=========================================
Please have a look at the git sources.

Some files were not shown because too many files have changed in this diff Show More