Compare commits
3 Commits
main
..
7c5d194376
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c5d194376 | |||
| 256cc2c7a7 | |||
| 90f81ee1b7 |
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(wc:*)",
|
|
||||||
"Bash(git -C /workspaces/ai.interactive.fiction log --oneline -15)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
FROM node:18
|
|
||||||
|
|
||||||
# Install basic development tools
|
|
||||||
RUN apt update && apt install -y less git procps
|
|
||||||
|
|
||||||
# Install Kokoro JS dependencies if needed
|
|
||||||
RUN apt install -y build-essential python3
|
|
||||||
|
|
||||||
# Ensure default `node` user has access to `sudo`
|
|
||||||
ARG USERNAME=node
|
|
||||||
RUN apt-get install -y sudo \
|
|
||||||
&& echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
|
|
||||||
&& chmod 0440 /etc/sudoers.d/$USERNAME
|
|
||||||
|
|
||||||
# Set the default user
|
|
||||||
USER node
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /workspace
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Node.js Development",
|
|
||||||
"build": {
|
|
||||||
"dockerfile": "Dockerfile"
|
|
||||||
},
|
|
||||||
"customizations": {
|
|
||||||
"vscode": {
|
|
||||||
"extensions": [
|
|
||||||
"dbaeumer.vscode-eslint",
|
|
||||||
"esbenp.prettier-vscode"
|
|
||||||
],
|
|
||||||
"settings": {
|
|
||||||
"terminal.integrated.defaultProfile.linux": "bash",
|
|
||||||
"terminal.integrated.profiles.linux": {
|
|
||||||
"bash": {
|
|
||||||
"path": "/bin/bash"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"forwardPorts": [3001],
|
|
||||||
"postCreateCommand": "npm install",
|
|
||||||
"remoteUser": "node"
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"parser": "@typescript-eslint/parser",
|
|
||||||
"extends": [
|
|
||||||
"eslint:recommended",
|
|
||||||
"plugin:@typescript-eslint/recommended"
|
|
||||||
],
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaVersion": 2020,
|
|
||||||
"sourceType": "module"
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
"@typescript-eslint"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"@typescript-eslint/no-explicit-any": "warn",
|
|
||||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
|
||||||
"no-console": "off"
|
|
||||||
},
|
|
||||||
"env": {
|
|
||||||
"node": true,
|
|
||||||
"jest": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,268 +1,103 @@
|
|||||||
# AI Interactive Fiction
|
# AI Interactive Fiction - Ink Coolify Release
|
||||||
|
|
||||||
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.
|
This branch is the deployable Ink edition of the AI Interactive Fiction client/server. It contains the browser UI, the Ink server, the Eibenreith Ink source, compiled Ink output, media assets, fonts, locale files, and Docker/Coolify configuration.
|
||||||
|
|
||||||
## Quick Start
|
The full multi-engine development tree lives on `main`. The historical prototype is intentionally not part of this branch; it is preserved on `codex/archive-prototype` and tag `prototype-archive-2026-05-19`.
|
||||||
|
|
||||||
Use Node.js 22 LTS for development. The project accepts Node >= 18.17, but current development has been done on Node 22.
|
## Local Ink Development
|
||||||
|
|
||||||
|
Use Node.js 22 LTS.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
nvm install 22
|
nvm install 22
|
||||||
nvm use 22
|
nvm use 22
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
`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.
|
`npm run dev` starts the Ink server through `ts-node` and watches `src/`, `data/ink-src/`, and `config/engines/ink.json`. The server compiles the configured Ink source when it starts.
|
||||||
|
|
||||||
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.
|
Useful commands:
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm run dev # Start the web UI through ts-node/nodemon
|
npm run build # Compile TypeScript to dist/
|
||||||
npm run start # Build/run the configured default engine from dist/
|
npm run start # Run the compiled Ink server
|
||||||
npm run dev:ink # Start the Ink engine server, watch ink source, compile on restart
|
npm run dev:debug # Development server with Ink debug logging
|
||||||
npm run dev:yaml # Start the YAML engine server
|
npm run dev:inspect # Development server with Node inspector on 0.0.0.0:9231
|
||||||
npm run dev:zcode # Start the Z-code engine server
|
npm run start:debug # Compiled server with Ink debug logging
|
||||||
npm run start:ink # Build and run the compiled Ink engine server
|
npm run start:inspect # Compiled server with Node inspector on 0.0.0.0:9231
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Set `PORT` to choose the server port. The Docker image defaults to `3000`.
|
||||||
|
|
||||||
## Docker / Coolify Ink Deployment
|
## Coolify 4 Deployment
|
||||||
|
|
||||||
The included `Dockerfile` builds and serves the Ink engine only. Coolify can use the repository Dockerfile directly.
|
Configure Coolify to deploy this branch with the repository `Dockerfile`.
|
||||||
|
|
||||||
Set the Coolify environment variables from `coolify.env.example`; at minimum:
|
Recommended environment:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
DEFAULT_GAME_ENGINE=ink
|
|
||||||
PORT=3000
|
PORT=3000
|
||||||
INK_CONFIG_FILE=./config/engines/ink.json
|
INK_CONFIG_FILE=./config/engines/ink.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The container compiles TypeScript during image build and compiles the configured Ink source to JSON when the server starts.
|
Coolify can watch `release/coolify-ink` and redeploy on webhook pushes. The intended flow is:
|
||||||
|
|
||||||
## Configuration
|
1. Write Ink locally in `data/ink-src/`.
|
||||||
|
2. Test locally with `npm run dev`.
|
||||||
|
3. Commit to the development branch.
|
||||||
|
4. Merge or cherry-pick the wanted deployment state into `release/coolify-ink`.
|
||||||
|
5. Push `release/coolify-ink` to the Git remote watched by Coolify.
|
||||||
|
|
||||||
Environment variables are loaded from `.env`.
|
The container builds TypeScript during image build and compiles the configured Ink source at server startup.
|
||||||
|
|
||||||
- `PORT`: preferred web server port.
|
## Ink Configuration
|
||||||
- `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.
|
The active game is configured in `config/engines/ink.json`.
|
||||||
|
|
||||||
## Starting A Game
|
Important paths:
|
||||||
|
|
||||||
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.
|
- `paths.inkSource`: main Ink source file.
|
||||||
|
- `paths.inkCompiled`: compiled Ink JSON target.
|
||||||
|
- `paths.mainGameFile`: compiled Ink JSON loaded by the server.
|
||||||
|
- `paths.music`: background music directory.
|
||||||
|
- `paths.sfx`: sound effect directory.
|
||||||
|
- `paths.images`: image directory.
|
||||||
|
|
||||||
The placeholder server API supports:
|
Game metadata and language are sent to the client before game start. The client uses game language for hyphenation and TTS language hints; UI locale can still be overridden by the player.
|
||||||
|
|
||||||
- `newGame()`
|
## Browser Client
|
||||||
- `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.
|
The client lives in `public/` and is served as native browser modules. It renders structured `TurnResult` output from the server, including paragraphs, headings, choices, media events, alerts, score messages, achievements, and errors.
|
||||||
|
|
||||||
## Web Client
|
TTS provider settings, volume controls, savegames, TTS cache, and rendered story history are stored in browser storage. Ink server state is also sent back to the browser save data so a client can recover after reload or server restart without server-side per-player sessions.
|
||||||
|
|
||||||
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.
|
## Story Tags
|
||||||
|
|
||||||
Major modules:
|
Ink tags are parsed server-side into structured output objects. The client consumes structured turn data only.
|
||||||
|
|
||||||
- `module-registry.js`, `base-module.js`, `loader.js`: module lifecycle, dependency graph, progress overlay, state reporting.
|
Common tags:
|
||||||
- `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
|
```text
|
||||||
|
#chapter[Title]
|
||||||
#section
|
#section
|
||||||
|
#image[file.png](landscape|portrait|square, pause=2)
|
||||||
The first paragraph starts a separated block without horizontal indent.
|
#music[file.mp3](crossfade|queue|cut, loop=true, lead=5)
|
||||||
|
#sfx[file.ogg](duration=4, fade=true)
|
||||||
The following paragraph returns to the normal indent.
|
#gloss[Term](Explanation shown on hover.)
|
||||||
|
#score[Optional score text]
|
||||||
|
#achievement[Optional achievement text]
|
||||||
|
#alert[Optional player hint]
|
||||||
|
#error[Optional error text]
|
||||||
```
|
```
|
||||||
|
|
||||||
`#textblock` is treated the same way. The first paragraph after the marker is separated from previous content by one line of vertical space.
|
Choice-local tags:
|
||||||
|
|
||||||
Images are story blocks:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
#image[mansion-rain.jpg](landscape)
|
#key:x
|
||||||
#image[portrait-letter.jpg](portrait pause=2)
|
#optional
|
||||||
#image[seal.png](square lead=1.5)
|
#action[name]
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Explicit choice keys are reserved first. Remaining choices receive keys from `1` through `0`, then `a` through `z`.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# TODO And Progress
|
|
||||||
|
|
||||||
This is the active implementation checklist. Architecture lives in `SPECIFICATION.md`; usage lives in `README.md`; authoring conventions live in `MARKUP_GUIDELINES.md`.
|
|
||||||
|
|
||||||
## Current Status
|
|
||||||
|
|
||||||
- The shared client is feature-rich enough for Ink gameplay: line-based book layout, animated text, TTS, music, sound effects, images, choices, glossary notes, save/load restoration, and localized UI are implemented.
|
|
||||||
- The Ink engine is the current primary development engine.
|
|
||||||
- The YAML engine and Z-code engine need regression testing after the Ink-heavy client changes.
|
|
||||||
- Browser TTS and Kokoro provider modules exist but are not yet proven reliable.
|
|
||||||
- The codebase still contains logging noise and older architecture fragments that need cleanup.
|
|
||||||
|
|
||||||
## Shared Client
|
|
||||||
|
|
||||||
### Completed
|
|
||||||
|
|
||||||
- [x] Native ES module loader, dependency graph, progress overlay, and ordered initialization.
|
|
||||||
- [x] Responsive book layout that scales page, font sizes, and word positions relative to page size.
|
|
||||||
- [x] SmartyPants, German guillemet normalization, Hyphenopoly, and Knuth-Plass layout.
|
|
||||||
- [x] Paragraph/chapter/section/drop-cap rules.
|
|
||||||
- [x] Markdown emphasis with `*` and `_` syntax.
|
|
||||||
- [x] Right-page `#gloss[term](definition)` hover/focus notes.
|
|
||||||
- [x] Image rendering for landscape, square, and portrait cases, with history/save restoration.
|
|
||||||
- [x] Sound effect and music playback, including music lead-in, loop/once, and ducking.
|
|
||||||
- [x] TTS `none`, OpenAI, 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.
|
|
||||||
|
|
||||||
### In Progress
|
|
||||||
|
|
||||||
- [ ] Polish custom scrollbar dragging so the thumb moves freely during drag and commits the scroll target only on release.
|
|
||||||
- [ ] Tighten automated checks around top-bar/options state initialization after reload.
|
|
||||||
- [ ] Improve automated visual regression coverage for page scaling, drop caps, image wrapping, and paragraph indentation.
|
|
||||||
- [ ] Improve automated audio tests for music ducking, sound effect timing, and fast-forward fadeout.
|
|
||||||
- [ ] Validate provider-specific speed conversion for all TTS providers against real API behavior.
|
|
||||||
|
|
||||||
### Pending
|
|
||||||
|
|
||||||
- [ ] Add a logging module with levels/categories to reduce console output and improve runtime performance.
|
|
||||||
- [ ] Show startup warnings/instructions when TTS APIs still need to be selected or configured.
|
|
||||||
- [ ] Put production-ready default option values into code/config.
|
|
||||||
- [ ] Get Browser TTS working reliably.
|
|
||||||
- [ ] Get Kokoro.js TTS working for English-language games.
|
|
||||||
- [ ] Get Kokoro.js TTS working for German-language games.
|
|
||||||
- [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.
|
|
||||||
|
|
||||||
## Shared Server Architecture
|
|
||||||
|
|
||||||
### Completed
|
|
||||||
|
|
||||||
- [x] Shared `TurnResult` protocol used by all engines.
|
|
||||||
- [x] Shared game API shape: `newGame`, `loadGame`, `saveGame`, `hasSaveGame`, `getSaveGames`, `isGameRunning`.
|
|
||||||
- [x] Per-engine config files with metadata, locale, main game file, and asset paths.
|
|
||||||
- [x] `.env` default engine selection for `npm run dev` and `npm run start`.
|
|
||||||
- [x] Engine-specific dev/start/debug/inspect scripts.
|
|
||||||
- [x] YAML server renamed to `server-yaml.ts` so it is no longer implied as the generic server.
|
|
||||||
- [x] Z-code server/config/scripts use `zcode` naming; Zork is only the current story/prompt target.
|
|
||||||
|
|
||||||
### Pending
|
|
||||||
|
|
||||||
- [ ] Extract duplicated Express/Socket.IO/static-file/port-fallback setup into a shared server base.
|
|
||||||
- [ ] Replace session-local placeholder saves with durable server-side or browser-coordinated saves where appropriate.
|
|
||||||
- [ ] Clean up start scripts and add a Dockerfile for hosting the selected engine on Coolify.
|
|
||||||
- [ ] Decide whether `src/index.ts` should remain as the YAML CLI entry or be replaced by clearer `cli-yaml.ts` and engine-specific launchers.
|
|
||||||
- [ ] Remove `test-server-yaml.ts` if no current workflow depends on it.
|
|
||||||
- [ ] Add logger configuration to scripts: `LOG_LEVEL`, `LOG_CATEGORIES`, and engine debug defaults.
|
|
||||||
|
|
||||||
## Ink Engine
|
|
||||||
|
|
||||||
### Completed
|
|
||||||
|
|
||||||
- [x] Ink source compilation through `inkjs/full`.
|
|
||||||
- [x] Split Ink source files with a master include file.
|
|
||||||
- [x] Ink metadata handoff to client.
|
|
||||||
- [x] Ink choices converted to `ChoiceResult`.
|
|
||||||
- [x] Ink tags converted to shared `StoryTag`.
|
|
||||||
- [x] Choice preview tags for `#key`, `#letter`, `#optional`, and `#action`.
|
|
||||||
- [x] Save/load of Ink state plus client history state.
|
|
||||||
- [x] `#score`, `#error`, `#achievement`, and `#alert` tag behavior.
|
|
||||||
- [x] `#gloss[term](definition)` support on right-page text.
|
|
||||||
|
|
||||||
### Pending
|
|
||||||
|
|
||||||
- [ ] Add text-input turns to Ink games, switching the UI to command input for one round and returning to choices afterward.
|
|
||||||
- [ ] Add a full dynamic description of the created character to the score panel after the game intro.
|
|
||||||
- [ ] Continue authoring and testing Eibenreith content.
|
|
||||||
- [ ] Test all documented tag syntax inside real Ink source, including edge cases with includes and choice-local tags.
|
|
||||||
|
|
||||||
## YAML Engine
|
|
||||||
|
|
||||||
### Completed
|
|
||||||
|
|
||||||
- [x] Deterministic YAML world model and `GameRunner`.
|
|
||||||
- [x] YAML CLI path for testing without browser UI.
|
|
||||||
- [x] YAML web server emits `TurnResult` objects.
|
|
||||||
|
|
||||||
### Pending
|
|
||||||
|
|
||||||
- [ ] Test/debug the YAML engine after Ink-driven client changes.
|
|
||||||
- [ ] Continue development of the YAML engine.
|
|
||||||
- [ ] Replace command mirroring with the full LLM/world-model command loop when typography/audio testing no longer needs mirroring.
|
|
||||||
- [ ] Validate YAML-generated `#` tags through the shared parser/protocol path.
|
|
||||||
|
|
||||||
## Z-code Engine
|
|
||||||
|
|
||||||
### Completed
|
|
||||||
|
|
||||||
- [x] Z-code naming for engine scripts/config/server.
|
|
||||||
- [x] Current Zork I narrator implementation using `ifvms` plus OpenRouter prompt templates.
|
|
||||||
- [x] Z-code engine emits shared `TurnResult` objects.
|
|
||||||
|
|
||||||
### Pending
|
|
||||||
|
|
||||||
- [ ] Test/debug the Z-code engine after Ink-driven client changes.
|
|
||||||
- [ ] Finish the Z-code version: optimize prompt templates, choose the best LLM for the task, and test project memory behavior.
|
|
||||||
- [ ] Separate Z-code-generic logic from Zork-specific prompt assumptions.
|
|
||||||
- [ ] Validate save/restore of Z-machine state.
|
|
||||||
- [ ] Merge this branch with `master` after YAML and Z-code regression testing.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"folders": [
|
|
||||||
{
|
|
||||||
"path": "."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"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."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"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."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
// eibenreith_01_zug.ink
|
// eibenreith_01_zug.ink
|
||||||
// Kapitel: Das Abteil.
|
// Kapitel: Die Reise / Zugabteil.
|
||||||
// Enthält Charaktergenerator, Abteil-Weave, Viktor-Beobachtung und Missionsbriefing.
|
// Enthält Charaktergenerator, Abteil-Weave, Viktor-Beobachtung und Missionsbriefing.
|
||||||
|
|
||||||
=== intro_train ===
|
=== intro_train ===
|
||||||
|
|
||||||
#chapter[Das Abteil] #music[Kaiserpunk Waltz.mp3](crossfade, loop, lead=8)
|
Der Zug lässt Wien hinter sich, doch Wien gibt dich noch nicht frei. #chapter[Die Reise] #music[Kaiserpunk Waltz.mp3](crossfade, loop, lead=8)
|
||||||
|
|
||||||
Der Zug lässt Wien hinter sich, doch Wien hat dich noch nicht freigegeben. Es bleibt an dir haften, im Puder am Kragen, im Kohlenrauch im Haar, im Druck des Korsetts unter dem Reisekleid und im leisen Schwitzen unter den Handschuhen. Im Ridikül ruht das versiegelte Schreiben des Hofes; in den Polstern der ersten Klasse ruht der Geruch fremder Reisen. Herr Viktor Nowak sitzt dir gegenüber, unbewegt, korrekt, aufmerksam. Kein Blick von ihm ist unhöflich. Keiner ist unschuldig. Das Abteil fährt nach Süden, doch für einen Augenblick scheint es weniger ein Reiseraum als ein gepolstertes Amtszimmer, in dem selbst dein Atem Haltung annehmen muss. #tts[Begin in a low, intimate narrative voice. Keep the pace unhurried and formal, with a thread of contained unease.] #gloss[Ridikül](Kleine Damenhandtasche oder Handarbeitsbeutel. Klein genug um harmlos zu wirken, groß genug für Briefe, Schlüssel und jene Dinge, von denen Herren später behaupten, sie hätten nichts davon gewusst.) #gloss[erste Klasse](Die teuerste Wagenklasse der Eisenbahn. Sie bietet weichere Polster, bessere Luft und vor allem weniger Menschen, was im bürgerlichen Fortschrittsglauben oft dasselbe bedeutet.) #gloss[Korsett](Formendes Mieder für Taille und Oberkörper. Es stützt Haltung, beschränkt Atem und beweist, dass weibliche Selbstbeherrschung zuerst am Körper verlangt wird.) #gloss[Amtszimmer](Zimmer der amtlichen Ordnung. Dort wird ein Mensch selten lauter unterworfen, als wenn alles ganz höflich und schriftlich geschieht.)
|
Es hängt noch am schwarzen Glanz deiner Reisestiefel, am Schnitt deines Mantels, am engen kleinen Gefängnis deiner Handschuhe. Es liegt im Siegel des Schreibens, das in deinem Ridikül ruht, im Geruch von Kohlenrauch, der sich selbst in die Polster der ersten Klasse geschlichen hat, und in der Tatsache, dass Herr Viktor Nowak dir gegenübersitzt, als wäre dieses Abteil kein mit Samt, Messing und poliertem Holz ausgekleideter Reiseraum, sondern ein provisorisches Amtszimmer auf Rädern. #gloss[Ridikül](Kleine Damenhandtasche oder Handarbeitsbeutel des achtzehnten und neunzehnten Jahrhunderts.) #gloss[erste Klasse](Vornehmste Wagenklasse der Eisenbahn mit besserer Polsterung mehr Raum und höherem Fahrpreis.) #gloss[Abteil](Abgeschlossener Raum eines Eisenbahnwagens.) #gloss[Amtszimmer](Zimmer einer Behörde oder Kanzlei in dem Amtsgeschäfte geführt werden.)
|
||||||
|
|
||||||
-> train_compartment
|
-> train_compartment
|
||||||
|
|
||||||
=== train_compartment ===
|
=== train_compartment ===
|
||||||
|
|
||||||
{not tut_choice_intro:
|
{not tut_choice_intro:
|
||||||
#alert[Links erscheinen deine Entscheidungen. Du kannst sie mit der Maus wählen oder die angezeigte Taste drücken. Das hervorgehobene Wort nennt die Handlung. Einige Kürzel folgen der alten Textadventure-Gewohnheit: L für Schaue, X für Untersuche.]
|
#alert[Jede Wahl beginnt mit einem hervorgehobenen Verb. Es nennt die Handlung, die du in diesem Augenblick ausführst: schauen, untersuchen, greifen, fragen, antworten oder warten. Einige vertraute Abenteuerbefehle besitzen feste Tasten, etwa L für Schaue und X für Untersuche.]
|
||||||
~ tut_choice_intro = true
|
~ tut_choice_intro = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,19 +45,19 @@ Der Zug lässt Wien hinter sich, doch Wien hat dich noch nicht freigegeben. Es b
|
|||||||
=== next_compartment_definition ===
|
=== next_compartment_definition ===
|
||||||
|
|
||||||
{not define_class_and_name:
|
{not define_class_and_name:
|
||||||
Was immer du im Abteil betrachtest, führt auf dieselbe unausgesprochene Frage zurück: Aus welcher Ordnung kommst du, dass Wien dich nun in eine andere schicken kann?
|
Was immer du im Abteil betrachtest, führt auf dieselbe unausgesprochene Frage zurück: Aus welcher Ordnung kommst du, dass Wien dich nun in eine andere schicken kann? #gloss[Abteil](Abgeschlossener Raum eines Eisenbahnwagens.)
|
||||||
-> define_class_and_name ->
|
-> define_class_and_name ->
|
||||||
->->
|
->->
|
||||||
}
|
}
|
||||||
|
|
||||||
{not define_religion_and_supernatural:
|
{not define_religion_and_supernatural:
|
||||||
Die Fahrt nach Hohenreith ist kein bloßer Auftrag. Sie berührt Kirche, Aberglauben, Spiritismus und die Frage, welche unsichtbaren Dinge du überhaupt für möglich hältst.
|
Die Fahrt nach Hohenreith ist kein bloßer Auftrag. Sie berührt Kirche, Aberglauben, Spiritismus und die Frage, welche unsichtbaren Dinge du überhaupt für möglich hältst. #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.) #gloss[Spiritismus](Lehre und Praxis des Verkehrs mit Geistern oder Verstorbenen.)
|
||||||
-> define_religion_and_supernatural ->
|
-> define_religion_and_supernatural ->
|
||||||
->->
|
->->
|
||||||
}
|
}
|
||||||
|
|
||||||
{not define_appearance:
|
{not define_appearance:
|
||||||
Der Zug fährt in einen Tunnel. Das Land draußen verschwindet; die Scheibe verliert die Berge, die Felder, den Himmel, und behält nur noch das Abteil. #tts[Drop the volume slightly and slow down. Make the tunnel feel like the room closing around the listener.]
|
Der Zug fährt in einen Tunnel. Das Land draußen verschwindet; die Scheibe verliert die Berge, die Felder, den Himmel, und behält nur noch das Abteil. #gloss[Abteil](Abgeschlossener Raum eines Eisenbahnwagens.)
|
||||||
-> define_appearance ->
|
-> define_appearance ->
|
||||||
->->
|
->->
|
||||||
}
|
}
|
||||||
@@ -66,29 +66,29 @@ Der Zug lässt Wien hinter sich, doch Wien hat dich noch nicht freigegeben. Es b
|
|||||||
|
|
||||||
=== compartment_room ===
|
=== compartment_room ===
|
||||||
|
|
||||||
Das Abteil bleibt, was es war: Polster, Messing, Glas, Bedienung, Abstand. Ein fahrender kleiner Salon, zusammengesetzt aus Dingen, die Menschen benutzen, ohne sie selbst zu putzen, zu polieren oder zu bezahlen. #gloss[Salon](Ein Raum, in dem Gesellschaft sich selbst für Geist hält. Wer dort spricht, spricht selten nur mit der Person vor sich.)
|
Das Abteil bleibt, was es war: Polster, Messing, Glas, Bedienung, Abstand. Ein fahrender kleiner Salon, zusammengesetzt aus Dingen, die Menschen benutzen, ohne sie selbst zu putzen, zu polieren oder zu bezahlen. #gloss[Abteil](Abgeschlossener Raum eines Eisenbahnwagens.) #gloss[Salon](Vornehmer Empfangsraum oder gesellschaftlicher Raum.)
|
||||||
|
|
||||||
->->
|
->->
|
||||||
|
|
||||||
=== compartment_letter ===
|
=== compartment_letter ===
|
||||||
|
|
||||||
Deine Hand findet das Ridikül. Unter Stoff, Verschluss und Handschuh liegt das Schreiben des Hofes, schwerer durch Bedeutung als durch Papier. #tts[Read more quietly here, as if the letter has physical weight. Put a slight pause before "schwerer".] #gloss[Ridikül](Kleine Damenhandtasche oder Handarbeitsbeutel. Klein genug um harmlos zu wirken, groß genug für Briefe, Schlüssel und jene Dinge, von denen Herren später behaupten, sie hätten nichts davon gewusst.)
|
Deine Hand findet das Ridikül. Unter Stoff, Verschluss und Handschuh liegt das Schreiben des Hofes, schwerer durch Bedeutung als durch Papier. #gloss[Ridikül](Kleine Damenhandtasche oder Handarbeitsbeutel des achtzehnten und neunzehnten Jahrhunderts.)
|
||||||
|
|
||||||
{define_religion_and_supernatural:
|
{define_religion_and_supernatural:
|
||||||
Du kennst seinen Ton nun, seine Auslassungen, seine Vorsicht und die Art, wie eine Kanzlei das Unheimliche in anständige Grammatik kleidet. #gloss[Kanzlei](Amtliche Schreibstube. Sie verwandelt Furcht in Formulierungen, Schuld in Zuständigkeit und Menschen in Aktenlagen.)
|
Du kennst seinen Ton nun, seine Auslassungen, seine Vorsicht und die Art, wie eine Kanzlei das Unheimliche in anständige Grammatik kleidet. #gloss[Kanzlei](Amtliche Schreib und Verwaltungsstelle für Schreiben Erlässe und Akten.)
|
||||||
}
|
}
|
||||||
|
|
||||||
->->
|
->->
|
||||||
|
|
||||||
=== look_out_window ===
|
=== look_out_window ===
|
||||||
|
|
||||||
Draußen zerfallen die letzten Ränder der Stadt in winterbraune Felder und Dörfer, deren Kirchtürme gegen den Pfiff der Lokomotive nichts auszurichten haben. Die Schienen nehmen sich das Land, ohne um Erlaubnis zu fragen. Dämme schneiden durch Obstgärten. Telegraphenstangen gleiten in regelmäßigen Abständen vorbei, eine nach der anderen, wie Gedanken, die man zu rasch verworfen hat. #tts[Let the rhythm lightly suggest train motion without becoming sing-song. Build a little momentum through the list of passing objects.] #gloss[Telegraphenstangen](Holzstangen der elektrischen Nachrichten. Die Monarchie liebt Drähte, weil sie Gerüchte schneller machen und zugleich wie Ordnung aussehen.) #sfx[steam-whistle.ogg]
|
Draußen zerfallen die letzten Ränder der Stadt in winterbraune Felder und Dörfer, deren Kirchtürme gegen den Pfiff der Lokomotive nichts auszurichten haben. Die Schienen nehmen sich das Land, ohne um Erlaubnis zu fragen. Dämme schneiden durch Obstgärten. Telegraphenstangen gleiten in regelmäßigen Abständen vorbei, eine nach der anderen, wie Gedanken, die man zu rasch verworfen hat. #gloss[Telegraphenstangen](Holzstangen für Leitungen des elektrischen Telegraphen.) #sfx[steam-whistle.ogg]
|
||||||
|
|
||||||
Du erwartest, dass sich die Eisenbahn wie ein Sieg des Jahrhunderts anfühlt.
|
Du erwartest, dass sich die Eisenbahn wie ein Sieg des Jahrhunderts anfühlt.
|
||||||
|
|
||||||
Stattdessen fühlt sie sich wie ein Streit an. #image[train_cabin.png](landscape)
|
Stattdessen fühlt sie sich wie ein Streit an. #image[suedbahn.png](landscape)
|
||||||
|
|
||||||
Die Maschine wirft sich mit einer Gewalt nach Süden, die gute Gesellschaft niemals offen bewundert hätte. Die Lampen zittern in ihren Fassungen. Deine Tasse schlägt leise gegen die Untertasse. Jenseits der Scheibe beginnt das Land zu steigen, zuerst beinahe höflich, dann mit festerem Willen, bis die Bahnlinie selbst mit den Bergen zu verhandeln scheint: durch Steinbögen, schwarze Tunnel und Viadukte, die mit dem ganzen Selbstvertrauen kaiserlicher Ingenieurskunst über Schluchten gesetzt sind. #tts[Give this paragraph more force and breath than the previous one. Make the machinery feel powerful, then widen into awe at the viaducts.] #gloss[Viadukte](Große Brücken der Eisenbahn über Täler und Schluchten. Ingenieurskunst hat den schönen Vorteil, dass sie Abgründe nicht leugnet, sondern überquert.)
|
Die Maschine wirft sich mit einer Gewalt nach Süden, die gute Gesellschaft niemals offen bewundert hätte. Die Lampen zittern in ihren Fassungen. Deine Tasse schlägt leise gegen die Untertasse. Jenseits der Scheibe beginnt das Land zu steigen, zuerst beinahe höflich, dann mit festerem Willen, bis die Bahnlinie selbst mit den Bergen zu verhandeln scheint: durch Steinbögen, schwarze Tunnel und Viadukte, die mit dem ganzen Selbstvertrauen kaiserlicher Ingenieurskunst über Schluchten gesetzt sind. #gloss[Viadukte](Große Brückenbauwerke über Täler und Schluchten.)
|
||||||
|
|
||||||
->->
|
->->
|
||||||
|
|
||||||
@@ -96,28 +96,28 @@ Die Maschine wirft sich mit einer Gewalt nach Süden, die gute Gesellschaft niem
|
|||||||
|
|
||||||
Viktor wirkt noch immer von nichts beeindruckt.
|
Viktor wirkt noch immer von nichts beeindruckt.
|
||||||
|
|
||||||
Seine Zivilkleidung ist korrekt genug, um keinen Widerspruch hervorzurufen: dunkler Gehrock, nüchterne Weste, Handschuhe, tadelloser Kragen, dazu die Haltung eines Mannes, der selbst im Sitzen nie ganz aufhört, im Dienst zu sein. Doch kein Schneider der Monarchie kann Disziplin verbergen. #tts[Use a precise, observant tone. Let the clothing list feel clipped and controlled, then sharpen the final sentence.] #gloss[Zivilkleidung](Bürgerliche Kleidung im Gegensatz zur Uniform. Manche Männer legen damit nur den Stoff ab, nicht den Befehlston.) #gloss[Gehrock](Langer Herrenrock für korrekte Tageskleidung. Ein Kleidungsstück, das Männern erlaubt, bürgerlich, amtlich und moralisch zugleich auszusehen.)
|
Seine Zivilkleidung ist korrekt genug, um keinen Widerspruch hervorzurufen: dunkler Gehrock, nüchterne Weste, Handschuhe, tadelloser Kragen, dazu die Haltung eines Mannes, der selbst im Sitzen nie ganz aufhört, im Dienst zu sein. Doch kein Schneider der Monarchie kann Disziplin verbergen. #gloss[Zivilkleidung](Bürgerliche Kleidung im Gegensatz zur Uniform.) #gloss[Gehrock](Vornehmer Herrenrock mit langen Schößen.)
|
||||||
|
|
||||||
* [__Untersuche__: Viktors Haltung.] #action:orientation
|
* [__Untersuche__: Viktors Haltung.] #action:orientation
|
||||||
Sie bleibt in seinen Schultern, in der Sparsamkeit seiner Bewegungen, in der Art, wie er selbst im Sitzen nie ganz aufhört, einen Raum zu sichern.
|
Sie bleibt in seinen Schultern, in der Sparsamkeit seiner Bewegungen, in der Art, wie er selbst im Sitzen nie ganz aufhört, einen Raum zu sichern.
|
||||||
|
|
||||||
* [__Schaue__: Viktors Blick nach.] #action:orientation
|
* [__Schaue__: Viktors Blick nach.] #action:orientation
|
||||||
Seine Augen messen Türen, Fenster, Gepäcknetz, Korridor, dein Gesicht und wieder die Tür. Nicht gierig. Nicht unhöflich. Nur vollständig. #gloss[Gepäcknetz](Ablage über den Sitzen des Eisenbahnabteils. Dort reisen Hüte, Schachteln und kleine Lügen, die zu leicht für den Koffer sind.)
|
Seine Augen messen Türen, Fenster, Gepäcknetz, Korridor, dein Gesicht und wieder die Tür. Nicht gierig. Nicht unhöflich. Nur vollständig. #gloss[Gepäcknetz](Netz oder Ablage im Eisenbahnabteil für kleinere Gepäckstücke.) #gloss[Korridor](Seitengang oder Verbindungsgang eines Wagens oder Gebäudes.)
|
||||||
|
|
||||||
* [__Untersuche__: Viktors Kleidung.] #action:orientation
|
* [__Untersuche__: Viktors Kleidung.] #action:orientation
|
||||||
Auf dem Papier ist er dein Sekretär und Reisebegleiter. #gloss[Sekretär](Schreib und Vertrauensgehilfe. Ein Mann dieses Namens ordnet Papier, Termine und manchmal auch die Wahrheit, bis sie in ein vorzeigbares Format passt.)
|
Auf dem Papier ist er dein Sekretär und Reisebegleiter. #gloss[Sekretär](Schreib und Vertrauensbeamter oder privater Gehilfe.)
|
||||||
|
|
||||||
In Wahrheit ist er ein Offizier, den man einer heiklen Angelegenheit beigegeben hat; aus Kanälen, die Namen haben, aber sie nicht unnötig gebrauchen. Sein wirklicher Rang bleibt im Jagdhaus Hohenreith ungenannt: Rittmeister Viktor Alois Nowak. #gloss[Jagdhaus](Adeliger Landsitz für Jagden und kurze Aufenthalte. Weniger Hauptsitz als Bühne für Gäste, Förster, Gewehre und Geheimnisse.) #gloss[Rittmeister](Kavallerieoffizier im Rang eines Hauptmanns. Ein solcher Mann kann Zivilkleidung tragen, aber nur selten Zivilist werden.)
|
In Wahrheit ist er ein Offizier, den man einer heiklen Angelegenheit beigegeben hat; aus Kanälen, die Namen haben, aber sie nicht unnötig gebrauchen. Sein wirklicher Rang bleibt im Jagdhaus Hohenreith ungenannt: Rittmeister Viktor Alois Nowak. #gloss[Jagdhaus](Ländliches Haus oder kleineres Schloss für Jagdaufenthalte.) #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.) #gloss[Rittmeister](Offiziersrang der Kavallerie ungefähr dem Hauptmann entsprechend.)
|
||||||
|
|
||||||
-
|
-
|
||||||
Die Menschen, zu denen ihr reist, haben nicht nach einer Ermittlerin verlangt. Nicht offiziell. Die Kabinettskanzlei hat dich geschickt. Das Militär hat ihn geschickt, damit aus dir kein Skandal wird, ehe du nützlich werden kannst. #gloss[Kabinettskanzlei](Stelle nahe am Monarchen, wo Bitten, Berichte und Befehle in jene Sprache gebracht werden, in der Macht wie Papier aussieht.)
|
Die Menschen, zu denen ihr reist, haben nicht nach einer Ermittlerin verlangt. Nicht offiziell. Die Kabinettskanzlei hat dich geschickt. Das Militär hat ihn geschickt, damit aus dir kein Skandal wird, ehe du nützlich werden kannst. #gloss[Kabinettskanzlei](Hof und Staatsstelle für unmittelbare Schreiben Eingaben und Weisungen im Umkreis des Monarchen.)
|
||||||
|
|
||||||
->->
|
->->
|
||||||
|
|
||||||
=== define_class_and_name ===
|
=== define_class_and_name ===
|
||||||
|
|
||||||
{not tut_character_intro:
|
{not tut_character_intro:
|
||||||
#alert[Einige Entscheidungen beschreiben nicht nur, was du tust, sondern wer du bist. Herkunft, Glaube, Auftreten und Ruf werden dadurch festgelegt und können später wieder Bedeutung gewinnen.]
|
#alert[Manche Entscheidungen legen fest, wer du bist. Herkunft, Glaube, Fähigkeiten, Aussehen und Auftreten können später Türen öffnen oder schließen.]
|
||||||
~ tut_character_intro = true
|
~ tut_character_intro = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,15 +127,15 @@ Die Menschen, zu denen ihr reist, haben nicht nach einer Ermittlerin verlangt. N
|
|||||||
~ class_confidence += 2
|
~ class_confidence += 2
|
||||||
~ court_loyalty += 1
|
~ court_loyalty += 1
|
||||||
|
|
||||||
Nicht der Luxus beunruhigt dich. Luxus ist nur Holz, Stoff, Messing, Bedienung, Stille. Entscheidend ist, ob die Diener zweimal hinsehen, ob der Schaffner die Stimme senkt, ob ein anderer Reisender deine Handschuhe prüft und beschließt, nicht nach deinem Auftrag zu fragen.
|
Nicht der Luxus beunruhigt dich. Luxus ist nur Holz, Stoff, Messing, Bedienung, Stille. Entscheidend ist, ob die Diener zweimal hinsehen, ob der Schaffner die Stimme senkt, ob ein anderer Reisender deine Handschuhe prüft und beschließt, nicht nach deinem Auftrag zu fragen. #gloss[Schaffner](Eisenbahnbediensteter für Reisende Fahrkarten und Ordnung im Zug.)
|
||||||
|
|
||||||
Du wurdest unter Menschen geboren, die solche Dinge früher verstanden als Freundlichkeit.
|
Du wurdest unter Menschen geboren, die solche Dinge früher verstanden als Freundlichkeit.
|
||||||
|
|
||||||
Du hast früh gelernt, dass jedes Zimmer einen Hof enthält, auch wenn kein Kaiser anwesend ist. Ein Mädchen deines Ranges wird darin unterrichtet, einzutreten, sich zu verneigen, vorgestellt, platziert und wieder vergessen zu werden; nur genug zu sprechen, mehr zu verstehen, als es zugibt, und zu wissen, dass ein Familienname zugleich Schlüssel und Kette sein kann.
|
Du hast früh gelernt, dass jedes Zimmer einen Hof enthält, auch wenn kein Kaiser anwesend ist. Ein Mädchen deines Ranges wird darin unterrichtet, einzutreten, sich zu verneigen, vorgestellt, platziert und wieder vergessen zu werden; nur genug zu sprechen, mehr zu verstehen, als es zugibt, und zu wissen, dass ein Familienname zugleich Schlüssel und Kette sein kann.
|
||||||
|
|
||||||
Deine eigene Familie besitzt keinen großen Sitz, keine Schar von Verwaltern, kein altes Recht, Provinzen zu befehlen. Doch dein Name öffnete Türen in Wiener Salons, und sobald du in diesen Zimmern warst, lerntest du, Menschen Geschichten wiederholen zu lassen, die sie nur hatten andeuten wollen. #gloss[Verwaltern](Männer, die Besitz in Ordnung halten. Ein Gut ohne Verwalter ist Adel als Romantik, ein Gut mit Verwaltern ist Adel als Rechnung.) #gloss[Salons](Gesellschaftliche Räume für Geist, Klavier, Ruf und Eheanbahnung. Man tritt ein, um zu sprechen, und wird oft erst beim Schweigen beurteilt.)
|
Deine eigene Familie besitzt keinen großen Sitz, keine Schar von Verwaltern, kein altes Recht, Provinzen zu befehlen. Doch dein Name öffnete Türen in Wiener Salons, und sobald du in diesen Zimmern warst, lerntest du, Menschen Geschichten wiederholen zu lassen, die sie nur hatten andeuten wollen. #gloss[Verwaltern](Beauftragte für Güter Einkünfte Personal und wirtschaftliche Angelegenheiten.) #gloss[Salons](Gesellschaftliche Empfangsräume und Zusammenkünfte.)
|
||||||
|
|
||||||
Dein Ruf als Medium ist nicht vom Himmel gefallen. Er wurde zusammengesetzt aus Halblicht, richtigen Vermutungen, sorgsamen Pausen und der Bereitschaft besser geborener Toren, Aufführung für Offenbarung zu halten. #gloss[Medium](Person, durch die Geister oder verborgene Kräfte sprechen sollen. Gesellschaftlich besonders brauchbar, weil eine Frau so Dinge sagen darf, die man ihr als eigene Meinung übel nähme.)
|
Dein Ruf als Medium ist nicht vom Himmel gefallen. Er wurde zusammengesetzt aus Halblicht, richtigen Vermutungen, sorgsamen Pausen und der Bereitschaft besser geborener Toren, Aufführung für Offenbarung zu halten. #gloss[Medium](Person der man Verkehr mit Geistern oder verborgenen Kräften zuschreibt.)
|
||||||
|
|
||||||
Bevor der Hof dich benutzen konnte, musste die Gesellschaft dich erst erfinden.
|
Bevor der Hof dich benutzen konnte, musste die Gesellschaft dich erst erfinden.
|
||||||
|
|
||||||
@@ -171,9 +171,9 @@ Die Menschen, zu denen ihr reist, haben nicht nach einer Ermittlerin verlangt. N
|
|||||||
|
|
||||||
Das war dein erster Vorteil.
|
Das war dein erster Vorteil.
|
||||||
|
|
||||||
Eine Dienstmagd weiß, welche Tür wichtig ist, weil sie die anderen benutzt. Eine Näherin lernt Körper, weil sie sie misst. Eine Zofe lernt Geheimnisse, weil feine Leute ihre Seelen wie Handschuhe liegen lassen, gewiss, dass niemand unter ihnen Hände hat. #gloss[Dienstmagd](Weibliche Hausbedienstete für niedere Arbeiten. Niedrig heißt dabei nur die Stellung, nicht die Menge dessen, was sie sieht.) #gloss[Näherin](Frau, die Kleidung näht, ändert und ausbessert. Wer Körper ausmisst, lernt mehr über Stand und Eitelkeit, als ein Schneider je zugeben muss.) #gloss[Zofe](Persönliche Dienerin einer Dame. Zuständig für Haar, Kleidung, kleine Notfälle und jene Wahrheiten, die eine Gesellschaft nur deshalb nicht kennt, weil Dienerinnen selten gefragt werden.)
|
Eine Dienstmagd weiß, welche Tür wichtig ist, weil sie die anderen benutzt. Eine Näherin lernt Körper, weil sie sie misst. Eine Zofe lernt Geheimnisse, weil feine Leute ihre Seelen wie Handschuhe liegen lassen, gewiss, dass niemand unter ihnen Hände hat. #gloss[Dienstmagd](Weibliche Hausbedienstete für niedere Arbeiten im Haushalt.) #gloss[Näherin](Frau die berufsmäßig Kleidungsstücke näht oder ausbessert.) #gloss[Zofe](Persönliche weibliche Dienerin einer Dame.)
|
||||||
|
|
||||||
Du stiegst auf durch Begabung, Protektion, Nachahmung, Nervenstärke und die furchtbare Bequemlichkeit, für harmlos gehalten zu werden. Als Wien zu flüstern begann, du sähest mehr als anständige Leute sehen, hattest du schon Jahre damit verbracht, das zu sehen, was anständige Leute übersahen. #gloss[Protektion](Förderung durch eine höherstehende Person. Eine verwerfliche Begünstigung, sobald sie anderen nützt, und eine notwendige Verbindung, sobald sie einem selbst nützt.)
|
Du stiegst auf durch Begabung, Protektion, Nachahmung, Nervenstärke und die furchtbare Bequemlichkeit, für harmlos gehalten zu werden. Als Wien zu flüstern begann, du sähest mehr als anständige Leute sehen, hattest du schon Jahre damit verbracht, das zu sehen, was anständige Leute übersahen. #gloss[Protektion](Förderung durch Schutz oder Empfehlung einer höherstehenden Person.)
|
||||||
|
|
||||||
-> choose_name_working
|
-> choose_name_working
|
||||||
|
|
||||||
@@ -210,9 +210,9 @@ Wien kannte dich unter dem Namen, den die Gesellschaft brauchbar gemacht hatte.
|
|||||||
|
|
||||||
=== choose_surname_noble ===
|
=== choose_surname_noble ===
|
||||||
|
|
||||||
Dein Titel ist durch Geburt und durch die vorsichtige Bescheidenheit deiner Familie bestimmt: keine Gräfin, keine Fürstin, keiner jener glänzenden Namen, die Botschafter und Gläubiger wie Staub anziehen. #gloss[Gräfin](Frau gräflichen Ranges. Der Titel öffnet Türen, aber auch Erwartungen, Schulden, Blicke und Verwandtschaften.) #gloss[Fürstin](Frau fürstlichen Ranges. So hoch, dass selbst Fehler zunächst als Eigenart erscheinen dürfen.)
|
Dein Titel ist durch Geburt und durch die vorsichtige Bescheidenheit deiner Familie bestimmt: keine Gräfin, keine Fürstin, keiner jener glänzenden Namen, die Botschafter und Gläubiger wie Staub anziehen. #gloss[Gräfin](Frau gräflichen Ranges.) #gloss[Fürstin](Frau fürstlichen Ranges.)
|
||||||
|
|
||||||
Eine Freiin. Freiherrlicher Rang. Brauchbar. Zugelassen, aber nicht thronend. #gloss[Freiin](Unverheiratete Dame freiherrlichen Ranges. Hoch genug, um angekündigt zu werden, niedrig genug, um von höheren Häusern übersehen werden zu dürfen.)
|
Eine Freiin. Freiherrlicher Rang. Brauchbar. Zugelassen, aber nicht thronend. #gloss[Freiin](Unverheiratete Tochter eines Freiherrn oder Dame freiherrlichen Ranges.)
|
||||||
|
|
||||||
* [__Führe den Namen__: Freiin von Rauhenfels] #action:thinking
|
* [__Führe den Namen__: Freiin von Rauhenfels] #action:thinking
|
||||||
~ title_part = "Freiin von"
|
~ title_part = "Freiin von"
|
||||||
@@ -268,7 +268,7 @@ Die Salons, die zuerst über dich lachten und dich dann wieder einluden, lernten
|
|||||||
|
|
||||||
=== choose_surname_middle ===
|
=== choose_surname_middle ===
|
||||||
|
|
||||||
Dein Familienname enthält kein Partikel, das den Aufstieg abfedert. Er muss allein aufrecht stehen. #gloss[Partikel](Das kleine von oder zu im Namen. Ein winziges Wort, das so viel Gewicht tragen darf, wie andere Menschen mit Arbeit füllen müssen.)
|
Dein Familienname enthält kein Partikel, das den Aufstieg abfedert. Er muss allein aufrecht stehen. #gloss[Partikel](Adelspartikel im Namen wie von oder zu.)
|
||||||
|
|
||||||
* [__Führe den Namen__: Leitner] #action:thinking
|
* [__Führe den Namen__: Leitner] #action:thinking
|
||||||
~ title_part = "Fräulein"
|
~ title_part = "Fräulein"
|
||||||
@@ -362,9 +362,9 @@ Ein einfacher Name kann in Wien eine Last sein. Er sagt den Leuten, wie wenig Ac
|
|||||||
=== assemble_full_name ===
|
=== assemble_full_name ===
|
||||||
|
|
||||||
{birth_class == "noble":
|
{birth_class == "noble":
|
||||||
Auf Visitenkarten, in Briefen, in den vorsichtigen Mündern der Dienerschaft bist du {given_names} {title_part} {surname}. #gloss[Visitenkarten](Gedruckte Besuchskarten mit Namen und Titel. Eine Dame gibt damit nicht nur ihre Anwesenheit ab, sondern eine kleine, korrekt geschnittene Behauptung.) #gloss[Dienerschaft](Die Bediensteten eines Hauses. Offiziell Teil der Ordnung, praktisch ihr Gedächtnis.)
|
Auf Visitenkarten, in Briefen, in den vorsichtigen Mündern der Dienerschaft bist du {given_names} {title_part} {surname}. #gloss[Visitenkarten](Gedruckte Besuchskarten mit Namen und Titel.) #gloss[Dienerschaft](Gesamtheit der Bediensteten eines Hauses oder Gutes.)
|
||||||
- else:
|
- else:
|
||||||
In Bahndokumenten, Hotelbüchern und auf den Zungen von Menschen, die noch nicht entschieden haben, wie viel Achtung du verdienst, bist du {title_part} {given_names} {surname}. #gloss[Bahndokumenten](Papiere der Eisenbahn. Sie beweisen, wohin man fahren darf, was man mitführt und dass selbst Bewegung ein Formular verlangt.) #gloss[Hotelbüchern](Gästebücher der Herbergen und Hotels. Wer reist, hinterlässt darin nicht nur einen Namen, sondern eine Spur für Wirte, Polizei und Neugier.)
|
In Bahndokumenten, Hotelbüchern und auf den Zungen von Menschen, die noch nicht entschieden haben, wie viel Achtung du verdienst, bist du {title_part} {given_names} {surname}. #gloss[Bahndokumenten](Schriftstücke der Eisenbahn wie Fahrkarten oder Gepäckscheine.) #gloss[Hotelbüchern](Gästebücher oder Meldebücher eines Gasthauses oder Hotels.)
|
||||||
}
|
}
|
||||||
|
|
||||||
Aber in der privaten Kammer, in der ein Name zuerst beantwortet wird, ehe er gespielt werden muss, bist du {common_name}.
|
Aber in der privaten Kammer, in der ein Name zuerst beantwortet wird, ehe er gespielt werden muss, bist du {common_name}.
|
||||||
@@ -373,21 +373,21 @@ Aber in der privaten Kammer, in der ein Name zuerst beantwortet wird, ehe er ges
|
|||||||
|
|
||||||
=== define_religion_and_supernatural ===
|
=== define_religion_and_supernatural ===
|
||||||
|
|
||||||
Du berührst das Ridikül, ohne es sofort zu öffnen. #gloss[Ridikül](Kleine Damenhandtasche oder Handarbeitsbeutel. Klein genug um harmlos zu wirken, groß genug für Briefe, Schlüssel und jene Dinge, von denen Herren später behaupten, sie hätten nichts davon gewusst.)
|
Du berührst das Ridikül, ohne es sofort zu öffnen. #gloss[Ridikül](Kleine Damenhandtasche oder Handarbeitsbeutel des achtzehnten und neunzehnten Jahrhunderts.)
|
||||||
|
|
||||||
Das Schreiben darin nennt dich nicht Ermittlerin. Es nennt dich, in einer Prosa trocken genug, um durch beliebig viele Ämter zu gelangen, eine Frau, deren ungewöhnlicher spiritistischer Ruf sie für eine heikle Haushaltsangelegenheit empfehle. Die Formulierung ist erlesen. Sie bejaht nicht und verneint nicht. Sie erlaubt allen Beteiligten, später zu glauben, sie hätten nichts Ungehöriges geglaubt. #gloss[spiritistischer Ruf](Ein Ruf im Umkreis des Spiritismus. Sehr nützlich, wenn eine Frau gehört werden soll, aber nicht zu deutlich als Urheberin ihrer eigenen Einsichten erscheinen darf.)
|
Das Schreiben darin nennt dich nicht Ermittlerin. Es nennt dich, in einer Prosa trocken genug, um durch beliebig viele Ämter zu gelangen, eine Frau, deren ungewöhnlicher spiritistischer Ruf sie für eine heikle Haushaltsangelegenheit empfehle. Die Formulierung ist erlesen. Sie bejaht nicht und verneint nicht. Sie erlaubt allen Beteiligten, später zu glauben, sie hätten nichts Ungehöriges geglaubt. #gloss[spiritistischer](Den Spiritismus betreffend.)
|
||||||
|
|
||||||
Die gräfliche Familie im Jagdhaus Hohenreith hat um Diskretion ersucht. Wien hat mit einem versiegelten Schreiben geantwortet, mit einer Frau, der man nachsagt, sie spreche mit dem Verborgenen, und mit einem Mann ihr gegenüber, der eigene Befehle hat. #gloss[Jagdhaus](Adeliger Landsitz für Jagden und kurze Aufenthalte. Weniger Hauptsitz als Bühne für Gäste, Förster, Gewehre und Geheimnisse.)
|
Die gräfliche Familie im Jagdhaus Hohenreith hat um Diskretion ersucht. Wien hat mit einem versiegelten Schreiben geantwortet, mit einer Frau, der man nachsagt, sie spreche mit dem Verborgenen, und mit einem Mann ihr gegenüber, der eigene Befehle hat. #gloss[Jagdhaus](Ländliches Haus oder kleineres Schloss für Jagdaufenthalte.) #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.)
|
||||||
|
|
||||||
Das Schreiben nennt keine Kirche. Gerade das macht die Kirche anwesend.
|
Das Schreiben nennt keine Kirche. Gerade das macht die Kirche anwesend.
|
||||||
|
|
||||||
* [__Glaube__: Der Glaube ist dir wirklich heilig.] #action:thinking
|
* [__Glaube__: Der Glaube ist dir wirklich heilig.] #action:thinking
|
||||||
~ religion_stance = "devout_catholic"
|
~ religion_stance = "devout_catholic"
|
||||||
Gott ist kein Gesprächsgegenstand für Abteile. Er ist kein Talent, kein Ruf, keine gesellschaftliche Bequemlichkeit. Du glaubst nicht kindlich, aber tief: an Sünde, Gnade, Sakrament, Versuchung und an die gefährliche Nähe der unsichtbaren Welt.
|
Gott ist kein Gesprächsgegenstand für Abteile. Er ist kein Talent, kein Ruf, keine gesellschaftliche Bequemlichkeit. Du glaubst nicht kindlich, aber tief: an Sünde, Gnade, Sakrament, Versuchung und an die gefährliche Nähe der unsichtbaren Welt. #gloss[Abteile](Abgeschlossene Räume eines Eisenbahnwagens.) #gloss[Sünde](Verstoß gegen göttliches Gebot oder sittliche Ordnung.) #gloss[Gnade](Göttliche Barmherzigkeit und Hilfe.) #gloss[Sakrament](Heilige kirchliche Handlung die göttliche Gnade vermittelt.)
|
||||||
|
|
||||||
* [__Bedenke__: Die katholische Ordnung, in der du erzogen wurdest.] #action:thinking
|
* [__Bedenke__: Die katholische Ordnung, in der du erzogen wurdest.] #action:thinking
|
||||||
~ religion_stance = "social_catholic"
|
~ religion_stance = "social_catholic"
|
||||||
Du kennst die Feste, die Gebete, das Gewicht der Beichte und die Macht eines Pfarrers über Menschen, die behaupten, ihn nicht zu fürchten. Dein Glaube ist nicht leer; aber er ist ebenso Gewohnheit wie Überzeugung, ebenso Ordnung wie Trost.
|
Du kennst die Feste, die Gebete, das Gewicht der Beichte und die Macht eines Pfarrers über Menschen, die behaupten, ihn nicht zu fürchten. Dein Glaube ist nicht leer; aber er ist ebenso Gewohnheit wie Überzeugung, ebenso Ordnung wie Trost. #gloss[Beichte](Katholisches Sakrament des Bekenntnisses und der Vergebung von Sünden.) #gloss[Pfarrer](Priester und Vorsteher einer Pfarre.)
|
||||||
|
|
||||||
* [__Misstraue__: Dem Weihrauch, hinter dem Behörden sitzen.] #action:thinking
|
* [__Misstraue__: Dem Weihrauch, hinter dem Behörden sitzen.] #action:thinking
|
||||||
~ religion_stance = "josephinian_sceptic"
|
~ religion_stance = "josephinian_sceptic"
|
||||||
@@ -395,7 +395,7 @@ Das Schreiben nennt keine Kirche. Gerade das macht die Kirche anwesend.
|
|||||||
|
|
||||||
* [__Verbinde__: Heiligenbilder, Totenmessen und Séancen.] #action:thinking
|
* [__Verbinde__: Heiligenbilder, Totenmessen und Séancen.] #action:thinking
|
||||||
~ religion_stance = "spiritist_syncretic"
|
~ religion_stance = "spiritist_syncretic"
|
||||||
Heiligenbilder, Totenmessen, Séancen, Ahnungen, Tischklopfen, Träume: Die sauberen Grenzen dazwischen scheinen dir eher von Männern gezogen als von der Ewigkeit selbst. Was überlebt, spricht vielleicht in Formen, die keine Kanzlei genehmigt hat. #gloss[Heiligenbilder](Bilder heiliger Personen für Andacht, Trost und häusliche Überwachung des Gewissens. Ein guter Blick von der Wand erspart manchen schlechten Gedanken.) #gloss[Totenmessen](Messen für Verstorbene. Die Kirche versteht den Umgang mit Toten besser als die Salons, doch nicht immer mit weniger Interesse.) #gloss[Séancen](Geistersitzungen mit Tisch, gedämpftem Licht und sehr viel Erwartung. Je dunkler der Raum, desto leichter glauben die Anwesenden, sie sähen klar.) #gloss[Tischklopfen](Klopfzeichen, die als Botschaften aus der unsichtbaren Welt gelten. Eine bequeme Methode, wenn Tote sprechen sollen, ohne die Gesprächsordnung der Lebenden zu stören.) #gloss[Kanzlei](Amtliche Schreibstube. Sie verwandelt Furcht in Formulierungen, Schuld in Zuständigkeit und Menschen in Aktenlagen.)
|
Heiligenbilder, Totenmessen, Séancen, Ahnungen, Tischklopfen, Träume: Die sauberen Grenzen dazwischen scheinen dir eher von Männern gezogen als von der Ewigkeit selbst. Was überlebt, spricht vielleicht in Formen, die keine Kanzlei genehmigt hat. #gloss[Heiligenbilder](Andachtsbilder oder Darstellungen heiliger Personen.) #gloss[Totenmessen](Katholische Messen für Verstorbene.) #gloss[Séancen](Spiritistische Sitzungen zum Verkehr mit Verstorbenen oder Geistern.) #gloss[Tischklopfen](Spiritistische Klopfzeichen eines Tisches als vermeintliche Botschaften.) #gloss[Kanzlei](Amtliche Schreib und Verwaltungsstelle für Schreiben Erlässe und Akten.)
|
||||||
|
|
||||||
* [__Erinnere dich__: Der Glaube hat dich geformt, bevor du dich wehren konntest.] #action:thinking
|
* [__Erinnere dich__: Der Glaube hat dich geformt, bevor du dich wehren konntest.] #action:thinking
|
||||||
~ religion_stance = "wounded_catholic"
|
~ religion_stance = "wounded_catholic"
|
||||||
@@ -419,7 +419,7 @@ Vor dieser Reise, vor diesem Zug, bevor die Berge beginnen, Stück für Stück d
|
|||||||
- religion_stance == "devout_catholic":
|
- religion_stance == "devout_catholic":
|
||||||
Gerade deshalb erschreckt dich der Gedanke. Wer die Toten ruft, lädt vielleicht nicht nur die Toten ein.
|
Gerade deshalb erschreckt dich der Gedanke. Wer die Toten ruft, lädt vielleicht nicht nur die Toten ein.
|
||||||
- religion_stance == "spiritist_syncretic":
|
- religion_stance == "spiritist_syncretic":
|
||||||
Der Satz fügt sich in dir nicht gegen den Glauben, sondern unter seine Ränder, dort, wo Volksfrömmigkeit, Séance und Totenmesse einander längst heimlich berühren. #gloss[Volksfrömmigkeit](Frömmigkeit des Alltags. Sie hält sich an Kirche, Küche, Wetter, Krankheit und Erzählungen und fragt selten, ob eine Grenze amtlich gezogen wurde.) #gloss[Séance](Geistersitzung mit Tisch, gedämpftem Licht und sehr viel Erwartung. Je dunkler der Raum, desto leichter glauben die Anwesenden, sie sähen klar.) #gloss[Totenmesse](Messe für einen Verstorbenen. Ein kirchlicher Versuch, Trauer in Ordnung zu bringen und Schuld wenigstens liturgisch zu beschäftigen.)
|
Der Satz fügt sich in dir nicht gegen den Glauben, sondern unter seine Ränder, dort, wo Volksfrömmigkeit, Séance und Totenmesse einander längst heimlich berühren. #gloss[Volksfrömmigkeit](Religiöse Bräuche und Vorstellungen des Volkes neben der offiziellen Lehre.) #gloss[Séance](Spiritistische Sitzung zum Verkehr mit Verstorbenen oder Geistern.) #gloss[Totenmesse](Katholische Messe für Verstorbene.)
|
||||||
- religion_stance == "josephinian_sceptic":
|
- religion_stance == "josephinian_sceptic":
|
||||||
Du nennst es nicht Frömmigkeit. Eher eine vorläufige Hypothese über Dinge, für die die amtliche Sprache zu grob ist.
|
Du nennst es nicht Frömmigkeit. Eher eine vorläufige Hypothese über Dinge, für die die amtliche Sprache zu grob ist.
|
||||||
- else:
|
- else:
|
||||||
@@ -456,7 +456,7 @@ Vor dieser Reise, vor diesem Zug, bevor die Berge beginnen, Stück für Stück d
|
|||||||
|
|
||||||
{
|
{
|
||||||
- religion_stance == "devout_catholic":
|
- religion_stance == "devout_catholic":
|
||||||
Irgendwo in dir notiert eine strengere Stimme das Wort Sünde. Eine andere, praktischere, antwortet: Auftrag.
|
Irgendwo in dir notiert eine strengere Stimme das Wort Sünde. Eine andere, praktischere, antwortet: Auftrag. #gloss[Sünde](Verstoß gegen göttliches Gebot oder sittliche Ordnung.)
|
||||||
- religion_stance == "wounded_catholic":
|
- religion_stance == "wounded_catholic":
|
||||||
Wenn der Glaube dich schon als Mädchen verkleidete, ist es nur gerecht, dass du nun lernst, Verkleidungen selbst zu wählen.
|
Wenn der Glaube dich schon als Mädchen verkleidete, ist es nur gerecht, dass du nun lernst, Verkleidungen selbst zu wählen.
|
||||||
- else:
|
- else:
|
||||||
@@ -493,7 +493,7 @@ Unter Ruf und Aufführung hat die Erinnerung ihre eigene Aussage.
|
|||||||
~ supernatural_senses = "genuine"
|
~ supernatural_senses = "genuine"
|
||||||
~ supernatural_exposure += 2
|
~ supernatural_exposure += 2
|
||||||
|
|
||||||
Einmal, als Kind, wusstest du es, bevor das Telegramm kam. Einmal, in einem überfüllten Zimmer, trat der Kummer einer Fremden mit solcher Gewalt in dich ein, dass deine eigenen Knie nachgaben. Einmal sahst du in einem Spiegel eine Tür hinter dir, die nicht im Raum war, als du dich umdrehtest.
|
Einmal, als Kind, wusstest du es, bevor das Telegramm kam. Einmal, in einem überfüllten Zimmer, trat der Kummer einer Fremden mit solcher Gewalt in dich ein, dass deine eigenen Knie nachgaben. Einmal sahst du in einem Spiegel eine Tür hinter dir, die nicht im Raum war, als du dich umdrehtest. #gloss[Telegramm](Durch Telegraphie übermittelte kurze schriftliche Nachricht.)
|
||||||
|
|
||||||
Danach lerntest du Vorsicht. Es ist unklug für eine Frau, Dinge zu wissen, bevor ein Mann ihre Meinung erbeten hat.
|
Danach lerntest du Vorsicht. Es ist unklug für eine Frau, Dinge zu wissen, bevor ein Mann ihre Meinung erbeten hat.
|
||||||
|
|
||||||
@@ -522,7 +522,7 @@ Unter Ruf und Aufführung hat die Erinnerung ihre eigene Aussage.
|
|||||||
~ supernatural_senses = "repressed"
|
~ supernatural_senses = "repressed"
|
||||||
~ eccentric += 1
|
~ eccentric += 1
|
||||||
|
|
||||||
Es gibt Kindheitserinnerungen, die hinter Höflichkeit versiegelt sind: ein Kinderzimmerspiegel, zur Wand gedreht; eine Amme, ohne Zeugnis entlassen; die Hand deiner Mutter um dein Handgelenk, so fest, dass die Knochen sich beschwerten. #gloss[Amme](Frau, die ein fremdes Kind stillt oder früh betreut. Sie kommt dem Körper eines Hauses oft näher als jene, denen das Haus gehört.) #gloss[Zeugnis](Schriftliche Bescheinigung über Dienst, Betragen und Befähigung. Wer keinen Rang besitzt, braucht Papier, das guten Leumund behauptet.)
|
Es gibt Kindheitserinnerungen, die hinter Höflichkeit versiegelt sind: ein Kinderzimmerspiegel, zur Wand gedreht; eine Amme, ohne Zeugnis entlassen; die Hand deiner Mutter um dein Handgelenk, so fest, dass die Knochen sich beschwerten. #gloss[Amme](Frau die ein fremdes Kind stillt oder betreut.) #gloss[Zeugnis](Schriftliche Bescheinigung über Dienst Betragen oder Befähigung.)
|
||||||
|
|
||||||
Danach wurdest du auf Arten sonderbar, die die Gesellschaft leichter bewundern als verstehen konnte.
|
Danach wurdest du auf Arten sonderbar, die die Gesellschaft leichter bewundern als verstehen konnte.
|
||||||
|
|
||||||
@@ -533,7 +533,7 @@ Unter Ruf und Aufführung hat die Erinnerung ihre eigene Aussage.
|
|||||||
|
|
||||||
Für einige Sekunden gibt dir das Fenster nichts als Viktor, die Lampe, deine Handschuhe, die Linie deines Hutes und den bleichen Umriss deines Gesichts zurück. Der Tunnel löscht jede andere Welt aus.
|
Für einige Sekunden gibt dir das Fenster nichts als Viktor, die Lampe, deine Handschuhe, die Linie deines Hutes und den bleichen Umriss deines Gesichts zurück. Der Tunnel löscht jede andere Welt aus.
|
||||||
|
|
||||||
Gerade deshalb wird das Glas nützlich. Nachdem Name und Auftrag in dir geordnet sind, zeigt es nicht bloß eine Dame im richtigen Abteil. Es zeigt die Frau, die in Eibenreith aussteigen wird.
|
Gerade deshalb wird das Glas nützlich. Nachdem Name und Auftrag in dir geordnet sind, zeigt es nicht bloß eine Dame im richtigen Abteil. Es zeigt die Frau, die in Eibenreith aussteigen wird. #gloss[Abteil](Abgeschlossener Raum eines Eisenbahnwagens.) #gloss[Eibenreith](Fiktives Dorf im obersteirischen Gebirge unterhalb von Jagdhaus Hohenreith.)
|
||||||
|
|
||||||
* [__Schaue__: Auf eine kleine, schmale Gestalt.] #action:orientation
|
* [__Schaue__: Auf eine kleine, schmale Gestalt.] #action:orientation
|
||||||
~ body_detail = "small_slender"
|
~ body_detail = "small_slender"
|
||||||
@@ -549,7 +549,7 @@ Gerade deshalb wird das Glas nützlich. Nachdem Name und Auftrag in dir geordnet
|
|||||||
|
|
||||||
* [__Schaue__: Auf eine kompakte, kräftigere Gestalt.] #action:orientation
|
* [__Schaue__: Auf eine kompakte, kräftigere Gestalt.] #action:orientation
|
||||||
~ body_detail = "compact_strong"
|
~ body_detail = "compact_strong"
|
||||||
Reisekleidung und Korsett ordnen dich, aber sie verleugnen nicht alles. In deinen Unterarmen, im Nacken, in der Art, wie du ein Gleichgewicht hältst, liegt mehr Kraft, als man einer Dame höflich zutraut. #gloss[Korsett](Formendes Mieder für Taille und Oberkörper. Es stützt Haltung, beschränkt Atem und beweist, dass weibliche Selbstbeherrschung zuerst am Körper verlangt wird.)
|
Reisekleidung und Korsett ordnen dich, aber sie verleugnen nicht alles. In deinen Unterarmen, im Nacken, in der Art, wie du ein Gleichgewicht hältst, liegt mehr Kraft, als man einer Dame höflich zutraut. #gloss[Korsett](Formendes Mieder das Taille und Oberkörper stützt.)
|
||||||
|
|
||||||
* [__Schaue__: Auf eine zierliche, empfindsam wirkende Gestalt.] #action:orientation
|
* [__Schaue__: Auf eine zierliche, empfindsam wirkende Gestalt.] #action:orientation
|
||||||
~ body_detail = "delicate"
|
~ body_detail = "delicate"
|
||||||
@@ -573,7 +573,7 @@ Die dunkle Scheibe hält nun Haar und Hut fest.
|
|||||||
|
|
||||||
* [__Schaue__: Auf hellbraunes Haar mit goldenen Strähnen.] #action:orientation
|
* [__Schaue__: Auf hellbraunes Haar mit goldenen Strähnen.] #action:orientation
|
||||||
~ hair_colour = "light_brown_gold"
|
~ hair_colour = "light_brown_gold"
|
||||||
Hellbraunes Haar mit goldenen Strähnen wirkt im Abteil beinahe zu warm für diese Reise, als habe Wien einen letzten Rest Nachmittag darin vergessen.
|
Hellbraunes Haar mit goldenen Strähnen wirkt im Abteil beinahe zu warm für diese Reise, als habe Wien einen letzten Rest Nachmittag darin vergessen. #gloss[Abteil](Abgeschlossener Raum eines Eisenbahnwagens.)
|
||||||
|
|
||||||
* [__Schaue__: Auf rotbraunes, sorgfältig gebändigtes Haar.] #action:orientation
|
* [__Schaue__: Auf rotbraunes, sorgfältig gebändigtes Haar.] #action:orientation
|
||||||
~ hair_colour = "auburn"
|
~ hair_colour = "auburn"
|
||||||
@@ -638,16 +638,16 @@ Der Rest der Spiegelung ist Kostüm, Rüstung und Beweismittel.
|
|||||||
- birth_class == "middle":
|
- birth_class == "middle":
|
||||||
Die Kleidung muss eine höhere Welt betreten können, ohne zu schreien, dass sie dafür gearbeitet hat.
|
Die Kleidung muss eine höhere Welt betreten können, ohne zu schreien, dass sie dafür gearbeitet hat.
|
||||||
- else:
|
- else:
|
||||||
Die Kleidung muss beweisen, dass man dich in die erste Klasse setzen konnte, ohne dass der Stoff gegen dich aussagt. #gloss[erste Klasse](Die teuerste Wagenklasse der Eisenbahn. Sie bietet weichere Polster, bessere Luft und vor allem weniger Menschen, was im bürgerlichen Fortschrittsglauben oft dasselbe bedeutet.)
|
Die Kleidung muss beweisen, dass man dich in die erste Klasse setzen konnte, ohne dass der Stoff gegen dich aussagt. #gloss[erste Klasse](Vornehmste Wagenklasse der Eisenbahn mit besserer Polsterung mehr Raum und höherem Fahrpreis.)
|
||||||
}
|
}
|
||||||
|
|
||||||
* [__Trage__: Ein dunkel anthrazitfarbenes Reisekostüm mit pflaumenfarbenem Samtkragen.] #action:thinking
|
* [__Trage__: Ein dunkel anthrazitfarbenes Reisekostüm mit pflaumenfarbenem Samtkragen.] #action:thinking
|
||||||
~ outfit_detail = "charcoal_plum_velvet"
|
~ outfit_detail = "charcoal_plum_velvet"
|
||||||
Du trägst ein geschneidertes Reisekostüm aus dunkler anthrazitfarbener Wolle. Am Kragen und an den Manschetten liegt ein pflaumenfarbener Samtton, gedämpft genug für den Tag, teuer genug für Menschen mit Augen. #gloss[Reisekostüm](Damenkleidung für die Reise. Fest genug für Bahnhofsschmutz, korrekt genug für fremde Blicke, und unbequem genug, damit niemand vergisst, dass auch Zweckmäßigkeit weiblich auszusehen hat.) #gloss[anthrazitfarbener](Von Anthrazit hergeleitet, also sehr dunkelgrau. Eine Farbe für Menschen, die nicht trauern, aber auch nicht verdächtig fröhlich wirken möchten.)
|
Du trägst ein geschneidertes Reisekostüm aus dunkler anthrazitfarbener Wolle. Am Kragen und an den Manschetten liegt ein pflaumenfarbener Samtton, gedämpft genug für den Tag, teuer genug für Menschen mit Augen. #gloss[Reisekostüm](Damen Reiseanzug aus Rock und Jacke oder Mantel.) #gloss[anthrazitfarbener](Sehr dunkles Grau beinahe schwarz.)
|
||||||
|
|
||||||
* [__Trage__: Ein schwarzbraunes Wollkostüm mit elfenbeinfarbener Bluse und schmaler Spitze.] #action:thinking
|
* [__Trage__: Ein schwarzbraunes Wollkostüm mit elfenbeinfarbener Bluse und schmaler Spitze.] #action:thinking
|
||||||
~ outfit_detail = "black_brown_ivory_lace"
|
~ outfit_detail = "black_brown_ivory_lace"
|
||||||
Der Rock ist dunkel und schwer genug für die Reise, die Jacke streng, die elfenbeinfarbene Bluse am Hals hochgeschlossen. Die Spitze ist schmal, sauber und gefährlich nahe an Frömmigkeit.
|
Der Rock ist dunkel und schwer genug für die Reise, die Jacke streng, die elfenbeinfarbene Bluse am Hals hochgeschlossen. Die Spitze ist schmal, sauber und gefährlich nahe an Frömmigkeit. #gloss[Bluse](Oberteil einer Damenkleidung unter Jacke oder Kostüm.)
|
||||||
|
|
||||||
* [__Trage__: Ein graublaues Reisekostüm mit kurzem Mantel und praktischen Knöpfen.] #action:thinking
|
* [__Trage__: Ein graublaues Reisekostüm mit kurzem Mantel und praktischen Knöpfen.] #action:thinking
|
||||||
~ outfit_detail = "blue_grey_practical"
|
~ outfit_detail = "blue_grey_practical"
|
||||||
@@ -659,18 +659,18 @@ Der Rest der Spiegelung ist Kostüm, Rüstung und Beweismittel.
|
|||||||
|
|
||||||
* [__Trage__: Ein schwarzes Reisekleid mit Schleier, zu ernst für bloße Mode.] #action:thinking
|
* [__Trage__: Ein schwarzes Reisekleid mit Schleier, zu ernst für bloße Mode.] #action:thinking
|
||||||
~ outfit_detail = "black_veil_severe"
|
~ outfit_detail = "black_veil_severe"
|
||||||
Das Schwarz ist nicht Trauer, jedenfalls nicht offiziell. Ein schmaler Schleier, dunkle Handschuhe, glatter Rock, hohe Knopfleiste. Es ist die Art Kleidung, in der skeptische Männer leichter an Ahnungen glauben.
|
Das Schwarz ist nicht Trauer, jedenfalls nicht offiziell. Ein schmaler Schleier, dunkle Handschuhe, glatter Rock, hohe Knopfleiste. Es ist die Art Kleidung, in der skeptische Männer leichter an Ahnungen glauben. #gloss[Schleier](Feines durchsichtiges Gewebe vor Gesicht oder Hut.)
|
||||||
|
|
||||||
-
|
-
|
||||||
{
|
{
|
||||||
- outfit_detail == "blue_grey_practical":
|
- outfit_detail == "blue_grey_practical":
|
||||||
Viktor wird dieses Kostüm später vermutlich für ein Zeichen von Vernunft halten. Das ist nützlich, auch wenn es nicht vollständig wahr ist.
|
Viktor wird dieses Kostüm später vermutlich für ein Zeichen von Vernunft halten. Das ist nützlich, auch wenn es nicht vollständig wahr ist.
|
||||||
- outfit_detail == "black_veil_severe":
|
- outfit_detail == "black_veil_severe":
|
||||||
Selbst Viktor wird Mühe haben, diese Kleidung ganz von deinem Ruf als Medium zu trennen. Das ist kein Zufall. #gloss[Medium](Person, durch die Geister oder verborgene Kräfte sprechen sollen. Gesellschaftlich besonders brauchbar, weil eine Frau so Dinge sagen darf, die man ihr als eigene Meinung übel nähme.)
|
Selbst Viktor wird Mühe haben, diese Kleidung ganz von deinem Ruf als Medium zu trennen. Das ist kein Zufall. #gloss[Medium](Person der man Verkehr mit Geistern oder verborgenen Kräften zuschreibt.)
|
||||||
- outfit_detail == "dark_green_black_trim":
|
- outfit_detail == "dark_green_black_trim":
|
||||||
Das Kostüm ist diskret genug für die Reise und bestimmt genug, um in Erinnerung zu bleiben. Es sagt nicht viel. Nur genug.
|
Das Kostüm ist diskret genug für die Reise und bestimmt genug, um in Erinnerung zu bleiben. Es sagt nicht viel. Nur genug.
|
||||||
- else:
|
- else:
|
||||||
Die Kleidung wird in Hohenreith sprechen, bevor du es tust. Das ist der Sinn guter Kleidung und die Gefahr schlechter.
|
Die Kleidung wird in Hohenreith sprechen, bevor du es tust. Das ist der Sinn guter Kleidung und die Gefahr schlechter. #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.)
|
||||||
}
|
}
|
||||||
|
|
||||||
Als die Berge zurückkehren, wirken sie näher.
|
Als die Berge zurückkehren, wirken sie näher.
|
||||||
@@ -680,18 +680,18 @@ Als die Berge zurückkehren, wirken sie näher.
|
|||||||
=== first_viktor_exchange ===
|
=== first_viktor_exchange ===
|
||||||
|
|
||||||
{not tut_dialog_intro:
|
{not tut_dialog_intro:
|
||||||
#alert[Steht eine Zeile in Anführungszeichen, sprichst du sie aus. Das hervorgehobene Verb zeigt, was du tust und meist, an wen du dich richtest: Frage Viktor, Antworte, Spotte oder Widersprich.]
|
#alert[Wenn eine Wahl in Anführungszeichen steht, sprichst du diese Worte aus. Das Verb davor verrät, ob du antwortest, fragst, mitteilst, spottest oder schweigst.]
|
||||||
~ tut_dialog_intro = true
|
~ tut_dialog_intro = true
|
||||||
}
|
}
|
||||||
|
|
||||||
Er faltet die Zeitung zusammen, obwohl du sehr sicher bist, dass er nicht gelesen hat.
|
Er faltet die Zeitung zusammen, obwohl du sehr sicher bist, dass er nicht gelesen hat.
|
||||||
|
|
||||||
{birth_class == "noble":
|
{birth_class == "noble":
|
||||||
„Sie sind sehr still, gnädiges Fräulein. Für eine Dame auf ihrer ersten amtlichen Reise beweisen Sie bemerkenswerte Zurückhaltung.“ #tts[For Viktor, use a restrained, polished officer's voice. Courteous on the surface, with a faint test hidden underneath.] #gloss[amtliche Reise](Eine Reise im Auftrag einer Behörde oder Hofstelle. Der Unterschied zur privaten Reise besteht vor allem darin, dass man weniger frei ist und mehr Papier mitführt.)
|
„Sie sind sehr still, gnädiges Fräulein. Für eine Dame auf ihrer ersten amtlichen Reise beweisen Sie bemerkenswerte Zurückhaltung.“ #gloss[amtliche Reise](Reise im Auftrag einer offiziellen Stelle oder Behörde.)
|
||||||
|
|
||||||
Die Anrede wahrt die Form und vermeidet den Titel. Gerade deshalb verrät sie Absicht.
|
Die Anrede wahrt die Form und vermeidet den Titel. Gerade deshalb verrät sie Absicht.
|
||||||
- else:
|
- else:
|
||||||
„Sie sind sehr still, Fräulein {surname}. Für eine Dame auf ihrer ersten amtlichen Reise beweisen Sie bemerkenswerte Zurückhaltung.“ #tts[For Viktor, use a restrained, polished officer's voice. Courteous on the surface, with a faint test hidden underneath.]
|
„Sie sind sehr still, Fräulein {surname}. Für eine Dame auf ihrer ersten amtlichen Reise beweisen Sie bemerkenswerte Zurückhaltung.“
|
||||||
|
|
||||||
Die Anrede ist korrekt, doch sie prüft dich mehr, als sie dich schützt. Er weiß noch nicht, welcher Teil von dir brauchbar ist, welcher Verkleidung und welcher Gefahr.
|
Die Anrede ist korrekt, doch sie prüft dich mehr, als sie dich schützt. Er weiß noch nicht, welcher Teil von dir brauchbar ist, welcher Verkleidung und welcher Gefahr.
|
||||||
}
|
}
|
||||||
@@ -772,9 +772,9 @@ Viktor wartet auf die Antwort, die seine Bemerkung verlangt. Der Zug ruckt einma
|
|||||||
~ class_confidence += 1
|
~ class_confidence += 1
|
||||||
„Eine Erziehung in Zimmern, in denen selbst jeder Stuhl Rang besitzt.“
|
„Eine Erziehung in Zimmern, in denen selbst jeder Stuhl Rang besitzt.“
|
||||||
|
|
||||||
„Dann wird Hohenreith Sie vielleicht nicht überraschen.“
|
„Dann wird Hohenreith Sie vielleicht nicht überraschen.“ #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.)
|
||||||
|
|
||||||
Die Möglichkeit, dass Hohenreith bessere Geheimnisse als Stühle besitzt, darf unausgesprochen bleiben.
|
Die Möglichkeit, dass Hohenreith bessere Geheimnisse als Stühle besitzt, darf unausgesprochen bleiben. #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.)
|
||||||
|
|
||||||
--
|
--
|
||||||
-> viktor_mission_briefing
|
-> viktor_mission_briefing
|
||||||
@@ -825,7 +825,7 @@ Viktors Bemerkung bleibt zwischen euch liegen wie ein höflicher Vorwurf.
|
|||||||
** [__Antworte__: „Eine nützliche. Bitterkeit ist nur der Geschmack, den Belehrung zurücklässt.“] #action:conversation
|
** [__Antworte__: „Eine nützliche. Bitterkeit ist nur der Geschmack, den Belehrung zurücklässt.“] #action:conversation
|
||||||
#route:eccentric
|
#route:eccentric
|
||||||
~ eccentric += 1
|
~ eccentric += 1
|
||||||
„Eine nützliche. Bitterkeit ist nur der Geschmack, den Belehrung zurücklässt.“ #tts[Read the protagonist's line dryly and evenly, with a small sting on "Bitterkeit".]
|
„Eine nützliche. Bitterkeit ist nur der Geschmack, den Belehrung zurücklässt.“
|
||||||
|
|
||||||
„Sie sammeln Redewendungen wie Waffen.“
|
„Sie sammeln Redewendungen wie Waffen.“
|
||||||
|
|
||||||
@@ -847,7 +847,7 @@ Viktors Bemerkung bleibt zwischen euch liegen wie ein höflicher Vorwurf.
|
|||||||
#route:lover
|
#route:lover
|
||||||
~ lover += 1
|
~ lover += 1
|
||||||
~ medium_reputation += 1
|
~ medium_reputation += 1
|
||||||
„Wenn ich schweige, Herr Nowak, so deshalb, weil Männer sich schneller erklären, wenn ihnen die Stille missfällt.“ #tts[Let this be calm and deliberate, almost conversationally intimate. Give "Stille" a tiny pause before continuing.]
|
„Wenn ich schweige, Herr Nowak, so deshalb, weil Männer sich schneller erklären, wenn ihnen die Stille missfällt.“
|
||||||
|
|
||||||
„Eine Methode?“
|
„Eine Methode?“
|
||||||
|
|
||||||
@@ -887,7 +887,7 @@ Viktors Bemerkung bleibt zwischen euch liegen wie ein höflicher Vorwurf.
|
|||||||
~ eccentric += 1
|
~ eccentric += 1
|
||||||
„Wie bequem. Die beiden anderen dürfen die Verantwortung leugnen.“
|
„Wie bequem. Die beiden anderen dürfen die Verantwortung leugnen.“
|
||||||
|
|
||||||
„Ich rate Ihnen, den Witz in Hohenreith nicht zu Ihrem ersten Werkzeug zu machen.“
|
„Ich rate Ihnen, den Witz in Hohenreith nicht zu Ihrem ersten Werkzeug zu machen.“ #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.)
|
||||||
|
|
||||||
Die Herabstufung des Witzes zum zweiten Werkzeug bleibt theoretisch genug, um ungefährlich zu sein.
|
Die Herabstufung des Witzes zum zweiten Werkzeug bleibt theoretisch genug, um ungefährlich zu sein.
|
||||||
|
|
||||||
@@ -905,7 +905,7 @@ Viktors Bemerkung bleibt zwischen euch liegen wie ein höflicher Vorwurf.
|
|||||||
|
|
||||||
=== viktor_class_working ===
|
=== viktor_class_working ===
|
||||||
|
|
||||||
Viktors Höflichkeit ist glatt genug, um keine Fingerabdrücke zu behalten. Darunter hörst du die Frage, wie sehr dieses Abteil dich verbessert hat.
|
Viktors Höflichkeit ist glatt genug, um keine Fingerabdrücke zu behalten. Darunter hörst du die Frage, wie sehr dieses Abteil dich verbessert hat. #gloss[Abteil](Abgeschlossener Raum eines Eisenbahnwagens.)
|
||||||
|
|
||||||
* [__Antworte__: „Zurückhaltung ist, was die Leute loben, wenn sie die Mühe dahinter nicht sehen wollen.“] #action:conversation
|
* [__Antworte__: „Zurückhaltung ist, was die Leute loben, wenn sie die Mühe dahinter nicht sehen wollen.“] #action:conversation
|
||||||
#route:detective
|
#route:detective
|
||||||
@@ -924,7 +924,7 @@ Viktors Höflichkeit ist glatt genug, um keine Fingerabdrücke zu behalten. Daru
|
|||||||
|
|
||||||
„Das dürfte schwer zu vermeiden sein.“
|
„Das dürfte schwer zu vermeiden sein.“
|
||||||
|
|
||||||
Wenn Hohenreith dich billig loben will, wird es die Ökonomie der Enttäuschung kennenlernen müssen.
|
Wenn Hohenreith dich billig loben will, wird es die Ökonomie der Enttäuschung kennenlernen müssen. #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.)
|
||||||
|
|
||||||
** [__Antworte__: „Nur, wenn es die Person verbirgt, die die Arbeit getan hat.“] #action:conversation
|
** [__Antworte__: „Nur, wenn es die Person verbirgt, die die Arbeit getan hat.“] #action:conversation
|
||||||
#route:sapphic
|
#route:sapphic
|
||||||
@@ -944,24 +944,6 @@ Viktors Höflichkeit ist glatt genug, um keine Fingerabdrücke zu behalten. Daru
|
|||||||
|
|
||||||
„Ich habe Sie nicht um Dankbarkeit gebeten.“
|
„Ich habe Sie nicht um Dankbarkeit gebeten.“
|
||||||
|
|
||||||
** [__Summe__: Eine Antwort, die keine Worte braucht.] #action:conversation #optional
|
|
||||||
#route:eccentric
|
|
||||||
~ eccentric += 1
|
|
||||||
Du summst so leise, dass es mehr Haltung als Ton ist. Der Rhythmus der Räder nimmt die Melodie auf und gibt sie höflicher zurück, als sie gekommen ist. #tts[Hum this line very softly, almost under the breath. Keep it restrained, intimate, and wordless, with a faint ironic composure.]
|
|
||||||
|
|
||||||
Viktor sieht nicht auf.
|
|
||||||
|
|
||||||
„Sie haben die ungewöhnliche Gabe, Schweigen beschäftigt wirken zu lassen.“
|
|
||||||
|
|
||||||
** [__Singe__: Die Kaiserhymne, beinahe ohne Stimme.] #action:conversation #optional
|
|
||||||
#route:eccentric
|
|
||||||
~ eccentric += 1
|
|
||||||
Du singst kaum lauter als das Rauschen im Polster: „Gott beschütze Franz den Kaiser ...“ Nicht fromm genug für ein Gebet, nicht spöttisch genug für Hochverrat. #tts[Sing the quoted hymn fragment almost silently, like a controlled private murmur. Use a formal, old Austrian ceremonial feel, but keep the delivery restrained and ambiguous.]
|
|
||||||
|
|
||||||
Ein Muskel an Viktors Kiefer bewegt sich.
|
|
||||||
|
|
||||||
„Eine riskante Art, Ihre Loyalität nachzuweisen.“
|
|
||||||
|
|
||||||
** [__Antworte__: „Nein. Sie haben verlangt, dass ich lenkbar sei.“] #action:conversation
|
** [__Antworte__: „Nein. Sie haben verlangt, dass ich lenkbar sei.“] #action:conversation
|
||||||
#route:eccentric
|
#route:eccentric
|
||||||
~ eccentric += 1
|
~ eccentric += 1
|
||||||
@@ -1012,9 +994,9 @@ Viktors Höflichkeit ist glatt genug, um keine Fingerabdrücke zu behalten. Daru
|
|||||||
|
|
||||||
Der Zug tritt aus dem Tunnel in einen blassen Nachmittag aus dunklen Tannen und weißem Fels. Tief unten zeigt sich Wasser nur in Blitzen. Das Tal ist kein Anblick aus einem Salonbild mehr. Es hat Tiefe genug, um Dinge zu verbergen.
|
Der Zug tritt aus dem Tunnel in einen blassen Nachmittag aus dunklen Tannen und weißem Fels. Tief unten zeigt sich Wasser nur in Blitzen. Das Tal ist kein Anblick aus einem Salonbild mehr. Es hat Tiefe genug, um Dinge zu verbergen.
|
||||||
|
|
||||||
Viktor öffnet eine Ledermappe und nimmt ein Memorandum heraus. Er reicht es dir nicht sofort. #gloss[Memorandum](Amtliche Denkschrift. Lang genug, um Zuständigkeit zu behaupten, kurz genug, um die gefährlichen Dinge nicht beim Namen nennen zu müssen.)
|
Viktor öffnet eine Ledermappe und nimmt ein Memorandum heraus. Er reicht es dir nicht sofort. #gloss[Memorandum](Schriftliche Denkschrift oder amtliche Notiz.)
|
||||||
|
|
||||||
„Wenn wir die Bahn verlassen“, sagt er, „werden wir von einer Kutsche aus Hohenreith erwartet. Von diesem Augenblick an sind Äußerlichkeiten von Bedeutung. Ihren Gastgebern wurde mitgeteilt, dass ich bei Korrespondenz, Reiseangelegenheiten und praktischen Vorkehrungen behilflich bin. Mit militärischen Definitionen muss man sie nicht behelligen.“ #gloss[Korrespondenz](Briefwechsel. Die vornehme Kunst, Absichten so lange zu falten, bis sie in ein Kuvert passen.)
|
„Wenn wir die Bahn verlassen“, sagt er, „werden wir von einer Kutsche aus Hohenreith erwartet. Von diesem Augenblick an sind Äußerlichkeiten von Bedeutung. Ihren Gastgebern wurde mitgeteilt, dass ich bei Korrespondenz, Reiseangelegenheiten und praktischen Vorkehrungen behilflich bin. Mit militärischen Definitionen muss man sie nicht behelligen.“ #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.) #gloss[Korrespondenz](Briefwechsel und schriftliche Erledigung von Nachrichten.)
|
||||||
|
|
||||||
* [__Frage Viktor__: „Und die Dorfbewohner?“] #action:conversation
|
* [__Frage Viktor__: „Und die Dorfbewohner?“] #action:conversation
|
||||||
„Und die Dorfbewohner?“
|
„Und die Dorfbewohner?“
|
||||||
@@ -1045,7 +1027,7 @@ Viktor öffnet eine Ledermappe und nimmt ein Memorandum heraus. Er reicht es dir
|
|||||||
|
|
||||||
-
|
-
|
||||||
|
|
||||||
„Man wird Sie nach dem Stand anreden, den Sie vorweisen“, fährt er fort. „Der Haushalt des Grafen wird den Rang beachten. Die Dienerschaft wird beachten, was der Haushalt beachtet. Die Dorfbewohner mögen weniger beachten und mehr behalten. Ich rate zur Zurückhaltung.“ #tts[For Viktor's briefing, sound exact and professional. Keep each sentence cleanly separated, like points in a confidential report.] #gloss[Haushalt des Grafen](Das Haus als Rangordnung. Familie, Dienerschaft, Gäste und Zuständigkeiten bilden darin ein Uhrwerk, das besonders laut tickt, wenn jemand falsch steht.) #gloss[Dienerschaft](Die Bediensteten eines Hauses. Offiziell Teil der Ordnung, praktisch ihr Gedächtnis.)
|
„Man wird Sie nach dem Stand anreden, den Sie vorweisen“, fährt er fort. „Der Haushalt des Grafen wird den Rang beachten. Die Dienerschaft wird beachten, was der Haushalt beachtet. Die Dorfbewohner mögen weniger beachten und mehr behalten. Ich rate zur Zurückhaltung.“ #gloss[Haushalt des Grafen](Häusliche Ordnung eines gräflichen Hauses mit Dienerschaft Verwaltung und Rang.) #gloss[Dienerschaft](Gesamtheit der Bediensteten eines Hauses oder Gutes.)
|
||||||
|
|
||||||
Der Rat ist vernünftig. Das macht ihn nicht weniger ärgerlich.
|
Der Rat ist vernünftig. Das macht ihn nicht weniger ärgerlich.
|
||||||
|
|
||||||
@@ -1064,14 +1046,14 @@ Der Rat ist vernünftig. Das macht ihn nicht weniger ärgerlich.
|
|||||||
** [__Antworte__: „Eine praktische.“] #action:conversation
|
** [__Antworte__: „Eine praktische.“] #action:conversation
|
||||||
„Eine praktische.“
|
„Eine praktische.“
|
||||||
|
|
||||||
„Sie gedenken, sie in Hohenreith anzuwenden?“
|
„Sie gedenken, sie in Hohenreith anzuwenden?“ #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.)
|
||||||
|
|
||||||
*** [__Antworte__: „Nur dort, wo die Pflicht gegen die Monarchie Opfer verlangt.“] #action:conversation
|
*** [__Antworte__: „Nur dort, wo die Pflicht gegen die Monarchie Opfer verlangt.“] #action:conversation
|
||||||
#route:lover
|
#route:lover
|
||||||
~ lover += 1
|
~ lover += 1
|
||||||
„Nur dort, wo die Pflicht gegen die Monarchie Opfer verlangt.“
|
„Nur dort, wo die Pflicht gegen die Monarchie Opfer verlangt.“
|
||||||
|
|
||||||
Er blickt auf das Memorandum hinunter, aber nicht schnell genug, um zu verbergen, dass er dich neu einschätzt. #gloss[Memorandum](Amtliche Denkschrift. Lang genug, um Zuständigkeit zu behaupten, kurz genug, um die gefährlichen Dinge nicht beim Namen nennen zu müssen.)
|
Er blickt auf das Memorandum hinunter, aber nicht schnell genug, um zu verbergen, dass er dich neu einschätzt. #gloss[Memorandum](Schriftliche Denkschrift oder amtliche Notiz.)
|
||||||
|
|
||||||
*** [__Antworte__: „Nur dort, wo Männer Begehren mit Urteil verwechseln.“] #action:conversation
|
*** [__Antworte__: „Nur dort, wo Männer Begehren mit Urteil verwechseln.“] #action:conversation
|
||||||
#route:lover
|
#route:lover
|
||||||
@@ -1087,9 +1069,9 @@ Der Rat ist vernünftig. Das macht ihn nicht weniger ärgerlich.
|
|||||||
~ eccentric += 1
|
~ eccentric += 1
|
||||||
„Gefährliche Lehren reisen am besten in guten Handschuhen.“
|
„Gefährliche Lehren reisen am besten in guten Handschuhen.“
|
||||||
|
|
||||||
„Sie gedenken, Hohenreith durch Charme zum Geständnis zu bringen?“
|
„Sie gedenken, Hohenreith durch Charme zum Geständnis zu bringen?“ #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.)
|
||||||
|
|
||||||
Wenn Hohenreith darauf besteht, bezaubert zu werden, wird es kaum deine Schuld sein.
|
Wenn Hohenreith darauf besteht, bezaubert zu werden, wird es kaum deine Schuld sein. #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.)
|
||||||
|
|
||||||
--
|
--
|
||||||
|
|
||||||
@@ -1135,11 +1117,11 @@ Der Rat ist vernünftig. Das macht ihn nicht weniger ärgerlich.
|
|||||||
„Sie wissen, dass Sie empfohlen kommen. Sie vermuten, dass Sie imstande sein könnten, die Störungen ohne Polizei, Priester oder Presse beizulegen. Ihnen ist gestattet, Betrug, Zwang, Gefährdung der öffentlichen Ordnung oder glaubwürdige, derzeit nicht einzuordnende Erscheinungen zu prüfen.“
|
„Sie wissen, dass Sie empfohlen kommen. Sie vermuten, dass Sie imstande sein könnten, die Störungen ohne Polizei, Priester oder Presse beizulegen. Ihnen ist gestattet, Betrug, Zwang, Gefährdung der öffentlichen Ordnung oder glaubwürdige, derzeit nicht einzuordnende Erscheinungen zu prüfen.“
|
||||||
|
|
||||||
** [__Antworte__: „Glaubwürdige, derzeit nicht einzuordnende Erscheinungen.“] #action:conversation
|
** [__Antworte__: „Glaubwürdige, derzeit nicht einzuordnende Erscheinungen.“] #action:conversation
|
||||||
„Glaubwürdige, derzeit nicht einzuordnende Erscheinungen.“ #tts[Repeat the phrase with quiet skepticism, tasting the bureaucracy of it.]
|
„Glaubwürdige, derzeit nicht einzuordnende Erscheinungen.“
|
||||||
|
|
||||||
„So lautet die Wendung.“
|
„So lautet die Wendung.“
|
||||||
|
|
||||||
Die Formulierung setzt sich in deinem Geist fest wie ein bürokratisches Gespenst. #tts[Make this sentence slightly colder and more uncanny, with a faint emphasis on "bürokratisches Gespenst".]
|
Die Formulierung setzt sich in deinem Geist fest wie ein bürokratisches Gespenst.
|
||||||
|
|
||||||
„Die ungefährlichste Art“, sagt er.
|
„Die ungefährlichste Art“, sagt er.
|
||||||
|
|
||||||
@@ -1168,7 +1150,7 @@ Der Rat ist vernünftig. Das macht ihn nicht weniger ärgerlich.
|
|||||||
|
|
||||||
„Wie praktisch.“
|
„Wie praktisch.“
|
||||||
|
|
||||||
Die Last der Sachlichkeit wandert zu ihm hinüber, so anmutig wie ein Ohnmachtssofa, das in ein Feldlazarett geschleppt wird. #gloss[Ohnmachtssofa](Ruhemöbel für weibliche Schwächeanfälle, echte oder erwartete. Eine Gesellschaft, die Damen eng schnürt, sorgt immerhin für passende Möbel, wenn sie nicht mehr stehen.) #gloss[Feldlazarett](Militärische Krankenstation im Feld. Dort endet die Sprache von Ehre und beginnt die Sprache von Blut, Listen und Verbänden.)
|
Die Last der Sachlichkeit wandert zu ihm hinüber, so anmutig wie ein Ohnmachtssofa, das in ein Feldlazarett geschleppt wird. #gloss[Ohnmachtssofa](Scherzhafte Bezeichnung für ein kleines Ruhemöbel bei Schwäche oder Ohnmacht.) #gloss[Feldlazarett](Militärische Kranken und Verwundetenstation im Feld.)
|
||||||
|
|
||||||
Seine Antwort verzögert sich um einen halben Atemzug.
|
Seine Antwort verzögert sich um einen halben Atemzug.
|
||||||
|
|
||||||
@@ -1199,7 +1181,7 @@ Der Rat ist vernünftig. Das macht ihn nicht weniger ärgerlich.
|
|||||||
** [__Antworte__: „Nein. Ich missbillige nur die Bequemlichkeit, Dummköpfe unentschieden bleiben zu lassen.“] #action:conversation
|
** [__Antworte__: „Nein. Ich missbillige nur die Bequemlichkeit, Dummköpfe unentschieden bleiben zu lassen.“] #action:conversation
|
||||||
„Nein. Ich missbillige nur die Bequemlichkeit, Dummköpfe unentschieden bleiben zu lassen.“
|
„Nein. Ich missbillige nur die Bequemlichkeit, Dummköpfe unentschieden bleiben zu lassen.“
|
||||||
|
|
||||||
„In Hohenreith könnte diese Abneigung kostspielig werden.“
|
„In Hohenreith könnte diese Abneigung kostspielig werden.“ #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.)
|
||||||
|
|
||||||
Wenn der Graf Fügsamkeit wollte, hätte er jemand Günstigeren einladen können.
|
Wenn der Graf Fügsamkeit wollte, hätte er jemand Günstigeren einladen können.
|
||||||
|
|
||||||
@@ -1214,13 +1196,13 @@ Der Rat ist vernünftig. Das macht ihn nicht weniger ärgerlich.
|
|||||||
|
|
||||||
-
|
-
|
||||||
|
|
||||||
Die Räder nehmen eine Kurve. Das Abteil neigt sich. Für einen Augenblick hält euch dieselbe schmale Schräglage.
|
Die Räder nehmen eine Kurve. Das Abteil neigt sich. Für einen Augenblick hält euch dieselbe schmale Schräglage. #gloss[Abteil](Abgeschlossener Raum eines Eisenbahnwagens.)
|
||||||
|
|
||||||
Viktor gibt dir endlich das Memorandum. #gloss[Memorandum](Amtliche Denkschrift. Lang genug, um Zuständigkeit zu behaupten, kurz genug, um die gefährlichen Dinge nicht beim Namen nennen zu müssen.)
|
Viktor gibt dir endlich das Memorandum. #gloss[Memorandum](Schriftliche Denkschrift oder amtliche Notiz.)
|
||||||
|
|
||||||
Das Schriftstück ist nicht lang. Das ist Teil seiner Bedrohlichkeit. Lange Schriftstücke laden zum Widerspruch ein; kurze tragen Autorität.
|
Das Schriftstück ist nicht lang. Das ist Teil seiner Bedrohlichkeit. Lange Schriftstücke laden zum Widerspruch ein; kurze tragen Autorität.
|
||||||
|
|
||||||
Ein gräflicher Haushalt. Ein Jagdhaus in der Obersteiermark, nicht der Hauptsitz der Familie. Berichte über Störungen unter Dienerschaft und Dorfbewohnern. Kein Einschreiten der Polizei erbeten. Keine öffentliche kirchliche Untersuchung erwünscht. Keine Presse. Keine Korrespondenz außerhalb genehmigter Kanäle. Deine Anwesenheit ist als diskrete Konsultation auf Wunsch der Familie zu erklären. Herr Nowak dient zur Unterstützung praktischer Angelegenheiten. #gloss[Jagdhaus](Adeliger Landsitz für Jagden und kurze Aufenthalte. Weniger Hauptsitz als Bühne für Gäste, Förster, Gewehre und Geheimnisse.) #gloss[Obersteiermark](Der gebirgige Norden der Steiermark. Wälder, Eisen, enge Täler und ein Wetter, das sich nicht für Wiener Empfindlichkeiten interessiert.) #gloss[Dienerschaft](Die Bediensteten eines Hauses. Offiziell Teil der Ordnung, praktisch ihr Gedächtnis.) #gloss[kirchliche Untersuchung](Prüfung durch geistliche Stellen. Sie ist besonders beruhigend, solange man sicher ist, dass der Schrecken der richtigen Art angehört.) #gloss[Korrespondenz](Briefwechsel. Die vornehme Kunst, Absichten so lange zu falten, bis sie in ein Kuvert passen.)
|
Ein gräflicher Haushalt. Ein Jagdhaus in der Obersteiermark, nicht der Hauptsitz der Familie. Berichte über Störungen unter Dienerschaft und Dorfbewohnern. Kein Einschreiten der Polizei erbeten. Keine öffentliche kirchliche Untersuchung erwünscht. Keine Presse. Keine Korrespondenz außerhalb genehmigter Kanäle. Deine Anwesenheit ist als diskrete Konsultation auf Wunsch der Familie zu erklären. Herr Nowak dient zur Unterstützung praktischer Angelegenheiten. #gloss[Jagdhaus](Ländliches Haus oder kleineres Schloss für Jagdaufenthalte.) #gloss[Obersteiermark](Nördlicher alpiner Teil der Steiermark.) #gloss[Dienerschaft](Gesamtheit der Bediensteten eines Hauses oder Gutes.) #gloss[kirchliche Untersuchung](Prüfung durch kirchliche Stellen.) #gloss[Korrespondenz](Briefwechsel und schriftliche Erledigung von Nachrichten.)
|
||||||
|
|
||||||
Niemand hat das Wort Geist geschrieben.
|
Niemand hat das Wort Geist geschrieben.
|
||||||
|
|
||||||
@@ -1231,11 +1213,11 @@ Niemand hat das Wort Tochter geschrieben.
|
|||||||
Doch die Auslassungen ordnen sich auf der Seite an wie Möbel um eine Leiche.
|
Doch die Auslassungen ordnen sich auf der Seite an wie Möbel um eine Leiche.
|
||||||
|
|
||||||
* [__Antworte__: „Es gibt noch eine weitere Weisung.“] #action:conversation
|
* [__Antworte__: „Es gibt noch eine weitere Weisung.“] #action:conversation
|
||||||
„Es gibt noch eine weitere Weisung.“ #gloss[Weisung](Ein Befehl, dem man die Uniform ausgezogen hat. Sehr nützlich, wenn Gehorsam erwartet wird, aber niemand persönlich Verantwortung tragen möchte.)
|
„Es gibt noch eine weitere Weisung.“ #gloss[Weisung](Amtlicher oder dienstlicher Auftrag dessen Befolgung erwartet wird.)
|
||||||
|
|
||||||
Viktor fragt nicht, woher du es weißt.
|
Viktor fragt nicht, woher du es weißt.
|
||||||
|
|
||||||
„Es gibt immer noch eine weitere Weisung“, sagt er. #gloss[Weisung](Ein Befehl, dem man die Uniform ausgezogen hat. Sehr nützlich, wenn Gehorsam erwartet wird, aber niemand persönlich Verantwortung tragen möchte.)
|
„Es gibt immer noch eine weitere Weisung“, sagt er. #gloss[Weisung](Amtlicher oder dienstlicher Auftrag dessen Befolgung erwartet wird.)
|
||||||
|
|
||||||
** [__Antworte__: „Für Sie.“] #action:conversation
|
** [__Antworte__: „Für Sie.“] #action:conversation
|
||||||
„Für Sie.“
|
„Für Sie.“
|
||||||
@@ -1272,7 +1254,7 @@ Doch die Auslassungen ordnen sich auf der Seite an wie Möbel um eine Leiche.
|
|||||||
#route:detective
|
#route:detective
|
||||||
~ detective += 1
|
~ detective += 1
|
||||||
~ viktor_trust += 1
|
~ viktor_trust += 1
|
||||||
„Ihre Fassung ist kürzer als Ihr Schweigen. Das bedeutet, es gibt noch eine weitere Weisung.“ #gloss[Weisung](Ein Befehl, dem man die Uniform ausgezogen hat. Sehr nützlich, wenn Gehorsam erwartet wird, aber niemand persönlich Verantwortung tragen möchte.)
|
„Ihre Fassung ist kürzer als Ihr Schweigen. Das bedeutet, es gibt noch eine weitere Weisung.“ #gloss[Weisung](Amtlicher oder dienstlicher Auftrag dessen Befolgung erwartet wird.)
|
||||||
|
|
||||||
Viktor fragt nicht, woher du es weißt.
|
Viktor fragt nicht, woher du es weißt.
|
||||||
|
|
||||||
@@ -1362,14 +1344,14 @@ Der Zug beginnt langsamer zu werden. Der Rhythmus verändert sich zuerst im Bode
|
|||||||
* [__Antworte__: „Dann werde ich die Weisung so kunstvoll enttäuschen, wie es die Umstände erlauben.“] #action:conversation
|
* [__Antworte__: „Dann werde ich die Weisung so kunstvoll enttäuschen, wie es die Umstände erlauben.“] #action:conversation
|
||||||
#route:eccentric
|
#route:eccentric
|
||||||
~ eccentric += 1
|
~ eccentric += 1
|
||||||
„Dann werde ich die Weisung so kunstvoll enttäuschen, wie es die Umstände erlauben.“ #gloss[Weisung](Ein Befehl, dem man die Uniform ausgezogen hat. Sehr nützlich, wenn Gehorsam erwartet wird, aber niemand persönlich Verantwortung tragen möchte.)
|
„Dann werde ich die Weisung so kunstvoll enttäuschen, wie es die Umstände erlauben.“ #gloss[Weisung](Amtlicher oder dienstlicher Auftrag dessen Befolgung erwartet wird.)
|
||||||
|
|
||||||
„Ich hoffe aufrichtig, dass Sie es nicht tun.“
|
„Ich hoffe aufrichtig, dass Sie es nicht tun.“
|
||||||
|
|
||||||
* [__Antworte__: „Dann behalten Sie Ihre zweite Weisung, Herr Nowak. Ich bevorzuge Quellen erster Hand.“] #action:conversation
|
* [__Antworte__: „Dann behalten Sie Ihre zweite Weisung, Herr Nowak. Ich bevorzuge Quellen erster Hand.“] #action:conversation
|
||||||
#route:detective
|
#route:detective
|
||||||
~ detective += 1
|
~ detective += 1
|
||||||
„Dann behalten Sie Ihre zweite Weisung, Herr Nowak. Ich bevorzuge Quellen erster Hand.“ #gloss[Weisung](Ein Befehl, dem man die Uniform ausgezogen hat. Sehr nützlich, wenn Gehorsam erwartet wird, aber niemand persönlich Verantwortung tragen möchte.)
|
„Dann behalten Sie Ihre zweite Weisung, Herr Nowak. Ich bevorzuge Quellen erster Hand.“ #gloss[Weisung](Amtlicher oder dienstlicher Auftrag dessen Befolgung erwartet wird.)
|
||||||
|
|
||||||
„Eine Vorliebe, die im kaiserlichen Dienst nicht immer gewährt wird.“
|
„Eine Vorliebe, die im kaiserlichen Dienst nicht immer gewährt wird.“
|
||||||
|
|
||||||
|
|||||||
@@ -20,16 +20,15 @@ Niemand muss das. Die Nachricht ist bereits ins Dorf eingetreten, auf Wegen schn
|
|||||||
|
|
||||||
Du sitzt sehr gerade, während Eibenreith dich zum ersten Mal betrachtet.
|
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
|
* [__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.
|
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
|
-> village_arrival_options
|
||||||
|
|
||||||
* [__Höre__: Auf das Wasser unter der Straße.] #action:orientation #optional
|
* [__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.
|
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
|
-> village_arrival_options
|
||||||
|
|
||||||
* [__Untersuche__: Die Kirche.] #action:orientation #optional
|
* [__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.
|
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.
|
||||||
@@ -43,7 +42,7 @@ Du sitzt sehr gerade, während Eibenreith dich zum ersten Mal betrachtet.
|
|||||||
Die Kirche sieht nicht aus, als habe sie den älteren Dingen im Tal widersprochen. Eher, als habe sie gelernt, über ihnen zu stehen.
|
Die Kirche sieht nicht aus, als habe sie den älteren Dingen im Tal widersprochen. Eher, als habe sie gelernt, über ihnen zu stehen.
|
||||||
}
|
}
|
||||||
|
|
||||||
-> dorfbeobachtung
|
-> village_arrival_options
|
||||||
|
|
||||||
* [__Warte__: Bis die Kutsche hält.] #action:social #key:z
|
* [__Warte__: Bis die Kutsche hält.] #action:social #key:z
|
||||||
-> village_exit_puzzle
|
-> village_exit_puzzle
|
||||||
|
|||||||
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
@@ -1,706 +0,0 @@
|
|||||||
title: The Mysterious Mansion
|
|
||||||
author: AI Interactive Fiction
|
|
||||||
version: 1.0.0
|
|
||||||
introduction: |
|
|
||||||
|
|
||||||
#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:
|
|
||||||
# Starting area
|
|
||||||
front_yard:
|
|
||||||
name: Front Yard
|
|
||||||
description: |
|
|
||||||
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
|
|
||||||
description: large wooden doors lead into the mansion
|
|
||||||
- direction: south
|
|
||||||
targetRoomId: street
|
|
||||||
description: wrought iron gates lead back to the street
|
|
||||||
objects:
|
|
||||||
- strange_letter
|
|
||||||
- garden_statue
|
|
||||||
characters: []
|
|
||||||
|
|
||||||
# Main entrance
|
|
||||||
entrance_hall:
|
|
||||||
name: Entrance Hall
|
|
||||||
description: |
|
|
||||||
Grand chandeliers hang from the high ceiling, their crystals covered in cobwebs.
|
|
||||||
A wide staircase curves up to the second floor, and paintings of stern-faced
|
|
||||||
individuals watch you from ornate frames on the walls.
|
|
||||||
The floor is polished marble, though dusty from neglect.
|
|
||||||
exits:
|
|
||||||
- direction: south
|
|
||||||
targetRoomId: front_yard
|
|
||||||
description: the main entrance doors
|
|
||||||
- direction: north
|
|
||||||
targetRoomId: grand_staircase
|
|
||||||
description: the grand staircase
|
|
||||||
- direction: east
|
|
||||||
targetRoomId: dining_room
|
|
||||||
description: an archway leads to what appears to be a dining room
|
|
||||||
- direction: west
|
|
||||||
targetRoomId: library
|
|
||||||
description: a door marked 'Library'
|
|
||||||
objects:
|
|
||||||
- dusty_key
|
|
||||||
- umbrella_stand
|
|
||||||
characters:
|
|
||||||
- butler_ghost
|
|
||||||
|
|
||||||
# Library
|
|
||||||
library:
|
|
||||||
name: Library
|
|
||||||
description: |
|
|
||||||
Bookshelves line every wall, reaching from floor to ceiling.
|
|
||||||
A reading desk sits in the center of the room, a leather-bound book
|
|
||||||
open upon it. A gentle fire crackles in the fireplace, casting
|
|
||||||
dancing shadows across the room.
|
|
||||||
exits:
|
|
||||||
- direction: east
|
|
||||||
targetRoomId: entrance_hall
|
|
||||||
description: the door back to the entrance hall
|
|
||||||
- direction: north
|
|
||||||
targetRoomId: secret_study
|
|
||||||
description: a hidden door in the bookshelf
|
|
||||||
isLocked: true
|
|
||||||
keyId: old_brass_key
|
|
||||||
objects:
|
|
||||||
- leather_book
|
|
||||||
- reading_glasses
|
|
||||||
- old_brass_key
|
|
||||||
characters: []
|
|
||||||
|
|
||||||
# Dining Room
|
|
||||||
dining_room:
|
|
||||||
name: Dining Room
|
|
||||||
description: |
|
|
||||||
A long table dominates this room, set for a dinner party that never happened.
|
|
||||||
Fine china and silverware rest atop an elegant tablecloth, now gray with dust.
|
|
||||||
A chandelier hangs above, and a sideboard against the wall holds various serving dishes.
|
|
||||||
exits:
|
|
||||||
- direction: west
|
|
||||||
targetRoomId: entrance_hall
|
|
||||||
description: the archway back to the entrance hall
|
|
||||||
- direction: north
|
|
||||||
targetRoomId: kitchen
|
|
||||||
description: a swinging door to what must be the kitchen
|
|
||||||
objects:
|
|
||||||
- silver_candlestick
|
|
||||||
- dusty_plate
|
|
||||||
characters:
|
|
||||||
- dining_ghost
|
|
||||||
|
|
||||||
# Kitchen
|
|
||||||
kitchen:
|
|
||||||
name: Kitchen
|
|
||||||
description: |
|
|
||||||
This once-busy kitchen now stands silent. Copper pots and pans hang from hooks,
|
|
||||||
and an old cast-iron stove sits cold against the wall. A large preparation table
|
|
||||||
occupies the center of the room, and a pantry door stands ajar.
|
|
||||||
exits:
|
|
||||||
- direction: south
|
|
||||||
targetRoomId: dining_room
|
|
||||||
description: the swinging door back to the dining room
|
|
||||||
- direction: east
|
|
||||||
targetRoomId: pantry
|
|
||||||
description: the pantry door
|
|
||||||
objects:
|
|
||||||
- rusty_knife
|
|
||||||
- cookbook
|
|
||||||
characters: []
|
|
||||||
|
|
||||||
# Pantry
|
|
||||||
pantry:
|
|
||||||
name: Pantry
|
|
||||||
description: |
|
|
||||||
Shelves line the walls of this small room, holding preserves in dusty jars
|
|
||||||
and sacks of long-expired ingredients. A small window provides minimal light,
|
|
||||||
and a musty smell permeates the air.
|
|
||||||
exits:
|
|
||||||
- direction: west
|
|
||||||
targetRoomId: kitchen
|
|
||||||
description: the door back to the kitchen
|
|
||||||
objects:
|
|
||||||
- dusty_jar
|
|
||||||
- strange_bottle
|
|
||||||
characters: []
|
|
||||||
|
|
||||||
# Grand Staircase
|
|
||||||
grand_staircase:
|
|
||||||
name: Grand Staircase
|
|
||||||
description: |
|
|
||||||
The staircase curves gracefully upward, its wooden railings polished to a soft glow
|
|
||||||
despite the overall neglect of the mansion. Family portraits line the walls,
|
|
||||||
following your movement with their painted eyes.
|
|
||||||
exits:
|
|
||||||
- direction: south
|
|
||||||
targetRoomId: entrance_hall
|
|
||||||
description: back down to the entrance hall
|
|
||||||
- direction: north
|
|
||||||
targetRoomId: upper_landing
|
|
||||||
description: up to the second floor
|
|
||||||
objects:
|
|
||||||
- family_portrait
|
|
||||||
characters: []
|
|
||||||
|
|
||||||
# Upper Landing
|
|
||||||
upper_landing:
|
|
||||||
name: Upper Landing
|
|
||||||
description: |
|
|
||||||
The upper landing connects several rooms on the second floor. A faded
|
|
||||||
carpet runs down the center of the hallway, and doors line both sides.
|
|
||||||
A large window at the end of the hall shows the rainy night outside.
|
|
||||||
exits:
|
|
||||||
- direction: south
|
|
||||||
targetRoomId: grand_staircase
|
|
||||||
description: down the grand staircase
|
|
||||||
- direction: east
|
|
||||||
targetRoomId: master_bedroom
|
|
||||||
description: a door marked 'Master Bedroom'
|
|
||||||
- direction: west
|
|
||||||
targetRoomId: study
|
|
||||||
description: a door marked 'Study'
|
|
||||||
objects: []
|
|
||||||
characters: []
|
|
||||||
|
|
||||||
# Master Bedroom
|
|
||||||
master_bedroom:
|
|
||||||
name: Master Bedroom
|
|
||||||
description: |
|
|
||||||
A large four-poster bed dominates this room, its once-luxurious hangings
|
|
||||||
now faded and torn. A vanity sits in the corner, its mirror clouded with age,
|
|
||||||
and a wardrobe stands against the far wall.
|
|
||||||
exits:
|
|
||||||
- direction: west
|
|
||||||
targetRoomId: upper_landing
|
|
||||||
description: the door back to the upper landing
|
|
||||||
objects:
|
|
||||||
- jewelry_box
|
|
||||||
- old_diary
|
|
||||||
characters:
|
|
||||||
- lady_ghost
|
|
||||||
|
|
||||||
# Study
|
|
||||||
study:
|
|
||||||
name: Study
|
|
||||||
description: |
|
|
||||||
This cozy room contains a large desk covered in papers, a comfortable
|
|
||||||
armchair, and a globe that seems to rotate slowly on its own. Bookshelves
|
|
||||||
line the walls, filled with volumes on various esoteric subjects.
|
|
||||||
exits:
|
|
||||||
- direction: east
|
|
||||||
targetRoomId: upper_landing
|
|
||||||
description: the door back to the upper landing
|
|
||||||
objects:
|
|
||||||
- strange_device
|
|
||||||
- important_letter
|
|
||||||
characters: []
|
|
||||||
|
|
||||||
# Secret Study (hidden room)
|
|
||||||
secret_study:
|
|
||||||
name: Secret Study
|
|
||||||
description: |
|
|
||||||
Hidden behind the library bookshelf, this small room appears to be a
|
|
||||||
private study. A desk with a locked drawer sits against one wall, and
|
|
||||||
shelves hold unusual artifacts and rare books. A single candle provides
|
|
||||||
dim illumination.
|
|
||||||
exits:
|
|
||||||
- direction: south
|
|
||||||
targetRoomId: library
|
|
||||||
description: the hidden door back to the library
|
|
||||||
objects:
|
|
||||||
- ancient_tome
|
|
||||||
- crystal_key
|
|
||||||
characters: []
|
|
||||||
|
|
||||||
# Street (exit area)
|
|
||||||
street:
|
|
||||||
name: Street
|
|
||||||
description: |
|
|
||||||
The quiet street outside the mansion is shrouded in fog. Streetlamps cast
|
|
||||||
pools of yellow light that barely penetrate the mist. The mansion's gates
|
|
||||||
loom behind you, while the way back to town lies ahead.
|
|
||||||
exits:
|
|
||||||
- direction: north
|
|
||||||
targetRoomId: front_yard
|
|
||||||
description: the mansion gates
|
|
||||||
objects: []
|
|
||||||
characters: []
|
|
||||||
|
|
||||||
# Object definitions
|
|
||||||
objects:
|
|
||||||
strange_letter:
|
|
||||||
name: Strange Letter
|
|
||||||
description: |
|
|
||||||
A weathered envelope containing an invitation to visit the mansion.
|
|
||||||
The handwriting is elegant but unfamiliar, and the letter is signed
|
|
||||||
simply with the initial "M".
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
- readable
|
|
||||||
states: {}
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- read
|
|
||||||
- examine
|
|
||||||
|
|
||||||
garden_statue:
|
|
||||||
name: Garden Statue
|
|
||||||
description: |
|
|
||||||
A weathered stone statue of a weeping angel. Its face is covered by its hands,
|
|
||||||
and detailed wings spread out from its back. Something about it makes you uneasy.
|
|
||||||
traits:
|
|
||||||
- fixed
|
|
||||||
states: {}
|
|
||||||
allowedActions:
|
|
||||||
- examine
|
|
||||||
|
|
||||||
dusty_key:
|
|
||||||
name: Dusty Key
|
|
||||||
description: |
|
|
||||||
An old iron key, covered in dust. It looks like it might fit an old door somewhere.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
- key
|
|
||||||
states: {}
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- examine
|
|
||||||
- use
|
|
||||||
|
|
||||||
umbrella_stand:
|
|
||||||
name: Umbrella Stand
|
|
||||||
description: |
|
|
||||||
A brass stand holding several antique umbrellas, all in various states of decay.
|
|
||||||
traits:
|
|
||||||
- fixed
|
|
||||||
- container
|
|
||||||
states:
|
|
||||||
open: true
|
|
||||||
containedObjects: []
|
|
||||||
allowedActions:
|
|
||||||
- examine
|
|
||||||
|
|
||||||
leather_book:
|
|
||||||
name: Leather Book
|
|
||||||
description: |
|
|
||||||
A thick tome bound in dark leather. The pages are filled with strange symbols
|
|
||||||
and diagrams that seem to shift slightly when you're not looking directly at them.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
- readable
|
|
||||||
states: {}
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- read
|
|
||||||
- examine
|
|
||||||
|
|
||||||
reading_glasses:
|
|
||||||
name: Reading Glasses
|
|
||||||
description: |
|
|
||||||
A pair of wire-rimmed spectacles. The lenses have a slight blue tint to them.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
- wearable
|
|
||||||
states:
|
|
||||||
worn: false
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- wear
|
|
||||||
- examine
|
|
||||||
|
|
||||||
old_brass_key:
|
|
||||||
name: Brass Key
|
|
||||||
description: |
|
|
||||||
A small brass key with intricate engravings. It seems to be quite old but well-maintained.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
- key
|
|
||||||
states: {}
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- examine
|
|
||||||
- use
|
|
||||||
|
|
||||||
silver_candlestick:
|
|
||||||
name: Silver Candlestick
|
|
||||||
description: |
|
|
||||||
A tarnished silver candlestick with an unlit candle. It feels heavy in your hand.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
- light_source
|
|
||||||
states:
|
|
||||||
lit: false
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- light
|
|
||||||
- examine
|
|
||||||
|
|
||||||
dusty_plate:
|
|
||||||
name: Dusty Plate
|
|
||||||
description: |
|
|
||||||
A fine china plate covered in a layer of dust. Despite its age, the painted pattern is still vivid.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
states: {}
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- examine
|
|
||||||
|
|
||||||
rusty_knife:
|
|
||||||
name: Rusty Knife
|
|
||||||
description: |
|
|
||||||
An old kitchen knife with a rusted blade. It's dull, but still might be useful.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
- sharp
|
|
||||||
states: {}
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- examine
|
|
||||||
- use
|
|
||||||
|
|
||||||
cookbook:
|
|
||||||
name: Cookbook
|
|
||||||
description: |
|
|
||||||
A yellowed cookbook filled with strange recipes. Some ingredients are unusual, and
|
|
||||||
the instructions sometimes reference phases of the moon or specific star alignments.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
- readable
|
|
||||||
states: {}
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- read
|
|
||||||
- examine
|
|
||||||
|
|
||||||
dusty_jar:
|
|
||||||
name: Dusty Jar
|
|
||||||
description: |
|
|
||||||
A glass jar containing what might once have been fruit preserves, now unidentifiable.
|
|
||||||
Best not to open it.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
- container
|
|
||||||
states:
|
|
||||||
open: false
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- examine
|
|
||||||
|
|
||||||
strange_bottle:
|
|
||||||
name: Strange Bottle
|
|
||||||
description: |
|
|
||||||
A small bottle containing a glowing blue liquid. The label is written in a language you don't recognize.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
- drinkable
|
|
||||||
states: {}
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- drink
|
|
||||||
- examine
|
|
||||||
|
|
||||||
family_portrait:
|
|
||||||
name: Family Portrait
|
|
||||||
description: |
|
|
||||||
A large painting of a stern-looking family - a husband, wife, and three children.
|
|
||||||
The father's eyes seem to follow you, and there's something oddly familiar about his face.
|
|
||||||
traits:
|
|
||||||
- fixed
|
|
||||||
states: {}
|
|
||||||
allowedActions:
|
|
||||||
- examine
|
|
||||||
|
|
||||||
jewelry_box:
|
|
||||||
name: Jewelry Box
|
|
||||||
description: |
|
|
||||||
An ornate wooden box inlaid with mother-of-pearl. Inside are several pieces of
|
|
||||||
antique jewelry, including a ruby necklace that catches the light strangely.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
- container
|
|
||||||
states:
|
|
||||||
open: true
|
|
||||||
containedObjects:
|
|
||||||
- ruby_necklace
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- open
|
|
||||||
- close
|
|
||||||
- examine
|
|
||||||
|
|
||||||
ruby_necklace:
|
|
||||||
name: Ruby Necklace
|
|
||||||
description: |
|
|
||||||
A delicate gold chain with a large ruby pendant. The gem seems to glow with an inner light,
|
|
||||||
and it feels warm to the touch.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
- wearable
|
|
||||||
states:
|
|
||||||
worn: false
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- wear
|
|
||||||
- examine
|
|
||||||
|
|
||||||
old_diary:
|
|
||||||
name: Old Diary
|
|
||||||
description: |
|
|
||||||
A leather-bound diary with yellowed pages. The entries detail the daily life of
|
|
||||||
the mansion's former mistress, and hint at a growing fear of something in the house.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
- readable
|
|
||||||
states: {}
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- read
|
|
||||||
- examine
|
|
||||||
|
|
||||||
strange_device:
|
|
||||||
name: Strange Device
|
|
||||||
description: |
|
|
||||||
A brass contraption with gears, dials, and a glass dome. It's purpose isn't clear,
|
|
||||||
but it occasionally ticks and whirs on its own.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
states:
|
|
||||||
active: false
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- use
|
|
||||||
- examine
|
|
||||||
|
|
||||||
important_letter:
|
|
||||||
name: Important Letter
|
|
||||||
description: |
|
|
||||||
A sealed envelope addressed to "The Heir." The wax seal bears the same crest
|
|
||||||
that you've seen throughout the mansion.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
- readable
|
|
||||||
states: {}
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- read
|
|
||||||
- examine
|
|
||||||
|
|
||||||
ancient_tome:
|
|
||||||
name: Ancient Tome
|
|
||||||
description: |
|
|
||||||
A massive book bound in what appears to be human skin. The title, "Liber Umbrarum,"
|
|
||||||
is embossed in gold on the spine. The pages contain rituals and incantations.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
- readable
|
|
||||||
states: {}
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- read
|
|
||||||
- examine
|
|
||||||
|
|
||||||
crystal_key:
|
|
||||||
name: Crystal Key
|
|
||||||
description: |
|
|
||||||
A key made of clear crystal that catches the light in mesmerizing ways. Despite
|
|
||||||
its appearance, it feels as solid as metal and cool to the touch.
|
|
||||||
traits:
|
|
||||||
- takeable
|
|
||||||
- key
|
|
||||||
states: {}
|
|
||||||
allowedActions:
|
|
||||||
- take
|
|
||||||
- use
|
|
||||||
- examine
|
|
||||||
|
|
||||||
# Character definitions
|
|
||||||
characters:
|
|
||||||
butler_ghost:
|
|
||||||
name: Ghostly Butler
|
|
||||||
description: |
|
|
||||||
The translucent figure of an elderly butler, dressed in formal attire from a bygone era.
|
|
||||||
He stands with perfect posture, hands clasped behind his back.
|
|
||||||
dialogue:
|
|
||||||
greeting: "Welcome to the mansion, sir/madam. We've been expecting you."
|
|
||||||
mansion: "This estate has belonged to the Montgomery family for generations. Such a shame what happened to them."
|
|
||||||
family: "The Montgomerys? All gone now, I'm afraid. The master, his wife, and their children. A tragedy."
|
|
||||||
tragedy: "I'm not at liberty to discuss the details, but the answers you seek may be found in the study."
|
|
||||||
yourself: "Me? I've served this house for longer than I care to remember. Even death couldn't release me from my duties."
|
|
||||||
defaultResponse: "I'm afraid I cannot help you with that particular inquiry."
|
|
||||||
inventory: []
|
|
||||||
mood: formal
|
|
||||||
|
|
||||||
dining_ghost:
|
|
||||||
name: Dining Guest
|
|
||||||
description: |
|
|
||||||
A spectral figure in elegant dinner attire, seated at the table. She appears to be
|
|
||||||
a young woman, and she plays absently with a spectral fork.
|
|
||||||
dialogue:
|
|
||||||
greeting: "Oh, a new guest! How delightful. Will you join us for dinner? It's been so long since we had fresh company."
|
|
||||||
dinner: "We've been waiting for the main course for... goodness, how long has it been now? Years, I suppose."
|
|
||||||
herself: "My name? It's... it's strange, I can't quite recall. I remember coming here for a dinner party, but then..."
|
|
||||||
party: "It was supposed to be a celebration. The master of the house had made some sort of discovery. Something important."
|
|
||||||
discovery: "In the secret study, I believe. Behind the library. The master was very excited about it."
|
|
||||||
defaultResponse: "I'm sorry, my mind isn't what it used to be. The years blur together when you're like this."
|
|
||||||
inventory: []
|
|
||||||
mood: wistful
|
|
||||||
|
|
||||||
lady_ghost:
|
|
||||||
name: Ghostly Lady
|
|
||||||
description: |
|
|
||||||
The elegant apparition of a woman in Victorian dress, her face partly obscured by a veil.
|
|
||||||
She sits at the vanity, brushing her long hair with a ghostly brush.
|
|
||||||
dialogue:
|
|
||||||
greeting: "A visitor? How unusual. Are you lost, or are you here for a purpose?"
|
|
||||||
purpose: "Everyone who comes to this house has a purpose, whether they know it or not."
|
|
||||||
herself: "I was the lady of this house once. Now I am bound to it, as are we all."
|
|
||||||
family: "My husband was obsessed with his research. My children... I tried to protect them. I failed."
|
|
||||||
research: "The barriers between worlds, the nature of reality itself. He found something, in the end. Something that should have remained hidden."
|
|
||||||
hidden: "In his secret study. The key is... well, I suppose you'll have to find that yourself. Some secrets reveal themselves only to those who seek them."
|
|
||||||
defaultResponse: "There are some things I cannot speak of. The house has its rules, even for the dead."
|
|
||||||
inventory: []
|
|
||||||
mood: melancholy
|
|
||||||
|
|
||||||
# Action definitions
|
|
||||||
actions:
|
|
||||||
look:
|
|
||||||
patterns:
|
|
||||||
- "look around"
|
|
||||||
- "look at [object]"
|
|
||||||
- "examine [object]"
|
|
||||||
- "check [object]"
|
|
||||||
- "inspect [object]"
|
|
||||||
- "observe [object]"
|
|
||||||
- "view [object]"
|
|
||||||
handler: "look"
|
|
||||||
|
|
||||||
go:
|
|
||||||
patterns:
|
|
||||||
- "go [direction]"
|
|
||||||
- "move [direction]"
|
|
||||||
- "walk [direction]"
|
|
||||||
- "head [direction]"
|
|
||||||
- "travel [direction]"
|
|
||||||
- "enter [direction]"
|
|
||||||
requiresObject: true
|
|
||||||
handler: "go"
|
|
||||||
|
|
||||||
take:
|
|
||||||
patterns:
|
|
||||||
- "take [object]"
|
|
||||||
- "get [object]"
|
|
||||||
- "pick up [object]"
|
|
||||||
- "grab [object]"
|
|
||||||
- "collect [object]"
|
|
||||||
requiresObject: true
|
|
||||||
handler: "take"
|
|
||||||
|
|
||||||
drop:
|
|
||||||
patterns:
|
|
||||||
- "drop [object]"
|
|
||||||
- "put down [object]"
|
|
||||||
- "discard [object]"
|
|
||||||
- "leave [object]"
|
|
||||||
requiresObject: true
|
|
||||||
handler: "drop"
|
|
||||||
|
|
||||||
inventory:
|
|
||||||
patterns:
|
|
||||||
- "inventory"
|
|
||||||
- "check inventory"
|
|
||||||
- "show inventory"
|
|
||||||
- "what am I carrying"
|
|
||||||
- "what do I have"
|
|
||||||
handler: "inventory"
|
|
||||||
|
|
||||||
use:
|
|
||||||
patterns:
|
|
||||||
- "use [object]"
|
|
||||||
- "use [object] on [target]"
|
|
||||||
- "use [object] with [target]"
|
|
||||||
- "apply [object] to [target]"
|
|
||||||
requiresObject: true
|
|
||||||
requiresTarget: false
|
|
||||||
handler: "use"
|
|
||||||
|
|
||||||
talk:
|
|
||||||
patterns:
|
|
||||||
- "talk to [object]"
|
|
||||||
- "speak to [object]"
|
|
||||||
- "ask [object] about [topic]"
|
|
||||||
- "tell [object] about [topic]"
|
|
||||||
- "converse with [object]"
|
|
||||||
requiresObject: true
|
|
||||||
handler: "talk"
|
|
||||||
|
|
||||||
read:
|
|
||||||
patterns:
|
|
||||||
- "read [object]"
|
|
||||||
- "read from [object]"
|
|
||||||
- "examine [object]"
|
|
||||||
- "look at [object]"
|
|
||||||
requiresObject: true
|
|
||||||
handler: "look"
|
|
||||||
|
|
||||||
help:
|
|
||||||
patterns:
|
|
||||||
- "help"
|
|
||||||
- "commands"
|
|
||||||
- "what can I do"
|
|
||||||
- "show help"
|
|
||||||
handler: "help"
|
|
||||||
|
|
||||||
wear:
|
|
||||||
patterns:
|
|
||||||
- "wear [object]"
|
|
||||||
- "put on [object]"
|
|
||||||
- "don [object]"
|
|
||||||
requiresObject: true
|
|
||||||
handler: "use"
|
|
||||||
|
|
||||||
# Initial game state
|
|
||||||
initialState:
|
|
||||||
currentRoomId: front_yard
|
|
||||||
inventory:
|
|
||||||
- strange_letter
|
|
||||||
visitedRooms: []
|
|
||||||
flags:
|
|
||||||
hasMetButler: false
|
|
||||||
hasFoundSecret: false
|
|
||||||
counters:
|
|
||||||
moveCount: 0
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# 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.
@@ -1,44 +0,0 @@
|
|||||||
# 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: ""
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# 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.
|
|
||||||
Vendored
-64
@@ -1,64 +0,0 @@
|
|||||||
/**
|
|
||||||
* Command-line interface for running the interactive fiction game
|
|
||||||
*/
|
|
||||||
export declare class GameRunner {
|
|
||||||
private engine;
|
|
||||||
private llmProvider;
|
|
||||||
private rl;
|
|
||||||
private gameContext;
|
|
||||||
private gameHistory;
|
|
||||||
private suggestedCommands;
|
|
||||||
constructor();
|
|
||||||
/**
|
|
||||||
* Initialize the game
|
|
||||||
*/
|
|
||||||
initialize(worldPath: string): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Start the game in CLI mode
|
|
||||||
*/
|
|
||||||
start(): Promise<void>;
|
|
||||||
/**
|
|
||||||
* The main game loop for CLI mode
|
|
||||||
*/
|
|
||||||
private gameLoop;
|
|
||||||
/**
|
|
||||||
* Process a player command and return the narrative response
|
|
||||||
* Used by both CLI and web interfaces
|
|
||||||
*/
|
|
||||||
processCommand(input: string): Promise<string>;
|
|
||||||
/**
|
|
||||||
* End the game
|
|
||||||
*/
|
|
||||||
end(): void;
|
|
||||||
/**
|
|
||||||
* Update the game context with new narrative
|
|
||||||
*/
|
|
||||||
private updateGameContext;
|
|
||||||
/**
|
|
||||||
* Get the current game state
|
|
||||||
* Used by web interface
|
|
||||||
*/
|
|
||||||
getGameState(): {
|
|
||||||
world: import("../interfaces/world-model").WorldModel;
|
|
||||||
currentRoomId: string;
|
|
||||||
inventory: string[];
|
|
||||||
visitedRooms: string[];
|
|
||||||
flags: Record<string, boolean>;
|
|
||||||
counters: Record<string, number>;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Get the current room description
|
|
||||||
* Used by web interface
|
|
||||||
*/
|
|
||||||
getCurrentRoomDescription(): string;
|
|
||||||
/**
|
|
||||||
* Get suggested actions for the current game state
|
|
||||||
* Used by web interface
|
|
||||||
*/
|
|
||||||
getSuggestions(): string[];
|
|
||||||
/**
|
|
||||||
* Load a saved game state
|
|
||||||
* Used by web interface
|
|
||||||
*/
|
|
||||||
loadGameState(savedState: any): void;
|
|
||||||
}
|
|
||||||
Vendored
-262
@@ -1,262 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
/**
|
|
||||||
* Command-line interface for running the interactive fiction game
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
exports.GameRunner = void 0;
|
|
||||||
const readline = __importStar(require("readline"));
|
|
||||||
const path = __importStar(require("path"));
|
|
||||||
const dotenv = __importStar(require("dotenv"));
|
|
||||||
const game_engine_1 = require("../engine/game-engine");
|
|
||||||
const openrouter_provider_1 = require("../llm/openrouter-provider");
|
|
||||||
// Load environment variables
|
|
||||||
dotenv.config();
|
|
||||||
class GameRunner {
|
|
||||||
constructor() {
|
|
||||||
this.rl = null;
|
|
||||||
this.gameContext = '';
|
|
||||||
this.gameHistory = [];
|
|
||||||
this.suggestedCommands = [];
|
|
||||||
this.engine = new game_engine_1.TextAdventureEngine();
|
|
||||||
this.llmProvider = new openrouter_provider_1.OpenRouterProvider();
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Initialize the game
|
|
||||||
*/
|
|
||||||
async initialize(worldPath) {
|
|
||||||
console.log('Initializing game...');
|
|
||||||
// Initialize LLM provider
|
|
||||||
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/or OPENROUTER_MODEL');
|
|
||||||
}
|
|
||||||
await this.llmProvider.initialize({
|
|
||||||
apiKey,
|
|
||||||
model,
|
|
||||||
temperature: 0.7,
|
|
||||||
maxTokens: 800
|
|
||||||
});
|
|
||||||
// Load the world
|
|
||||||
const resolvedPath = path.resolve(worldPath);
|
|
||||||
console.log(`Loading world from ${resolvedPath}...`);
|
|
||||||
await this.engine.loadWorld(resolvedPath);
|
|
||||||
console.log('Game initialized successfully!');
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Start the game in CLI mode
|
|
||||||
*/
|
|
||||||
async start() {
|
|
||||||
// Create readline interface for CLI mode
|
|
||||||
this.rl = readline.createInterface({
|
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
// Display introduction
|
|
||||||
const introText = await this.engine.start();
|
|
||||||
console.log('\n' + introText + '\n');
|
|
||||||
// Look at initial room
|
|
||||||
const initialLook = this.engine.processAction({ action: 'look', confidence: 1 });
|
|
||||||
// Generate narrative description
|
|
||||||
const narrativeRequest = {
|
|
||||||
action: 'look',
|
|
||||||
result: initialLook.message,
|
|
||||||
roomDescription: this.engine.getCurrentRoomDescription(),
|
|
||||||
visibleObjects: this.engine.getVisibleObjects(),
|
|
||||||
visibleCharacters: this.engine.getVisibleCharacters(),
|
|
||||||
tone: 'descriptive'
|
|
||||||
};
|
|
||||||
const narrative = await this.llmProvider.generateNarrative(narrativeRequest);
|
|
||||||
console.log('\n' + narrative.text + '\n');
|
|
||||||
// Store suggestions if available
|
|
||||||
if (narrative.suggestions && narrative.suggestions.length > 0) {
|
|
||||||
this.suggestedCommands = narrative.suggestions;
|
|
||||||
}
|
|
||||||
// Update game context
|
|
||||||
this.updateGameContext(narrative.text);
|
|
||||||
// Start the game loop
|
|
||||||
this.gameLoop();
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Error starting game:', error);
|
|
||||||
this.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* The main game loop for CLI mode
|
|
||||||
*/
|
|
||||||
gameLoop() {
|
|
||||||
if (!this.rl)
|
|
||||||
return;
|
|
||||||
this.rl.question('> ', async (input) => {
|
|
||||||
if (input.toLowerCase() === 'quit' || input.toLowerCase() === 'exit') {
|
|
||||||
this.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const response = await this.processCommand(input);
|
|
||||||
console.log('\n' + response + '\n');
|
|
||||||
// Continue the game loop
|
|
||||||
this.gameLoop();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Process a player command and return the narrative response
|
|
||||||
* Used by both CLI and web interfaces
|
|
||||||
*/
|
|
||||||
async processCommand(input) {
|
|
||||||
try {
|
|
||||||
// Process player input
|
|
||||||
const actionRequest = {
|
|
||||||
playerInput: input,
|
|
||||||
currentRoom: this.engine.getWorldModel().rooms[this.engine.getCurrentState().currentRoomId].name,
|
|
||||||
visibleObjects: this.engine.getVisibleObjects().map(id => this.engine.getWorldModel().objects[id].name),
|
|
||||||
visibleCharacters: this.engine.getVisibleCharacters().map(id => this.engine.getWorldModel().characters[id].name),
|
|
||||||
possibleActions: this.engine.getAvailableActions(),
|
|
||||||
inventory: this.engine.getCurrentState().inventory.map(id => this.engine.getWorldModel().objects[id].name),
|
|
||||||
gameContext: this.gameContext
|
|
||||||
};
|
|
||||||
if (this.rl) {
|
|
||||||
console.log('Thinking...');
|
|
||||||
}
|
|
||||||
// Translate player input to action
|
|
||||||
const action = await this.llmProvider.translateAction(actionRequest);
|
|
||||||
// Process the action in the game engine
|
|
||||||
const actionResult = this.engine.processAction(action);
|
|
||||||
// If state changed, update it
|
|
||||||
if (actionResult.stateChanged && actionResult.newState) {
|
|
||||||
this.engine.getCurrentState().currentRoomId = actionResult.newState.currentRoomId;
|
|
||||||
this.engine.getCurrentState().inventory = actionResult.newState.inventory;
|
|
||||||
this.engine.getCurrentState().visitedRooms = actionResult.newState.visitedRooms;
|
|
||||||
this.engine.getCurrentState().flags = actionResult.newState.flags;
|
|
||||||
this.engine.getCurrentState().counters = actionResult.newState.counters;
|
|
||||||
}
|
|
||||||
// Generate narrative description
|
|
||||||
const narrativeRequest = {
|
|
||||||
action: `${action.action}${action.object ? ' ' + action.object : ''}${action.target ? ' on ' + action.target : ''}`,
|
|
||||||
result: actionResult.message,
|
|
||||||
roomDescription: this.engine.getCurrentRoomDescription(),
|
|
||||||
visibleObjects: this.engine.getVisibleObjects().map(id => this.engine.getWorldModel().objects[id].name),
|
|
||||||
visibleCharacters: this.engine.getVisibleCharacters().map(id => this.engine.getWorldModel().characters[id].name),
|
|
||||||
previousContext: this.gameHistory.slice(-3).join('\n'),
|
|
||||||
tone: 'descriptive'
|
|
||||||
};
|
|
||||||
const narrative = await this.llmProvider.generateNarrative(narrativeRequest);
|
|
||||||
// Store suggestions if available
|
|
||||||
if (narrative.suggestions && narrative.suggestions.length > 0) {
|
|
||||||
this.suggestedCommands = narrative.suggestions;
|
|
||||||
}
|
|
||||||
// Update game context with the new narrative
|
|
||||||
this.updateGameContext(narrative.text);
|
|
||||||
// Return the narrative text
|
|
||||||
return narrative.text;
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Error processing input:', error);
|
|
||||||
return 'Something went wrong. Please try again.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* End the game
|
|
||||||
*/
|
|
||||||
end() {
|
|
||||||
console.log('\nThanks for playing!');
|
|
||||||
if (this.rl) {
|
|
||||||
this.rl.close();
|
|
||||||
this.rl = null;
|
|
||||||
}
|
|
||||||
this.engine.end();
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Update the game context with new narrative
|
|
||||||
*/
|
|
||||||
updateGameContext(narrative) {
|
|
||||||
// Add to history
|
|
||||||
this.gameHistory.push(narrative);
|
|
||||||
// Keep history limited to last 10 entries
|
|
||||||
if (this.gameHistory.length > 10) {
|
|
||||||
this.gameHistory.shift();
|
|
||||||
}
|
|
||||||
// Update current context (last 5 entries)
|
|
||||||
this.gameContext = this.gameHistory.slice(-5).join('\n');
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Get the current game state
|
|
||||||
* Used by web interface
|
|
||||||
*/
|
|
||||||
getGameState() {
|
|
||||||
return {
|
|
||||||
world: this.engine.getWorldModel(),
|
|
||||||
currentRoomId: this.engine.getCurrentState().currentRoomId,
|
|
||||||
inventory: this.engine.getCurrentState().inventory,
|
|
||||||
visitedRooms: this.engine.getCurrentState().visitedRooms,
|
|
||||||
flags: this.engine.getCurrentState().flags,
|
|
||||||
counters: this.engine.getCurrentState().counters
|
|
||||||
};
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Get the current room description
|
|
||||||
* Used by web interface
|
|
||||||
*/
|
|
||||||
getCurrentRoomDescription() {
|
|
||||||
const roomId = this.engine.getCurrentState().currentRoomId;
|
|
||||||
return this.engine.getWorldModel().rooms[roomId].description;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Get suggested actions for the current game state
|
|
||||||
* Used by web interface
|
|
||||||
*/
|
|
||||||
getSuggestions() {
|
|
||||||
return this.suggestedCommands;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Load a saved game state
|
|
||||||
* Used by web interface
|
|
||||||
*/
|
|
||||||
loadGameState(savedState) {
|
|
||||||
// Set the current state to match the saved state
|
|
||||||
this.engine.getCurrentState().currentRoomId = savedState.currentRoomId;
|
|
||||||
this.engine.getCurrentState().inventory = savedState.inventory;
|
|
||||||
this.engine.getCurrentState().visitedRooms = savedState.visitedRooms;
|
|
||||||
this.engine.getCurrentState().flags = savedState.flags;
|
|
||||||
this.engine.getCurrentState().counters = savedState.counters;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
exports.GameRunner = GameRunner;
|
|
||||||
//# sourceMappingURL=game-runner.js.map
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-39
@@ -1,39 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
Vendored
-96
@@ -1,96 +0,0 @@
|
|||||||
"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
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"game-config.js","sourceRoot":"","sources":["../../src/config/game-config.ts"],"names":[],"mappings":";;;;;AA4DA,kCAIC;AAED,wCAsBC;AAED,4EAkBC;AAED,4CAYC;AA1HD,gDAAwB;AACxB,2BAAyD;AA+BzD,MAAM,YAAY,GAAG,cAAI,CAAC,OAAO,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;AAEtD,SAAS,cAAc,CAAC,MAAkB;IACxC,OAAO;QACL,MAAM;QACN,MAAM,EAAE,OAAO;QACf,KAAK,EAAE;YACL,YAAY,EACV,MAAM,KAAK,KAAK;gBACd,CAAC,CAAC,yBAAyB;gBAC3B,CAAC,CAAC,MAAM,KAAK,OAAO;oBAClB,CAAC,CAAC,uBAAuB;oBACzB,CAAC,CAAC,+BAA+B;YACvC,KAAK,EAAE,cAAc;YACrB,GAAG,EAAE,eAAe;YACpB,MAAM,EAAE,eAAe;SACxB;QACD,QAAQ,EAAE;YACR,KAAK,EAAE,wBAAwB;YAC/B,MAAM,EAAE,eAAe;YACvB,QAAQ,EAAE,8BAA8B;YACxC,OAAO,EAAE,OAAO;YAChB,SAAS,EAAE,EAAE;YACb,QAAQ,EAAE,OAAO;SAClB;KACF,CAAC;AACJ,CAAC;AAED,SAAgB,WAAW,CAAC,sBAA8B;IACxD,OAAO,cAAI,CAAC,UAAU,CAAC,sBAAsB,CAAC;QAC5C,CAAC,CAAC,sBAAsB;QACxB,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,YAAY,EAAE,sBAAsB,CAAC,CAAC;AACzD,CAAC;AAED,SAAgB,cAAc,CAAC,UAAkB,EAAE,MAAkB;IACnE,MAAM,YAAY,GAAG,WAAW,CAAC,UAAU,CAAC,CAAC;IAC7C,IAAI,CAAC,IAAA,eAAU,EAAC,YAAY,CAAC,EAAE,CAAC;QAC9B,OAAO,CAAC,IAAI,CAAC,oBAAoB,YAAY,WAAW,MAAM,YAAY,CAAC,CAAC;QAC5E,OAAO,cAAc,CAAC,MAAM,CAAC,CAAC;IAChC,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAA,iBAAY,EAAC,YAAY,EAAE,MAAM,CAAC,CAA8B,CAAC;IAC3F,MAAM,QAAQ,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM;QACxC,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM;QACxC,KAAK,EAAE;YACL,GAAG,QAAQ,CAAC,KAAK;YACjB,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC;SACxB;QACD,QAAQ,EAAE;YACR,GAAG,QAAQ,CAAC,QAAQ;YACpB,GAAG,CAAC,MAAM,CAAC,QAAQ,IAAI,EAAE,CAAC;YAC1B,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,QAAQ,IAAI,MAAM,CAAC,MAAM,IAAI,QAAQ,CAAC,QAAQ,CAAC,QAAQ;SACnF;KACF,CAAC;AACJ,CAAC;AAED,SAAgB,gCAAgC,CAAC,MAAwB;IACvE,MAAM,WAAW,GAAG;QAClB,MAAM,CAAC,KAAK,CAAC,KAAK;QAClB,MAAM,CAAC,KAAK,CAAC,GAAG;QAChB,MAAM,CAAC,KAAK,CAAC,MAAM;QACnB,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS;QACzE,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,SAAS;QAC7E,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,SAAS;QAC/E,MAAM,CAAC,KAAK,CAAC,SAAS;KACvB,CAAC;IAEF,KAAK,MAAM,SAAS,IAAI,WAAW,EAAE,CAAC;QACpC,IAAI,CAAC,SAAS;YAAE,SAAS;QACzB,MAAM,YAAY,GAAG,WAAW,CAAC,SAAS,CAAC,CAAC;QAC5C,IAAI,CAAC,IAAA,eAAU,EAAC,YAAY,CAAC,EAAE,CAAC;YAC9B,IAAA,cAAS,EAAC,YAAY,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC/C,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAgB,gBAAgB,CAAC,MAAwB;IACvD,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,MAAM,EAAE;YACN,KAAK,EAAE,SAAS;YAChB,GAAG,EAAE,UAAU;YACf,MAAM,EAAE,UAAU;YAClB,MAAM,EAAE,UAAU;SACnB;KACF,CAAC;AACJ,CAAC"}
|
|
||||||
Vendored
-77
@@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* Core Game Engine
|
|
||||||
* Manages game state and processes actions
|
|
||||||
*/
|
|
||||||
import { GameEngine, ActionResult } from '../interfaces/engine';
|
|
||||||
import { WorldModel, GameState } from '../interfaces/world-model';
|
|
||||||
import { ActionResponse } from '../interfaces/llm';
|
|
||||||
export declare class TextAdventureEngine implements GameEngine {
|
|
||||||
private worldModel;
|
|
||||||
private gameState;
|
|
||||||
private actionHandlers;
|
|
||||||
constructor();
|
|
||||||
/**
|
|
||||||
* Load a world model from a file
|
|
||||||
*/
|
|
||||||
loadWorld(worldModelPath: string): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Get the current game state
|
|
||||||
*/
|
|
||||||
getCurrentState(): GameState;
|
|
||||||
/**
|
|
||||||
* Get the world model
|
|
||||||
*/
|
|
||||||
getWorldModel(): WorldModel;
|
|
||||||
/**
|
|
||||||
* Process an action from the player
|
|
||||||
*/
|
|
||||||
processAction(action: ActionResponse): ActionResult;
|
|
||||||
/**
|
|
||||||
* Save the current game state to a file
|
|
||||||
*/
|
|
||||||
saveGame(filename: string): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Load a game state from a save file
|
|
||||||
*/
|
|
||||||
loadGame(filename: string): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Get a list of available actions in the current context
|
|
||||||
*/
|
|
||||||
getAvailableActions(): string[];
|
|
||||||
/**
|
|
||||||
* Get a list of visible objects in the current room
|
|
||||||
*/
|
|
||||||
getVisibleObjects(): string[];
|
|
||||||
/**
|
|
||||||
* Get a list of visible characters in the current room
|
|
||||||
*/
|
|
||||||
getVisibleCharacters(): string[];
|
|
||||||
/**
|
|
||||||
* Get the description of the current room
|
|
||||||
*/
|
|
||||||
getCurrentRoomDescription(): string;
|
|
||||||
/**
|
|
||||||
* Start the game and return the introduction text
|
|
||||||
*/
|
|
||||||
start(): Promise<string>;
|
|
||||||
/**
|
|
||||||
* End the game (cleanup resources if needed)
|
|
||||||
*/
|
|
||||||
end(): void;
|
|
||||||
/**
|
|
||||||
* Get the current room object
|
|
||||||
*/
|
|
||||||
private getCurrentRoom;
|
|
||||||
/**
|
|
||||||
* Register default action handlers
|
|
||||||
*/
|
|
||||||
private registerDefaultActionHandlers;
|
|
||||||
/**
|
|
||||||
* Find an object by name in a list of object IDs
|
|
||||||
*/
|
|
||||||
private findObjectByName;
|
|
||||||
/**
|
|
||||||
* Find a character by name in a list of character IDs
|
|
||||||
*/
|
|
||||||
private findCharacterByName;
|
|
||||||
}
|
|
||||||
Vendored
-607
@@ -1,607 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
/**
|
|
||||||
* Core Game Engine
|
|
||||||
* Manages game state and processes actions
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
exports.TextAdventureEngine = void 0;
|
|
||||||
const fs = __importStar(require("fs/promises"));
|
|
||||||
const yaml_parser_1 = require("../world-model/yaml-parser");
|
|
||||||
class TextAdventureEngine {
|
|
||||||
constructor() {
|
|
||||||
this.worldModel = null;
|
|
||||||
this.gameState = null;
|
|
||||||
this.actionHandlers = {};
|
|
||||||
this.registerDefaultActionHandlers();
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Load a world model from a file
|
|
||||||
*/
|
|
||||||
async loadWorld(worldModelPath) {
|
|
||||||
try {
|
|
||||||
this.worldModel = await yaml_parser_1.YamlWorldParser.loadFromFile(worldModelPath);
|
|
||||||
this.gameState = { ...this.worldModel.initialState };
|
|
||||||
// Mark the initial room as visited
|
|
||||||
if (!this.gameState.visitedRooms.includes(this.gameState.currentRoomId)) {
|
|
||||||
this.gameState.visitedRooms.push(this.gameState.currentRoomId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error(`Failed to load world from ${worldModelPath}:`, error);
|
|
||||||
throw new Error(`Could not load world: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Get the current game state
|
|
||||||
*/
|
|
||||||
getCurrentState() {
|
|
||||||
if (!this.gameState) {
|
|
||||||
throw new Error('Game state not initialized. Please load a world first.');
|
|
||||||
}
|
|
||||||
return { ...this.gameState };
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Get the world model
|
|
||||||
*/
|
|
||||||
getWorldModel() {
|
|
||||||
if (!this.worldModel) {
|
|
||||||
throw new Error('World model not initialized. Please load a world first.');
|
|
||||||
}
|
|
||||||
return this.worldModel;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Process an action from the player
|
|
||||||
*/
|
|
||||||
processAction(action) {
|
|
||||||
if (!this.worldModel || !this.gameState) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'Game not initialized',
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const handler = this.actionHandlers[action.action.toLowerCase()];
|
|
||||||
if (!handler) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `I don't know how to "${action.action}"`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return handler(this.gameState, this.worldModel, action);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Save the current game state to a file
|
|
||||||
*/
|
|
||||||
async saveGame(filename) {
|
|
||||||
if (!this.gameState || !this.worldModel) {
|
|
||||||
throw new Error('Cannot save: game not initialized');
|
|
||||||
}
|
|
||||||
const saveData = {
|
|
||||||
worldModelName: this.worldModel.title,
|
|
||||||
worldModelVersion: this.worldModel.version,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
gameState: this.gameState
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await fs.writeFile(filename, JSON.stringify(saveData, null, 2), 'utf8');
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error(`Failed to save game to ${filename}:`, error);
|
|
||||||
throw new Error(`Could not save game: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Load a game state from a save file
|
|
||||||
*/
|
|
||||||
async loadGame(filename) {
|
|
||||||
try {
|
|
||||||
const fileContents = await fs.readFile(filename, 'utf8');
|
|
||||||
const saveData = JSON.parse(fileContents);
|
|
||||||
// Check if the save file matches the current world model
|
|
||||||
if (!this.worldModel) {
|
|
||||||
throw new Error('World model not loaded');
|
|
||||||
}
|
|
||||||
if (saveData.worldModelName !== this.worldModel.title ||
|
|
||||||
saveData.worldModelVersion !== this.worldModel.version) {
|
|
||||||
throw new Error('Save file is for a different world or version');
|
|
||||||
}
|
|
||||||
// Load the game state
|
|
||||||
this.gameState = saveData.gameState;
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error(`Failed to load game from ${filename}:`, error);
|
|
||||||
throw new Error(`Could not load save file: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Get a list of available actions in the current context
|
|
||||||
*/
|
|
||||||
getAvailableActions() {
|
|
||||||
if (!this.worldModel)
|
|
||||||
return [];
|
|
||||||
// Common actions always available
|
|
||||||
const availableActions = ['look', 'inventory', 'help'];
|
|
||||||
// Add movement actions based on current room exits
|
|
||||||
const currentRoom = this.getCurrentRoom();
|
|
||||||
if (currentRoom) {
|
|
||||||
currentRoom.exits.forEach(exit => {
|
|
||||||
availableActions.push(`go ${exit.direction.toLowerCase()}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Add object interactions based on visible objects
|
|
||||||
const visibleObjects = this.getVisibleObjects();
|
|
||||||
const objects = this.worldModel.objects;
|
|
||||||
visibleObjects.forEach(objId => {
|
|
||||||
const obj = objects[objId];
|
|
||||||
if (obj) {
|
|
||||||
obj.allowedActions.forEach(action => {
|
|
||||||
availableActions.push(`${action} ${obj.name.toLowerCase()}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Add character interactions
|
|
||||||
const visibleCharacters = this.getVisibleCharacters();
|
|
||||||
visibleCharacters.forEach(charId => {
|
|
||||||
availableActions.push(`talk to ${this.worldModel.characters[charId].name.toLowerCase()}`);
|
|
||||||
});
|
|
||||||
// Add inventory object actions
|
|
||||||
this.gameState.inventory.forEach(objId => {
|
|
||||||
const obj = objects[objId];
|
|
||||||
if (obj) {
|
|
||||||
obj.allowedActions.forEach(action => {
|
|
||||||
availableActions.push(`${action} ${obj.name.toLowerCase()}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Array.from(new Set(availableActions)); // Remove duplicates
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Get a list of visible objects in the current room
|
|
||||||
*/
|
|
||||||
getVisibleObjects() {
|
|
||||||
if (!this.worldModel || !this.gameState)
|
|
||||||
return [];
|
|
||||||
const currentRoom = this.getCurrentRoom();
|
|
||||||
if (!currentRoom)
|
|
||||||
return [];
|
|
||||||
const visibleObjects = [...currentRoom.objects];
|
|
||||||
// Add objects from open containers
|
|
||||||
currentRoom.objects.forEach(objId => {
|
|
||||||
const obj = this.worldModel.objects[objId];
|
|
||||||
if (obj && obj.traits.includes('container') && obj.states?.open && obj.containedObjects) {
|
|
||||||
visibleObjects.push(...obj.containedObjects);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return visibleObjects;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Get a list of visible characters in the current room
|
|
||||||
*/
|
|
||||||
getVisibleCharacters() {
|
|
||||||
if (!this.worldModel || !this.gameState)
|
|
||||||
return [];
|
|
||||||
const currentRoom = this.getCurrentRoom();
|
|
||||||
return currentRoom ? currentRoom.characters : [];
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Get the description of the current room
|
|
||||||
*/
|
|
||||||
getCurrentRoomDescription() {
|
|
||||||
const currentRoom = this.getCurrentRoom();
|
|
||||||
if (!currentRoom)
|
|
||||||
return 'You are in a void. Something has gone wrong.';
|
|
||||||
return currentRoom.description;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Start the game and return the introduction text
|
|
||||||
*/
|
|
||||||
async start() {
|
|
||||||
if (!this.worldModel) {
|
|
||||||
throw new Error('World not loaded. Please load a world before starting.');
|
|
||||||
}
|
|
||||||
// Reset game state to initial state
|
|
||||||
this.gameState = { ...this.worldModel.initialState };
|
|
||||||
return this.worldModel.introduction;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* End the game (cleanup resources if needed)
|
|
||||||
*/
|
|
||||||
end() {
|
|
||||||
// Cleanup could happen here if needed
|
|
||||||
console.log('Game ended');
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Get the current room object
|
|
||||||
*/
|
|
||||||
getCurrentRoom() {
|
|
||||||
if (!this.worldModel || !this.gameState)
|
|
||||||
return null;
|
|
||||||
const roomId = this.gameState.currentRoomId;
|
|
||||||
return this.worldModel.rooms[roomId] || null;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Register default action handlers
|
|
||||||
*/
|
|
||||||
registerDefaultActionHandlers() {
|
|
||||||
// Look action
|
|
||||||
this.actionHandlers['look'] = (state, world, action) => {
|
|
||||||
const room = world.rooms[state.currentRoomId];
|
|
||||||
// If an object is specified, look at that object
|
|
||||||
if (action.object) {
|
|
||||||
// Try to find the object in the room or inventory
|
|
||||||
const visibleObjects = this.getVisibleObjects();
|
|
||||||
const objId = this.findObjectByName(action.object, [...visibleObjects, ...state.inventory]);
|
|
||||||
if (!objId) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `You don't see any ${action.object} here.`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const obj = world.objects[objId];
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: obj.description,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Look at the room
|
|
||||||
const objectDescriptions = room.objects
|
|
||||||
.map(id => world.objects[id])
|
|
||||||
.map(obj => `You can see ${obj.name.toLowerCase()} here.`);
|
|
||||||
const characterDescriptions = room.characters
|
|
||||||
.map(id => world.characters[id])
|
|
||||||
.map(char => `${char.name} is here.`);
|
|
||||||
const exitDescriptions = room.exits
|
|
||||||
.map(exit => `There is an exit ${exit.direction.toLowerCase()}${exit.description ? ` (${exit.description})` : ''}.`);
|
|
||||||
const fullDescription = [
|
|
||||||
room.description,
|
|
||||||
...objectDescriptions,
|
|
||||||
...characterDescriptions,
|
|
||||||
...exitDescriptions
|
|
||||||
].join('\n');
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: fullDescription,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
};
|
|
||||||
// Go action
|
|
||||||
this.actionHandlers['go'] = (state, world, action) => {
|
|
||||||
const room = world.rooms[state.currentRoomId];
|
|
||||||
if (!action.object) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'Go where?',
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Find the exit that matches the direction
|
|
||||||
const direction = action.object.toLowerCase();
|
|
||||||
const exit = room.exits.find(e => e.direction.toLowerCase() === direction);
|
|
||||||
if (!exit) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `You can't go ${direction} from here.`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (exit.isLocked) {
|
|
||||||
if (!exit.keyId) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `The way ${direction} is locked.`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!state.inventory.includes(exit.keyId)) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `The way ${direction} is locked and you don't have the key.`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Player has the key, unlock the exit
|
|
||||||
exit.isLocked = false;
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `You unlock the way ${direction} and proceed.`,
|
|
||||||
stateChanged: true,
|
|
||||||
newState: {
|
|
||||||
...state,
|
|
||||||
currentRoomId: exit.targetRoomId,
|
|
||||||
visitedRooms: state.visitedRooms.includes(exit.targetRoomId)
|
|
||||||
? state.visitedRooms
|
|
||||||
: [...state.visitedRooms, exit.targetRoomId]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Exit is not locked, just move
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `You go ${direction}.`,
|
|
||||||
stateChanged: true,
|
|
||||||
newState: {
|
|
||||||
...state,
|
|
||||||
currentRoomId: exit.targetRoomId,
|
|
||||||
visitedRooms: state.visitedRooms.includes(exit.targetRoomId)
|
|
||||||
? state.visitedRooms
|
|
||||||
: [...state.visitedRooms, exit.targetRoomId]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
// Take action
|
|
||||||
this.actionHandlers['take'] = (state, world, action) => {
|
|
||||||
if (!action.object) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'Take what?',
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Find the object in the current room
|
|
||||||
const visibleObjects = this.getVisibleObjects();
|
|
||||||
const objId = this.findObjectByName(action.object, visibleObjects);
|
|
||||||
if (!objId) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `You don't see any ${action.object} here.`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const obj = world.objects[objId];
|
|
||||||
// Check if the object can be taken
|
|
||||||
if (!obj.traits.includes('takeable')) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `You can't take the ${obj.name.toLowerCase()}.`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Remove object from room and add to inventory
|
|
||||||
const room = world.rooms[state.currentRoomId];
|
|
||||||
const newRoomObjects = room.objects.filter(id => id !== objId);
|
|
||||||
room.objects = newRoomObjects;
|
|
||||||
// Update state
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `You take the ${obj.name.toLowerCase()}.`,
|
|
||||||
stateChanged: true,
|
|
||||||
newState: {
|
|
||||||
...state,
|
|
||||||
inventory: [...state.inventory, objId]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
// Inventory action
|
|
||||||
this.actionHandlers['inventory'] = (state, world) => {
|
|
||||||
if (state.inventory.length === 0) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Your inventory is empty.',
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const items = state.inventory
|
|
||||||
.map(id => world.objects[id])
|
|
||||||
.map(obj => obj.name)
|
|
||||||
.join(', ');
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `You are carrying: ${items}.`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
};
|
|
||||||
// Drop action
|
|
||||||
this.actionHandlers['drop'] = (state, world, action) => {
|
|
||||||
if (!action.object) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'Drop what?',
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Find the object in the inventory
|
|
||||||
const objId = this.findObjectByName(action.object, state.inventory);
|
|
||||||
if (!objId) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `You don't have any ${action.object}.`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const obj = world.objects[objId];
|
|
||||||
// Remove object from inventory and add to room
|
|
||||||
const room = world.rooms[state.currentRoomId];
|
|
||||||
room.objects.push(objId);
|
|
||||||
// Update state
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `You drop the ${obj.name.toLowerCase()}.`,
|
|
||||||
stateChanged: true,
|
|
||||||
newState: {
|
|
||||||
...state,
|
|
||||||
inventory: state.inventory.filter(id => id !== objId)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
// Use action
|
|
||||||
this.actionHandlers['use'] = (state, world, action) => {
|
|
||||||
if (!action.object) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'Use what?',
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Find the object in inventory or visible objects
|
|
||||||
const visibleObjects = this.getVisibleObjects();
|
|
||||||
const objId = this.findObjectByName(action.object, [...state.inventory, ...visibleObjects]);
|
|
||||||
if (!objId) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `You don't see any ${action.object} here.`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const obj = world.objects[objId];
|
|
||||||
// Check if the object can be used
|
|
||||||
if (!obj.allowedActions.includes('use')) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `You can't use the ${obj.name.toLowerCase()}.`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Check if there's a target
|
|
||||||
if (action.target) {
|
|
||||||
const targetId = this.findObjectByName(action.target, [...state.inventory, ...visibleObjects]);
|
|
||||||
if (!targetId) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `You don't see any ${action.target} here.`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const target = world.objects[targetId];
|
|
||||||
// TODO: Implement object-specific use logic (could be extended with a more sophisticated system)
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `You use the ${obj.name.toLowerCase()} on the ${target.name.toLowerCase()}.`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Simple use without target
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `You use the ${obj.name.toLowerCase()}.`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
};
|
|
||||||
// Talk action
|
|
||||||
this.actionHandlers['talk'] = (state, world, action) => {
|
|
||||||
if (!action.object) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'Talk to whom?',
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Find the character in the room
|
|
||||||
const visibleCharacters = this.getVisibleCharacters();
|
|
||||||
const charId = this.findCharacterByName(action.object, visibleCharacters);
|
|
||||||
if (!charId) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `You don't see anyone called ${action.object} here.`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const character = world.characters[charId];
|
|
||||||
// If a topic is provided
|
|
||||||
if (action.parameters?.topic) {
|
|
||||||
const topic = action.parameters.topic.toLowerCase();
|
|
||||||
const response = character.dialogue[topic] || character.defaultResponse;
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `${character.name}: "${response}"`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// No specific topic
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `${character.name} looks ready to talk. You could ask about: ${Object.keys(character.dialogue).join(', ')}.`,
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
};
|
|
||||||
// Help action
|
|
||||||
this.actionHandlers['help'] = () => {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: [
|
|
||||||
'Available commands:',
|
|
||||||
'- look: Examine your surroundings or a specific object',
|
|
||||||
'- go [direction]: Move in a direction',
|
|
||||||
'- take [object]: Pick up an object',
|
|
||||||
'- drop [object]: Put down an object',
|
|
||||||
'- inventory: Check what you\'re carrying',
|
|
||||||
'- use [object] (on [target]): Use an object, optionally on another object',
|
|
||||||
'- talk to [character] (about [topic]): Speak with a character',
|
|
||||||
'- help: Show this help text',
|
|
||||||
'',
|
|
||||||
'You can type commands in natural language. The AI will interpret your intent.'
|
|
||||||
].join('\n'),
|
|
||||||
stateChanged: false
|
|
||||||
};
|
|
||||||
};
|
|
||||||
// Examine action (alias for look)
|
|
||||||
this.actionHandlers['examine'] = this.actionHandlers['look'];
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Find an object by name in a list of object IDs
|
|
||||||
*/
|
|
||||||
findObjectByName(name, objectIds) {
|
|
||||||
if (!this.worldModel)
|
|
||||||
return null;
|
|
||||||
const normalizedName = name.toLowerCase();
|
|
||||||
for (const id of objectIds) {
|
|
||||||
const obj = this.worldModel.objects[id];
|
|
||||||
if (obj && obj.name.toLowerCase() === normalizedName) {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Find a character by name in a list of character IDs
|
|
||||||
*/
|
|
||||||
findCharacterByName(name, characterIds) {
|
|
||||||
if (!this.worldModel)
|
|
||||||
return null;
|
|
||||||
const normalizedName = name.toLowerCase();
|
|
||||||
for (const id of characterIds) {
|
|
||||||
const character = this.worldModel.characters[id];
|
|
||||||
if (character && character.name.toLowerCase() === normalizedName) {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
exports.TextAdventureEngine = TextAdventureEngine;
|
|
||||||
//# sourceMappingURL=game-engine.js.map
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-33
@@ -1,33 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
Vendored
-359
@@ -1,359 +0,0 @@
|
|||||||
"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
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-84
@@ -1,84 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
Vendored
-989
@@ -1,989 +0,0 @@
|
|||||||
"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
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-4
@@ -1,4 +0,0 @@
|
|||||||
/**
|
|
||||||
* Main entry point for the AI Interactive Fiction application
|
|
||||||
*/
|
|
||||||
export {};
|
|
||||||
Vendored
-112
@@ -1,112 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
/**
|
|
||||||
* Main entry point for the AI Interactive Fiction application
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
const dotenv = __importStar(require("dotenv"));
|
|
||||||
const game_runner_1 = require("./cli/game-runner");
|
|
||||||
// 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 {
|
|
||||||
const result = dotenv.config();
|
|
||||||
if (result.error) {
|
|
||||||
console.error('Error loading .env file:', result.error);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
console.log('Environment variables loaded successfully');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Exception when loading env:', error);
|
|
||||||
}
|
|
||||||
async function main() {
|
|
||||||
try {
|
|
||||||
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 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'}`);
|
|
||||||
// Check if we should run in CLI mode
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const cliMode = args.includes('--cli') || args.includes('-c');
|
|
||||||
if (cliMode) {
|
|
||||||
// CLI mode
|
|
||||||
console.log('Starting in CLI mode...');
|
|
||||||
// Create game runner and initialize
|
|
||||||
console.log('Creating game runner...');
|
|
||||||
const gameRunner = new game_runner_1.GameRunner();
|
|
||||||
console.log('Initializing game...');
|
|
||||||
await gameRunner.initialize(worldFile);
|
|
||||||
// Start the CLI game
|
|
||||||
console.log('Starting CLI game...');
|
|
||||||
await gameRunner.start();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Web interface mode - explicitly start the server with port fallback
|
|
||||||
console.log('Starting in web interface mode...');
|
|
||||||
// Get port configuration
|
|
||||||
const DEFAULT_PORT = 3000;
|
|
||||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
|
|
||||||
const PORT_RANGE = 300;
|
|
||||||
// Start the web server with port fallback
|
|
||||||
console.log('Starting web server...');
|
|
||||||
await (0, server_yaml_1.startServer)(PORT, PORT_RANGE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Failed to start:', error);
|
|
||||||
if (error instanceof Error) {
|
|
||||||
console.error('Error name:', error.name);
|
|
||||||
console.error('Error message:', error.message);
|
|
||||||
console.error('Error stack:', error.stack);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Start the application
|
|
||||||
console.log('Starting application...');
|
|
||||||
main().catch(error => {
|
|
||||||
console.error('Unhandled error in main:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
//# sourceMappingURL=index.js.map
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"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"}
|
|
||||||
Vendored
-39
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Interfaces for the game engine
|
|
||||||
*/
|
|
||||||
import { WorldModel, GameState } from './world-model';
|
|
||||||
import { ActionResponse, NarrativeResponse } from './llm';
|
|
||||||
export interface ActionResult {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
stateChanged: boolean;
|
|
||||||
newState?: GameState;
|
|
||||||
}
|
|
||||||
export interface GameEngine {
|
|
||||||
loadWorld(worldModelPath: string): Promise<void>;
|
|
||||||
getCurrentState(): GameState;
|
|
||||||
getWorldModel(): WorldModel;
|
|
||||||
processAction(action: ActionResponse): ActionResult;
|
|
||||||
saveGame(filename: string): Promise<void>;
|
|
||||||
loadGame(filename: string): Promise<void>;
|
|
||||||
getAvailableActions(): string[];
|
|
||||||
getVisibleObjects(): string[];
|
|
||||||
getVisibleCharacters(): string[];
|
|
||||||
getCurrentRoomDescription(): string;
|
|
||||||
start(): Promise<string>;
|
|
||||||
end(): void;
|
|
||||||
}
|
|
||||||
export interface GameSession {
|
|
||||||
engine: GameEngine;
|
|
||||||
history: {
|
|
||||||
playerInput: string;
|
|
||||||
actionResponse: ActionResponse;
|
|
||||||
actionResult: ActionResult;
|
|
||||||
narrativeResponse: NarrativeResponse;
|
|
||||||
}[];
|
|
||||||
startTime: Date;
|
|
||||||
lastInteractionTime: Date;
|
|
||||||
}
|
|
||||||
export interface ActionHandler {
|
|
||||||
execute(gameState: GameState, worldModel: WorldModel, action: ActionResponse): ActionResult;
|
|
||||||
}
|
|
||||||
Vendored
-6
@@ -1,6 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
/**
|
|
||||||
* Interfaces for the game engine
|
|
||||||
*/
|
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
//# sourceMappingURL=engine.js.map
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"engine.js","sourceRoot":"","sources":["../../src/interfaces/engine.ts"],"names":[],"mappings":";AAAA;;GAEG"}
|
|
||||||
Vendored
-46
@@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* Interfaces for LLM integration
|
|
||||||
*/
|
|
||||||
export interface LlmConfig {
|
|
||||||
apiKey: string;
|
|
||||||
model: string;
|
|
||||||
temperature?: number;
|
|
||||||
maxTokens?: number;
|
|
||||||
topP?: number;
|
|
||||||
frequencyPenalty?: number;
|
|
||||||
presencePenalty?: number;
|
|
||||||
}
|
|
||||||
export interface ActionRequest {
|
|
||||||
playerInput: string;
|
|
||||||
currentRoom: string;
|
|
||||||
visibleObjects: string[];
|
|
||||||
visibleCharacters: string[];
|
|
||||||
possibleActions: string[];
|
|
||||||
inventory: string[];
|
|
||||||
gameContext: string;
|
|
||||||
}
|
|
||||||
export interface ActionResponse {
|
|
||||||
action: string;
|
|
||||||
object?: string;
|
|
||||||
target?: string;
|
|
||||||
parameters?: Record<string, string>;
|
|
||||||
confidence: number;
|
|
||||||
}
|
|
||||||
export interface NarrativeRequest {
|
|
||||||
action: string;
|
|
||||||
result: string;
|
|
||||||
roomDescription: string;
|
|
||||||
visibleObjects: string[];
|
|
||||||
visibleCharacters: string[];
|
|
||||||
previousContext?: string;
|
|
||||||
tone?: string;
|
|
||||||
}
|
|
||||||
export interface NarrativeResponse {
|
|
||||||
text: string;
|
|
||||||
suggestions?: string[];
|
|
||||||
}
|
|
||||||
export interface LlmProvider {
|
|
||||||
initialize(config: LlmConfig): Promise<void>;
|
|
||||||
translateAction(request: ActionRequest): Promise<ActionResponse>;
|
|
||||||
generateNarrative(request: NarrativeRequest): Promise<NarrativeResponse>;
|
|
||||||
}
|
|
||||||
Vendored
-6
@@ -1,6 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
/**
|
|
||||||
* Interfaces for LLM integration
|
|
||||||
*/
|
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
//# sourceMappingURL=llm.js.map
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"llm.js","sourceRoot":"","sources":["../../src/interfaces/llm.ts"],"names":[],"mappings":";AAAA;;GAEG"}
|
|
||||||
Vendored
-36
@@ -1,36 +0,0 @@
|
|||||||
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[];
|
|
||||||
Vendored
-36
@@ -1,36 +0,0 @@
|
|||||||
"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
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"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"}
|
|
||||||
Vendored
-61
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Core interfaces for the interactive fiction world model
|
|
||||||
*/
|
|
||||||
export interface Room {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
exits: Exit[];
|
|
||||||
objects: string[];
|
|
||||||
characters: string[];
|
|
||||||
}
|
|
||||||
export interface Exit {
|
|
||||||
direction: string;
|
|
||||||
targetRoomId: string;
|
|
||||||
description?: string;
|
|
||||||
isLocked?: boolean;
|
|
||||||
keyId?: string;
|
|
||||||
}
|
|
||||||
export interface GameObject {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
traits: string[];
|
|
||||||
states: Record<string, boolean>;
|
|
||||||
containedObjects?: string[];
|
|
||||||
allowedActions: string[];
|
|
||||||
}
|
|
||||||
export interface Character {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
dialogue: Record<string, string>;
|
|
||||||
inventory: string[];
|
|
||||||
defaultResponse: string;
|
|
||||||
mood?: string;
|
|
||||||
}
|
|
||||||
export interface Action {
|
|
||||||
name: string;
|
|
||||||
patterns: string[];
|
|
||||||
requiresObject?: boolean;
|
|
||||||
requiresTarget?: boolean;
|
|
||||||
handler: string;
|
|
||||||
}
|
|
||||||
export interface GameState {
|
|
||||||
currentRoomId: string;
|
|
||||||
inventory: string[];
|
|
||||||
visitedRooms: string[];
|
|
||||||
flags: Record<string, boolean>;
|
|
||||||
counters: Record<string, number>;
|
|
||||||
}
|
|
||||||
export interface WorldModel {
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
version: string;
|
|
||||||
introduction: string;
|
|
||||||
rooms: Record<string, Room>;
|
|
||||||
objects: Record<string, GameObject>;
|
|
||||||
characters: Record<string, Character>;
|
|
||||||
actions: Record<string, Action>;
|
|
||||||
initialState: GameState;
|
|
||||||
}
|
|
||||||
Vendored
-6
@@ -1,6 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
/**
|
|
||||||
* Core interfaces for the interactive fiction world model
|
|
||||||
*/
|
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
//# sourceMappingURL=world-model.js.map
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"world-model.js","sourceRoot":"","sources":["../../src/interfaces/world-model.ts"],"names":[],"mappings":";AAAA;;GAEG"}
|
|
||||||
Vendored
-36
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* OpenRouter LLM Provider
|
|
||||||
* Handles communication with OpenRouter API for LLM interactions
|
|
||||||
*/
|
|
||||||
import { LlmProvider, LlmConfig, ActionRequest, ActionResponse, NarrativeRequest, NarrativeResponse } from '../interfaces/llm';
|
|
||||||
export declare class OpenRouterProvider implements LlmProvider {
|
|
||||||
private apiKey;
|
|
||||||
private model;
|
|
||||||
private client;
|
|
||||||
private temperature;
|
|
||||||
private maxTokens;
|
|
||||||
/**
|
|
||||||
* Initialize the OpenRouter provider with configuration
|
|
||||||
*/
|
|
||||||
initialize(config: LlmConfig): Promise<void>;
|
|
||||||
/**
|
|
||||||
* Translate player input into a structured action for the game engine
|
|
||||||
*/
|
|
||||||
translateAction(request: ActionRequest): Promise<ActionResponse>;
|
|
||||||
/**
|
|
||||||
* Generate narrative prose based on game events
|
|
||||||
*/
|
|
||||||
generateNarrative(request: NarrativeRequest): Promise<NarrativeResponse>;
|
|
||||||
/**
|
|
||||||
* Build the system and user prompts for action translation
|
|
||||||
*/
|
|
||||||
private buildActionPrompt;
|
|
||||||
/**
|
|
||||||
* Build the system and user prompts for narrative generation
|
|
||||||
*/
|
|
||||||
private buildNarrativePrompt;
|
|
||||||
/**
|
|
||||||
* Validate and normalize the action response
|
|
||||||
*/
|
|
||||||
private validateActionResponse;
|
|
||||||
}
|
|
||||||
Vendored
-192
@@ -1,192 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
/**
|
|
||||||
* OpenRouter LLM Provider
|
|
||||||
* Handles communication with OpenRouter API for LLM interactions
|
|
||||||
*/
|
|
||||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
||||||
};
|
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
exports.OpenRouterProvider = void 0;
|
|
||||||
const axios_1 = __importDefault(require("axios"));
|
|
||||||
class OpenRouterProvider {
|
|
||||||
constructor() {
|
|
||||||
this.apiKey = '';
|
|
||||||
this.model = '';
|
|
||||||
this.temperature = 0.7;
|
|
||||||
this.maxTokens = 800;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Initialize the OpenRouter provider with configuration
|
|
||||||
*/
|
|
||||||
async initialize(config) {
|
|
||||||
this.apiKey = config.apiKey;
|
|
||||||
this.model = config.model;
|
|
||||||
this.temperature = config.temperature ?? 0.7;
|
|
||||||
this.maxTokens = config.maxTokens ?? 800;
|
|
||||||
this.client = axios_1.default.create({
|
|
||||||
baseURL: 'https://openrouter.ai/api/v1',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${this.apiKey}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Translate player input into a structured action for the game engine
|
|
||||||
*/
|
|
||||||
async translateAction(request) {
|
|
||||||
try {
|
|
||||||
const prompt = this.buildActionPrompt(request);
|
|
||||||
const response = await this.client.post('/chat/completions', {
|
|
||||||
model: this.model,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: prompt.system
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: prompt.user
|
|
||||||
}
|
|
||||||
],
|
|
||||||
temperature: 0.2, // Lower temperature for more deterministic outputs
|
|
||||||
max_tokens: 150,
|
|
||||||
response_format: { type: 'json_object' }
|
|
||||||
});
|
|
||||||
const content = response.data.choices[0].message.content;
|
|
||||||
const parsedResponse = JSON.parse(content);
|
|
||||||
return this.validateActionResponse(parsedResponse);
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Error translating action:', error);
|
|
||||||
// Fallback to a simple "look" action when errors occur
|
|
||||||
return {
|
|
||||||
action: 'look',
|
|
||||||
confidence: 0.5
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Generate narrative prose based on game events
|
|
||||||
*/
|
|
||||||
async generateNarrative(request) {
|
|
||||||
try {
|
|
||||||
const prompt = this.buildNarrativePrompt(request);
|
|
||||||
const response = await this.client.post('/chat/completions', {
|
|
||||||
model: this.model,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: prompt.system
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: prompt.user
|
|
||||||
}
|
|
||||||
],
|
|
||||||
temperature: this.temperature,
|
|
||||||
max_tokens: this.maxTokens
|
|
||||||
});
|
|
||||||
const content = response.data.choices[0].message.content;
|
|
||||||
// Check if response is JSON format or plain text
|
|
||||||
try {
|
|
||||||
const parsedResponse = JSON.parse(content);
|
|
||||||
return {
|
|
||||||
text: parsedResponse.text,
|
|
||||||
suggestions: parsedResponse.suggestions || []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
// Plain text response, just use the content directly
|
|
||||||
return {
|
|
||||||
text: content
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Error generating narrative:', error);
|
|
||||||
return {
|
|
||||||
text: `Something happened, but the narrator is at a loss for words. (Error: ${error instanceof Error ? error.message : String(error)})`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Build the system and user prompts for action translation
|
|
||||||
*/
|
|
||||||
buildActionPrompt(request) {
|
|
||||||
const systemPrompt = `You are an AI assistant that translates natural language input into structured action commands for an interactive fiction game.
|
|
||||||
Your task is to convert player input into a JSON object representing an action that can be understood by the game engine.
|
|
||||||
|
|
||||||
The player is currently in the "${request.currentRoom}" room.
|
|
||||||
Visible objects: ${request.visibleObjects.join(', ')}
|
|
||||||
Visible characters: ${request.visibleCharacters.join(', ')}
|
|
||||||
Inventory: ${request.inventory.join(', ')}
|
|
||||||
Available actions: ${request.possibleActions.join(', ')}
|
|
||||||
|
|
||||||
Game context: ${request.gameContext}
|
|
||||||
|
|
||||||
Respond ONLY with a JSON object that follows this structure:
|
|
||||||
{
|
|
||||||
"action": "string", // Name of the action (e.g., "take", "examine", "go", "talk", etc.)
|
|
||||||
"object": "string", // Optional: Primary object of the action
|
|
||||||
"target": "string", // Optional: Secondary object/target of the action
|
|
||||||
"parameters": {}, // Optional: Additional parameters as key-value pairs
|
|
||||||
"confidence": number // How confident you are in this interpretation (0.0-1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
Choose the action from the list of available actions. If the player's input is ambiguous or doesn't map well to an available action, choose the closest match and set a lower confidence score.`;
|
|
||||||
const userPrompt = request.playerInput;
|
|
||||||
return {
|
|
||||||
system: systemPrompt,
|
|
||||||
user: userPrompt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Build the system and user prompts for narrative generation
|
|
||||||
*/
|
|
||||||
buildNarrativePrompt(request) {
|
|
||||||
const tone = request.tone || 'descriptive';
|
|
||||||
const systemPrompt = `You are an AI assistant that generates engaging narrative prose for an interactive fiction game.
|
|
||||||
Your task is to describe what happens when a player performs an action in the game world.
|
|
||||||
|
|
||||||
Craft a vivid, ${tone} description that tells the player what happened as a result of their action. Make your prose engaging and atmospheric.
|
|
||||||
|
|
||||||
Current room description: "${request.roomDescription}"
|
|
||||||
Visible objects: ${request.visibleObjects.join(', ')}
|
|
||||||
Visible characters: ${request.visibleCharacters.join(', ')}
|
|
||||||
|
|
||||||
${request.previousContext ? `Previous context: ${request.previousContext}` : ''}
|
|
||||||
|
|
||||||
Respond with engaging prose that describes the outcome of the player's action.
|
|
||||||
You can optionally include 1-3 subtle hints about interesting things to try next.`;
|
|
||||||
const userPrompt = `The player has performed this action: "${request.action}".
|
|
||||||
The result of the action is: "${request.result}".
|
|
||||||
Please describe what happens in an engaging, narrative way.`;
|
|
||||||
return {
|
|
||||||
system: systemPrompt,
|
|
||||||
user: userPrompt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Validate and normalize the action response
|
|
||||||
*/
|
|
||||||
validateActionResponse(response) {
|
|
||||||
const validatedResponse = {
|
|
||||||
action: typeof response.action === 'string' ? response.action : 'look',
|
|
||||||
confidence: typeof response.confidence === 'number' ? response.confidence : 0.5
|
|
||||||
};
|
|
||||||
if (typeof response.object === 'string') {
|
|
||||||
validatedResponse.object = response.object;
|
|
||||||
}
|
|
||||||
if (typeof response.target === 'string') {
|
|
||||||
validatedResponse.target = response.target;
|
|
||||||
}
|
|
||||||
if (response.parameters && typeof response.parameters === 'object') {
|
|
||||||
validatedResponse.parameters = response.parameters;
|
|
||||||
}
|
|
||||||
return validatedResponse;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
exports.OpenRouterProvider = OpenRouterProvider;
|
|
||||||
//# sourceMappingURL=openrouter-provider.js.map
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"openrouter-provider.js","sourceRoot":"","sources":["../../src/llm/openrouter-provider.ts"],"names":[],"mappings":";AAAA;;;GAGG;;;;;;AAEH,kDAA6C;AAU7C,MAAa,kBAAkB;IAA/B;QACU,WAAM,GAAW,EAAE,CAAC;QACpB,UAAK,GAAW,EAAE,CAAC;QAEnB,gBAAW,GAAW,GAAG,CAAC;QAC1B,cAAS,GAAW,GAAG,CAAC;IA+LlC,CAAC;IA7LC;;OAEG;IACI,KAAK,CAAC,UAAU,CAAC,MAAiB;QACvC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;QAC5B,IAAI,CAAC,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;QAC1B,IAAI,CAAC,WAAW,GAAG,MAAM,CAAC,WAAW,IAAI,GAAG,CAAC;QAC7C,IAAI,CAAC,SAAS,GAAG,MAAM,CAAC,SAAS,IAAI,GAAG,CAAC;QAEzC,IAAI,CAAC,MAAM,GAAG,eAAK,CAAC,MAAM,CAAC;YACzB,OAAO,EAAE,8BAA8B;YACvC,OAAO,EAAE;gBACP,eAAe,EAAE,UAAU,IAAI,CAAC,MAAM,EAAE;gBACxC,cAAc,EAAE,kBAAkB;aACnC;SACF,CAAC,CAAC;IACL,CAAC;IAED;;OAEG;IACI,KAAK,CAAC,eAAe,CAAC,OAAsB;QACjD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC;YAE/C,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,mBAAmB,EAAE;gBAC3D,KAAK,EAAE,IAAI,CAAC,KAAK;gBACjB,QAAQ,EAAE;oBACR;wBACE,IAAI,EAAE,QAAQ;wBACd,OAAO,EAAE,MAAM,CAAC,MAAM;qBACvB;oBACD;wBACE,IAAI,EAAE,MAAM;wBACZ,OAAO,EAAE,MAAM,CAAC,IAAI;qBACrB;iBACF;gBACD,WAAW,EAAE,GAAG,EAAE,mDAAmD;gBACrE,UAAU,EAAE,GAAG;gBACf,eAAe,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE;aACzC,CAAC,CAAC;YAEH,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC;YACzD,MAAM,cAAc,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;YAE3C,OAAO,IAAI,CAAC,sBAAsB,CAAC,cAAc,CAAC,CAAC;QACrD,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,2BAA2B,EAAE,KAAK,CAAC,CAAC;YAClD,uDAAuD;YACvD,OAAO;gBACL,MAAM,EAAE,MAAM;gBACd,UAAU,EAAE,GAAG;aAChB,CAAC;QACJ,CAAC;IACH,CAAC;IAED;;OAEG;IACI,KAAK,CAAC,iBAAiB,CAAC,OAAyB;QACtD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,oBAAoB,CAAC,OAAO,CAAC,CAAC;YAElD,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,mBAAmB,EAAE;gBAC3D,KAAK,EAAE,IAAI,CAAC,KAAK;gBACjB,QAAQ,EAAE;oBACR;wBACE,IAAI,EAAE,QAAQ;wBACd,OAAO,EAAE,MAAM,CAAC,MAAM;qBACvB;oBACD;wBACE,IAAI,EAAE,MAAM;wBACZ,OAAO,EAAE,MAAM,CAAC,IAAI;qBACrB;iBACF;gBACD,WAAW,EAAE,IAAI,CAAC,WAAW;gBAC7B,UAAU,EAAE,IAAI,CAAC,SAAS;aAC3B,CAAC,CAAC;YAEH,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC;YAEzD,iDAAiD;YACjD,IAAI,CAAC;gBACH,MAAM,cAAc,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;gBAC3C,OAAO;oBACL,IAAI,EAAE,cAAc,CAAC,IAAI;oBACzB,WAAW,EAAE,cAAc,CAAC,WAAW,IAAI,EAAE;iBAC9C,CAAC;YACJ,CAAC;YAAC,MAAM,CAAC;gBACP,qDAAqD;gBACrD,OAAO;oBACL,IAAI,EAAE,OAAO;iBACd,CAAC;YACJ,CAAC;QACH,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,6BAA6B,EAAE,KAAK,CAAC,CAAC;YACpD,OAAO;gBACL,IAAI,EAAE,wEAAwE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG;aACxI,CAAC;QACJ,CAAC;IACH,CAAC;IAED;;OAEG;IACK,iBAAiB,CAAC,OAAsB;QAC9C,MAAM,YAAY,GAAG;;;kCAGS,OAAO,CAAC,WAAW;mBAClC,OAAO,CAAC,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC;sBAC9B,OAAO,CAAC,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC;aAC7C,OAAO,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC;qBACpB,OAAO,CAAC,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC;;gBAEvC,OAAO,CAAC,WAAW;;;;;;;;;;;gMAW6J,CAAC;QAE7L,MAAM,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC;QAEvC,OAAO;YACL,MAAM,EAAE,YAAY;YACpB,IAAI,EAAE,UAAU;SACjB,CAAC;IACJ,CAAC;IAED;;OAEG;IACK,oBAAoB,CAAC,OAAyB;QACpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,aAAa,CAAC;QAE3C,MAAM,YAAY,GAAG;;;iBAGR,IAAI;;6BAEQ,OAAO,CAAC,eAAe;mBACjC,OAAO,CAAC,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC;sBAC9B,OAAO,CAAC,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC;;EAExD,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,qBAAqB,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC,CAAC,EAAE;;;kFAGG,CAAC;QAE/E,MAAM,UAAU,GAAG,0CAA0C,OAAO,CAAC,MAAM;gCAC/C,OAAO,CAAC,MAAM;4DACc,CAAC;QAEzD,OAAO;YACL,MAAM,EAAE,YAAY;YACpB,IAAI,EAAE,UAAU;SACjB,CAAC;IACJ,CAAC;IAED;;OAEG;IACK,sBAAsB,CAAC,QAAiC;QAC9D,MAAM,iBAAiB,GAAmB;YACxC,MAAM,EAAE,OAAO,QAAQ,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM;YACtE,UAAU,EAAE,OAAO,QAAQ,CAAC,UAAU,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG;SAChF,CAAC;QAEF,IAAI,OAAO,QAAQ,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;YACxC,iBAAiB,CAAC,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC;QAC7C,CAAC;QAED,IAAI,OAAO,QAAQ,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;YACxC,iBAAiB,CAAC,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC;QAC7C,CAAC;QAED,IAAI,QAAQ,CAAC,UAAU,IAAI,OAAO,QAAQ,CAAC,UAAU,KAAK,QAAQ,EAAE,CAAC;YACnE,iBAAiB,CAAC,UAAU,GAAG,QAAQ,CAAC,UAAoC,CAAC;QAC/E,CAAC;QAED,OAAO,iBAAiB,CAAC;IAC3B,CAAC;CACF;AApMD,gDAoMC"}
|
|
||||||
Vendored
-13
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 };
|
|
||||||
Vendored
-307
@@ -1,307 +0,0 @@
|
|||||||
"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
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-11
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* AI Interactive Fiction - Web Server
|
|
||||||
* Serves the web UI and handles WebSocket communication
|
|
||||||
*/
|
|
||||||
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 };
|
|
||||||
Vendored
-315
@@ -1,315 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
/**
|
|
||||||
* AI Interactive Fiction - Web Server
|
|
||||||
* Serves the web UI and handles WebSocket communication
|
|
||||||
*/
|
|
||||||
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 express_1 = __importDefault(require("express"));
|
|
||||||
const http_1 = __importDefault(require("http"));
|
|
||||||
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
|
|
||||||
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;
|
|
||||||
// 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 = 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}`);
|
|
||||||
socket.emit('gameConfig', (0, game_config_1.clientGameConfig)(engineConfig));
|
|
||||||
socket.data.saveGames = new Map();
|
|
||||||
socket.on('gameApi', async (request, respond) => {
|
|
||||||
try {
|
|
||||||
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('Game API error:', error);
|
|
||||||
if (typeof respond === 'function') {
|
|
||||||
respond({ success: false, error: error instanceof Error ? error.message : String(error) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Process player command
|
|
||||||
socket.on('playerCommand', async (data) => {
|
|
||||||
try {
|
|
||||||
const gameRunner = gameSessions.get(socket.id);
|
|
||||||
if (!gameRunner) {
|
|
||||||
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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.' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Handle disconnection
|
|
||||||
socket.on('disconnect', () => {
|
|
||||||
console.log(`Client disconnected: ${socket.id}`);
|
|
||||||
// Clean up game session
|
|
||||||
if (gameSessions.has(socket.id)) {
|
|
||||||
gameSessions.delete(socket.id);
|
|
||||||
}
|
|
||||||
nextTurnIds.delete(socket.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Ensure required asset folders exist
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
// Copy kokoro-js library from node_modules if not already present
|
|
||||||
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);
|
|
||||||
console.log(`Copied kokoro-js from ${source} to ${destination}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Start the server with port fallback
|
|
||||||
async function startServer(initialPort, range) {
|
|
||||||
let currentPort = initialPort;
|
|
||||||
const maxPort = initialPort + range;
|
|
||||||
// Try ports in the specified range
|
|
||||||
while (currentPort < maxPort) {
|
|
||||||
try {
|
|
||||||
// Ensure directories exist
|
|
||||||
ensureDirectories();
|
|
||||||
// Ensure kokoro-js is copied
|
|
||||||
try {
|
|
||||||
ensureKokoroJs();
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Error copying kokoro-js:', error);
|
|
||||||
}
|
|
||||||
// Try to start the server on the current port
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
server.removeAllListeners('error');
|
|
||||||
server.removeAllListeners('listening');
|
|
||||||
server.once('listening', () => {
|
|
||||||
console.log(`AI Interactive Fiction web server running on http://localhost:${currentPort}`);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
server.once('error', (error) => {
|
|
||||||
// If port is in use, try next port
|
|
||||||
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') {
|
|
||||||
console.log(`Port ${currentPort} is unavailable (${error.code}), trying next port...`);
|
|
||||||
server.close();
|
|
||||||
currentPort++;
|
|
||||||
reject();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// For other errors, log and reject
|
|
||||||
console.error('Server error:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
server.listen(currentPort);
|
|
||||||
});
|
|
||||||
// If we reach here, server started successfully
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
// If we reach the max port and still fail, throw an error
|
|
||||||
if (currentPort >= maxPort - 1) {
|
|
||||||
throw new Error(`Failed to start server on ports ${initialPort} to ${maxPort - 1}`);
|
|
||||||
}
|
|
||||||
// Otherwise try the next port
|
|
||||||
// The loop continues as the rejection above increments currentPort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Start the server when this module is run directly
|
|
||||||
if (require.main === module) {
|
|
||||||
startServer(PORT, PORT_RANGE).catch(error => {
|
|
||||||
console.error('Failed to start server:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
//# sourceMappingURL=server-yaml.js.map
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-16
@@ -1,16 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 {};
|
|
||||||
Vendored
-361
@@ -1,361 +0,0 @@
|
|||||||
"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
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-10
@@ -1,10 +0,0 @@
|
|||||||
/**
|
|
||||||
* Test Server for AI Interactive Fiction
|
|
||||||
* Simplified version that sends test paragraphs instead of using LLM
|
|
||||||
*/
|
|
||||||
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 { app, server, io };
|
|
||||||
Vendored
-282
@@ -1,282 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
/**
|
|
||||||
* Test Server for AI Interactive Fiction
|
|
||||||
* Simplified version that sends test paragraphs instead of using LLM
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
const path_1 = __importDefault(require("path"));
|
|
||||||
const express_1 = __importDefault(require("express"));
|
|
||||||
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
|
|
||||||
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;
|
|
||||||
// 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 = 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.",
|
|
||||||
"As you venture deeper, the passage narrows. Stalactites hang from the ceiling like stone daggers, their surfaces glistening with moisture. The sound of dripping water echoes through the silence.",
|
|
||||||
"Suddenly, the passage opens into a vast chamber. Crystal formations catch the light of your torch, sending rainbow reflections across the walls. In the center of the room stands an ancient stone pedestal, its surface carved with symbols from a forgotten language."
|
|
||||||
];
|
|
||||||
// Handle socket connections
|
|
||||||
io.on('connection', (socket) => {
|
|
||||||
console.log(`New client connected: ${socket.id}`);
|
|
||||||
let currentParagraphIndex = 0;
|
|
||||||
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 {
|
|
||||||
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) {
|
|
||||||
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}`);
|
|
||||||
// Send narrative response to client
|
|
||||||
socket.emit('narrativeResponse', {
|
|
||||||
turnId: nextTurnId++,
|
|
||||||
paragraphs: (0, turn_result_1.textToParagraphs)(String(data.command || '')),
|
|
||||||
choices: [],
|
|
||||||
inputMode: 'text',
|
|
||||||
gameState: {
|
|
||||||
currentRoomId: "test-room"
|
|
||||||
},
|
|
||||||
suggestions: ["look around", "examine pedestal", "touch crystals"]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Error processing command:', error);
|
|
||||||
socket.emit('error', { message: 'Failed to process command. Please try again.' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Handle disconnection
|
|
||||||
socket.on('disconnect', () => {
|
|
||||||
console.log(`Client disconnected: ${socket.id}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Ensure required asset folders exist
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Copy kokoro-js library from node_modules if not already present
|
|
||||||
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);
|
|
||||||
console.log(`Copied kokoro-js from ${source} to ${destination}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Start the server with port fallback
|
|
||||||
async function startServer(initialPort, range) {
|
|
||||||
let currentPort = initialPort;
|
|
||||||
const maxPort = initialPort + range;
|
|
||||||
// Try ports in the specified range
|
|
||||||
while (currentPort < maxPort) {
|
|
||||||
try {
|
|
||||||
// Ensure directories exist
|
|
||||||
ensureDirectories();
|
|
||||||
// Ensure kokoro-js is copied
|
|
||||||
try {
|
|
||||||
ensureKokoroJs();
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Error copying kokoro-js:', error);
|
|
||||||
}
|
|
||||||
// Try to start the server on the current port
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
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.once('error', (error) => {
|
|
||||||
// If port is in use, try next port
|
|
||||||
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') {
|
|
||||||
console.log(`Port ${currentPort} is unavailable (${error.code}), trying next port...`);
|
|
||||||
server.close();
|
|
||||||
currentPort++;
|
|
||||||
reject();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// For other errors, log and reject
|
|
||||||
console.error('Server error:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
server.listen(currentPort);
|
|
||||||
});
|
|
||||||
// If we reach here, server started successfully
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
// If we reach the max port and still fail, throw an error
|
|
||||||
if (currentPort >= maxPort - 1) {
|
|
||||||
throw new Error(`Failed to start server on ports ${initialPort} to ${maxPort - 1}`);
|
|
||||||
}
|
|
||||||
// Otherwise try the next port
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Start the server when this module is run directly
|
|
||||||
if (require.main === module) {
|
|
||||||
startServer(PORT, PORT_RANGE).catch(error => {
|
|
||||||
console.error('Failed to start server:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
//# sourceMappingURL=test-server-yaml.js.map
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-4
@@ -1,4 +0,0 @@
|
|||||||
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;
|
|
||||||
Vendored
-53
@@ -1,53 +0,0 @@
|
|||||||
"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
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"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"}
|
|
||||||
Vendored
-71
@@ -1,71 +0,0 @@
|
|||||||
/**
|
|
||||||
* YAML World Model Parser
|
|
||||||
* Loads and validates world definitions from YAML files
|
|
||||||
*/
|
|
||||||
import { WorldModel } from '../interfaces/world-model';
|
|
||||||
export declare class YamlWorldParser {
|
|
||||||
/**
|
|
||||||
* Load a world model from a YAML file
|
|
||||||
*/
|
|
||||||
static loadFromFile(filePath: string): Promise<WorldModel>;
|
|
||||||
/**
|
|
||||||
* Validate the loaded YAML data and transform it into a WorldModel
|
|
||||||
*/
|
|
||||||
private static validateAndTransform;
|
|
||||||
/**
|
|
||||||
* Validate that an object has all required fields
|
|
||||||
*/
|
|
||||||
private static validateRequiredFields;
|
|
||||||
/**
|
|
||||||
* Validate that a value is a string
|
|
||||||
*/
|
|
||||||
private static validateString;
|
|
||||||
/**
|
|
||||||
* Validate room definitions
|
|
||||||
*/
|
|
||||||
private static validateRooms;
|
|
||||||
/**
|
|
||||||
* Validate exit definitions
|
|
||||||
*/
|
|
||||||
private static validateExits;
|
|
||||||
/**
|
|
||||||
* Validate object definitions
|
|
||||||
*/
|
|
||||||
private static validateObjects;
|
|
||||||
/**
|
|
||||||
* Validate character definitions
|
|
||||||
*/
|
|
||||||
private static validateCharacters;
|
|
||||||
/**
|
|
||||||
* Validate action definitions
|
|
||||||
*/
|
|
||||||
private static validateActions;
|
|
||||||
/**
|
|
||||||
* Validate initial game state
|
|
||||||
*/
|
|
||||||
private static validateInitialState;
|
|
||||||
/**
|
|
||||||
* Validate object states (record of boolean values)
|
|
||||||
*/
|
|
||||||
private static validateObjectStates;
|
|
||||||
/**
|
|
||||||
* Validate dialogue (record of string values)
|
|
||||||
*/
|
|
||||||
private static validateDialogue;
|
|
||||||
/**
|
|
||||||
* Validate flags (record of boolean values)
|
|
||||||
*/
|
|
||||||
private static validateFlags;
|
|
||||||
/**
|
|
||||||
* Validate counters (record of number values)
|
|
||||||
*/
|
|
||||||
private static validateCounters;
|
|
||||||
/**
|
|
||||||
* Validate that an array of strings is valid
|
|
||||||
*/
|
|
||||||
private static validateStringArray;
|
|
||||||
/**
|
|
||||||
* Validate references between entities
|
|
||||||
*/
|
|
||||||
private static validateReferences;
|
|
||||||
}
|
|
||||||
Vendored
-399
@@ -1,399 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
/**
|
|
||||||
* YAML World Model Parser
|
|
||||||
* Loads and validates world definitions from YAML files
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
exports.YamlWorldParser = void 0;
|
|
||||||
const fs = __importStar(require("fs/promises"));
|
|
||||||
const yaml = __importStar(require("js-yaml"));
|
|
||||||
class YamlWorldParser {
|
|
||||||
/**
|
|
||||||
* Load a world model from a YAML file
|
|
||||||
*/
|
|
||||||
static async loadFromFile(filePath) {
|
|
||||||
try {
|
|
||||||
const fileContents = await fs.readFile(filePath, 'utf8');
|
|
||||||
const worldData = yaml.load(fileContents);
|
|
||||||
return this.validateAndTransform(worldData);
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error(`Error loading world from ${filePath}:`, error);
|
|
||||||
throw new Error(`Failed to load world from ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Validate the loaded YAML data and transform it into a WorldModel
|
|
||||||
*/
|
|
||||||
static validateAndTransform(data) {
|
|
||||||
if (!data || typeof data !== 'object') {
|
|
||||||
throw new Error('Invalid world data: must be an object');
|
|
||||||
}
|
|
||||||
const worldData = data;
|
|
||||||
// Validate required top-level fields
|
|
||||||
this.validateRequiredFields(worldData, ['title', 'author', 'version', 'introduction', 'rooms', 'initialState']);
|
|
||||||
// Transform and validate the world model
|
|
||||||
const worldModel = {
|
|
||||||
title: this.validateString(worldData.title, 'title'),
|
|
||||||
author: this.validateString(worldData.author, 'author'),
|
|
||||||
version: this.validateString(worldData.version, 'version'),
|
|
||||||
introduction: this.validateString(worldData.introduction, 'introduction'),
|
|
||||||
rooms: this.validateRooms(worldData.rooms),
|
|
||||||
objects: this.validateObjects(worldData.objects),
|
|
||||||
characters: this.validateCharacters(worldData.characters),
|
|
||||||
actions: this.validateActions(worldData.actions),
|
|
||||||
initialState: this.validateInitialState(worldData.initialState)
|
|
||||||
};
|
|
||||||
// Validate references between entities
|
|
||||||
this.validateReferences(worldModel);
|
|
||||||
return worldModel;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Validate that an object has all required fields
|
|
||||||
*/
|
|
||||||
static validateRequiredFields(data, requiredFields) {
|
|
||||||
for (const field of requiredFields) {
|
|
||||||
if (!(field in data)) {
|
|
||||||
throw new Error(`Missing required field: ${field}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Validate that a value is a string
|
|
||||||
*/
|
|
||||||
static validateString(value, fieldName) {
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
throw new Error(`Field ${fieldName} must be a string`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Validate room definitions
|
|
||||||
*/
|
|
||||||
static validateRooms(rooms) {
|
|
||||||
if (!rooms || typeof rooms !== 'object') {
|
|
||||||
throw new Error('Rooms must be an object mapping room IDs to room definitions');
|
|
||||||
}
|
|
||||||
const roomsData = rooms;
|
|
||||||
const validatedRooms = {};
|
|
||||||
for (const [roomId, roomData] of Object.entries(roomsData)) {
|
|
||||||
if (!roomData || typeof roomData !== 'object') {
|
|
||||||
throw new Error(`Room ${roomId} must be an object`);
|
|
||||||
}
|
|
||||||
const room = roomData;
|
|
||||||
this.validateRequiredFields(room, ['name', 'description', 'exits']);
|
|
||||||
validatedRooms[roomId] = {
|
|
||||||
id: roomId,
|
|
||||||
name: this.validateString(room.name, `rooms.${roomId}.name`),
|
|
||||||
description: this.validateString(room.description, `rooms.${roomId}.description`),
|
|
||||||
exits: this.validateExits(room.exits, roomId),
|
|
||||||
objects: this.validateStringArray(room.objects || [], `rooms.${roomId}.objects`),
|
|
||||||
characters: this.validateStringArray(room.characters || [], `rooms.${roomId}.characters`)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return validatedRooms;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Validate exit definitions
|
|
||||||
*/
|
|
||||||
static validateExits(exits, roomId) {
|
|
||||||
if (!Array.isArray(exits)) {
|
|
||||||
throw new Error(`Exits for room ${roomId} must be an array`);
|
|
||||||
}
|
|
||||||
return exits.map((exit, index) => {
|
|
||||||
if (!exit || typeof exit !== 'object') {
|
|
||||||
throw new Error(`Exit ${index} in room ${roomId} must be an object`);
|
|
||||||
}
|
|
||||||
const exitData = exit;
|
|
||||||
this.validateRequiredFields(exitData, ['direction', 'targetRoomId']);
|
|
||||||
return {
|
|
||||||
direction: this.validateString(exitData.direction, `rooms.${roomId}.exits[${index}].direction`),
|
|
||||||
targetRoomId: this.validateString(exitData.targetRoomId, `rooms.${roomId}.exits[${index}].targetRoomId`),
|
|
||||||
description: exitData.description ? this.validateString(exitData.description, `rooms.${roomId}.exits[${index}].description`) : undefined,
|
|
||||||
isLocked: typeof exitData.isLocked === 'boolean' ? exitData.isLocked : false,
|
|
||||||
keyId: exitData.keyId ? this.validateString(exitData.keyId, `rooms.${roomId}.exits[${index}].keyId`) : undefined
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Validate object definitions
|
|
||||||
*/
|
|
||||||
static validateObjects(objects) {
|
|
||||||
if (!objects)
|
|
||||||
return {}; // Objects are optional
|
|
||||||
if (typeof objects !== 'object') {
|
|
||||||
throw new Error('Objects must be an object mapping object IDs to object definitions');
|
|
||||||
}
|
|
||||||
const objectsData = objects;
|
|
||||||
const validatedObjects = {};
|
|
||||||
for (const [objectId, objectData] of Object.entries(objectsData)) {
|
|
||||||
if (!objectData || typeof objectData !== 'object') {
|
|
||||||
throw new Error(`Object ${objectId} must be an object`);
|
|
||||||
}
|
|
||||||
const obj = objectData;
|
|
||||||
this.validateRequiredFields(obj, ['name', 'description', 'traits', 'allowedActions']);
|
|
||||||
validatedObjects[objectId] = {
|
|
||||||
id: objectId,
|
|
||||||
name: this.validateString(obj.name, `objects.${objectId}.name`),
|
|
||||||
description: this.validateString(obj.description, `objects.${objectId}.description`),
|
|
||||||
traits: this.validateStringArray(obj.traits, `objects.${objectId}.traits`),
|
|
||||||
states: this.validateObjectStates(obj.states, objectId),
|
|
||||||
allowedActions: this.validateStringArray(obj.allowedActions, `objects.${objectId}.allowedActions`),
|
|
||||||
containedObjects: obj.containedObjects ? this.validateStringArray(obj.containedObjects, `objects.${objectId}.containedObjects`) : []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return validatedObjects;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Validate character definitions
|
|
||||||
*/
|
|
||||||
static validateCharacters(characters) {
|
|
||||||
if (!characters)
|
|
||||||
return {}; // Characters are optional
|
|
||||||
if (typeof characters !== 'object') {
|
|
||||||
throw new Error('Characters must be an object mapping character IDs to character definitions');
|
|
||||||
}
|
|
||||||
const charactersData = characters;
|
|
||||||
const validatedCharacters = {};
|
|
||||||
for (const [characterId, characterData] of Object.entries(charactersData)) {
|
|
||||||
if (!characterData || typeof characterData !== 'object') {
|
|
||||||
throw new Error(`Character ${characterId} must be an object`);
|
|
||||||
}
|
|
||||||
const character = characterData;
|
|
||||||
this.validateRequiredFields(character, ['name', 'description', 'dialogue', 'defaultResponse']);
|
|
||||||
validatedCharacters[characterId] = {
|
|
||||||
id: characterId,
|
|
||||||
name: this.validateString(character.name, `characters.${characterId}.name`),
|
|
||||||
description: this.validateString(character.description, `characters.${characterId}.description`),
|
|
||||||
dialogue: this.validateDialogue(character.dialogue, characterId),
|
|
||||||
inventory: this.validateStringArray(character.inventory || [], `characters.${characterId}.inventory`),
|
|
||||||
defaultResponse: this.validateString(character.defaultResponse, `characters.${characterId}.defaultResponse`),
|
|
||||||
mood: character.mood ? this.validateString(character.mood, `characters.${characterId}.mood`) : undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return validatedCharacters;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Validate action definitions
|
|
||||||
*/
|
|
||||||
static validateActions(actions) {
|
|
||||||
if (!actions)
|
|
||||||
return {}; // Actions are optional
|
|
||||||
if (typeof actions !== 'object') {
|
|
||||||
throw new Error('Actions must be an object mapping action names to action definitions');
|
|
||||||
}
|
|
||||||
const actionsData = actions;
|
|
||||||
const validatedActions = {};
|
|
||||||
for (const [actionName, actionData] of Object.entries(actionsData)) {
|
|
||||||
if (!actionData || typeof actionData !== 'object') {
|
|
||||||
throw new Error(`Action ${actionName} must be an object`);
|
|
||||||
}
|
|
||||||
const action = actionData;
|
|
||||||
this.validateRequiredFields(action, ['patterns', 'handler']);
|
|
||||||
validatedActions[actionName] = {
|
|
||||||
name: actionName,
|
|
||||||
patterns: this.validateStringArray(action.patterns, `actions.${actionName}.patterns`),
|
|
||||||
requiresObject: typeof action.requiresObject === 'boolean' ? action.requiresObject : false,
|
|
||||||
requiresTarget: typeof action.requiresTarget === 'boolean' ? action.requiresTarget : false,
|
|
||||||
handler: this.validateString(action.handler, `actions.${actionName}.handler`)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return validatedActions;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Validate initial game state
|
|
||||||
*/
|
|
||||||
static validateInitialState(initialState) {
|
|
||||||
if (!initialState || typeof initialState !== 'object') {
|
|
||||||
throw new Error('Initial state must be an object');
|
|
||||||
}
|
|
||||||
const stateData = initialState;
|
|
||||||
this.validateRequiredFields(stateData, ['currentRoomId']);
|
|
||||||
return {
|
|
||||||
currentRoomId: this.validateString(stateData.currentRoomId, 'initialState.currentRoomId'),
|
|
||||||
inventory: this.validateStringArray(stateData.inventory || [], 'initialState.inventory'),
|
|
||||||
visitedRooms: this.validateStringArray(stateData.visitedRooms || [], 'initialState.visitedRooms'),
|
|
||||||
flags: this.validateFlags(stateData.flags),
|
|
||||||
counters: this.validateCounters(stateData.counters)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Validate object states (record of boolean values)
|
|
||||||
*/
|
|
||||||
static validateObjectStates(states, objectId) {
|
|
||||||
if (!states)
|
|
||||||
return {};
|
|
||||||
if (typeof states !== 'object') {
|
|
||||||
throw new Error(`States for object ${objectId} must be an object`);
|
|
||||||
}
|
|
||||||
const statesData = states;
|
|
||||||
const validatedStates = {};
|
|
||||||
for (const [stateName, stateValue] of Object.entries(statesData)) {
|
|
||||||
if (typeof stateValue !== 'boolean') {
|
|
||||||
throw new Error(`State ${stateName} for object ${objectId} must be a boolean value`);
|
|
||||||
}
|
|
||||||
validatedStates[stateName] = stateValue;
|
|
||||||
}
|
|
||||||
return validatedStates;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Validate dialogue (record of string values)
|
|
||||||
*/
|
|
||||||
static validateDialogue(dialogue, characterId) {
|
|
||||||
if (!dialogue || typeof dialogue !== 'object') {
|
|
||||||
throw new Error(`Dialogue for character ${characterId} must be an object`);
|
|
||||||
}
|
|
||||||
const dialogueData = dialogue;
|
|
||||||
const validatedDialogue = {};
|
|
||||||
for (const [topic, response] of Object.entries(dialogueData)) {
|
|
||||||
validatedDialogue[topic] = this.validateString(response, `characters.${characterId}.dialogue.${topic}`);
|
|
||||||
}
|
|
||||||
return validatedDialogue;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Validate flags (record of boolean values)
|
|
||||||
*/
|
|
||||||
static validateFlags(flags) {
|
|
||||||
if (!flags)
|
|
||||||
return {};
|
|
||||||
if (typeof flags !== 'object') {
|
|
||||||
throw new Error('Flags must be an object');
|
|
||||||
}
|
|
||||||
const flagsData = flags;
|
|
||||||
const validatedFlags = {};
|
|
||||||
for (const [flagName, flagValue] of Object.entries(flagsData)) {
|
|
||||||
if (typeof flagValue !== 'boolean') {
|
|
||||||
throw new Error(`Flag ${flagName} must be a boolean value`);
|
|
||||||
}
|
|
||||||
validatedFlags[flagName] = flagValue;
|
|
||||||
}
|
|
||||||
return validatedFlags;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Validate counters (record of number values)
|
|
||||||
*/
|
|
||||||
static validateCounters(counters) {
|
|
||||||
if (!counters)
|
|
||||||
return {};
|
|
||||||
if (typeof counters !== 'object') {
|
|
||||||
throw new Error('Counters must be an object');
|
|
||||||
}
|
|
||||||
const countersData = counters;
|
|
||||||
const validatedCounters = {};
|
|
||||||
for (const [counterName, counterValue] of Object.entries(countersData)) {
|
|
||||||
if (typeof counterValue !== 'number') {
|
|
||||||
throw new Error(`Counter ${counterName} must be a numeric value`);
|
|
||||||
}
|
|
||||||
validatedCounters[counterName] = counterValue;
|
|
||||||
}
|
|
||||||
return validatedCounters;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Validate that an array of strings is valid
|
|
||||||
*/
|
|
||||||
static validateStringArray(arr, fieldName) {
|
|
||||||
if (!arr)
|
|
||||||
return [];
|
|
||||||
if (!Array.isArray(arr)) {
|
|
||||||
throw new Error(`Field ${fieldName} must be an array`);
|
|
||||||
}
|
|
||||||
return arr.map((item, index) => {
|
|
||||||
if (typeof item !== 'string') {
|
|
||||||
throw new Error(`Item at index ${index} in ${fieldName} must be a string`);
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Validate references between entities
|
|
||||||
*/
|
|
||||||
static validateReferences(worldModel) {
|
|
||||||
const { rooms, objects, characters, initialState } = worldModel;
|
|
||||||
// Check that the initial room exists
|
|
||||||
if (!rooms[initialState.currentRoomId]) {
|
|
||||||
throw new Error(`Initial room ${initialState.currentRoomId} does not exist`);
|
|
||||||
}
|
|
||||||
// Check room exits
|
|
||||||
for (const [roomId, room] of Object.entries(rooms)) {
|
|
||||||
for (const exit of room.exits) {
|
|
||||||
if (!rooms[exit.targetRoomId]) {
|
|
||||||
throw new Error(`Room ${roomId} has an exit to non-existent room ${exit.targetRoomId}`);
|
|
||||||
}
|
|
||||||
if (exit.keyId && !objects[exit.keyId]) {
|
|
||||||
throw new Error(`Room ${roomId} has an exit requiring non-existent key ${exit.keyId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check room objects
|
|
||||||
for (const objectId of room.objects) {
|
|
||||||
if (!objects[objectId]) {
|
|
||||||
throw new Error(`Room ${roomId} contains non-existent object ${objectId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check room characters
|
|
||||||
for (const characterId of room.characters) {
|
|
||||||
if (!characters[characterId]) {
|
|
||||||
throw new Error(`Room ${roomId} contains non-existent character ${characterId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check object containment
|
|
||||||
for (const [objectId, object] of Object.entries(objects)) {
|
|
||||||
if (object.containedObjects) {
|
|
||||||
for (const containedId of object.containedObjects) {
|
|
||||||
if (!objects[containedId]) {
|
|
||||||
throw new Error(`Object ${objectId} contains non-existent object ${containedId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check character inventory
|
|
||||||
for (const [characterId, character] of Object.entries(characters)) {
|
|
||||||
for (const objectId of character.inventory) {
|
|
||||||
if (!objects[objectId]) {
|
|
||||||
throw new Error(`Character ${characterId} has non-existent object ${objectId} in inventory`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check player inventory
|
|
||||||
for (const objectId of initialState.inventory) {
|
|
||||||
if (!objects[objectId]) {
|
|
||||||
throw new Error(`Initial inventory contains non-existent object ${objectId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
exports.YamlWorldParser = YamlWorldParser;
|
|
||||||
//# sourceMappingURL=yaml-parser.js.map
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
@@ -1,12 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
preset: 'ts-jest',
|
|
||||||
testEnvironment: 'node',
|
|
||||||
roots: ['<rootDir>/src'],
|
|
||||||
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
|
|
||||||
transform: {
|
|
||||||
'^.+\\.tsx?$': 'ts-jest',
|
|
||||||
},
|
|
||||||
collectCoverage: true,
|
|
||||||
coverageDirectory: 'coverage',
|
|
||||||
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'],
|
|
||||||
};
|
|
||||||
+9
-36
@@ -6,43 +6,16 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"check:node": "node scripts/check-node-version.js",
|
"check:node": "node scripts/check-node-version.js",
|
||||||
"prestart": "npm run check:node",
|
"prestart": "npm run check:node",
|
||||||
"start": "node scripts/run-engine.js start",
|
"start": "node dist/server-ink.js",
|
||||||
"prestart:cli": "npm run check:node",
|
|
||||||
"start:cli": "node dist/index.js --cli",
|
|
||||||
"predev": "npm run check:node",
|
"predev": "npm run check:node",
|
||||||
"dev": "node scripts/run-engine.js dev",
|
"dev": "nodemon --watch src --watch data/ink-src --watch config/engines/ink.json --ext ts,json,ink --exec \"ts-node src/server-ink.ts\"",
|
||||||
"predev:yaml": "npm run check:node",
|
"dev:debug": "node -e \"process.env.INK_DEBUG='1'; require('child_process').spawn('npm', ['run', 'dev'], { stdio: 'inherit', shell: true, env: process.env })\"",
|
||||||
"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:inspect": "nodemon --watch src --watch data/ink-src --watch config/engines/ink.json --ext ts,json,ink --exec \"node --inspect=0.0.0.0:9231 -r ts-node/register src/server-ink.ts\"",
|
||||||
"dev:yaml:debug": "powershell -NoProfile -Command \"$env:YAML_DEBUG='1'; npm run dev:yaml\"",
|
"prestart:debug": "npm run check:node",
|
||||||
"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\\\"\"",
|
"start:debug": "node -e \"process.env.INK_DEBUG='1'; require('./dist/server-ink.js')\"",
|
||||||
"predev:cli": "npm run check:node",
|
"prestart:inspect": "npm run check:node",
|
||||||
"dev:cli": "nodemon --watch src --ext ts,json --exec \"ts-node src/index.ts --cli\"",
|
"start:inspect": "node --inspect=0.0.0.0:9231 dist/server-ink.js",
|
||||||
"predev:zcode": "npm run check:node",
|
"build": "tsc"
|
||||||
"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": {
|
"engines": {
|
||||||
"node": ">=18.17"
|
"node": ">=18.17"
|
||||||
|
|||||||
@@ -1668,8 +1668,7 @@ body:not([data-game-running="true"]) #start_prompt {
|
|||||||
.modal-footer button,
|
.modal-footer button,
|
||||||
.option-item input[type="text"],
|
.option-item input[type="text"],
|
||||||
.option-item input[type="password"],
|
.option-item input[type="password"],
|
||||||
.option-item input[type="url"],
|
.option-item input[type="url"] {
|
||||||
.option-item input[type="number"] {
|
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 1px solid var(--panel-border);
|
border: 1px solid var(--panel-border);
|
||||||
border-radius: var(--control-radius);
|
border-radius: var(--control-radius);
|
||||||
@@ -1685,8 +1684,7 @@ body:not([data-game-running="true"]) #start_prompt {
|
|||||||
|
|
||||||
.option-item input[type="text"],
|
.option-item input[type="text"],
|
||||||
.option-item input[type="password"],
|
.option-item input[type="password"],
|
||||||
.option-item input[type="url"],
|
.option-item input[type="url"] {
|
||||||
.option-item input[type="number"] {
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: min(18rem, 60%);
|
width: min(18rem, 60%);
|
||||||
padding: 0.3rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
@@ -1694,8 +1692,7 @@ body:not([data-game-running="true"]) #start_prompt {
|
|||||||
|
|
||||||
.option-item input[type="text"]:focus,
|
.option-item input[type="text"]:focus,
|
||||||
.option-item input[type="password"]:focus,
|
.option-item input[type="password"]:focus,
|
||||||
.option-item input[type="url"]:focus,
|
.option-item input[type="url"]:focus {
|
||||||
.option-item input[type="number"]:focus {
|
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 2px rgba(90, 57, 33, 0.14);
|
box-shadow: 0 0 0 2px rgba(90, 57, 33, 0.14);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class AnimationQueueModule extends BaseModule {
|
|||||||
|
|
||||||
// Animation timing properties - use parent's config system
|
// Animation timing properties - use parent's config system
|
||||||
this.updateConfig({
|
this.updateConfig({
|
||||||
speed: 1.0,
|
speed: 1.0, // Speed multiplier for delays (1.0 = no scaling, delays are pre-calculated)
|
||||||
fastForwardEnabled: false
|
fastForwardEnabled: false
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,9 +44,7 @@ class AnimationQueueModule extends BaseModule {
|
|||||||
// Listen for speed changes from UI
|
// Listen for speed changes from UI
|
||||||
document.addEventListener('animation:speed:change', (event) => {
|
document.addEventListener('animation:speed:change', (event) => {
|
||||||
if (event.detail && typeof event.detail.speed === 'number') {
|
if (event.detail && typeof event.detail.speed === 'number') {
|
||||||
// Word timings are already speed-scaled before they reach
|
// Speed from UI is a rate multiplier (0.5-2.0 typically)
|
||||||
// the scheduler. Keep the value only for diagnostics/API
|
|
||||||
// compatibility; do not apply it again in schedule().
|
|
||||||
this.config.speed = event.detail.speed;
|
this.config.speed = event.detail.speed;
|
||||||
console.log(`AnimationQueue: Speed updated to ${this.config.speed}`);
|
console.log(`AnimationQueue: Speed updated to ${this.config.speed}`);
|
||||||
}
|
}
|
||||||
@@ -73,9 +71,8 @@ class AnimationQueueModule extends BaseModule {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delays are absolute timings calculated from the prepared sentence
|
// Adjust delay based on fast-forward or speed settings
|
||||||
// duration. TTS/app speed has already been applied at that stage.
|
const actualDelay = this.config.fastForwardEnabled ? 0 : Math.max(0, delay * this.config.speed);
|
||||||
const actualDelay = this.config.fastForwardEnabled ? 0 : Math.max(0, delay);
|
|
||||||
|
|
||||||
// Record the delay for tracking
|
// Record the delay for tracking
|
||||||
this.delay = Math.max(this.delay, delay);
|
this.delay = Math.max(this.delay, delay);
|
||||||
@@ -321,7 +318,7 @@ class AnimationQueueModule extends BaseModule {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the animation speed
|
* Set the animation speed
|
||||||
* @param {number} speed - Stored speed value for compatibility/diagnostics
|
* @param {number} speed - Animation speed factor (lower is faster)
|
||||||
*/
|
*/
|
||||||
setSpeed(speed) {
|
setSpeed(speed) {
|
||||||
if (typeof speed !== 'number' || speed <= 0) {
|
if (typeof speed !== 'number' || speed <= 0) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class BrowserTTSModule extends TTSHandlerModule {
|
|||||||
this.currentUtterance = null;
|
this.currentUtterance = null;
|
||||||
|
|
||||||
// Bind additional methods
|
// Bind additional methods
|
||||||
this.bindMethods(['handleVoicePreferenceChanged', 'estimateSpeechDuration']);
|
this.bindMethods(['handleVoicePreferenceChanged']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -368,29 +368,26 @@ export class BrowserTTSModule extends TTSHandlerModule {
|
|||||||
|
|
||||||
// Set up event handlers
|
// Set up event handlers
|
||||||
utterance.onstart = this.utteranceHandlers.start;
|
utterance.onstart = this.utteranceHandlers.start;
|
||||||
|
utterance.onend = () => {
|
||||||
|
this.utteranceHandlers.end();
|
||||||
|
if (callback) {
|
||||||
|
callback({ success: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
utterance.onerror = (event) => {
|
||||||
|
this.utteranceHandlers.error(event);
|
||||||
|
if (callback) {
|
||||||
|
callback({ success: false, reason: 'synthesis_error', error: event });
|
||||||
|
}
|
||||||
|
};
|
||||||
utterance.onpause = this.utteranceHandlers.pause;
|
utterance.onpause = this.utteranceHandlers.pause;
|
||||||
utterance.onresume = this.utteranceHandlers.resume;
|
utterance.onresume = this.utteranceHandlers.resume;
|
||||||
|
|
||||||
// Start speaking
|
// Start speaking
|
||||||
this.currentUtterance = utterance;
|
this.currentUtterance = utterance;
|
||||||
|
speechSynthesis.speak(utterance);
|
||||||
|
|
||||||
return new Promise(resolve => {
|
return true;
|
||||||
utterance.onend = () => {
|
|
||||||
this.utteranceHandlers.end();
|
|
||||||
if (callback) {
|
|
||||||
callback({ success: true });
|
|
||||||
}
|
|
||||||
resolve(true);
|
|
||||||
};
|
|
||||||
utterance.onerror = (event) => {
|
|
||||||
this.utteranceHandlers.error(event);
|
|
||||||
if (callback) {
|
|
||||||
callback({ success: false, reason: 'synthesis_error', error: event });
|
|
||||||
}
|
|
||||||
resolve(false);
|
|
||||||
};
|
|
||||||
speechSynthesis.speak(utterance);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Browser TTS: Failed to speak:', error);
|
console.error('Browser TTS: Failed to speak:', error);
|
||||||
if (callback) {
|
if (callback) {
|
||||||
@@ -472,7 +469,7 @@ export class BrowserTTSModule extends TTSHandlerModule {
|
|||||||
|
|
||||||
if (typeof options.speed === 'number') {
|
if (typeof options.speed === 'number') {
|
||||||
// Web Speech rate uses 1.0 as normal, matching the app-wide slider.
|
// Web Speech rate uses 1.0 as normal, matching the app-wide slider.
|
||||||
this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed));
|
this.voiceOptions.speed = Math.max(0.1, Math.min(10.0, options.speed));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof options.pitch === 'number') {
|
if (typeof options.pitch === 'number') {
|
||||||
@@ -497,23 +494,8 @@ export class BrowserTTSModule extends TTSHandlerModule {
|
|||||||
* @returns {Promise<Object>} - Promise that resolves to null
|
* @returns {Promise<Object>} - Promise that resolves to null
|
||||||
*/
|
*/
|
||||||
async preloadSpeech(text) {
|
async preloadSpeech(text) {
|
||||||
if (!this.isReady || !text) {
|
// Browser TTS can't preload speech
|
||||||
return { success: false, reason: 'not_ready_or_empty_text' };
|
return { success: false, reason: 'not_supported' };
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
text,
|
|
||||||
duration: this.estimateSpeechDuration(text),
|
|
||||||
directPlayback: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
estimateSpeechDuration(text) {
|
|
||||||
const processedText = this.preprocessText(text);
|
|
||||||
const charactersPerSecond = 12;
|
|
||||||
const speed = Math.max(0.5, Math.min(2.0, Number(this.voiceOptions.speed) || 1.0));
|
|
||||||
return Math.max((processedText.length / (charactersPerSecond * speed)) * 1000, 800);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ class ChoiceDisplayModule extends BaseModule {
|
|||||||
'render',
|
'render',
|
||||||
'clear',
|
'clear',
|
||||||
'normalizeChoices',
|
'normalizeChoices',
|
||||||
'orderChoicesForPresentation',
|
|
||||||
'shuffleChoices',
|
|
||||||
'randomInt',
|
|
||||||
'assignLetters',
|
'assignLetters',
|
||||||
'selectChoice',
|
'selectChoice',
|
||||||
'getTagValue',
|
'getTagValue',
|
||||||
@@ -140,7 +137,7 @@ class ChoiceDisplayModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
normalizeChoices(choices) {
|
normalizeChoices(choices) {
|
||||||
const normalized = choices.slice(0, 36).map((choice, order) => {
|
return this.assignLetters(choices.slice(0, 36).map((choice, order) => {
|
||||||
const tags = Array.isArray(choice.tags) ? choice.tags : [];
|
const tags = Array.isArray(choice.tags) ? choice.tags : [];
|
||||||
const category = choice.category || this.getTagValue(tags, 'action');
|
const category = choice.category || this.getTagValue(tags, 'action');
|
||||||
return {
|
return {
|
||||||
@@ -148,64 +145,11 @@ class ChoiceDisplayModule extends BaseModule {
|
|||||||
text: String(choice.text || ''),
|
text: String(choice.text || ''),
|
||||||
tags,
|
tags,
|
||||||
category,
|
category,
|
||||||
sourceOrder: order,
|
|
||||||
optional: this.hasTag(tags, 'optional'),
|
optional: this.hasTag(tags, 'optional'),
|
||||||
letter: '',
|
letter: '',
|
||||||
templateCell: this.getTemplateCell({ ...choice, tags, category })
|
templateCell: this.getTemplateCell({ ...choice, tags, category })
|
||||||
};
|
};
|
||||||
});
|
}));
|
||||||
|
|
||||||
return this.assignLetters(this.orderChoicesForPresentation(normalized));
|
|
||||||
}
|
|
||||||
|
|
||||||
orderChoicesForPresentation(choices) {
|
|
||||||
const groupOrder = [];
|
|
||||||
const grouped = new Map();
|
|
||||||
const ungrouped = [];
|
|
||||||
|
|
||||||
choices.forEach((choice) => {
|
|
||||||
const group = String(choice.category || '').trim();
|
|
||||||
if (!group) {
|
|
||||||
ungrouped.push(choice);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!grouped.has(group)) {
|
|
||||||
grouped.set(group, []);
|
|
||||||
groupOrder.push(group);
|
|
||||||
}
|
|
||||||
grouped.get(group).push(choice);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ordered = [];
|
|
||||||
groupOrder.forEach((group) => {
|
|
||||||
ordered.push(...this.shuffleChoices(grouped.get(group) || []));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ungrouped.length > 0) {
|
|
||||||
ordered.push(...this.shuffleChoices(ungrouped));
|
|
||||||
}
|
|
||||||
|
|
||||||
return ordered;
|
|
||||||
}
|
|
||||||
|
|
||||||
shuffleChoices(choices) {
|
|
||||||
const shuffled = choices.slice();
|
|
||||||
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
|
||||||
const swapIndex = this.randomInt(index + 1);
|
|
||||||
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]];
|
|
||||||
}
|
|
||||||
return shuffled;
|
|
||||||
}
|
|
||||||
|
|
||||||
randomInt(exclusiveMax) {
|
|
||||||
const max = Math.max(1, Number(exclusiveMax) || 1);
|
|
||||||
if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
|
|
||||||
const values = new Uint32Array(1);
|
|
||||||
window.crypto.getRandomValues(values);
|
|
||||||
return values[0] % max;
|
|
||||||
}
|
|
||||||
return Math.floor(Math.random() * max);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assignLetters(choices) {
|
assignLetters(choices) {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
|
|||||||
|
|
||||||
const preferredSpeed = persistenceManager.getPreference('tts', 'speed', this.voiceOptions.speed);
|
const preferredSpeed = persistenceManager.getPreference('tts', 'speed', this.voiceOptions.speed);
|
||||||
if (typeof preferredSpeed === 'number') {
|
if (typeof preferredSpeed === 'number') {
|
||||||
this.voiceOptions.speed = this.normalizeAppSpeed(preferredSpeed);
|
this.voiceOptions.speed = this.getApiSpeed(preferredSpeed);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isReady = true;
|
this.isReady = true;
|
||||||
@@ -255,7 +255,7 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof options.speed === 'number') {
|
if (typeof options.speed === 'number') {
|
||||||
this.voiceOptions.speed = this.normalizeAppSpeed(options.speed);
|
this.voiceOptions.speed = this.getApiSpeed(options.speed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle ElevenLabs-specific options
|
// Handle ElevenLabs-specific options
|
||||||
@@ -271,17 +271,7 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getApiSpeed(speed) {
|
getApiSpeed(speed) {
|
||||||
const appSpeed = this.normalizeAppSpeed(speed);
|
return Math.max(0.7, Math.min(1.2, Number.isFinite(speed) ? speed : 1.0));
|
||||||
if (appSpeed <= 1.0) {
|
|
||||||
return 0.7 + ((appSpeed - 0.5) / 0.5) * 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1.0 + (appSpeed - 1.0) * 0.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeAppSpeed(speed) {
|
|
||||||
const value = Number.isFinite(Number(speed)) ? Number(speed) : 1.0;
|
|
||||||
return Math.max(0.5, Math.min(2.0, value));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,6 @@ class GameLoopModule extends BaseModule {
|
|||||||
this.autoSaveQueued = false;
|
this.autoSaveQueued = false;
|
||||||
this.resumeAttempted = false;
|
this.resumeAttempted = false;
|
||||||
this.lastInkState = null;
|
this.lastInkState = null;
|
||||||
this.clientResetGeneration = 0;
|
|
||||||
this.restoreGeneration = 0;
|
|
||||||
this.pendingHistoryRestoreCleanup = null;
|
|
||||||
|
|
||||||
// Bind methods using parent's bindMethods utility
|
// Bind methods using parent's bindMethods utility
|
||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
@@ -56,17 +53,6 @@ class GameLoopModule extends BaseModule {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearPendingHistoryRestore(reason = 'cancelled') {
|
|
||||||
if (this.pendingHistoryRestoreCleanup) {
|
|
||||||
this.pendingHistoryRestoreCleanup(reason);
|
|
||||||
this.pendingHistoryRestoreCleanup = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
document.dispatchEvent(new CustomEvent('story:history-restoring', {
|
|
||||||
detail: { active: false, reason }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
this.reportProgress(100, "Game loop initialized");
|
this.reportProgress(100, "Game loop initialized");
|
||||||
return true;
|
return true;
|
||||||
@@ -224,21 +210,9 @@ class GameLoopModule extends BaseModule {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.resetClientPlaybackAndDisplay();
|
|
||||||
this.currentChoices = [];
|
|
||||||
this.currentInputMode = 'none';
|
|
||||||
document.dispatchEvent(new CustomEvent('story:choices', { detail: [] }));
|
|
||||||
document.dispatchEvent(new CustomEvent('story:input-mode', { detail: 'none' }));
|
|
||||||
document.dispatchEvent(new CustomEvent('story:history-restoring', {
|
|
||||||
detail: { active: true, reason: 'autosave-reconnect-prepare' }
|
|
||||||
}));
|
|
||||||
|
|
||||||
const response = await socketClient.resumeGame(browserSave.inkState);
|
const response = await socketClient.resumeGame(browserSave.inkState);
|
||||||
if (!response?.success) {
|
if (!response?.success) {
|
||||||
console.warn('GameLoop: autosave resume failed', response);
|
console.warn('GameLoop: autosave resume failed', response);
|
||||||
document.dispatchEvent(new CustomEvent('story:history-restoring', {
|
|
||||||
detail: { active: false, reason: 'autosave-reconnect-failed' }
|
|
||||||
}));
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +222,7 @@ class GameLoopModule extends BaseModule {
|
|||||||
this.gameState.canSave = this.gameState.started;
|
this.gameState.canSave = this.gameState.started;
|
||||||
this.gameState.canLoad = true;
|
this.gameState.canLoad = true;
|
||||||
this.updateUIState();
|
this.updateUIState();
|
||||||
await this.restoreBrowserSave(browserSave, 'autosave-resume', { resetDisplay: false });
|
await this.restoreBrowserSave(browserSave, 'autosave-resume', { resetDisplay: true });
|
||||||
this.restoreInputStateFromSave(browserSave, 'autosave-resume');
|
this.restoreInputStateFromSave(browserSave, 'autosave-resume');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -307,14 +281,6 @@ class GameLoopModule extends BaseModule {
|
|||||||
const storyHistory = this.getModule('story-history');
|
const storyHistory = this.getModule('story-history');
|
||||||
if (storyHistory && typeof storyHistory.startNewGame === 'function') {
|
if (storyHistory && typeof storyHistory.startNewGame === 'function') {
|
||||||
await storyHistory.startNewGame();
|
await storyHistory.startNewGame();
|
||||||
if (typeof storyHistory.saveSlot === 'function') {
|
|
||||||
await storyHistory.saveSlot(this.autoSaveSlot, {
|
|
||||||
inkState: null,
|
|
||||||
choices: [],
|
|
||||||
inputMode: 'none',
|
|
||||||
running: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const response = await socketClient.newGame();
|
const response = await socketClient.newGame();
|
||||||
if (!response?.success) {
|
if (!response?.success) {
|
||||||
@@ -330,15 +296,6 @@ class GameLoopModule extends BaseModule {
|
|||||||
this.gameState.canSave = true;
|
this.gameState.canSave = true;
|
||||||
this.gameState.canLoad = Boolean(response.canLoad);
|
this.gameState.canLoad = Boolean(response.canLoad);
|
||||||
this.updateUIState();
|
this.updateUIState();
|
||||||
if (response.savedState && storyHistory && typeof storyHistory.saveSlot === 'function') {
|
|
||||||
await storyHistory.saveSlot(this.autoSaveSlot, {
|
|
||||||
inkState: response.savedState,
|
|
||||||
choices: [],
|
|
||||||
inputMode: 'none',
|
|
||||||
running: true
|
|
||||||
});
|
|
||||||
this.lastInkState = response.savedState;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -416,12 +373,6 @@ class GameLoopModule extends BaseModule {
|
|||||||
if (options.resetDisplay) {
|
if (options.resetDisplay) {
|
||||||
await this.resetClientPlaybackAndDisplay();
|
await this.resetClientPlaybackAndDisplay();
|
||||||
}
|
}
|
||||||
const restoreGeneration = ++this.restoreGeneration;
|
|
||||||
const resetGeneration = this.clientResetGeneration;
|
|
||||||
const isCurrentRestore = () =>
|
|
||||||
restoreGeneration === this.restoreGeneration &&
|
|
||||||
resetGeneration === this.clientResetGeneration;
|
|
||||||
this.clearPendingHistoryRestore(`${reason}-superseded`);
|
|
||||||
document.dispatchEvent(new CustomEvent('story:history-restoring', {
|
document.dispatchEvent(new CustomEvent('story:history-restoring', {
|
||||||
detail: { active: true, reason }
|
detail: { active: true, reason }
|
||||||
}));
|
}));
|
||||||
@@ -436,12 +387,10 @@ class GameLoopModule extends BaseModule {
|
|||||||
const uiController = this.getModule('ui-controller');
|
const uiController = this.getModule('ui-controller');
|
||||||
if (browserSave && uiController?.displayHandler?.restoreFromHistory) {
|
if (browserSave && uiController?.displayHandler?.restoreFromHistory) {
|
||||||
await uiController.displayHandler.restoreFromHistory(browserSave);
|
await uiController.displayHandler.restoreFromHistory(browserSave);
|
||||||
if (!isCurrentRestore()) return;
|
|
||||||
}
|
}
|
||||||
const audioManager = this.getModule('audio-manager');
|
const audioManager = this.getModule('audio-manager');
|
||||||
if (browserSave?.musicState && audioManager?.restoreMusicState) {
|
if (browserSave?.musicState && audioManager?.restoreMusicState) {
|
||||||
await audioManager.restoreMusicState(browserSave.musicState);
|
await audioManager.restoreMusicState(browserSave.musicState);
|
||||||
if (!isCurrentRestore()) return;
|
|
||||||
}
|
}
|
||||||
const hasUnrenderedHistory = this.hasUnrenderedHistory(browserSave);
|
const hasUnrenderedHistory = this.hasUnrenderedHistory(browserSave);
|
||||||
if (hasUnrenderedHistory) {
|
if (hasUnrenderedHistory) {
|
||||||
@@ -452,27 +401,19 @@ class GameLoopModule extends BaseModule {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (hasUnrenderedHistory) {
|
if (hasUnrenderedHistory) {
|
||||||
await this.queueUnrenderedHistoryBlocks(browserSave, isCurrentRestore);
|
await this.queueUnrenderedHistoryBlocks(browserSave);
|
||||||
if (!isCurrentRestore()) return;
|
|
||||||
}
|
}
|
||||||
if (!hasUnrenderedHistory) {
|
if (!hasUnrenderedHistory) {
|
||||||
document.dispatchEvent(new CustomEvent('story:history-restoring', {
|
document.dispatchEvent(new CustomEvent('story:history-restoring', {
|
||||||
detail: { active: false, reason: `${reason}-complete` }
|
detail: { active: false, reason: `${reason}-complete` }
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
const clearRestoring = (eventOrReason = 'pending-output-drained') => {
|
const clearRestoring = () => {
|
||||||
const clearReason = typeof eventOrReason === 'string'
|
|
||||||
? eventOrReason
|
|
||||||
: 'pending-output-drained';
|
|
||||||
document.dispatchEvent(new CustomEvent('story:history-restoring', {
|
document.dispatchEvent(new CustomEvent('story:history-restoring', {
|
||||||
detail: { active: false, reason: clearReason }
|
detail: { active: false, reason: 'pending-output-drained' }
|
||||||
}));
|
}));
|
||||||
document.removeEventListener('tts:queue-empty', clearRestoring);
|
document.removeEventListener('tts:queue-empty', clearRestoring);
|
||||||
if (this.pendingHistoryRestoreCleanup === clearRestoring) {
|
|
||||||
this.pendingHistoryRestoreCleanup = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
this.pendingHistoryRestoreCleanup = clearRestoring;
|
|
||||||
document.addEventListener('tts:queue-empty', clearRestoring);
|
document.addEventListener('tts:queue-empty', clearRestoring);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -552,7 +493,7 @@ class GameLoopModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async queueUnrenderedHistoryBlocks(saveRecord = {}, isCurrentRestore = null) {
|
async queueUnrenderedHistoryBlocks(saveRecord = {}) {
|
||||||
const storyHistory = this.getModule('story-history');
|
const storyHistory = this.getModule('story-history');
|
||||||
const textBuffer = this.getModule('text-buffer');
|
const textBuffer = this.getModule('text-buffer');
|
||||||
if (!storyHistory || !textBuffer || typeof textBuffer.addBlocks !== 'function') return;
|
if (!storyHistory || !textBuffer || typeof textBuffer.addBlocks !== 'function') return;
|
||||||
@@ -560,16 +501,10 @@ class GameLoopModule extends BaseModule {
|
|||||||
const end = Math.max(0, Number(saveRecord.latestBlockId || 0));
|
const end = Math.max(0, Number(saveRecord.latestBlockId || 0));
|
||||||
if (end < start) return;
|
if (end < start) return;
|
||||||
const blocks = await storyHistory.getBlocksRange(saveRecord.gameId, start, end);
|
const blocks = await storyHistory.getBlocksRange(saveRecord.gameId, start, end);
|
||||||
if (typeof isCurrentRestore === 'function' && !isCurrentRestore()) return;
|
|
||||||
if (saveRecord.gameId && storyHistory.currentGameId !== saveRecord.gameId) return;
|
|
||||||
textBuffer.addBlocks(blocks);
|
textBuffer.addBlocks(blocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetClientPlaybackAndDisplay() {
|
async resetClientPlaybackAndDisplay() {
|
||||||
this.clientResetGeneration += 1;
|
|
||||||
this.restoreGeneration += 1;
|
|
||||||
this.clearPendingHistoryRestore('client-reset');
|
|
||||||
|
|
||||||
const playbackCoordinator = this.getModule('playback-coordinator');
|
const playbackCoordinator = this.getModule('playback-coordinator');
|
||||||
if (playbackCoordinator && typeof playbackCoordinator.stop === 'function') {
|
if (playbackCoordinator && typeof playbackCoordinator.stop === 'function') {
|
||||||
await playbackCoordinator.stop();
|
await playbackCoordinator.stop();
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export class KokoroTTSModule extends TTSHandlerModule {
|
|||||||
this.lastProgressTime = null;
|
this.lastProgressTime = null;
|
||||||
this.lastProgressValue = null;
|
this.lastProgressValue = null;
|
||||||
this.modelLoaded = false;
|
this.modelLoaded = false;
|
||||||
this.unsupportedReason = '';
|
|
||||||
|
|
||||||
// Options for playback
|
// Options for playback
|
||||||
this.options = {
|
this.options = {
|
||||||
@@ -38,8 +37,7 @@ export class KokoroTTSModule extends TTSHandlerModule {
|
|||||||
'pause',
|
'pause',
|
||||||
'resume',
|
'resume',
|
||||||
'getDefaultVoices',
|
'getDefaultVoices',
|
||||||
'setVoiceOptions',
|
'setVoiceOptions'
|
||||||
'supportsGameLanguage'
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,18 +59,6 @@ export class KokoroTTSModule extends TTSHandlerModule {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gameConfig = this.getModule('game-config');
|
|
||||||
const gameLanguage = gameConfig?.getLocale?.() || 'en_US';
|
|
||||||
if (!this.supportsGameLanguage(gameLanguage)) {
|
|
||||||
this.voices = [];
|
|
||||||
this.isReady = false;
|
|
||||||
this.unsupportedReason = `Kokoro TTS supports English and Chinese only; game language is ${gameLanguage}`;
|
|
||||||
this.reportProgress(100, 'Kokoro TTS disabled for this language');
|
|
||||||
console.log(`Kokoro TTS: ${this.unsupportedReason}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
this.unsupportedReason = '';
|
|
||||||
|
|
||||||
this.addEventListener(document, 'preference-updated', (event) => {
|
this.addEventListener(document, 'preference-updated', (event) => {
|
||||||
const { category, key } = event.detail || {};
|
const { category, key } = event.detail || {};
|
||||||
if (category === 'audio' && ['masterVolume', 'ttsVolume', 'masterVolumeEnabled', 'ttsVolumeEnabled'].includes(key) && this.currentAudio) {
|
if (category === 'audio' && ['masterVolume', 'ttsVolume', 'masterVolumeEnabled', 'ttsVolumeEnabled'].includes(key) && this.currentAudio) {
|
||||||
@@ -403,26 +389,11 @@ export class KokoroTTSModule extends TTSHandlerModule {
|
|||||||
return Math.max(0, Math.min(1, this.options.volume * (masterEnabled ? masterVolume : 0) * (ttsEnabled ? ttsVolume : 0)));
|
return Math.max(0, Math.min(1, this.options.volume * (masterEnabled ? masterVolume : 0) * (ttsEnabled ? ttsVolume : 0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
supportsGameLanguage(language) {
|
|
||||||
const normalized = String(language || '').trim().replace('_', '-').toLowerCase();
|
|
||||||
const languageCode = normalized.split('-')[0];
|
|
||||||
return languageCode === 'en'
|
|
||||||
|| languageCode === 'english'
|
|
||||||
|| languageCode === 'zh'
|
|
||||||
|| languageCode === 'chinese'
|
|
||||||
|| languageCode === 'cmn'
|
|
||||||
|| languageCode === 'yue';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available voices
|
* Get available voices
|
||||||
* @returns {Array} - Array of voice objects
|
* @returns {Array} - Array of voice objects
|
||||||
*/
|
*/
|
||||||
async getVoices() {
|
async getVoices() {
|
||||||
if (this.unsupportedReason) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no voices are loaded yet, return default voices
|
// If no voices are loaded yet, return default voices
|
||||||
if (!this.voices || this.voices.length === 0) {
|
if (!this.voices || this.voices.length === 0) {
|
||||||
return this.getDefaultVoices();
|
return this.getDefaultVoices();
|
||||||
|
|||||||
@@ -27,12 +27,6 @@ class LayoutRendererModule extends BaseModule {
|
|||||||
'decorateInlineWord',
|
'decorateInlineWord',
|
||||||
'applyGlossaryEntries',
|
'applyGlossaryEntries',
|
||||||
'normalizeGlossaryText',
|
'normalizeGlossaryText',
|
||||||
'normalizeGlossaryToken',
|
|
||||||
'normalizeGlossaryCompact',
|
|
||||||
'buildGlossaryTermPatterns',
|
|
||||||
'buildCompactGlossaryTermPatterns',
|
|
||||||
'decorateGlossarySegment',
|
|
||||||
'decorateGlossaryRange',
|
|
||||||
'decorateGlossaryWord',
|
'decorateGlossaryWord',
|
||||||
'ensureGlossaryTooltip',
|
'ensureGlossaryTooltip',
|
||||||
'showGlossaryTooltip',
|
'showGlossaryTooltip',
|
||||||
@@ -343,56 +337,34 @@ class LayoutRendererModule extends BaseModule {
|
|||||||
|
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
const segments = [];
|
const segments = [];
|
||||||
let compactCursor = 0;
|
|
||||||
const compactSegments = [];
|
|
||||||
const fullText = words.map((word, index) => {
|
const fullText = words.map((word, index) => {
|
||||||
if (index > 0) cursor += 1;
|
if (index > 0) cursor += 1;
|
||||||
const start = cursor;
|
const start = cursor;
|
||||||
cursor += word.text.length;
|
cursor += word.text.length;
|
||||||
segments.push({ ...word, start, end: cursor });
|
segments.push({ ...word, start, end: cursor });
|
||||||
|
|
||||||
const compactText = this.normalizeGlossaryCompact(word.text);
|
|
||||||
if (compactText) {
|
|
||||||
const compactStart = compactCursor;
|
|
||||||
compactCursor += compactText.length;
|
|
||||||
compactSegments.push({ ...word, start: compactStart, end: compactCursor });
|
|
||||||
}
|
|
||||||
|
|
||||||
return word.text;
|
return word.text;
|
||||||
}).join(' ');
|
}).join(' ');
|
||||||
const compactFullText = words.map(word => this.normalizeGlossaryCompact(word.text)).join('');
|
|
||||||
|
|
||||||
entries
|
entries
|
||||||
.filter(entry => entry && entry.term && entry.definition)
|
.filter(entry => entry && entry.term && entry.definition)
|
||||||
.forEach(entry => {
|
.forEach(entry => {
|
||||||
this.buildGlossaryTermPatterns(entry.term).forEach((pattern) => {
|
const normalizedTerm = this.normalizeGlossaryText(entry.term);
|
||||||
const matcher = new RegExp(`(^|\\s)(${pattern})(?=\\s|$|[.,;:!?])`, 'giu');
|
if (!normalizedTerm) return;
|
||||||
let match;
|
|
||||||
while ((match = matcher.exec(fullText)) !== null) {
|
const matcher = new RegExp(`(^|\\s)(${this.escapeRegExp(normalizedTerm)})(?=\\s|$|[.,;:!?])`, 'giu');
|
||||||
const matchStart = match.index + match[1].length;
|
let match;
|
||||||
const matchEnd = matchStart + match[2].length;
|
while ((match = matcher.exec(fullText)) !== null) {
|
||||||
segments
|
const matchStart = match.index + match[1].length;
|
||||||
.filter(segment => segment.end > matchStart && segment.start < matchEnd)
|
const matchEnd = matchStart + match[2].length;
|
||||||
.forEach(segment => this.decorateGlossarySegment(segment, entry, matchStart, matchEnd, 'text'));
|
segments
|
||||||
}
|
.filter(segment => segment.end > matchStart && segment.start < matchEnd)
|
||||||
});
|
.forEach(segment => this.decorateGlossaryWord(segment.element, entry));
|
||||||
this.buildCompactGlossaryTermPatterns(entry.term).forEach((pattern) => {
|
}
|
||||||
const matcher = new RegExp(pattern, 'giu');
|
|
||||||
let match;
|
|
||||||
while ((match = matcher.exec(compactFullText)) !== null) {
|
|
||||||
const matchStart = match.index;
|
|
||||||
const matchEnd = matchStart + match[0].length;
|
|
||||||
compactSegments
|
|
||||||
.filter(segment => segment.end > matchStart && segment.start < matchEnd)
|
|
||||||
.forEach(segment => this.decorateGlossarySegment(segment, entry, matchStart, matchEnd, 'compact'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizeGlossaryText(text) {
|
normalizeGlossaryText(text) {
|
||||||
return String(text || '')
|
return String(text || '')
|
||||||
.normalize('NFC')
|
|
||||||
.replace(/\u200c/g, '')
|
.replace(/\u200c/g, '')
|
||||||
.replace(/\u00ad/g, '')
|
.replace(/\u00ad/g, '')
|
||||||
.replace(/-\s*$/g, '')
|
.replace(/-\s*$/g, '')
|
||||||
@@ -400,157 +372,6 @@ class LayoutRendererModule extends BaseModule {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizeGlossaryToken(text) {
|
|
||||||
return this.normalizeGlossaryText(text)
|
|
||||||
.replace(/^[.,;:!?()[\]{}"'„“”‚‘’»«]+|[.,;:!?()[\]{}"'„“”‚‘’»«]+$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeGlossaryCompact(text) {
|
|
||||||
return this.normalizeGlossaryToken(text)
|
|
||||||
.replace(/[-\s]+/g, '')
|
|
||||||
.replace(/[.,;:!?()[\]{}"'„“”‚‘’»«]+/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
buildGlossaryTermPatterns(term) {
|
|
||||||
const normalizedTerm = this.normalizeGlossaryText(term);
|
|
||||||
if (!normalizedTerm) return [];
|
|
||||||
|
|
||||||
const exact = normalizedTerm
|
|
||||||
.split(/\s+/)
|
|
||||||
.map(token => this.escapeRegExp(this.normalizeGlossaryToken(token)))
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\\s+');
|
|
||||||
if (!exact) return [];
|
|
||||||
|
|
||||||
const inflected = normalizedTerm
|
|
||||||
.split(/\s+/)
|
|
||||||
.map((token, index, tokens) => {
|
|
||||||
const normalized = this.normalizeGlossaryToken(token);
|
|
||||||
if (!normalized) return '';
|
|
||||||
const escaped = this.escapeRegExp(normalized);
|
|
||||||
const isLast = index === tokens.length - 1;
|
|
||||||
return isLast ? `${escaped}(?:s|es|e|en|er|n)?` : `${escaped}(?:e|en|er|es|n)?`;
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\\s+');
|
|
||||||
|
|
||||||
return [...new Set([exact, inflected])];
|
|
||||||
}
|
|
||||||
|
|
||||||
buildCompactGlossaryTermPatterns(term) {
|
|
||||||
const tokens = this.normalizeGlossaryText(term)
|
|
||||||
.split(/\s+/)
|
|
||||||
.map(token => this.normalizeGlossaryCompact(token))
|
|
||||||
.filter(Boolean);
|
|
||||||
if (tokens.length === 0) return [];
|
|
||||||
|
|
||||||
const exact = tokens.map(token => this.escapeRegExp(token)).join('');
|
|
||||||
const inflected = tokens
|
|
||||||
.map((token, index) => {
|
|
||||||
const escaped = this.escapeRegExp(token);
|
|
||||||
const isLast = index === tokens.length - 1;
|
|
||||||
return isLast ? `${escaped}(?:s|es|e|en|er|n)?` : `${escaped}(?:e|en|er|es|n)?`;
|
|
||||||
})
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
return [...new Set([exact, inflected])];
|
|
||||||
}
|
|
||||||
|
|
||||||
decorateGlossarySegment(segment, entry, matchStart, matchEnd, mode = 'text') {
|
|
||||||
if (!segment?.element || !entry?.definition) return;
|
|
||||||
|
|
||||||
const localStart = Math.max(0, matchStart - segment.start);
|
|
||||||
const localEnd = Math.min(segment.end - segment.start, matchEnd - segment.start);
|
|
||||||
if (localEnd <= localStart) return;
|
|
||||||
|
|
||||||
const segmentLength = mode === 'compact'
|
|
||||||
? this.normalizeGlossaryCompact(segment.text).length
|
|
||||||
: segment.text.length;
|
|
||||||
|
|
||||||
if (localStart <= 0 && localEnd >= segmentLength) {
|
|
||||||
this.decorateGlossaryWord(segment.element, entry);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === 'compact') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.decorateGlossaryRange(segment.element, entry, localStart, localEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
decorateGlossaryRange(word, entry, start, end) {
|
|
||||||
if (!word || !entry?.definition) return;
|
|
||||||
|
|
||||||
const text = word.textContent || '';
|
|
||||||
const safeStart = Math.max(0, Math.min(text.length, start));
|
|
||||||
const safeEnd = Math.max(safeStart, Math.min(text.length, end));
|
|
||||||
if (safeStart === 0 && safeEnd >= text.length) {
|
|
||||||
this.decorateGlossaryWord(word, entry);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (safeEnd <= safeStart) return;
|
|
||||||
|
|
||||||
word.dataset.glossaryPartial = 'true';
|
|
||||||
|
|
||||||
const textNodes = [];
|
|
||||||
const filter = window.NodeFilter || NodeFilter;
|
|
||||||
const walker = document.createTreeWalker(word, filter.SHOW_TEXT);
|
|
||||||
let node;
|
|
||||||
while ((node = walker.nextNode())) {
|
|
||||||
textNodes.push(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
let offset = 0;
|
|
||||||
textNodes.forEach((textNode) => {
|
|
||||||
const nodeText = textNode.nodeValue || '';
|
|
||||||
const nodeStart = offset;
|
|
||||||
const nodeEnd = nodeStart + nodeText.length;
|
|
||||||
offset = nodeEnd;
|
|
||||||
|
|
||||||
const overlapStart = Math.max(safeStart, nodeStart);
|
|
||||||
const overlapEnd = Math.min(safeEnd, nodeEnd);
|
|
||||||
if (overlapEnd <= overlapStart || !textNode.parentNode) return;
|
|
||||||
|
|
||||||
const localStart = overlapStart - nodeStart;
|
|
||||||
const localEnd = overlapEnd - nodeStart;
|
|
||||||
const before = nodeText.slice(0, localStart);
|
|
||||||
const matched = nodeText.slice(localStart, localEnd);
|
|
||||||
const after = nodeText.slice(localEnd);
|
|
||||||
const parent = textNode.parentNode;
|
|
||||||
|
|
||||||
if (before) {
|
|
||||||
parent.insertBefore(document.createTextNode(before), textNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matched) {
|
|
||||||
const gloss = document.createElement('span');
|
|
||||||
gloss.textContent = matched;
|
|
||||||
this.decorateGlossaryWord(gloss, entry);
|
|
||||||
parent.insertBefore(gloss, textNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (after) {
|
|
||||||
parent.insertBefore(document.createTextNode(after), textNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
parent.removeChild(textNode);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (textNodes.length === 0) {
|
|
||||||
const before = text.slice(0, safeStart);
|
|
||||||
const matched = text.slice(safeStart, safeEnd);
|
|
||||||
const after = text.slice(safeEnd);
|
|
||||||
word.textContent = '';
|
|
||||||
if (before) word.appendChild(document.createTextNode(before));
|
|
||||||
const gloss = document.createElement('span');
|
|
||||||
gloss.textContent = matched;
|
|
||||||
this.decorateGlossaryWord(gloss, entry);
|
|
||||||
word.appendChild(gloss);
|
|
||||||
if (after) word.appendChild(document.createTextNode(after));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
decorateGlossaryWord(word, entry) {
|
decorateGlossaryWord(word, entry) {
|
||||||
if (!word || !entry?.definition) return;
|
if (!word || !entry?.definition) return;
|
||||||
word.classList.add('story-glossary-word');
|
word.classList.add('story-glossary-word');
|
||||||
|
|||||||
@@ -122,7 +122,6 @@ const ModuleLoader = (function() {
|
|||||||
{ id: 'browser-tts', script: '/js/browser-tts-module.js', weight: 12 },
|
{ id: 'browser-tts', script: '/js/browser-tts-module.js', weight: 12 },
|
||||||
{ id: 'elevenlabs-tts', script: '/js/elevenlabs-tts-module.js', weight: 12 },
|
{ id: 'elevenlabs-tts', script: '/js/elevenlabs-tts-module.js', weight: 12 },
|
||||||
{ id: 'openai-tts', script: '/js/openai-tts-module.js', weight: 12 },
|
{ id: 'openai-tts', script: '/js/openai-tts-module.js', weight: 12 },
|
||||||
{ id: 'local-openai-tts', script: '/js/local-openai-tts-module.js', weight: 12 },
|
|
||||||
{ id: 'tts-factory', script: '/js/tts-factory-module.js', weight: 13 }, // TTSFactory must be loaded before TTSPlayer
|
{ id: 'tts-factory', script: '/js/tts-factory-module.js', weight: 13 }, // TTSFactory must be loaded before TTSPlayer
|
||||||
|
|
||||||
// UI and interaction modules
|
// UI and interaction modules
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
/**
|
|
||||||
* LocalOpenAITTSModule
|
|
||||||
* Provides TTS via local or self-hosted OpenAI-compatible /audio/speech APIs.
|
|
||||||
*/
|
|
||||||
import { ApiTTSModuleBase } from './api-tts-module-base.js';
|
|
||||||
|
|
||||||
export class LocalOpenAITTSModule extends ApiTTSModuleBase {
|
|
||||||
constructor() {
|
|
||||||
super('local-openai-tts', 'Local OpenAI TTS');
|
|
||||||
|
|
||||||
this.voiceOptions = {
|
|
||||||
voice: 'alloy',
|
|
||||||
model: 'tts-1',
|
|
||||||
speed: 1.0,
|
|
||||||
response_format: 'mp3'
|
|
||||||
};
|
|
||||||
this.voices = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
getDefaultApiBaseUrl() {
|
|
||||||
return 'http://localhost:8000/v1';
|
|
||||||
}
|
|
||||||
|
|
||||||
async initialize() {
|
|
||||||
try {
|
|
||||||
this.reportProgress(10, 'Initializing Local OpenAI TTS');
|
|
||||||
|
|
||||||
const parentInit = await super.initialize();
|
|
||||||
if (!parentInit) {
|
|
||||||
console.error('Local OpenAI TTS: Parent initialization failed');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistenceManager = this.getModule('persistence-manager');
|
|
||||||
if (!persistenceManager) {
|
|
||||||
console.error('Local OpenAI TTS: Required dependency persistence-manager not found');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredVoice = persistenceManager.getPreference('tts', `${this.id}_voice`, this.voiceOptions.voice);
|
|
||||||
if (preferredVoice) {
|
|
||||||
this.voiceOptions.voice = this.normalizeTextOption(preferredVoice, this.voiceOptions.voice);
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredModel = persistenceManager.getPreference('tts', `${this.id}_model`, this.voiceOptions.model);
|
|
||||||
if (preferredModel) {
|
|
||||||
this.voiceOptions.model = this.normalizeTextOption(preferredModel, this.voiceOptions.model);
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredFormat = persistenceManager.getPreference('tts', `${this.id}_format`, this.voiceOptions.response_format);
|
|
||||||
if (preferredFormat) {
|
|
||||||
this.voiceOptions.response_format = this.normalizeResponseFormat(preferredFormat);
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredSpeed = persistenceManager.getPreference('tts', 'speed', this.voiceOptions.speed);
|
|
||||||
if (typeof preferredSpeed === 'number') {
|
|
||||||
this.voiceOptions.speed = this.normalizeAppSpeed(preferredSpeed);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isReady = Boolean(this.apiBaseUrl && this.voiceOptions.voice && this.voiceOptions.model);
|
|
||||||
this.reportProgress(100, this.isReady ? 'Local OpenAI TTS initialized' : 'Local OpenAI TTS not configured');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Local OpenAI TTS: Initialization error:', error);
|
|
||||||
this.isReady = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadVoices() {
|
|
||||||
this.voices = [];
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectVoiceForLocale() {
|
|
||||||
return this.selectDefaultVoice();
|
|
||||||
}
|
|
||||||
|
|
||||||
selectDefaultVoice() {
|
|
||||||
this.voiceOptions.voice = this.normalizeTextOption(this.voiceOptions.voice, 'alloy');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
getAvailableVoices() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async getVoices() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async generateSpeechAudio(text, options = {}) {
|
|
||||||
if (!this.isReady || !this.apiBaseUrl) {
|
|
||||||
return { success: false, reason: 'not_ready' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const processedText = this.preprocessText(text);
|
|
||||||
if (!processedText) {
|
|
||||||
return { success: false, reason: 'empty_text' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
model: this.normalizeTextOption(this.voiceOptions.model, 'tts-1'),
|
|
||||||
input: processedText,
|
|
||||||
voice: this.normalizeTextOption(this.voiceOptions.voice, 'alloy'),
|
|
||||||
response_format: this.normalizeResponseFormat(this.voiceOptions.response_format),
|
|
||||||
speed: this.getApiSpeed(this.voiceOptions.speed)
|
|
||||||
};
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
};
|
|
||||||
if (this.apiKey) {
|
|
||||||
headers.Authorization = `Bearer ${this.apiKey}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${this.apiBaseUrl.replace(/\/+$/, '')}/audio/speech`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
signal: options.signal
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`API error: ${response.status} ${response.statusText} - ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioBlob = await response.blob();
|
|
||||||
const arrayBuffer = await audioBlob.arrayBuffer();
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
audioData: arrayBuffer
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.name === 'AbortError') {
|
|
||||||
console.error('Local OpenAI TTS: Speech request was aborted:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
reason: 'aborted',
|
|
||||||
error: error.message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
console.error('Local OpenAI TTS: Error generating speech:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
reason: 'api_error',
|
|
||||||
error: error.message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setVoiceOptions(options = {}) {
|
|
||||||
const persistenceManager = this.getModule('persistence-manager');
|
|
||||||
|
|
||||||
if (typeof options.voice === 'string') {
|
|
||||||
this.voiceOptions.voice = this.normalizeTextOption(options.voice, this.voiceOptions.voice);
|
|
||||||
if (persistenceManager) {
|
|
||||||
persistenceManager.updatePreference('tts', `${this.id}_voice`, this.voiceOptions.voice);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof options.speed === 'number') {
|
|
||||||
this.voiceOptions.speed = this.normalizeAppSpeed(options.speed);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof options.model === 'string') {
|
|
||||||
this.voiceOptions.model = this.normalizeTextOption(options.model, this.voiceOptions.model);
|
|
||||||
if (persistenceManager) {
|
|
||||||
persistenceManager.updatePreference('tts', `${this.id}_model`, this.voiceOptions.model);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof options.response_format === 'string') {
|
|
||||||
this.voiceOptions.response_format = this.normalizeResponseFormat(options.response_format);
|
|
||||||
if (persistenceManager) {
|
|
||||||
persistenceManager.updatePreference('tts', `${this.id}_format`, this.voiceOptions.response_format);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isReady = Boolean(this.apiBaseUrl && this.voiceOptions.voice && this.voiceOptions.model);
|
|
||||||
this.notifyReadyState();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleApiKeyChanged(event) {
|
|
||||||
if (!event?.detail || event.detail.provider !== this.id) return;
|
|
||||||
const newKey = event.detail.key || '';
|
|
||||||
if (newKey && /^https?:\/\//i.test(newKey)) {
|
|
||||||
console.error('Local OpenAI TTS: Received URL instead of API key, ignoring it');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldKey = this.apiKey;
|
|
||||||
this.apiKey = newKey;
|
|
||||||
|
|
||||||
const persistenceManager = this.getModule('persistence-manager');
|
|
||||||
if (persistenceManager && oldKey !== newKey) {
|
|
||||||
persistenceManager.updatePreference('tts', `${this.id}_api_key`, newKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasReady = this.isReady;
|
|
||||||
this.isReady = Boolean(this.apiBaseUrl && this.voiceOptions.voice && this.voiceOptions.model);
|
|
||||||
if (wasReady !== this.isReady) {
|
|
||||||
this.notifyReadyState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleApiUrlChanged(event) {
|
|
||||||
if (!event?.detail || event.detail.provider !== this.id) return;
|
|
||||||
const oldUrl = this.apiBaseUrl;
|
|
||||||
const newUrl = String(event.detail.url || this.getDefaultApiBaseUrl()).trim().replace(/\/+$/, '');
|
|
||||||
this.apiBaseUrl = newUrl;
|
|
||||||
|
|
||||||
const persistenceManager = this.getModule('persistence-manager');
|
|
||||||
if (persistenceManager && oldUrl !== newUrl) {
|
|
||||||
persistenceManager.updatePreference('tts', `${this.id}_api_url`, newUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasReady = this.isReady;
|
|
||||||
this.isReady = Boolean(this.apiBaseUrl && this.voiceOptions.voice && this.voiceOptions.model);
|
|
||||||
if (wasReady !== this.isReady || oldUrl !== newUrl) {
|
|
||||||
this.notifyReadyState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeTextOption(value, fallback) {
|
|
||||||
const text = String(value || '').trim();
|
|
||||||
return text || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeResponseFormat(value) {
|
|
||||||
const format = String(value || '').trim().toLowerCase();
|
|
||||||
const validFormats = ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm'];
|
|
||||||
return validFormats.includes(format) ? format : 'mp3';
|
|
||||||
}
|
|
||||||
|
|
||||||
getApiSpeed(speed) {
|
|
||||||
const value = Number.isFinite(Number(speed)) ? Number(speed) : this.normalizeAppSpeed(speed);
|
|
||||||
return Math.max(0.25, Math.min(4.0, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeAppSpeed(speed) {
|
|
||||||
const value = Number.isFinite(Number(speed)) ? Number(speed) : 1.0;
|
|
||||||
return Math.max(0.5, Math.min(2.0, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const localOpenAITTSModule = new LocalOpenAITTSModule();
|
|
||||||
|
|
||||||
export { localOpenAITTSModule };
|
|
||||||
|
|
||||||
if (window.moduleRegistry) {
|
|
||||||
window.moduleRegistry.register(localOpenAITTSModule);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.LocalOpenAITTSModule = localOpenAITTSModule;
|
|
||||||
@@ -19,8 +19,6 @@ class MarkupParserModule extends BaseModule {
|
|||||||
'parseParagraph',
|
'parseParagraph',
|
||||||
'parseInline',
|
'parseInline',
|
||||||
'extractGlossaryTags',
|
'extractGlossaryTags',
|
||||||
'extractTtsInstructionTags',
|
|
||||||
'normalizeTtsInstructionProvider',
|
|
||||||
'parseImageOptions',
|
'parseImageOptions',
|
||||||
'parseSfxOptions',
|
'parseSfxOptions',
|
||||||
'parseMusicOptions',
|
'parseMusicOptions',
|
||||||
@@ -245,52 +243,6 @@ class MarkupParserModule extends BaseModule {
|
|||||||
.sort((a, b) => b.term.length - a.term.length);
|
.sort((a, b) => b.term.length - a.term.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
extractTtsInstructionTags(tags = []) {
|
|
||||||
if (!Array.isArray(tags)) return [];
|
|
||||||
|
|
||||||
return tags
|
|
||||||
.map(tag => {
|
|
||||||
const key = String(tag?.key || '').toLowerCase();
|
|
||||||
const value = String(tag?.value || '').trim();
|
|
||||||
const param = String(tag?.param || '').trim();
|
|
||||||
|
|
||||||
if (key === 'tts') {
|
|
||||||
if (param) {
|
|
||||||
return {
|
|
||||||
provider: this.normalizeTtsInstructionProvider(value),
|
|
||||||
instruction: param
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider: null,
|
|
||||||
instruction: value
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.startsWith('tts-') && value) {
|
|
||||||
return {
|
|
||||||
provider: this.normalizeTtsInstructionProvider(key.slice(4)),
|
|
||||||
instruction: value
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter(entry => entry && entry.instruction);
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeTtsInstructionProvider(provider) {
|
|
||||||
const normalized = String(provider || '').trim().toLowerCase();
|
|
||||||
if (!normalized) return null;
|
|
||||||
if (normalized === 'openai' || normalized === 'openai-tts') return 'openai-tts';
|
|
||||||
if (normalized === 'local-openai' || normalized === 'local-openai-tts') return 'local-openai-tts';
|
|
||||||
if (normalized === 'elevenlabs' || normalized === 'elevenlabs-tts') return 'elevenlabs-tts';
|
|
||||||
if (normalized === 'kokoro' || normalized === 'kokoro-tts') return 'kokoro-tts';
|
|
||||||
if (normalized === 'browser' || normalized === 'browser-tts') return 'browser-tts';
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
smartypants(text) {
|
smartypants(text) {
|
||||||
const result = String(text)
|
const result = String(text)
|
||||||
.replace(/---/g, '\u2014')
|
.replace(/---/g, '\u2014')
|
||||||
|
|||||||
+18
-106
@@ -8,13 +8,7 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
|||||||
constructor() {
|
constructor() {
|
||||||
super('openai-tts', 'OpenAI TTS');
|
super('openai-tts', 'OpenAI TTS');
|
||||||
|
|
||||||
this.supportedModels = [
|
this.supportedVoices = [
|
||||||
{ id: 'tts-1', name: 'TTS-1' },
|
|
||||||
{ id: 'tts-1-hd', name: 'TTS-1 HD' },
|
|
||||||
{ id: 'gpt-4o-mini-tts', name: 'GPT-4o mini TTS' }
|
|
||||||
];
|
|
||||||
|
|
||||||
this.legacyVoices = [
|
|
||||||
{ id: 'alloy', name: 'Alloy', language: 'en' },
|
{ id: 'alloy', name: 'Alloy', language: 'en' },
|
||||||
{ id: 'ash', name: 'Ash', language: 'en' },
|
{ id: 'ash', name: 'Ash', language: 'en' },
|
||||||
{ id: 'coral', name: 'Coral', language: 'en' },
|
{ id: 'coral', name: 'Coral', language: 'en' },
|
||||||
@@ -26,25 +20,6 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
|||||||
{ id: 'shimmer', name: 'Shimmer', language: 'en' }
|
{ id: 'shimmer', name: 'Shimmer', language: 'en' }
|
||||||
];
|
];
|
||||||
|
|
||||||
this.gpt4oMiniVoices = [
|
|
||||||
{ id: 'alloy', name: 'Alloy', language: 'en' },
|
|
||||||
{ id: 'ash', name: 'Ash', language: 'en' },
|
|
||||||
{ id: 'ballad', name: 'Ballad', language: 'en' },
|
|
||||||
{ id: 'coral', name: 'Coral', language: 'en' },
|
|
||||||
{ id: 'echo', name: 'Echo', language: 'en' },
|
|
||||||
{ id: 'fable', name: 'Fable', language: 'en' },
|
|
||||||
{ id: 'nova', name: 'Nova', language: 'en' },
|
|
||||||
{ id: 'onyx', name: 'Onyx', language: 'en' },
|
|
||||||
{ id: 'sage', name: 'Sage', language: 'en' },
|
|
||||||
{ id: 'shimmer', name: 'Shimmer', language: 'en' },
|
|
||||||
{ id: 'verse', name: 'Verse', language: 'en' },
|
|
||||||
{ id: 'marin', name: 'Marin', language: 'en' },
|
|
||||||
{ id: 'cedar', name: 'Cedar', language: 'en' }
|
|
||||||
];
|
|
||||||
|
|
||||||
this.supportedVoices = [...this.gpt4oMiniVoices];
|
|
||||||
this.supportsTtsInstructions = true;
|
|
||||||
|
|
||||||
// Voice options specific to OpenAI
|
// Voice options specific to OpenAI
|
||||||
this.voiceOptions = {
|
this.voiceOptions = {
|
||||||
voice: 'alloy', // Default voice for OpenAI
|
voice: 'alloy', // Default voice for OpenAI
|
||||||
@@ -87,6 +62,15 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API key is already loaded in parent initialize() method
|
||||||
|
// Just check if it's available
|
||||||
|
if (!this.apiKey) {
|
||||||
|
console.info('OpenAI TTS: API key not configured; provider unavailable until configured');
|
||||||
|
this.isReady = false;
|
||||||
|
this.reportProgress(100, 'OpenAI TTS not configured');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Load preferences
|
// Load preferences
|
||||||
const preferredVoice = persistenceManager.getPreference('tts', `${this.id}_voice`, this.voiceOptions.voice);
|
const preferredVoice = persistenceManager.getPreference('tts', `${this.id}_voice`, this.voiceOptions.voice);
|
||||||
if (preferredVoice) {
|
if (preferredVoice) {
|
||||||
@@ -95,25 +79,12 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
|||||||
|
|
||||||
const preferredModel = persistenceManager.getPreference('tts', `${this.id}_model`, this.voiceOptions.model);
|
const preferredModel = persistenceManager.getPreference('tts', `${this.id}_model`, this.voiceOptions.model);
|
||||||
if (preferredModel) {
|
if (preferredModel) {
|
||||||
this.voiceOptions.model = this.normalizeModelId(preferredModel);
|
this.voiceOptions.model = preferredModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.voices = this.getAvailableVoices();
|
|
||||||
this.voiceOptions.voice = this.normalizeVoiceId(this.voiceOptions.voice);
|
|
||||||
|
|
||||||
const preferredSpeed = persistenceManager.getPreference('tts', 'speed', this.voiceOptions.speed);
|
const preferredSpeed = persistenceManager.getPreference('tts', 'speed', this.voiceOptions.speed);
|
||||||
if (typeof preferredSpeed === 'number') {
|
if (typeof preferredSpeed === 'number') {
|
||||||
this.voiceOptions.speed = this.normalizeAppSpeed(preferredSpeed);
|
this.voiceOptions.speed = this.getApiSpeed(preferredSpeed);
|
||||||
}
|
|
||||||
|
|
||||||
// API key is already loaded in parent initialize() method.
|
|
||||||
// Model and voice preferences still need to be available for the
|
|
||||||
// options UI even before credentials are configured.
|
|
||||||
if (!this.apiKey) {
|
|
||||||
console.info('OpenAI TTS: API key not configured; provider unavailable until configured');
|
|
||||||
this.isReady = false;
|
|
||||||
this.reportProgress(100, 'OpenAI TTS not configured');
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiReachable = await this.loadVoices();
|
const apiReachable = await this.loadVoices();
|
||||||
@@ -193,14 +164,10 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
|||||||
* @returns {Array} - Array of voice objects
|
* @returns {Array} - Array of voice objects
|
||||||
*/
|
*/
|
||||||
getAvailableVoices() {
|
getAvailableVoices() {
|
||||||
this.voices = this.getVoicesForModel(this.voiceOptions.model);
|
this.voices = [...this.supportedVoices];
|
||||||
return this.voices;
|
return this.voices;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVoices() {
|
|
||||||
return this.getAvailableVoices();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate speech audio data using OpenAI API
|
* Generate speech audio data using OpenAI API
|
||||||
* @param {string} text - Text to generate speech for
|
* @param {string} text - Text to generate speech for
|
||||||
@@ -224,11 +191,6 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
|||||||
speed: this.getApiSpeed(this.voiceOptions.speed)
|
speed: this.getApiSpeed(this.voiceOptions.speed)
|
||||||
};
|
};
|
||||||
|
|
||||||
const instructions = this.getRequestInstructions(options);
|
|
||||||
if (instructions) {
|
|
||||||
payload.instructions = instructions;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make API request
|
// Make API request
|
||||||
const response = await fetch(`${this.apiBaseUrl}/audio/speech`, {
|
const response = await fetch(`${this.apiBaseUrl}/audio/speech`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -284,20 +246,17 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
|||||||
if (typeof options.speed === 'number') {
|
if (typeof options.speed === 'number') {
|
||||||
// OpenAI speech speed uses 1.0 as normal. The app-wide slider also
|
// OpenAI speech speed uses 1.0 as normal. The app-wide slider also
|
||||||
// uses 1.0 as normal, so only clamp at the provider API boundary.
|
// uses 1.0 as normal, so only clamp at the provider API boundary.
|
||||||
this.voiceOptions.speed = this.normalizeAppSpeed(options.speed);
|
this.voiceOptions.speed = this.getApiSpeed(options.speed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle OpenAI-specific options
|
// Handle OpenAI-specific options
|
||||||
if (options.model) {
|
if (options.model) {
|
||||||
this.voiceOptions.model = this.normalizeModelId(options.model);
|
this.voiceOptions.model = options.model;
|
||||||
this.voices = this.getAvailableVoices();
|
|
||||||
this.voiceOptions.voice = this.normalizeVoiceId(this.voiceOptions.voice);
|
|
||||||
|
|
||||||
// Save the model preference
|
// Save the model preference
|
||||||
const persistenceManager = this.getModule('persistence-manager');
|
const persistenceManager = this.getModule('persistence-manager');
|
||||||
if (persistenceManager) {
|
if (persistenceManager) {
|
||||||
persistenceManager.updatePreference('tts', `${this.id}_model`, this.voiceOptions.model);
|
persistenceManager.updatePreference('tts', `${this.id}_model`, options.model);
|
||||||
persistenceManager.updatePreference('tts', `${this.id}_voice`, this.voiceOptions.voice);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +283,7 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
|||||||
|
|
||||||
normalizeVoiceId(voice) {
|
normalizeVoiceId(voice) {
|
||||||
const voiceId = this.getVoiceId(voice).toLowerCase();
|
const voiceId = this.getVoiceId(voice).toLowerCase();
|
||||||
const supported = new Set(this.getVoicesForModel(this.voiceOptions.model).map(item => item.id));
|
const supported = new Set(this.supportedVoices.map(item => item.id));
|
||||||
|
|
||||||
if (supported.has(voiceId)) {
|
if (supported.has(voiceId)) {
|
||||||
return voiceId;
|
return voiceId;
|
||||||
@@ -337,57 +296,10 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
|
|||||||
return 'alloy';
|
return 'alloy';
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizeModelId(model) {
|
|
||||||
const modelId = String(model || '').trim();
|
|
||||||
const supported = new Set(this.supportedModels.map(item => item.id));
|
|
||||||
if (supported.has(modelId)) {
|
|
||||||
return modelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modelId) {
|
|
||||||
console.warn(`OpenAI TTS: Unsupported model "${modelId}", falling back to tts-1-hd`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'tts-1-hd';
|
|
||||||
}
|
|
||||||
|
|
||||||
getVoicesForModel(model) {
|
|
||||||
const modelId = this.normalizeModelId(model || this.voiceOptions.model);
|
|
||||||
if (modelId === 'gpt-4o-mini-tts') {
|
|
||||||
return [...this.gpt4oMiniVoices];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...this.legacyVoices];
|
|
||||||
}
|
|
||||||
|
|
||||||
getRequestInstructions(options = {}) {
|
|
||||||
if (this.normalizeModelId(this.voiceOptions.model) !== 'gpt-4o-mini-tts') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const instructions = Array.isArray(options.ttsInstructions)
|
|
||||||
? options.ttsInstructions
|
|
||||||
: [];
|
|
||||||
const matching = instructions
|
|
||||||
.filter(entry => {
|
|
||||||
const provider = String(entry?.provider || '').trim();
|
|
||||||
return !provider || provider === this.id;
|
|
||||||
})
|
|
||||||
.map(entry => String(entry?.instruction || '').trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return matching.length > 0 ? matching[matching.length - 1] : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
getApiSpeed(speed) {
|
getApiSpeed(speed) {
|
||||||
const value = Number.isFinite(Number(speed)) ? Number(speed) : this.normalizeAppSpeed(speed);
|
const value = Number.isFinite(speed) ? speed : 1.0;
|
||||||
return Math.max(0.25, Math.min(4.0, value));
|
return Math.max(0.25, Math.min(4.0, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizeAppSpeed(speed) {
|
|
||||||
const value = Number.isFinite(Number(speed)) ? Number(speed) : 1.0;
|
|
||||||
return Math.max(0.5, Math.min(2.0, value));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openAITTSModule = new OpenAITTSModule();
|
const openAITTSModule = new OpenAITTSModule();
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ class OptionsUIModule extends BaseModule {
|
|||||||
'createModal',
|
'createModal',
|
||||||
'populateTtsSystems',
|
'populateTtsSystems',
|
||||||
'populateVoices',
|
'populateVoices',
|
||||||
'ensureSelectedVoiceIsAvailable',
|
|
||||||
'updateVoiceControlVisibility',
|
|
||||||
'populateLanguages',
|
'populateLanguages',
|
||||||
'loadPreferences',
|
'loadPreferences',
|
||||||
'createVolumeControl',
|
'createVolumeControl',
|
||||||
@@ -235,10 +233,10 @@ class OptionsUIModule extends BaseModule {
|
|||||||
this.elements.ttsSpeed = createUIElement('input', {
|
this.elements.ttsSpeed = createUIElement('input', {
|
||||||
type: 'range',
|
type: 'range',
|
||||||
min: 50,
|
min: 50,
|
||||||
max: 200,
|
max: 150,
|
||||||
value: 100,
|
value: 100,
|
||||||
'data-pref-bind': 'tts.speed',
|
'data-pref-bind': 'tts.speed',
|
||||||
'data-pref-transform': 'multiplier-percent'
|
'data-pref-transform': 'centered-speed'
|
||||||
}, null, speedContainer);
|
}, null, speedContainer);
|
||||||
|
|
||||||
// Update displayed value when slider changes
|
// Update displayed value when slider changes
|
||||||
@@ -304,14 +302,6 @@ class OptionsUIModule extends BaseModule {
|
|||||||
'data-pref-bind': 'tts.voice'
|
'data-pref-bind': 'tts.voice'
|
||||||
}, null, ttsVoiceContainer);
|
}, null, ttsVoiceContainer);
|
||||||
|
|
||||||
this.elements.localOpenAiVoice = createUIElement('input', {
|
|
||||||
id: 'local-openai-voice',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: 'alloy',
|
|
||||||
'data-pref-bind': 'tts.local-openai-tts_voice'
|
|
||||||
}, null, ttsVoiceContainer);
|
|
||||||
this.elements.localOpenAiVoice.style.display = 'none';
|
|
||||||
|
|
||||||
ttsSection.appendChild(ttsVoiceContainer);
|
ttsSection.appendChild(ttsVoiceContainer);
|
||||||
|
|
||||||
// Add API Settings
|
// Add API Settings
|
||||||
@@ -514,107 +504,9 @@ class OptionsUIModule extends BaseModule {
|
|||||||
|
|
||||||
openaiSettings.appendChild(openaiApiUrlContainer);
|
openaiSettings.appendChild(openaiApiUrlContainer);
|
||||||
|
|
||||||
const openaiModelContainer = document.createElement('div');
|
|
||||||
openaiModelContainer.className = 'option-item';
|
|
||||||
|
|
||||||
const openaiModelLabel = document.createElement('label');
|
|
||||||
openaiModelLabel.textContent = this.t('options.model') + ':';
|
|
||||||
openaiModelContainer.appendChild(openaiModelLabel);
|
|
||||||
|
|
||||||
this.elements.openaiModel = createUIElement('select', {
|
|
||||||
id: 'openai-model',
|
|
||||||
'data-pref-bind': 'tts.openai-tts_model'
|
|
||||||
}, null, openaiModelContainer);
|
|
||||||
|
|
||||||
[
|
|
||||||
{ id: 'tts-1', name: 'TTS-1' },
|
|
||||||
{ id: 'tts-1-hd', name: 'TTS-1 HD' },
|
|
||||||
{ id: 'gpt-4o-mini-tts', name: 'GPT-4o mini TTS' }
|
|
||||||
].forEach(model => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = model.id;
|
|
||||||
option.textContent = model.name;
|
|
||||||
this.elements.openaiModel.appendChild(option);
|
|
||||||
});
|
|
||||||
|
|
||||||
openaiSettings.appendChild(openaiModelContainer);
|
|
||||||
|
|
||||||
// Local OpenAI-compatible API settings
|
|
||||||
const localOpenAiSettings = document.createElement('div');
|
|
||||||
localOpenAiSettings.className = 'api-settings local-openai-tts-settings';
|
|
||||||
localOpenAiSettings.style.display = 'none';
|
|
||||||
|
|
||||||
const localOpenAiTitle = document.createElement('h3');
|
|
||||||
localOpenAiTitle.textContent = this.t('options.localOpenAiSettings');
|
|
||||||
localOpenAiSettings.appendChild(localOpenAiTitle);
|
|
||||||
|
|
||||||
const localOpenAiApiKeyContainer = document.createElement('div');
|
|
||||||
localOpenAiApiKeyContainer.className = 'option-item';
|
|
||||||
|
|
||||||
const localOpenAiApiKeyLabel = document.createElement('label');
|
|
||||||
localOpenAiApiKeyLabel.textContent = this.t('options.optionalApiKey') + ':';
|
|
||||||
localOpenAiApiKeyContainer.appendChild(localOpenAiApiKeyLabel);
|
|
||||||
|
|
||||||
this.elements.localOpenAiApiKey = createUIElement('input', {
|
|
||||||
type: 'password',
|
|
||||||
'data-pref-bind': 'tts.local-openai-tts_api_key'
|
|
||||||
}, null, localOpenAiApiKeyContainer);
|
|
||||||
|
|
||||||
localOpenAiSettings.appendChild(localOpenAiApiKeyContainer);
|
|
||||||
|
|
||||||
const localOpenAiApiUrlContainer = document.createElement('div');
|
|
||||||
localOpenAiApiUrlContainer.className = 'option-item';
|
|
||||||
|
|
||||||
const localOpenAiApiUrlLabel = document.createElement('label');
|
|
||||||
localOpenAiApiUrlLabel.textContent = this.t('options.apiUrl') + ':';
|
|
||||||
localOpenAiApiUrlContainer.appendChild(localOpenAiApiUrlLabel);
|
|
||||||
|
|
||||||
this.elements.localOpenAiApiUrl = createUIElement('input', {
|
|
||||||
type: 'text',
|
|
||||||
'data-pref-bind': 'tts.local-openai-tts_api_url'
|
|
||||||
}, null, localOpenAiApiUrlContainer);
|
|
||||||
|
|
||||||
localOpenAiSettings.appendChild(localOpenAiApiUrlContainer);
|
|
||||||
|
|
||||||
const localOpenAiModelContainer = document.createElement('div');
|
|
||||||
localOpenAiModelContainer.className = 'option-item';
|
|
||||||
|
|
||||||
const localOpenAiModelLabel = document.createElement('label');
|
|
||||||
localOpenAiModelLabel.textContent = this.t('options.model') + ':';
|
|
||||||
localOpenAiModelContainer.appendChild(localOpenAiModelLabel);
|
|
||||||
|
|
||||||
this.elements.localOpenAiModel = createUIElement('input', {
|
|
||||||
id: 'local-openai-model',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: 'tts-1',
|
|
||||||
'data-pref-bind': 'tts.local-openai-tts_model'
|
|
||||||
}, null, localOpenAiModelContainer);
|
|
||||||
|
|
||||||
localOpenAiSettings.appendChild(localOpenAiModelContainer);
|
|
||||||
|
|
||||||
const localOpenAiTimeoutContainer = document.createElement('div');
|
|
||||||
localOpenAiTimeoutContainer.className = 'option-item';
|
|
||||||
|
|
||||||
const localOpenAiTimeoutLabel = document.createElement('label');
|
|
||||||
localOpenAiTimeoutLabel.textContent = this.t('options.requestTimeoutMs') + ':';
|
|
||||||
localOpenAiTimeoutContainer.appendChild(localOpenAiTimeoutLabel);
|
|
||||||
|
|
||||||
this.elements.localOpenAiTimeout = createUIElement('input', {
|
|
||||||
id: 'local-openai-timeout-ms',
|
|
||||||
type: 'number',
|
|
||||||
min: 1000,
|
|
||||||
max: 600000,
|
|
||||||
step: 1000,
|
|
||||||
'data-pref-bind': 'tts.local-openai-tts_timeout_ms',
|
|
||||||
'data-pref-transform': 'integer:1000,600000'
|
|
||||||
}, null, localOpenAiTimeoutContainer);
|
|
||||||
|
|
||||||
localOpenAiSettings.appendChild(localOpenAiTimeoutContainer);
|
|
||||||
|
|
||||||
// Add all API settings to container
|
// Add all API settings to container
|
||||||
apiSettings.appendChild(elevenLabsSettings);
|
apiSettings.appendChild(elevenLabsSettings);
|
||||||
apiSettings.appendChild(openaiSettings);
|
apiSettings.appendChild(openaiSettings);
|
||||||
apiSettings.appendChild(localOpenAiSettings);
|
|
||||||
|
|
||||||
return apiSettings;
|
return apiSettings;
|
||||||
}
|
}
|
||||||
@@ -730,15 +622,6 @@ class OptionsUIModule extends BaseModule {
|
|||||||
if (!ttsFactory || !this.elements.ttsVoice) return;
|
if (!ttsFactory || !this.elements.ttsVoice) return;
|
||||||
|
|
||||||
const selectedHandler = this.elements.ttsSystem?.value || this.getPreference('tts', 'preferred_handler', 'none');
|
const selectedHandler = this.elements.ttsSystem?.value || this.getPreference('tts', 'preferred_handler', 'none');
|
||||||
this.updateVoiceControlVisibility(selectedHandler);
|
|
||||||
|
|
||||||
if (selectedHandler === 'local-openai-tts') {
|
|
||||||
if (this.elements.localOpenAiVoice) {
|
|
||||||
this.elements.localOpenAiVoice.value = this.getPreference('tts', 'local-openai-tts_voice', 'alloy');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const voices = typeof ttsFactory.getVoicesForHandler === 'function'
|
const voices = typeof ttsFactory.getVoicesForHandler === 'function'
|
||||||
? await ttsFactory.getVoicesForHandler(selectedHandler) || []
|
? await ttsFactory.getVoicesForHandler(selectedHandler) || []
|
||||||
: await ttsFactory.getVoices() || [];
|
: await ttsFactory.getVoices() || [];
|
||||||
@@ -752,34 +635,6 @@ class OptionsUIModule extends BaseModule {
|
|||||||
'name',
|
'name',
|
||||||
this.getPreference('tts', `${selectedHandler}_voice`, this.getPreference('tts', 'voice', ''))
|
this.getPreference('tts', `${selectedHandler}_voice`, this.getPreference('tts', 'voice', ''))
|
||||||
);
|
);
|
||||||
|
|
||||||
this.ensureSelectedVoiceIsAvailable(selectedHandler, voices);
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureSelectedVoiceIsAvailable(selectedHandler, voices = []) {
|
|
||||||
if (!this.elements.ttsVoice || selectedHandler === 'local-openai-tts') return;
|
|
||||||
if (!Array.isArray(voices) || voices.length === 0) return;
|
|
||||||
|
|
||||||
const available = new Set(voices.map(voice => String(voice.id || '').toLowerCase()));
|
|
||||||
const current = String(this.elements.ttsVoice.value || '').toLowerCase();
|
|
||||||
if (current && available.has(current)) return;
|
|
||||||
|
|
||||||
const fallback = voices.some(voice => voice.id === 'alloy') ? 'alloy' : voices[0].id;
|
|
||||||
this.elements.ttsVoice.value = fallback;
|
|
||||||
this.updatePreference('tts', 'voice', fallback);
|
|
||||||
if (selectedHandler && selectedHandler !== 'none') {
|
|
||||||
this.updatePreference('tts', `${selectedHandler}_voice`, fallback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateVoiceControlVisibility(selectedHandler) {
|
|
||||||
const useTextVoice = selectedHandler === 'local-openai-tts';
|
|
||||||
if (this.elements.ttsVoice) {
|
|
||||||
this.elements.ttsVoice.style.display = useTextVoice ? 'none' : '';
|
|
||||||
}
|
|
||||||
if (this.elements.localOpenAiVoice) {
|
|
||||||
this.elements.localOpenAiVoice.style.display = useTextVoice ? '' : 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderProviderStatuses() {
|
renderProviderStatuses() {
|
||||||
@@ -843,7 +698,6 @@ class OptionsUIModule extends BaseModule {
|
|||||||
// Update API settings visibility based on current TTS system
|
// Update API settings visibility based on current TTS system
|
||||||
if (this.elements.ttsSystem) {
|
if (this.elements.ttsSystem) {
|
||||||
this.updateApiSettingsVisibility(this.elements.ttsSystem.value);
|
this.updateApiSettingsVisibility(this.elements.ttsSystem.value);
|
||||||
this.updateVoiceControlVisibility(this.elements.ttsSystem.value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,36 +753,6 @@ class OptionsUIModule extends BaseModule {
|
|||||||
if (!this.getPreference('tts', 'openai-tts_api_key')) {
|
if (!this.getPreference('tts', 'openai-tts_api_key')) {
|
||||||
this.updatePreference('tts', 'openai-tts_api_key', '');
|
this.updatePreference('tts', 'openai-tts_api_key', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.getPreference('tts', 'openai-tts_model')) {
|
|
||||||
this.updatePreference('tts', 'openai-tts_model', 'tts-1-hd');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.elements.localOpenAiApiUrl) {
|
|
||||||
const savedUrl = this.getPreference('tts', 'local-openai-tts_api_url');
|
|
||||||
const defaultUrl = 'http://localhost:8000/v1';
|
|
||||||
|
|
||||||
if (!savedUrl) {
|
|
||||||
console.log('Options UI: Setting default local OpenAI-compatible API URL:', defaultUrl);
|
|
||||||
this.updatePreference('tts', 'local-openai-tts_api_url', defaultUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.getPreference('tts', 'local-openai-tts_api_key')) {
|
|
||||||
this.updatePreference('tts', 'local-openai-tts_api_key', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.getPreference('tts', 'local-openai-tts_voice')) {
|
|
||||||
this.updatePreference('tts', 'local-openai-tts_voice', 'alloy');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.getPreference('tts', 'local-openai-tts_model')) {
|
|
||||||
this.updatePreference('tts', 'local-openai-tts_model', 'tts-1');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.getPreference('tts', 'local-openai-tts_timeout_ms')) {
|
|
||||||
this.updatePreference('tts', 'local-openai-tts_timeout_ms', 60000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1071,7 +895,6 @@ class OptionsUIModule extends BaseModule {
|
|||||||
this.renderProviderStatuses();
|
this.renderProviderStatuses();
|
||||||
});
|
});
|
||||||
this.updateApiSettingsVisibility(value);
|
this.updateApiSettingsVisibility(value);
|
||||||
this.updateVoiceControlVisibility(value);
|
|
||||||
} else if (key === 'voice') {
|
} else if (key === 'voice') {
|
||||||
ttsFactory.configure({ voice: value });
|
ttsFactory.configure({ voice: value });
|
||||||
} else if (key === 'speed') {
|
} else if (key === 'speed') {
|
||||||
@@ -1096,24 +919,6 @@ class OptionsUIModule extends BaseModule {
|
|||||||
const provider = key.replace('_api_url', '');
|
const provider = key.replace('_api_url', '');
|
||||||
this.dispatchApiChangeEvent('api:urlChanged', provider, 'url', value);
|
this.dispatchApiChangeEvent('api:urlChanged', provider, 'url', value);
|
||||||
ttsFactory.refreshHandlerStatus(provider).then(() => this.renderProviderStatuses());
|
ttsFactory.refreshHandlerStatus(provider).then(() => this.renderProviderStatuses());
|
||||||
} else if (key.endsWith('_voice')) {
|
|
||||||
const provider = key.replace('_voice', '');
|
|
||||||
const handler = typeof ttsFactory.getHandler === 'function' ? ttsFactory.getHandler(provider) : null;
|
|
||||||
if (handler && typeof handler.setVoiceOptions === 'function') {
|
|
||||||
handler.setVoiceOptions({ voice: value });
|
|
||||||
}
|
|
||||||
if (ttsFactory.activeHandler === provider) {
|
|
||||||
ttsFactory.voice = value;
|
|
||||||
}
|
|
||||||
} else if (key.endsWith('_model')) {
|
|
||||||
const provider = key.replace('_model', '');
|
|
||||||
const handler = typeof ttsFactory.getHandler === 'function' ? ttsFactory.getHandler(provider) : null;
|
|
||||||
if (handler && typeof handler.setVoiceOptions === 'function') {
|
|
||||||
handler.setVoiceOptions({ model: value });
|
|
||||||
}
|
|
||||||
if (provider === 'openai-tts') {
|
|
||||||
this.populateVoices();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (key === 'speed' && this.elements.ttsSpeed) {
|
if (key === 'speed' && this.elements.ttsSpeed) {
|
||||||
this.updateSpeedDisplay();
|
this.updateSpeedDisplay();
|
||||||
|
|||||||
@@ -35,20 +35,10 @@ class PersistenceManagerModule extends BaseModule {
|
|||||||
speed: 1.0,
|
speed: 1.0,
|
||||||
language: 'en_US',
|
language: 'en_US',
|
||||||
voice: '',
|
voice: '',
|
||||||
'browser-tts_timeout_ms': 60000,
|
|
||||||
'kokoro-tts_timeout_ms': 60000,
|
|
||||||
'elevenlabs-tts_api_key': '',
|
'elevenlabs-tts_api_key': '',
|
||||||
'elevenlabs-tts_api_url': 'https://api.elevenlabs.io/v1',
|
'elevenlabs-tts_api_url': 'https://api.elevenlabs.io/v1',
|
||||||
'elevenlabs-tts_timeout_ms': 60000,
|
|
||||||
'openai-tts_api_key': '',
|
'openai-tts_api_key': '',
|
||||||
'openai-tts_api_url': 'https://api.openai.com/v1',
|
'openai-tts_api_url': 'https://api.openai.com/v1'
|
||||||
'openai-tts_model': 'tts-1-hd',
|
|
||||||
'openai-tts_timeout_ms': 60000,
|
|
||||||
'local-openai-tts_api_key': '',
|
|
||||||
'local-openai-tts_api_url': 'http://localhost:8000/v1',
|
|
||||||
'local-openai-tts_voice': 'alloy',
|
|
||||||
'local-openai-tts_model': 'tts-1',
|
|
||||||
'local-openai-tts_timeout_ms': 60000
|
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
masterVolume: 1.0,
|
masterVolume: 1.0,
|
||||||
@@ -639,39 +629,13 @@ class PersistenceManagerModule extends BaseModule {
|
|||||||
// Check if it's a range transformer in format 'range:min,max'
|
// Check if it's a range transformer in format 'range:min,max'
|
||||||
if (element.dataset.prefTransform === 'centered-speed') {
|
if (element.dataset.prefTransform === 'centered-speed') {
|
||||||
transformer = {
|
transformer = {
|
||||||
toElement: (value) => Math.round(Math.max(0.5, Math.min(2.0, Number(value) || 1)) * 100),
|
toElement: (value) => Math.round(((Number(value) || 1) * 50) + 50),
|
||||||
toPreference: (value) => {
|
toPreference: (value) => Math.max(0.5, Math.min(2.0, (parseInt(value, 10) - 50) / 50))
|
||||||
const percent = parseInt(value, 10);
|
|
||||||
return Math.max(0.5, Math.min(2.0, (Number.isFinite(percent) ? percent : 100) / 100));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
} else if (element.dataset.prefTransform === 'multiplier-percent') {
|
} else if (element.dataset.prefTransform === 'multiplier-percent') {
|
||||||
transformer = {
|
transformer = {
|
||||||
toElement: (value) => Math.round(Math.max(0.5, Math.min(2.0, Number(value) || 1)) * 100),
|
toElement: (value) => Math.round((Number(value) || 1) * 100),
|
||||||
toPreference: (value) => {
|
toPreference: (value) => Math.max(0.25, Math.min(4.0, parseInt(value, 10) / 100))
|
||||||
const percent = parseInt(value, 10);
|
|
||||||
return Math.max(0.5, Math.min(2.0, (Number.isFinite(percent) ? percent : 100) / 100));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else if (element.dataset.prefTransform.startsWith('integer:')) {
|
|
||||||
const rangeValues = element.dataset.prefTransform.substring(8).split(',');
|
|
||||||
const min = Number.parseInt(rangeValues[0], 10);
|
|
||||||
const max = Number.parseInt(rangeValues[1], 10);
|
|
||||||
transformer = {
|
|
||||||
toElement: (value) => Number.parseInt(value, 10),
|
|
||||||
toPreference: (value) => {
|
|
||||||
const parsed = Number.parseInt(value, 10);
|
|
||||||
if (!Number.isFinite(parsed)) {
|
|
||||||
return Number.isFinite(min) ? min : 0;
|
|
||||||
}
|
|
||||||
if (Number.isFinite(min) && parsed < min) {
|
|
||||||
return min;
|
|
||||||
}
|
|
||||||
if (Number.isFinite(max) && parsed > max) {
|
|
||||||
return max;
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
} else if (element.dataset.prefTransform.startsWith('range:')) {
|
} else if (element.dataset.prefTransform.startsWith('range:')) {
|
||||||
const rangeValues = element.dataset.prefTransform.substring(6).split(',');
|
const rangeValues = element.dataset.prefTransform.substring(6).split(',');
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
'prepareSpeechMetadata',
|
'prepareSpeechMetadata',
|
||||||
'preloadAssetsForItem',
|
'preloadAssetsForItem',
|
||||||
'normalizeTtsText',
|
'normalizeTtsText',
|
||||||
'getConfiguredTtsGenerationTimeoutMs',
|
|
||||||
'normalizeTtsGenerationTimeoutMs',
|
|
||||||
'runTtsPreloadWithTimeout',
|
'runTtsPreloadWithTimeout',
|
||||||
'cancelBlockingGeneration',
|
'cancelBlockingGeneration',
|
||||||
'cancelGenerationRequests',
|
'cancelGenerationRequests',
|
||||||
@@ -91,25 +89,19 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
const persistenceManager = this.getModule('persistence-manager');
|
const persistenceManager = this.getModule('persistence-manager');
|
||||||
if (persistenceManager && typeof persistenceManager.getPreference === 'function') {
|
if (persistenceManager && typeof persistenceManager.getPreference === 'function') {
|
||||||
this.autoplay = persistenceManager.getPreference('app', 'autoplay', true) !== false;
|
this.autoplay = persistenceManager.getPreference('app', 'autoplay', true) !== false;
|
||||||
this.ttsGenerationTimeoutMs = this.getConfiguredTtsGenerationTimeoutMs();
|
|
||||||
}
|
}
|
||||||
this.addEventListener(document, 'preference-updated', (event) => {
|
this.addEventListener(document, 'preference-updated', (event) => {
|
||||||
const { category, key, value } = event.detail || {};
|
const { category, key, value } = event.detail || {};
|
||||||
if (category === 'app' && key === 'autoplay') {
|
if (category === 'app' && key === 'autoplay') {
|
||||||
this.autoplay = value !== false;
|
this.autoplay = value !== false;
|
||||||
}
|
}
|
||||||
if (category === 'tts' && (key === 'preferred_handler' || key.endsWith('_timeout_ms'))) {
|
|
||||||
this.ttsGenerationTimeoutMs = this.getConfiguredTtsGenerationTimeoutMs();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
this.addEventListener(document, 'story:input-mode', (event) => {
|
this.addEventListener(document, 'story:input-mode', (event) => {
|
||||||
this.inputMode = ['text', 'choice', 'end'].includes(event.detail) ? event.detail : 'text';
|
this.inputMode = ['text', 'choice', 'end'].includes(event.detail) ? event.detail : 'text';
|
||||||
});
|
});
|
||||||
this.addEventListener(document, 'ui:command', (event) => {
|
this.addEventListener(document, 'ui:command', (event) => {
|
||||||
if (event.detail?.type === 'continue') {
|
if (event.detail?.type === 'continue') {
|
||||||
if (event.detail?.source !== 'display-clear') {
|
this.lastContinueAt = performance.now();
|
||||||
this.lastContinueAt = performance.now();
|
|
||||||
}
|
|
||||||
this.cancelBlockingGeneration('user-fast-forward', {
|
this.cancelBlockingGeneration('user-fast-forward', {
|
||||||
minWaitMs: USER_CANCEL_BLOCKING_WAIT_MIN_MS
|
minWaitMs: USER_CANCEL_BLOCKING_WAIT_MIN_MS
|
||||||
});
|
});
|
||||||
@@ -313,35 +305,11 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
getConfiguredTtsGenerationTimeoutMs() {
|
|
||||||
const persistenceManager = this.getModule('persistence-manager');
|
|
||||||
if (!persistenceManager || typeof persistenceManager.getPreference !== 'function') {
|
|
||||||
return TTS_GENERATION_TIMEOUT_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler', 'none');
|
|
||||||
const providerTimeout = preferredHandler && preferredHandler !== 'none'
|
|
||||||
? persistenceManager.getPreference('tts', `${preferredHandler}_timeout_ms`)
|
|
||||||
: undefined;
|
|
||||||
const genericTimeout = persistenceManager.getPreference('tts', 'generation_timeout_ms');
|
|
||||||
|
|
||||||
return this.normalizeTtsGenerationTimeoutMs(providerTimeout ?? genericTimeout ?? TTS_GENERATION_TIMEOUT_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeTtsGenerationTimeoutMs(value) {
|
|
||||||
const timeout = Number(value);
|
|
||||||
if (!Number.isFinite(timeout)) {
|
|
||||||
return TTS_GENERATION_TIMEOUT_MS;
|
|
||||||
}
|
|
||||||
return Math.max(1000, Math.min(600000, Math.round(timeout)));
|
|
||||||
}
|
|
||||||
|
|
||||||
runTtsPreloadWithTimeout(ttsFactory, text, context = {}) {
|
runTtsPreloadWithTimeout(ttsFactory, text, context = {}) {
|
||||||
const sentenceId = context.sentenceId || context.id || `tts-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const sentenceId = context.sentenceId || context.id || `tts-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const requestId = `${sentenceId}:${context.prefetch ? 'prefetch' : 'blocking'}:${Date.now()}`;
|
const requestId = `${sentenceId}:${context.prefetch ? 'prefetch' : 'blocking'}:${Date.now()}`;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const timeoutMs = this.getConfiguredTtsGenerationTimeoutMs();
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -356,12 +324,12 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
console.warn('SentenceQueue: TTS generation timed out; continuing without audio', {
|
console.warn('SentenceQueue: TTS generation timed out; continuing without audio', {
|
||||||
sentenceId,
|
sentenceId,
|
||||||
timeoutMs,
|
timeoutMs: this.ttsGenerationTimeoutMs,
|
||||||
textPreview: text.slice(0, 120)
|
textPreview: text.slice(0, 120)
|
||||||
});
|
});
|
||||||
controller.abort('tts-generation-timeout');
|
controller.abort('tts-generation-timeout');
|
||||||
finish({ success: false, reason: 'tts_generation_timeout', timedOut: true });
|
finish({ success: false, reason: 'tts_generation_timeout', timedOut: true });
|
||||||
}, timeoutMs);
|
}, this.ttsGenerationTimeoutMs);
|
||||||
|
|
||||||
this.generationRequests.set(requestId, {
|
this.generationRequests.set(requestId, {
|
||||||
controller,
|
controller,
|
||||||
@@ -372,10 +340,7 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
finish
|
finish
|
||||||
});
|
});
|
||||||
|
|
||||||
Promise.resolve(ttsFactory.preloadSpeech(text, {
|
Promise.resolve(ttsFactory.preloadSpeech(text, { signal: controller.signal }))
|
||||||
signal: controller.signal,
|
|
||||||
ttsInstructions: Array.isArray(context.ttsInstructions) ? context.ttsInstructions : []
|
|
||||||
}))
|
|
||||||
.then(result => finish(result || { success: false, reason: 'empty_tts_result' }))
|
.then(result => finish(result || { success: false, reason: 'empty_tts_result' }))
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
@@ -461,10 +426,7 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
let speedMultiplier = 1.0;
|
let speedMultiplier = 1.0;
|
||||||
const ttsFactory = this.getModule('tts-factory');
|
const ttsFactory = this.getModule('tts-factory');
|
||||||
if (ttsFactory) {
|
if (ttsFactory) {
|
||||||
const configuredSpeed = Number(ttsFactory.speed);
|
speedMultiplier = Number.isFinite(ttsFactory.speed) ? Math.max(0.25, ttsFactory.speed) : 1.0;
|
||||||
speedMultiplier = Number.isFinite(configuredSpeed)
|
|
||||||
? Math.max(0.5, Math.min(2.0, configuredSpeed))
|
|
||||||
: 1.0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate estimated duration in milliseconds
|
// Calculate estimated duration in milliseconds
|
||||||
@@ -524,7 +486,6 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
sentenceId: id,
|
sentenceId: id,
|
||||||
blockId: metadata.blockId ?? null,
|
blockId: metadata.blockId ?? null,
|
||||||
turnId: metadata.turnId ?? null,
|
turnId: metadata.turnId ?? null,
|
||||||
ttsInstructions: Array.isArray(metadata.ttsInstructions) ? metadata.ttsInstructions : [],
|
|
||||||
blocking: true
|
blocking: true
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -540,7 +501,6 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
paragraphIndex: metadata.paragraphIndex ?? null,
|
paragraphIndex: metadata.paragraphIndex ?? null,
|
||||||
layoutText: metadata.layoutText || text,
|
layoutText: metadata.layoutText || text,
|
||||||
glossaryEntries: Array.isArray(metadata.glossaryEntries) ? metadata.glossaryEntries : [],
|
glossaryEntries: Array.isArray(metadata.glossaryEntries) ? metadata.glossaryEntries : [],
|
||||||
ttsInstructions: Array.isArray(metadata.ttsInstructions) ? metadata.ttsInstructions : [],
|
|
||||||
isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter),
|
isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter),
|
||||||
role: metadata.role || (metadata.type === 'heading' ? 'chapter-heading' : 'body'),
|
role: metadata.role || (metadata.type === 'heading' ? 'chapter-heading' : 'body'),
|
||||||
dropCap: Boolean(metadata.dropCap),
|
dropCap: Boolean(metadata.dropCap),
|
||||||
@@ -793,6 +753,9 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
if (this.lastContinueAt >= (sentence.playbackStartedAt || 0)) {
|
if (this.lastContinueAt >= (sentence.playbackStartedAt || 0)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (this.inputMode === 'choice') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return this.sentenceQueue.length > 1;
|
return this.sentenceQueue.length > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,7 +848,6 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
sentenceId: nextItem.id,
|
sentenceId: nextItem.id,
|
||||||
blockId: nextItem.blockId ?? null,
|
blockId: nextItem.blockId ?? null,
|
||||||
turnId: nextItem.turnId ?? null,
|
turnId: nextItem.turnId ?? null,
|
||||||
ttsInstructions: Array.isArray(nextItem.ttsInstructions) ? nextItem.ttsInstructions : [],
|
|
||||||
queueIndex: index,
|
queueIndex: index,
|
||||||
prefetch: true,
|
prefetch: true,
|
||||||
blocking: false
|
blocking: false
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ class SocketClientModule extends BaseModule {
|
|||||||
this.pendingCommand = null;
|
this.pendingCommand = null;
|
||||||
this.gameApiTimeoutMs = GAME_API_TIMEOUT_MS;
|
this.gameApiTimeoutMs = GAME_API_TIMEOUT_MS;
|
||||||
this.playerCommandTimeoutMs = PLAYER_COMMAND_TIMEOUT_MS;
|
this.playerCommandTimeoutMs = PLAYER_COMMAND_TIMEOUT_MS;
|
||||||
this.gameApiRequestId = 0;
|
|
||||||
this.latestNarrativeRequestId = 0;
|
|
||||||
|
|
||||||
// Bind methods using parent's bindMethods utility
|
// Bind methods using parent's bindMethods utility
|
||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
@@ -222,15 +220,6 @@ class SocketClientModule extends BaseModule {
|
|||||||
|
|
||||||
// Special handling for narrative text
|
// Special handling for narrative text
|
||||||
this.socket.on('narrativeResponse', (data) => {
|
this.socket.on('narrativeResponse', (data) => {
|
||||||
const responseRequestId = Number(data?.clientRequestId || 0);
|
|
||||||
if (responseRequestId > 0 && responseRequestId !== this.latestNarrativeRequestId) {
|
|
||||||
console.warn('Socket Client: Ignoring stale narrative response', {
|
|
||||||
responseRequestId,
|
|
||||||
latestNarrativeRequestId: this.latestNarrativeRequestId,
|
|
||||||
turnId: data?.turnId
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.clearPendingCommand('narrative-response');
|
this.clearPendingCommand('narrative-response');
|
||||||
this.processTurnResult(data);
|
this.processTurnResult(data);
|
||||||
});
|
});
|
||||||
@@ -302,13 +291,12 @@ class SocketClientModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.storeAndQueueBlocks(turnBlocks);
|
||||||
|
|
||||||
const choices = Array.isArray(data.choices) ? data.choices : [];
|
const choices = Array.isArray(data.choices) ? data.choices : [];
|
||||||
const inputMode = data.inputMode || (choices.length > 0 ? 'choice' : 'none');
|
const inputMode = data.inputMode || (choices.length > 0 ? 'choice' : 'none');
|
||||||
this.dispatchChoices(choices);
|
this.dispatchChoices(choices);
|
||||||
this.dispatchInputMode(inputMode);
|
this.dispatchInputMode(inputMode);
|
||||||
|
|
||||||
await this.storeAndQueueBlocks(turnBlocks);
|
|
||||||
|
|
||||||
document.dispatchEvent(new CustomEvent('story:turn-complete', {
|
document.dispatchEvent(new CustomEvent('story:turn-complete', {
|
||||||
detail: { turnId, turn: data, choices, inputMode }
|
detail: { turnId, turn: data, choices, inputMode }
|
||||||
}));
|
}));
|
||||||
@@ -404,9 +392,6 @@ class SocketClientModule extends BaseModule {
|
|||||||
const glossaryEntries = markupParser && typeof markupParser.extractGlossaryTags === 'function'
|
const glossaryEntries = markupParser && typeof markupParser.extractGlossaryTags === 'function'
|
||||||
? markupParser.extractGlossaryTags(tags)
|
? markupParser.extractGlossaryTags(tags)
|
||||||
: [];
|
: [];
|
||||||
const ttsInstructions = markupParser && typeof markupParser.extractTtsInstructionTags === 'function'
|
|
||||||
? markupParser.extractTtsInstructionTags(tags)
|
|
||||||
: [];
|
|
||||||
const cueTags = tags.filter(tag => this.isTimedCueTag(tag));
|
const cueTags = tags.filter(tag => this.isTimedCueTag(tag));
|
||||||
const deferredTags = tags.filter(tag => this.isDeferredPopupTag(tag));
|
const deferredTags = tags.filter(tag => this.isDeferredPopupTag(tag));
|
||||||
const immediateTags = tags.filter(tag =>
|
const immediateTags = tags.filter(tag =>
|
||||||
@@ -448,7 +433,6 @@ class SocketClientModule extends BaseModule {
|
|||||||
text,
|
text,
|
||||||
layoutText,
|
layoutText,
|
||||||
glossaryEntries,
|
glossaryEntries,
|
||||||
ttsInstructions,
|
|
||||||
cueMarkers,
|
cueMarkers,
|
||||||
deferredTags: [
|
deferredTags: [
|
||||||
...(Array.isArray(pending.deferredTags) ? pending.deferredTags : []),
|
...(Array.isArray(pending.deferredTags) ? pending.deferredTags : []),
|
||||||
@@ -519,7 +503,7 @@ class SocketClientModule extends BaseModule {
|
|||||||
|
|
||||||
isRenderMetadataTag(tag) {
|
isRenderMetadataTag(tag) {
|
||||||
const key = String(tag?.key || '').toLowerCase();
|
const key = String(tag?.key || '').toLowerCase();
|
||||||
return key === 'gloss' || key === 'tts' || key.startsWith('tts-');
|
return ['gloss'].includes(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
isDeferredPopupTag(tag) {
|
isDeferredPopupTag(tag) {
|
||||||
@@ -845,11 +829,6 @@ class SocketClientModule extends BaseModule {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = ++this.gameApiRequestId;
|
|
||||||
const normalizedMethod = String(method || '').replace(/\(\)$/, '');
|
|
||||||
if (['newGame', 'loadGame', 'chooseChoice'].includes(normalizedMethod)) {
|
|
||||||
this.latestNarrativeRequestId = requestId;
|
|
||||||
}
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const finish = (response) => {
|
const finish = (response) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
@@ -868,7 +847,7 @@ class SocketClientModule extends BaseModule {
|
|||||||
finish({ success: false, error: 'timeout', method });
|
finish({ success: false, error: 'timeout', method });
|
||||||
}, this.gameApiTimeoutMs);
|
}, this.gameApiTimeoutMs);
|
||||||
|
|
||||||
this.socket.emit('gameApi', { method, args, requestId }, (response) => {
|
this.socket.emit('gameApi', { method, args }, (response) => {
|
||||||
finish(response);
|
finish(response);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ class TTSFactoryModule extends BaseModule {
|
|||||||
'browser-tts', // Browser TTS handler
|
'browser-tts', // Browser TTS handler
|
||||||
'kokoro-tts', // Kokoro TTS handler
|
'kokoro-tts', // Kokoro TTS handler
|
||||||
'elevenlabs-tts',// ElevenLabs TTS handler
|
'elevenlabs-tts',// ElevenLabs TTS handler
|
||||||
'openai-tts', // OpenAI TTS handler
|
'openai-tts' // OpenAI TTS handler
|
||||||
'local-openai-tts' // Local OpenAI-compatible TTS handler
|
|
||||||
];
|
];
|
||||||
this.handlers = {};
|
this.handlers = {};
|
||||||
this.initStatus = {};
|
this.initStatus = {};
|
||||||
@@ -357,7 +356,7 @@ class TTSFactoryModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add placeholder entries for important API handlers that might not be registered yet
|
// Add placeholder entries for important API handlers that might not be registered yet
|
||||||
const apiHandlerIds = ['elevenlabs-tts', 'openai-tts', 'local-openai-tts'];
|
const apiHandlerIds = ['elevenlabs-tts', 'openai-tts'];
|
||||||
for (const id of apiHandlerIds) {
|
for (const id of apiHandlerIds) {
|
||||||
// Only add if not already in the list
|
// Only add if not already in the list
|
||||||
if (!this.handlers[id] && !availableHandlers.some(h => h.id === id)) {
|
if (!this.handlers[id] && !availableHandlers.some(h => h.id === id)) {
|
||||||
@@ -408,24 +407,10 @@ class TTSFactoryModule extends BaseModule {
|
|||||||
'voice': '', // Empty default - will be selected based on handler
|
'voice': '', // Empty default - will be selected based on handler
|
||||||
'language': 'en_US', // Legacy stored value; game metadata now owns active TTS language
|
'language': 'en_US', // Legacy stored value; game metadata now owns active TTS language
|
||||||
'volume': 1.0, // Default volume
|
'volume': 1.0, // Default volume
|
||||||
'browser-tts_timeout_ms': 60000,
|
|
||||||
'kokoro-tts_timeout_ms': 60000,
|
|
||||||
'elevenlabs_api_key': '', // Empty API key by default
|
'elevenlabs_api_key': '', // Empty API key by default
|
||||||
'elevenlabs_api_url': 'https://api.elevenlabs.io/v1', // Default ElevenLabs API URL
|
'elevenlabs_api_url': 'https://api.elevenlabs.io/v1', // Default ElevenLabs API URL
|
||||||
'openai_api_key': '', // Empty API key by default
|
'openai_api_key': '', // Empty API key by default
|
||||||
'openai_api_url': 'https://api.openai.com/v1', // Default OpenAI API URL
|
'openai_api_url': 'https://api.openai.com/v1' // Default OpenAI API URL
|
||||||
'elevenlabs-tts_api_key': '',
|
|
||||||
'elevenlabs-tts_api_url': 'https://api.elevenlabs.io/v1',
|
|
||||||
'elevenlabs-tts_timeout_ms': 60000,
|
|
||||||
'openai-tts_api_key': '',
|
|
||||||
'openai-tts_api_url': 'https://api.openai.com/v1',
|
|
||||||
'openai-tts_model': 'tts-1-hd',
|
|
||||||
'openai-tts_timeout_ms': 60000,
|
|
||||||
'local-openai-tts_api_key': '',
|
|
||||||
'local-openai-tts_api_url': 'http://localhost:8000/v1',
|
|
||||||
'local-openai-tts_voice': 'alloy',
|
|
||||||
'local-openai-tts_model': 'tts-1',
|
|
||||||
'local-openai-tts_timeout_ms': 60000
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure all defaults are set in persistence if they don't exist
|
// Ensure all defaults are set in persistence if they don't exist
|
||||||
@@ -490,8 +475,7 @@ class TTSFactoryModule extends BaseModule {
|
|||||||
{ id: 'kokoro-tts', displayName: 'Kokoro TTS' },
|
{ id: 'kokoro-tts', displayName: 'Kokoro TTS' },
|
||||||
{ id: 'browser-tts', displayName: 'Browser TTS' },
|
{ id: 'browser-tts', displayName: 'Browser TTS' },
|
||||||
{ id: 'elevenlabs-tts', displayName: 'ElevenLabs TTS' },
|
{ id: 'elevenlabs-tts', displayName: 'ElevenLabs TTS' },
|
||||||
{ id: 'openai-tts', displayName: 'OpenAI TTS' },
|
{ id: 'openai-tts', displayName: 'OpenAI TTS' }
|
||||||
{ id: 'local-openai-tts', displayName: 'Local OpenAI TTS' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Register each handler
|
// Register each handler
|
||||||
@@ -796,7 +780,7 @@ class TTSFactoryModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have this speech cached
|
// Check if we have this speech cached
|
||||||
const hash = await this.generateSpeechHash(text, options);
|
const hash = await this.generateSpeechHash(text);
|
||||||
const cached = await this.getCachedSpeech(hash);
|
const cached = await this.getCachedSpeech(hash);
|
||||||
|
|
||||||
if (cached && cached.success) {
|
if (cached && cached.success) {
|
||||||
@@ -861,7 +845,7 @@ class TTSFactoryModule extends BaseModule {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate a hash for this speech request
|
// Generate a hash for this speech request
|
||||||
const hash = await this.generateSpeechHash(text, options);
|
const hash = await this.generateSpeechHash(text);
|
||||||
|
|
||||||
// Check if we have this speech cached
|
// Check if we have this speech cached
|
||||||
const cached = await this.getCachedSpeech(hash);
|
const cached = await this.getCachedSpeech(hash);
|
||||||
@@ -1113,7 +1097,6 @@ class TTSFactoryModule extends BaseModule {
|
|||||||
getHandlerStatusMessage(id, handler) {
|
getHandlerStatusMessage(id, handler) {
|
||||||
if (!handler) return 'Not registered';
|
if (!handler) return 'Not registered';
|
||||||
if (handler.isReady === true) return 'Ready';
|
if (handler.isReady === true) return 'Ready';
|
||||||
if (handler.unsupportedReason) return handler.unsupportedReason;
|
|
||||||
if (id === 'kokoro-tts') return handler.state === 'INITIALIZING' ? 'Loading model' : 'Not loaded';
|
if (id === 'kokoro-tts') return handler.state === 'INITIALIZING' ? 'Loading model' : 'Not loaded';
|
||||||
if (handler.apiKey === '') return 'API key missing';
|
if (handler.apiKey === '') return 'API key missing';
|
||||||
if (handler.apiKey && handler.isReady !== true) return 'API unavailable or invalid settings';
|
if (handler.apiKey && handler.isReady !== true) return 'API unavailable or invalid settings';
|
||||||
@@ -1251,7 +1234,7 @@ class TTSFactoryModule extends BaseModule {
|
|||||||
let generationStarted = false;
|
let generationStarted = false;
|
||||||
try {
|
try {
|
||||||
// Generate a hash for this speech request
|
// Generate a hash for this speech request
|
||||||
hash = await this.generateSpeechHash(text, options);
|
hash = await this.generateSpeechHash(text);
|
||||||
|
|
||||||
// Check if we have this audio in cache
|
// Check if we have this audio in cache
|
||||||
const cachedData = await this.getCachedSpeech(hash);
|
const cachedData = await this.getCachedSpeech(hash);
|
||||||
@@ -1303,23 +1286,17 @@ class TTSFactoryModule extends BaseModule {
|
|||||||
* @param {string} text - Text to generate hash for
|
* @param {string} text - Text to generate hash for
|
||||||
* @returns {Promise<string>} - Hash string
|
* @returns {Promise<string>} - Hash string
|
||||||
*/
|
*/
|
||||||
async generateSpeechHash(text, options = {}) {
|
async generateSpeechHash(text) {
|
||||||
const handler = this.getActiveHandler();
|
const handler = this.getActiveHandler();
|
||||||
const provider = this.activeHandler || 'none';
|
const provider = this.activeHandler || 'none';
|
||||||
const voiceInfo = this.getEffectiveVoiceId(handler);
|
const voiceInfo = this.getEffectiveVoiceId(handler);
|
||||||
const model = handler?.voiceOptions?.model || handler?.model || '';
|
|
||||||
const speed = this.speed || 1.0;
|
const speed = this.speed || 1.0;
|
||||||
const language = this.language || 'en-us';
|
const language = this.language || 'en-us';
|
||||||
const ttsInstruction = handler && typeof handler.getRequestInstructions === 'function'
|
|
||||||
? handler.getRequestInstructions(options)
|
|
||||||
: '';
|
|
||||||
const key = JSON.stringify({
|
const key = JSON.stringify({
|
||||||
provider,
|
provider,
|
||||||
voice: voiceInfo,
|
voice: voiceInfo,
|
||||||
model,
|
|
||||||
speed,
|
speed,
|
||||||
language,
|
language,
|
||||||
ttsInstruction,
|
|
||||||
text
|
text
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1956,7 +1933,7 @@ class TTSFactoryModule extends BaseModule {
|
|||||||
const handler = this.handlers[id];
|
const handler = this.handlers[id];
|
||||||
const isInitialized = !!this.initStatus[id];
|
const isInitialized = !!this.initStatus[id];
|
||||||
const isReady = handler && handler.isReady;
|
const isReady = handler && handler.isReady;
|
||||||
const isApiHandler = ['elevenlabs-tts', 'openai-tts', 'local-openai-tts', 'kokoro-tts'].includes(id);
|
const isApiHandler = ['elevenlabs', 'openai', 'kokoro'].includes(id);
|
||||||
|
|
||||||
console.log(`Handler ID: ${id}`);
|
console.log(`Handler ID: ${id}`);
|
||||||
console.log(` - Handler Exists: ${!!handler}`);
|
console.log(` - Handler Exists: ${!!handler}`);
|
||||||
|
|||||||
@@ -387,12 +387,12 @@ class UIControllerModule extends BaseModule {
|
|||||||
|
|
||||||
sliderValueFromSpeed(speed) {
|
sliderValueFromSpeed(speed) {
|
||||||
const value = Number.isFinite(Number(speed)) ? Number(speed) : 1;
|
const value = Number.isFinite(Number(speed)) ? Number(speed) : 1;
|
||||||
return Math.round(Math.max(0.5, Math.min(2.0, value)) * 100);
|
return Math.round((Math.max(0.5, Math.min(2.0, value)) * 50) + 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
speedFromSliderValue(value) {
|
speedFromSliderValue(value) {
|
||||||
const sliderValue = Number.isFinite(Number(value)) ? Number(value) : 100;
|
const sliderValue = Number.isFinite(Number(value)) ? Number(value) : 50;
|
||||||
return Math.max(0.5, Math.min(2.0, sliderValue / 100));
|
return Math.max(0.5, Math.min(2.0, (sliderValue - 50) / 50));
|
||||||
}
|
}
|
||||||
|
|
||||||
bindTopControls() {
|
bindTopControls() {
|
||||||
@@ -453,13 +453,14 @@ class UIControllerModule extends BaseModule {
|
|||||||
|
|
||||||
if (speedSlider && speedSlider.dataset.uiControllerBound !== 'true') {
|
if (speedSlider && speedSlider.dataset.uiControllerBound !== 'true') {
|
||||||
speedSlider.dataset.uiControllerBound = 'true';
|
speedSlider.dataset.uiControllerBound = 'true';
|
||||||
speedSlider.min = '50';
|
speedSlider.min = speedSlider.min || '50';
|
||||||
speedSlider.max = '200';
|
speedSlider.max = speedSlider.max || '150';
|
||||||
speedSlider.addEventListener('input', (event) => {
|
speedSlider.addEventListener('input', (event) => {
|
||||||
|
const persistenceManager = this.getModule('persistence-manager');
|
||||||
const speed = this.speedFromSliderValue(event.target.value);
|
const speed = this.speedFromSliderValue(event.target.value);
|
||||||
|
|
||||||
document.dispatchEvent(new CustomEvent('animation:speed:change', {
|
document.dispatchEvent(new CustomEvent('animation:speed:change', {
|
||||||
detail: { speed }
|
detail: { speed: 1 }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
document.dispatchEvent(new CustomEvent('tts:speed:change', {
|
document.dispatchEvent(new CustomEvent('tts:speed:change', {
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
|||||||
controls.innerHTML = `
|
controls.innerHTML = `
|
||||||
<a id="speech"></a>
|
<a id="speech"></a>
|
||||||
<a id="autoplay"></a>
|
<a id="autoplay"></a>
|
||||||
<span><a id="speed_reset"><span id="speed_label"></span></a><input type="range" min="50" max="200" value="100" id="speed" name="speed" /></span>
|
<span><a id="speed_reset"><span id="speed_label"></span></a><input type="range" min="50" max="150" value="100" id="speed" name="speed" /></span>
|
||||||
<a id="rewind"></a>
|
<a id="rewind"></a>
|
||||||
<a id="save"></a>
|
<a id="save"></a>
|
||||||
<a id="reload" disabled="disabled"></a>
|
<a id="reload" disabled="disabled"></a>
|
||||||
@@ -2335,19 +2335,6 @@ class UIDisplayHandlerModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.renderWindowToken += 1;
|
|
||||||
this.scrollRequestId += 1;
|
|
||||||
if (this.scrollAnimationFrameId != null) {
|
|
||||||
cancelAnimationFrame(this.scrollAnimationFrameId);
|
|
||||||
this.scrollAnimationFrameId = null;
|
|
||||||
}
|
|
||||||
if (this.scrollAnimationResolve) {
|
|
||||||
this.scrollAnimationResolve();
|
|
||||||
this.scrollAnimationResolve = null;
|
|
||||||
this.scrollAnimationPromise = null;
|
|
||||||
}
|
|
||||||
this.storyScrollAnimation = null;
|
|
||||||
|
|
||||||
if (document.documentElement.dataset.skippablePause === 'true') {
|
if (document.documentElement.dataset.skippablePause === 'true') {
|
||||||
document.dispatchEvent(new CustomEvent('ui:command', {
|
document.dispatchEvent(new CustomEvent('ui:command', {
|
||||||
detail: { moduleId: this.id, type: 'continue', source: 'display-clear' }
|
detail: { moduleId: this.id, type: 'continue', source: 'display-clear' }
|
||||||
|
|||||||
@@ -47,12 +47,8 @@
|
|||||||
"options.enableMusicDucking": "Musikabsenkung einschalten",
|
"options.enableMusicDucking": "Musikabsenkung einschalten",
|
||||||
"options.elevenLabsSettings": "ElevenLabs API-Einstellungen",
|
"options.elevenLabsSettings": "ElevenLabs API-Einstellungen",
|
||||||
"options.openAiSettings": "OpenAI API-Einstellungen",
|
"options.openAiSettings": "OpenAI API-Einstellungen",
|
||||||
"options.localOpenAiSettings": "Lokale OpenAI API-Einstellungen",
|
|
||||||
"options.optionalApiKey": "API-Schluessel (optional)",
|
|
||||||
"options.apiKey": "API-Schlüssel",
|
"options.apiKey": "API-Schlüssel",
|
||||||
"options.apiUrl": "API-URL",
|
"options.apiUrl": "API-URL",
|
||||||
"options.model": "Modell",
|
|
||||||
"options.requestTimeoutMs": "Anfrage-Timeout (ms)",
|
|
||||||
"credits.button": "Credits",
|
"credits.button": "Credits",
|
||||||
"credits.buttonTitle": "Mitwirkende und Lizenzen anzeigen",
|
"credits.buttonTitle": "Mitwirkende und Lizenzen anzeigen",
|
||||||
"credits.title": "Mitwirkende und Lizenzen",
|
"credits.title": "Mitwirkende und Lizenzen",
|
||||||
|
|||||||
@@ -47,12 +47,8 @@
|
|||||||
"options.enableMusicDucking": "Enable music ducking",
|
"options.enableMusicDucking": "Enable music ducking",
|
||||||
"options.elevenLabsSettings": "ElevenLabs API Settings",
|
"options.elevenLabsSettings": "ElevenLabs API Settings",
|
||||||
"options.openAiSettings": "OpenAI API Settings",
|
"options.openAiSettings": "OpenAI API Settings",
|
||||||
"options.localOpenAiSettings": "Local OpenAI API Settings",
|
|
||||||
"options.apiKey": "API Key",
|
"options.apiKey": "API Key",
|
||||||
"options.optionalApiKey": "API Key (optional)",
|
|
||||||
"options.apiUrl": "API URL",
|
"options.apiUrl": "API URL",
|
||||||
"options.model": "Model",
|
|
||||||
"options.requestTimeoutMs": "Request timeout (ms)",
|
|
||||||
"credits.button": "credits",
|
"credits.button": "credits",
|
||||||
"credits.buttonTitle": "Show credits and third-party licenses",
|
"credits.buttonTitle": "Show credits and third-party licenses",
|
||||||
"credits.title": "Credits and Licenses",
|
"credits.title": "Credits and Licenses",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user