Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6faee20268 | |||
| b8fe8535aa | |||
| 74be77b267 | |||
| 9a6bb009f2 | |||
| b5829ed773 | |||
| 873049f7e6 | |||
| c745efd1d2 | |||
| b1387f4833 | |||
| 0842cbfefc | |||
| 0ab639fd25 | |||
| fc693ae695 |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(wc:*)",
|
||||
"Bash(git -C /workspaces/ai.interactive.fiction log --oneline -15)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
# OpenRouter API Configuration
|
||||
OPENROUTER_API_KEY=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
|
||||
OPENROUTER_MODEL=anthropic/claude-3-opus-20240229
|
||||
OPENROUTER_MODEL=openai/gpt-5.5
|
||||
OPENROUTER_REASONING_EFFORT=none
|
||||
|
||||
# Application Configuration
|
||||
PORT=3001
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# OpenRouter API Configuration
|
||||
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
||||
OPENROUTER_MODEL=your_selected_model_here
|
||||
OPENROUTER_MODEL=openai/gpt-5.5
|
||||
# GPT-5 reasoning tokens can consume short completion budgets; keep narration calls direct by default.
|
||||
OPENROUTER_REASONING_EFFORT=none
|
||||
|
||||
# Application Configuration
|
||||
PORT=3000
|
||||
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
# Client Specification And Progress Report
|
||||
|
||||
This file is the single living technical specification, implementation checklist, and progress report for the web client. Usage instructions and changelog live in `README.md`.
|
||||
|
||||
## Product Goal
|
||||
|
||||
Build an AI-assisted interactive fiction client that feels like a carefully typeset illustrated novel rather than a chat window. The game server owns game state and narrative generation. The client renders incoming narrative as synchronized animated prose with optional speech, sound effects, music, future image blocks, and persistent player options.
|
||||
|
||||
The production client must tolerate TTS being unavailable. The safe default TTS provider is `none`; a game, user preference, or explicit option can select another provider.
|
||||
|
||||
## Current Status
|
||||
|
||||
- Done: native ES module loader with dependency graph, module states, progress overlay, cache-busted development loading, and ordered async initialization.
|
||||
- Done: responsive book layout that scales page, font sizes, and word positions relative to page size.
|
||||
- Done: story parser for chapters, text blocks, Markdown emphasis, image blocks, sound effect cues, and music cues.
|
||||
- Done: SmartyPants punctuation, language-aware Hyphenopoly integration, and Knuth-Plass paragraph line breaking.
|
||||
- Done: paragraph rules for normal paragraphs, chapter-first paragraphs, textblock-first paragraphs, drop caps, and first-line indentation.
|
||||
- Done: sentence queue and playback coordinator for preparing text and TTS before synchronized playback.
|
||||
- Done: TTS providers for none, browser speech synthesis, Kokoro, ElevenLabs, and OpenAI, with status reporting in options.
|
||||
- Done: TTS cache keyed by request parameters rather than text alone.
|
||||
- Done: persisted speech enable state, provider, voice, speed, language, and volume preferences.
|
||||
- Done: top-bar and options controls for speech and speed synchronization after recent fixes.
|
||||
- Done: command input focus behavior and global typing redirection into the command input while a game is running.
|
||||
- Done: fast-forward by page click or space, including animation completion and TTS fade/stop.
|
||||
- Done: mouse cursor state reporting by process state.
|
||||
- Done: placeholder game API for new/load/save/running state.
|
||||
- Done: sound effect and music folders, sound effect playback, music playback, and music ducking during TTS.
|
||||
- Partial: image markup is parsed and queued, but actual image rendering is still future work.
|
||||
- Partial: save-game API is a placeholder; saves are per socket session and do not survive reload.
|
||||
- Pending: deeper automated tests for layout, playback timing, TTS provider switching, and media cue timing.
|
||||
|
||||
## Module System Specification
|
||||
|
||||
The client uses native browser ES modules. No bundler is required for the web client modules in `public/js/`.
|
||||
|
||||
Required module rules:
|
||||
|
||||
- Every app module extends `BaseModule`.
|
||||
- Every app module registers with `moduleRegistry`.
|
||||
- Every app module declares all required dependencies in its `dependencies` list.
|
||||
- The loader loads module scripts, resolves the dependency graph, initializes modules in dependency order, awaits async initialization, and only then hides the loading overlay.
|
||||
- Modules must rely on the loader for dependency readiness. Do not add fallback paths for missing dependencies inside modules.
|
||||
- Module states are `PENDING`, `LOADING`, `WAITING`, `INITIALIZING`, `FINISHED`, and `ERROR`.
|
||||
- Modules must report real state transitions. A module must not report `FINISHED` until its critical initialization is actually ready.
|
||||
- `setTimeout` must not be used to paper over dependency or async ordering bugs. It is acceptable inside isolated scheduling systems such as animation timing, debounce, throttle, or browser rendering workarounds when documented by context.
|
||||
|
||||
Core loader components:
|
||||
|
||||
- `loader.js`: dynamic import orchestration, dependency order, loading UI, cache-busted module URLs in development.
|
||||
- `module-registry.js`: module registration, dependency metadata, readiness promises.
|
||||
- `base-module.js`: shared lifecycle, state changes, event listener tracking, progress reporting.
|
||||
|
||||
The loader is deliberately the conductor, not the orchestra. Module-specific configuration, resource loading, and progress detail belong inside the module that owns the work.
|
||||
|
||||
## Current Module Responsibilities
|
||||
|
||||
- `markup-parser-module.js`: converts story text into text blocks, inline styled spans, image blocks, sound cues, and music cues.
|
||||
- `text-processor-module.js`: applies SmartyPants and Hyphenopoly according to active language.
|
||||
- `paragraph-layout-module.js`: measures text and computes Knuth-Plass layout.
|
||||
- `layout-renderer-module.js`: turns layout data into page DOM with stable word positions and animation metadata.
|
||||
- `sentence-queue-module.js`: prepares sentence objects and coordinates layout/TTS readiness.
|
||||
- `playback-coordinator-module.js`: starts synchronized text/audio playback in the right order.
|
||||
- `animation-queue-module.js`: schedules and fast-forwards visual text animation.
|
||||
- `audio-manager-module.js`: owns sound effects, music tracks, music ducking, volume application, and speech audio playback helpers.
|
||||
- `tts-factory-module.js`: selects provider, applies preferences, generates/preloads speech, caches speech data, and exposes unified TTS operations.
|
||||
- `tts-handler-module.js`: common TTS handler base.
|
||||
- `browser-tts-module.js`: Web Speech API provider.
|
||||
- `kokoro-tts-module.js`: Kokoro provider and loading bridge.
|
||||
- `elevenlabs-tts-module.js`: ElevenLabs provider.
|
||||
- `openai-tts-module.js`: OpenAI speech provider with fixed supported voices.
|
||||
- `persistence-manager-module.js`: browser preferences and durable client state.
|
||||
- `localization-module.js`: language state used by UI, hyphenation, and TTS selection.
|
||||
- `options-ui-module.js`: options modal, persisted controls, provider status displays.
|
||||
- `ui-controller-module.js`: top-bar commands, global input behavior, game API control wiring.
|
||||
- `ui-display-handler-module.js`: book page display, startup prompt, text insertion, and media block dispatch.
|
||||
- `ui-input-handler-module.js`: command entry, history, fast-forward key handling.
|
||||
- `socket-client-module.js`: socket connection and game API request wrapper.
|
||||
- `game-loop-module.js`: high-level client/game flow.
|
||||
|
||||
## Text Layout Specification
|
||||
|
||||
The right page must look like typeset book text:
|
||||
|
||||
- Paragraphs are laid out by paragraph, not as one continuous text run.
|
||||
- Normal following paragraphs have a first-line indent.
|
||||
- There is no blank line between ordinary paragraphs.
|
||||
- A chapter marker creates a centered italic heading and makes the first following paragraph special.
|
||||
- A textblock/section marker creates one line of vertical separation and makes the first following paragraph special.
|
||||
- Special first paragraphs after chapter or textblock markers have no horizontal first-line indent.
|
||||
- Chapter-first paragraphs use a drop cap aligned to an exact multiple of the body line height. Current target is a two-line drop cap unless visual testing justifies three lines.
|
||||
- Lines are justified so line starts and line ends touch the intended measure. Final lines are not force-justified.
|
||||
- Hyphenation must be real language-aware hyphenation from Hyphenopoly, not a fallback-only emergency split.
|
||||
- Line breaking uses the Knuth-Plass algorithm over the full paragraph.
|
||||
- Punctuation and short marks should not visually break the measure; optical margin handling is desirable future polish.
|
||||
- The page must scale as a fixed-aspect book page. Font sizes and word positions scale with page size, preserving the composition when the window is resized.
|
||||
|
||||
Processing order:
|
||||
|
||||
1. Parse block and inline story markup.
|
||||
2. Remove media markers from display and TTS text while keeping cue positions.
|
||||
3. Convert Markdown emphasis to inline style spans.
|
||||
4. Apply SmartyPants typographic punctuation.
|
||||
5. Apply Hyphenopoly for the active language.
|
||||
6. Measure words/spans.
|
||||
7. Run Knuth-Plass line breaking.
|
||||
8. Render stable positioned spans.
|
||||
9. Animate spans in sync with audio duration or estimated duration.
|
||||
|
||||
## Prototype Lessons To Preserve
|
||||
|
||||
The prototype in `prototype/` and the former `PROTOTYPE_ANALYSIS.md` proved the target text pipeline. Any future layout refactor should preserve these details:
|
||||
|
||||
- Hyphenopoly must emit pipe markers for hyphenation points through the `.hyphenatePipe` configuration.
|
||||
- The layout measurement function must treat the pipe marker as zero width.
|
||||
- `knuth-and-plass.js` must split text on spaces, punctuation separators, HTML tags, and pipe markers.
|
||||
- Pipe markers become penalty nodes, not visible text.
|
||||
- A visible hyphen is emitted only when the chosen line break uses a hyphenation penalty.
|
||||
- Text measurement should use the same real CSS font, size, and style that the book page uses.
|
||||
- Justification is applied through the Knuth-Plass break ratio by stretching or shrinking glue nodes.
|
||||
- Hyphenated syllables after a penalty must be rendered as one visual word group, preserving the prototype's no-overlap behavior.
|
||||
- The prototype used percentage-based positions so rendered text stayed proportional when the page scaled.
|
||||
|
||||
Known inherited implementation note: the old `linked-list.js` analysis identified `get last()` returning `this.last` instead of `this.tail`, which can recurse if used. The current implementation should be checked and corrected before future line-breaking refactors rely on `last`.
|
||||
|
||||
## Story Markup Specification
|
||||
|
||||
Markdown emphasis:
|
||||
|
||||
```text
|
||||
*italic* or _italic_
|
||||
**bold** or __bold__
|
||||
***bold italic*** or ___bold italic___
|
||||
```
|
||||
|
||||
Chapter:
|
||||
|
||||
```text
|
||||
::chapter[The Mysterious Mansion]
|
||||
|
||||
The first paragraph has a drop cap and no first-line indent.
|
||||
```
|
||||
|
||||
The heading is centered, italic, and uses the body font size. Following ordinary paragraphs return to normal first-line indentation.
|
||||
|
||||
Section or text block:
|
||||
|
||||
```text
|
||||
::section
|
||||
|
||||
The first paragraph is vertically separated from previous content and has no first-line indent.
|
||||
```
|
||||
|
||||
`::textblock` is an alias. Following ordinary paragraphs return to normal indentation.
|
||||
|
||||
Images:
|
||||
|
||||
```text
|
||||
::image[widescreen](file-name.jpg)
|
||||
::image[portrait](file-name.jpg)
|
||||
```
|
||||
|
||||
File names resolve relative to `public/images/`. `widescreen` means full page width and half page height. `portrait` means full page width and full page height. Parsing is implemented; visual rendering is pending.
|
||||
|
||||
Sound effects:
|
||||
|
||||
```text
|
||||
The old door opens {{sfx:squeaky-door.ogg}} into the dark.
|
||||
```
|
||||
|
||||
File names resolve relative to `public/sounds/`. The marker is not displayed and is not sent to TTS. Playback starts when animation reaches the marker position.
|
||||
|
||||
Music:
|
||||
|
||||
```text
|
||||
::music[crossfade, loop, lead=4](track.ogg)
|
||||
{{music:cut:track.ogg}}
|
||||
```
|
||||
|
||||
File names resolve relative to `public/music/`. Modes:
|
||||
|
||||
- `queue`: wait until the current track ends.
|
||||
- `crossfade`: fade from the current track into the new track.
|
||||
- `cut`: stop the current track and start the new one immediately.
|
||||
- `loop`: repeat the track.
|
||||
- `once`: do not repeat the track.
|
||||
- `lead=<seconds>`: for block music, let music play alone before the following text/TTS paragraph starts.
|
||||
|
||||
## TTS And Playback Specification
|
||||
|
||||
The playback system must keep text animation and audio synchronized.
|
||||
|
||||
- Complete sentences enter a preparation queue.
|
||||
- TTS generation/preload starts as soon as possible, including while previous prepared sentences are playing.
|
||||
- Text layout and TTS generation can be prepared in parallel.
|
||||
- Playback of a sentence starts only when the required audio duration is known, or when TTS is disabled/unavailable and an estimated duration has been calculated.
|
||||
- With measured TTS audio, animation duration follows the measured audio length.
|
||||
- With TTS `none` or unavailable providers, duration is estimated from text length and speed.
|
||||
- The speed slider is persisted and has normal speed at the center.
|
||||
- Provider-specific speed ranges must be converted from the app-level speed value:
|
||||
- OpenAI speech speed uses `1.0` as normal and supports a bounded multiplier.
|
||||
- ElevenLabs speed must be clamped to its accepted range.
|
||||
- Browser speech synthesis uses utterance rate.
|
||||
- Kokoro uses its provider-supported speed option.
|
||||
- `none` uses the same app-level speed to scale estimated animation duration.
|
||||
- Switching provider, voice, language, or speed during gameplay should apply to the next not-yet-generated sentence.
|
||||
- Kokoro is special: loading is expensive, so it should be loaded on startup only when selected and may require reload to switch into it.
|
||||
- Fast-forward completes visible animation and fades/stops active TTS playback so the next sentence can start earlier.
|
||||
- Music ducks to 70% of its configured volume while TTS playback is active, then fades back when the TTS playback queue is empty.
|
||||
|
||||
TTS cache keys must include:
|
||||
|
||||
- provider
|
||||
- voice
|
||||
- provider speed value
|
||||
- language
|
||||
- exact input string after markup/media removal
|
||||
|
||||
When all cache parameters match, cached audio should be played instead of regenerated.
|
||||
|
||||
OpenAI voices for the current speech endpoint are:
|
||||
|
||||
- `nova`
|
||||
- `shimmer`
|
||||
- `echo`
|
||||
- `onyx`
|
||||
- `fable`
|
||||
- `alloy`
|
||||
- `ash`
|
||||
- `sage`
|
||||
- `coral`
|
||||
|
||||
## Cursor And Interaction States
|
||||
|
||||
The mouse cursor, not the text insertion caret, indicates process state. The command input caret keeps its normal text-editing appearance.
|
||||
|
||||
Required states:
|
||||
|
||||
- ready for new command
|
||||
- command sent, waiting for game server answer
|
||||
- waiting silently for required TTS generation before animation can continue
|
||||
- audio and animation playing while another sentence is being generated
|
||||
- audio and animation playing while no further generation is needed
|
||||
|
||||
State changes should also be logged to the console during development.
|
||||
|
||||
Typing behavior:
|
||||
|
||||
- While a game is running, printable keyboard input should go to the command input regardless of the clicked element.
|
||||
- Enter sends the command when input is non-empty.
|
||||
- Space still inserts a space in the input, but if playback is active it also fast-forwards current playback.
|
||||
- Clicking on the book while playback is active fast-forwards current playback.
|
||||
|
||||
## Game API Specification
|
||||
|
||||
The client/server game API supports:
|
||||
|
||||
```text
|
||||
newGame()
|
||||
loadGame(slot)
|
||||
saveGame(slot)
|
||||
hasSaveGame(slot)
|
||||
getSaveGames()
|
||||
isGameRunning()
|
||||
```
|
||||
|
||||
`slot` is a positive integer from `1` to the number of save slots exposed by the UI.
|
||||
|
||||
Current placeholder behavior:
|
||||
|
||||
- `newGame()` starts the demo game and emits the introduction/current room description.
|
||||
- `saveGame(slot)` records a placeholder save for the current socket session.
|
||||
- `hasSaveGame(slot)` checks that session-local placeholder save.
|
||||
- `getSaveGames()` returns the saved slot numbers for the session.
|
||||
- `loadGame(slot)` requires a placeholder save and then starts the demo game like `newGame()`.
|
||||
- Saves do not persist across reloads yet.
|
||||
- `isGameRunning()` returns true after a game starts and until the session ends.
|
||||
|
||||
UI requirements before a game starts:
|
||||
|
||||
- Command input is hidden.
|
||||
- Right page shows the startup prompt.
|
||||
- `new game` is enabled.
|
||||
- `load` is enabled only when `hasSaveGame(slot)` is true.
|
||||
- `save` is disabled.
|
||||
|
||||
UI requirements after a game starts:
|
||||
|
||||
- Command input is visible and focused.
|
||||
- `save` is enabled.
|
||||
- `load` reflects save availability.
|
||||
- `new game` remains enabled and restarts the game.
|
||||
|
||||
## Server And World Model
|
||||
|
||||
The TypeScript server serves the web client and owns socket communication. The CLI and web modes use `GameRunner` and a YAML world file.
|
||||
|
||||
Current development world:
|
||||
|
||||
- Default file: `./data/worlds/example_world.yml`
|
||||
- Server-side command handling is currently mirrored for UI testing: entered commands are sent to the server and returned as narrative text through the socket response path.
|
||||
|
||||
Longer-term goal:
|
||||
|
||||
- Keep a deterministic world model for rooms, objects, actions, state changes, and validation.
|
||||
- Use the LLM to translate natural language player intent into game actions.
|
||||
- Use the LLM to render state changes as prose without allowing hallucinated state.
|
||||
|
||||
## Quality Rules
|
||||
|
||||
- Preserve existing module boundaries when changing code.
|
||||
- Prefer event-driven and Promise-based coordination over timing hacks.
|
||||
- Do not use loader fallbacks to hide bad dependency declarations.
|
||||
- Keep the book page visually stable under resizing.
|
||||
- Validate typography visually after layout changes.
|
||||
- Validate TTS timing with real provider playback and with TTS `none`.
|
||||
- Treat the ad-blocker console error `onpage-dialog.preload.js:121 Uncaught ReferenceError: browser is not defined` as unrelated noise.
|
||||
|
||||
## Progress Checklist
|
||||
|
||||
### Completed
|
||||
|
||||
- [x] Split monolithic prototype concepts into focused client modules.
|
||||
- [x] Added loader, registry, base module, states, dependency declarations, and ordered initialization.
|
||||
- [x] Added development cache busting and no-cache static file serving.
|
||||
- [x] Added socket-backed game API wrapper.
|
||||
- [x] Added manual game start flow for browser audio policies.
|
||||
- [x] Added SmartyPants support.
|
||||
- [x] Added Hyphenopoly support.
|
||||
- [x] Added Knuth-Plass paragraph layout.
|
||||
- [x] Fixed overfull words, incorrect spacing, and hyphenation integration regressions from the prototype migration.
|
||||
- [x] Added chapter heading and dropcap markup.
|
||||
- [x] Added section/textblock markup.
|
||||
- [x] Added Markdown emphasis parsing.
|
||||
- [x] Added image markup parsing for future image rendering.
|
||||
- [x] Added sound effect markup and playback.
|
||||
- [x] Added music markup, playback modes, loop/once, and lead-in.
|
||||
- [x] Added music ducking during TTS.
|
||||
- [x] Added TTS `none` mode.
|
||||
- [x] Added OpenAI TTS support and restricted OpenAI voice list.
|
||||
- [x] Added ElevenLabs speed clamping.
|
||||
- [x] Added persisted speed and speech state.
|
||||
- [x] Added speech toggle synchronization between top bar and options.
|
||||
- [x] Added volume controls for speech, music, and sound effects.
|
||||
- [x] Added command input focus and global typing behavior.
|
||||
- [x] Added command mirroring through the server path for UI testing.
|
||||
- [x] Added fast-forward for animation and TTS fade/stop.
|
||||
|
||||
### In Progress
|
||||
|
||||
- [ ] Keep validating provider-specific speed conversion for all TTS providers against real API behavior.
|
||||
- [ ] Tighten automated checks around top-bar/options state initialization after reload.
|
||||
- [ ] Improve automated visual regression coverage for page scaling, dropcap line-height alignment, and paragraph indentation.
|
||||
- [ ] Improve automated audio tests for music ducking, sound effect timing, and fast-forward fadeout.
|
||||
|
||||
### Pending
|
||||
|
||||
- [ ] Implement image rendering for `::image[widescreen]` and `::image[portrait]`.
|
||||
- [ ] Replace placeholder save implementation with durable save files or server-side save storage.
|
||||
- [ ] Replace command mirroring with the full LLM/world-model command loop when typography/audio testing no longer needs mirroring.
|
||||
- [ ] Add optical margin alignment or punctuation protrusion support for line endings.
|
||||
- [ ] Add more provider readiness tests and richer diagnostics in options.
|
||||
- [ ] Add unit tests for `SentenceQueue`, markup parsing, TTS cache key generation, and game API methods.
|
||||
- [ ] Add browser integration tests for module loading, new-game startup, command input behavior, and playback controls.
|
||||
|
||||
## Consolidation Notes
|
||||
|
||||
The durable content from the former root and `references/` documents has been merged here or into `README.md`. The old reference files should remain untouched until the user approves cleanup, but they are no longer intended as the source of truth.
|
||||
@@ -1,38 +1,186 @@
|
||||
# AI Interactive Fiction
|
||||
|
||||
A modern take on classic text adventures that combines traditional world modeling with Large Language Models (LLMs) to create natural language interactive fiction experiences.
|
||||
AI Interactive Fiction is a web and CLI text adventure prototype that combines a deterministic world model with LLM-assisted command interpretation and narrative output. The web client presents the story as an animated, novel-like book page with synchronized text animation, optional TTS, music, and sound effects.
|
||||
|
||||
## Project Overview
|
||||
## Quick Start
|
||||
|
||||
This application reimagines the classic text adventure game genre by replacing the traditional parser with an LLM. The system consists of:
|
||||
Use Node.js 22 LTS for development. The project accepts Node >= 18.17, but current development has been done on Node 22.
|
||||
|
||||
1. **World Model**: A traditional game engine that manages rooms, objects, actions, and game state - similar to old-school Infocom games.
|
||||
```powershell
|
||||
nvm install 22
|
||||
nvm use 22
|
||||
npm install
|
||||
npm run build
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. **LLM Interface**: An AI layer that processes natural language input from players and translates it into actions the game engine can understand.
|
||||
The web app starts on `http://localhost:3000` when launched through `src/index.ts` or `dist/index.js`. The lower-level server module defaults to `3001` when started directly. Set `PORT` to choose a port; the server will try the next few ports if the requested one is already in use.
|
||||
|
||||
3. **Narrative Generation**: The LLM converts the world state changes into rich, contextual prose for the player.
|
||||
## Commands
|
||||
|
||||
## Key Features
|
||||
```powershell
|
||||
npm run dev # Start the web UI through ts-node/nodemon
|
||||
npm run start # Run the compiled web server from dist/
|
||||
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
|
||||
```
|
||||
|
||||
- **Natural Language Understanding**: Players can express their intent in plain language without worrying about specific command syntax.
|
||||
- **Rich Narrative**: Dynamic descriptions that adapt to the current game state and player history.
|
||||
- **Consistent World Model**: The underlying game engine enforces world rules to prevent hallucinations or inconsistencies.
|
||||
- **Modular Design**: Easily swap between different world models, including YAML-based custom worlds or integrations with classic Z-machine games.
|
||||
## Configuration
|
||||
|
||||
## How It Works
|
||||
Environment variables are loaded from `.env`.
|
||||
|
||||
1. Player enters natural language input
|
||||
2. LLM analyzes input and translates it into game actions
|
||||
3. Game engine processes valid actions and updates the game state
|
||||
4. LLM receives the state change information and generates narrative prose
|
||||
5. Player receives the beautifully written response
|
||||
- `PORT`: preferred web server port.
|
||||
- `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.
|
||||
|
||||
## Technical Structure
|
||||
TTS provider settings are configured in the browser options menu and persisted in browser storage. Providers currently include `none`, browser speech synthesis, Kokoro, ElevenLabs, and OpenAI. Production should not assume a universal TTS default; the game or player state selects the active mode, and `none` is the safe fallback.
|
||||
|
||||
- YAML-based world definition (rooms, objects, actions)
|
||||
- OpenRouter API integration for accessing suitable LLMs
|
||||
- Modular design allowing for Z-machine integration in the future
|
||||
## Starting A Game
|
||||
|
||||
## Getting Started
|
||||
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.
|
||||
|
||||
[Installation and running instructions will be added here]
|
||||
The placeholder server API supports:
|
||||
|
||||
- `newGame()`
|
||||
- `loadGame(slot)`
|
||||
- `saveGame(slot)`
|
||||
- `hasSaveGame(slot)`
|
||||
- `getSaveGames()`
|
||||
- `isGameRunning()`
|
||||
|
||||
Save slots are positive integers. In the current placeholder implementation, save availability is per socket session and is lost on reload. Loading a save starts the demo content like `newGame()`.
|
||||
|
||||
## Web Client
|
||||
|
||||
The browser app is built from native ES modules in `public/js/`. The loader dynamically imports modules, applies a cache-busting query string during development, resolves declared dependencies, and awaits module initialization in dependency order before the UI becomes usable.
|
||||
|
||||
Major modules:
|
||||
|
||||
- `module-registry.js`, `base-module.js`, `loader.js`: module lifecycle, dependency graph, progress overlay, state reporting.
|
||||
- `text-processor-module.js`, `paragraph-layout-module.js`, `layout-renderer-module.js`: SmartyPants, language-aware hyphenation, Knuth-Plass line breaking, DOM rendering.
|
||||
- `markup-parser-module.js`: story markup for chapters, sections, Markdown emphasis, 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.
|
||||
|
||||
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___
|
||||
```
|
||||
|
||||
Chapter:
|
||||
|
||||
```text
|
||||
::chapter[The Mysterious Mansion]
|
||||
|
||||
The first paragraph uses a drop cap and no first-line indent.
|
||||
|
||||
Following paragraphs use the normal paragraph indent.
|
||||
```
|
||||
|
||||
The heading is centered, italic, and uses the same text face as the body. The first paragraph after a chapter marker is unindented and receives the drop cap treatment.
|
||||
|
||||
Section or text block:
|
||||
|
||||
```text
|
||||
::section
|
||||
|
||||
The first paragraph starts a separated block without horizontal indent.
|
||||
|
||||
The following paragraph returns to the normal indent.
|
||||
```
|
||||
|
||||
`::textblock` is treated the same way. The first paragraph after the marker is separated from previous content by one line of vertical space.
|
||||
|
||||
Images are parsed for future rendering:
|
||||
|
||||
```text
|
||||
::image[widescreen](mansion-rain.jpg)
|
||||
::image[portrait](portrait-letter.jpg)
|
||||
```
|
||||
|
||||
Image file names are relative to `public/images/`. `widescreen` means 100% page width and 50% page height. `portrait` means 100% page width and 100% page height.
|
||||
|
||||
Sound effects can be placed inline:
|
||||
|
||||
```text
|
||||
The door opens {{sfx:squeaky-door.ogg}} and the hall exhales.
|
||||
```
|
||||
|
||||
The marker is removed from display text and TTS text. It becomes a timed media cue that fires when the text animation reaches that point. Sound effect paths are relative to `public/sounds/`.
|
||||
|
||||
Music can be placed as a block:
|
||||
|
||||
```text
|
||||
::music[crossfade, loop, lead=4](rain-theme.ogg)
|
||||
```
|
||||
|
||||
Music can also be placed inline:
|
||||
|
||||
```text
|
||||
The candles gutter. {{music:cut:danger.ogg}} Something moves upstairs.
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Assets
|
||||
|
||||
- `public/sounds/`: sound effects referenced by `{{sfx:file}}`.
|
||||
- `public/music/`: background music referenced by `::music[...]` or `{{music:mode:file}}`.
|
||||
- `public/images/`: story images referenced by `::image[...]`.
|
||||
- `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.
|
||||
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.
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-05-14
|
||||
|
||||
- Consolidated usage, markup, and architecture documentation into `README.md` and `CLIENT_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,106 +0,0 @@
|
||||
# Module System Refactoring TODO
|
||||
|
||||
## High Priority (Critical Architectural Issues)
|
||||
|
||||
### 1. Asynchronous Flow Control Improvements
|
||||
- [ ] Remove all `setTimeout` calls used for synchronization in modules
|
||||
- [X] Replace timeout in `browser-tts-handler.js` with proper Promise handling for voice loading
|
||||
- [X] Eliminate race condition in `tts-player.js` that uses a hard-coded 1000ms timeout
|
||||
- [ ] Remove all `setTimeout` calls in `ui-controller.js` for UI updates
|
||||
- [ ] Implement proper Promise-based flow control in all modules
|
||||
- [ ] Update `kokoro-handler.js` to correctly handle loading events
|
||||
- [ ] Ensure all `async/await` patterns follow best practices
|
||||
- [ ] Fix race conditions in module loading sequences
|
||||
|
||||
### 2. Module State Management
|
||||
- [ ] Fix premature reporting of `FINISHED` state
|
||||
- [ ] Ensure `tts-player.js` properly waits for Kokoro loading before reporting FINISHED
|
||||
- [ ] Add proper state checks in all modules before reporting FINISHED
|
||||
- [ ] Implement proper state transition reporting
|
||||
- [ ] Update modules to use event system for reporting state transitions
|
||||
- [ ] Add better error handling during module initialization
|
||||
|
||||
### 3. Module Dependencies & Loading
|
||||
- [ ] Fix missing dependency declarations
|
||||
- [ ] Update `ui-controller.js` to properly declare its TTS dependency
|
||||
- [ ] Ensure all modules correctly specify all dependencies
|
||||
- [ ] Remove dependency availability checks within modules
|
||||
- [ ] Remove conditional checks like `if (!this.ttsHandler)` in `ui-controller.js`
|
||||
- [ ] Rely on the module loader for dependency management
|
||||
|
||||
## Medium Priority (Functionality & Implementation Issues)
|
||||
|
||||
### 4. TTS Handler Implementation
|
||||
- [ ] Implement missing `tts-handler.js` file content
|
||||
- [ ] Create proper implementation with consistent interface
|
||||
- [ ] Ensure it uses proper event-based communication
|
||||
- [ ] Fix inconsistent event usage across TTS handlers
|
||||
- [ ] Replace direct callbacks with event system
|
||||
- [ ] Standardize event names and parameters
|
||||
|
||||
### 5. Animation Queue Enhancements
|
||||
- [ ] Implement proper queue control mechanisms
|
||||
- [ ] Add pause/resume functionality
|
||||
- [ ] Implement more robust animation timing
|
||||
- [ ] Add priority management for animations
|
||||
|
||||
### 6. UI Controller Cleanup
|
||||
- [ ] Fix duplicate methods in UI Controller
|
||||
- [ ] Deduplicate code for creating UI elements
|
||||
- [ ] Consolidate event handling functions
|
||||
- [ ] Remove redundant `ModuleEvent` class implementation
|
||||
- [ ] Use the shared implementation from `base-module.js`
|
||||
|
||||
### 7. Kokoro Loading Implementation
|
||||
- [ ] Implement proper `requestIdleCallback` for Kokoro loading
|
||||
- [ ] Follow the pattern described in the specification
|
||||
- [ ] Add progress reporting during Kokoro loading
|
||||
- [ ] Fix event handling for loading completion
|
||||
|
||||
## Lower Priority (Refinements & Optimizations)
|
||||
|
||||
### 8. Code Quality & Consistency
|
||||
- [ ] Standardize module registration pattern
|
||||
- [ ] Ensure all modules follow the same pattern
|
||||
- [ ] Fix inconsistencies in export approaches
|
||||
- [ ] Improve module progress reporting
|
||||
- [ ] Make progress reporting more granular
|
||||
- [ ] Add more descriptive status messages
|
||||
|
||||
### 9. Error Handling Improvements
|
||||
- [ ] Add better error recovery mechanisms
|
||||
- [ ] Implement fallbacks for critical failures
|
||||
- [ ] Add user-friendly error messages
|
||||
- [ ] Improve error logging
|
||||
- [ ] Add structured error reporting
|
||||
- [ ] Implement debugging tools
|
||||
|
||||
### 10. Performance Optimizations
|
||||
- [ ] Optimize module loading sequence
|
||||
- [ ] Prioritize critical modules
|
||||
- [ ] Defer non-essential loading
|
||||
- [ ] Improve resource utilization
|
||||
- [ ] Minimize memory footprint
|
||||
- [ ] Reduce CPU usage during animations
|
||||
|
||||
## Documentation & Testing
|
||||
|
||||
### 11. Documentation
|
||||
- [ ] Add JSDoc comments to all public methods
|
||||
- [ ] Create architectural documentation
|
||||
- [ ] Document module dependencies
|
||||
- [ ] Explain event system
|
||||
- [ ] Add example usage for modules
|
||||
|
||||
### 12. Testing
|
||||
- [ ] Create unit tests for modules
|
||||
- [ ] Implement integration tests for module system
|
||||
- [ ] Add browser compatibility tests
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### 13. New Features
|
||||
- [ ] Add module versioning support
|
||||
- [ ] Implement module hot-reloading
|
||||
- [ ] Create plugin system for extending modules
|
||||
- [ ] Add internationalization support for UI
|
||||
@@ -2,10 +2,24 @@ title: The Mysterious Mansion
|
||||
author: AI Interactive Fiction
|
||||
version: 1.0.0
|
||||
introduction: |
|
||||
You find yourself standing outside an old, abandoned mansion on a hill.
|
||||
Rain patters gently on the gravel path leading to the front door.
|
||||
A strange letter in your pocket invited you here, but you can't remember who sent it.
|
||||
Perhaps the answers lie within...
|
||||
::music[crossfade, loop, lead=10](Dark Jodler.mp3)
|
||||
|
||||
::chapter[The Mysterious Mansion]
|
||||
|
||||
The last thing you remember is the letter: heavy paper, black wax, your name written in a hand you almost recognized.
|
||||
It asked you to come after dusk, alone, and promised that the house would answer what the sender could not.
|
||||
|
||||
Now you stand beyond the wrought iron gate, with rain cooling your face and the hill rising before you.
|
||||
At its crest waits the old Victorian mansion, every dark window turned toward the path as if the building has been expecting you.
|
||||
|
||||
The gate gives under your hand with no protest, though its ironwork is wet enough to shine black.
|
||||
Gravel shifts beneath your boots as you pass between the pillars, and the garden closes behind you with the soft finality of a curtain.
|
||||
|
||||
Halfway up the path, you stop and listen.
|
||||
The rain has thinned to a whisper, but the house answers with other sounds: timber settling, gutters ticking, and something deep inside the walls that might be machinery or breath.
|
||||
|
||||
For a heartbeat you think the mansion is about to speak ... but only the wind moves through the ivy.
|
||||
It drags the leaves across the brickwork in slow strokes, as if wiping dust from an old name.
|
||||
|
||||
# Room definitions
|
||||
rooms:
|
||||
@@ -13,9 +27,18 @@ rooms:
|
||||
front_yard:
|
||||
name: Front Yard
|
||||
description: |
|
||||
You stand on a gravel path leading to an imposing Victorian mansion.
|
||||
The rain has softened to a drizzle, and moonlight peeks through gaps in the clouds.
|
||||
You follow the gravel path up the hill.
|
||||
The rain softens to a drizzle, and moonlight peeks through gaps in the clouds.
|
||||
Ancient oak trees frame the property, their branches swaying in the gentle breeze.
|
||||
At the top of three worn stone steps, the mansion's front door waits under a sagging porch roof.
|
||||
The porch boards are swollen with rain, each one bending under your weight before it remembers its shape.
|
||||
A brass knocker hangs at eye level, polished bright at the edges where countless hands have touched it and left no warmth behind.
|
||||
The letter in your pocket presses against your ribs.
|
||||
You remember the last line now: come before the clocks learn your name.
|
||||
Somewhere above you, behind a blind upper window, a pale shape passes from left to right and is gone.
|
||||
You tell yourself it was a reflection, then look back at the path and find no light behind you bright enough to make one.
|
||||
The house waits.
|
||||
When you reach for the handle, it turns before your fingers touch it, and the door opens {{sfx:squeaky-door.ogg}} with a long, complaining squeak.
|
||||
exits:
|
||||
- direction: north
|
||||
targetRoomId: entrance_hall
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Z-Code Story Files
|
||||
|
||||
Place your Z-machine story files here. The Zork Narrator engine looks for
|
||||
`zork1.bin` by default. This can be overridden with the `ZORK_STORY_FILE`
|
||||
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 `ZORK_STORY_FILE=./path/to/your/file` in your `.env`.
|
||||
|
||||
## Supported Formats
|
||||
|
||||
The `ifvms` interpreter accepts:
|
||||
- `.z3`, `.z4`, `.z5`, `.z8` — raw Z-machine story files
|
||||
- `.zblorb` — Blorb-wrapped story files (may include sound resources)
|
||||
- Any file with the correct Z-machine header (the extension is ignored)
|
||||
|
||||
Zork I is a Z-machine version 3 (`.z3`) game.
|
||||
Binary file not shown.
@@ -0,0 +1,44 @@
|
||||
# Character Generation Prompt
|
||||
# Called once at game start to create a unique player character.
|
||||
# No user_template is needed — the system message IS the full prompt.
|
||||
# Expected output: 300-500 words of vivid character description prose. No JSON.
|
||||
|
||||
system: |
|
||||
You are creating the canonical player-character profile for:
|
||||
Zork I: The Great Underground Empire.
|
||||
|
||||
Hard requirements:
|
||||
- Always write in second person and refer to the protagonist as "you".
|
||||
- Never call the protagonist "he", "she", "they", or by a third-person noun.
|
||||
- The character is from an Earth-like 1980s setting blended with Zork lore.
|
||||
- The character is NOT an American treasure hunter.
|
||||
- Tone: vivid, concrete, grounded, literary, and emotionally specific.
|
||||
- Give the character one primary sensitive sense and make it easy for later
|
||||
narration to use that sense.
|
||||
|
||||
Generate a complete persona that includes:
|
||||
- Random full name.
|
||||
- Gender, nationality, race, age.
|
||||
- Skin color, eye color, hair color, body size, body build.
|
||||
- Personal style, hairstyle.
|
||||
- Tattoos (optional), piercings (optional), scars (optional).
|
||||
- Distinctive standout trait (at least one clearly unusual detail).
|
||||
- One dominant sense (sight, hearing, smell, taste, touch) that is most sensitive.
|
||||
- Exactly three sentences of backstory.
|
||||
- Personality, likes, dislikes, hopes, fears, worldview.
|
||||
- Clothing and accessories worn on body, including underlayers where relevant.
|
||||
- Do NOT list bags, tools, or equipment.
|
||||
- Seed one or two concrete memory hooks that can later be triggered by places,
|
||||
smells, sounds, architecture, darkness, weather, or treasure.
|
||||
|
||||
Output format (strict):
|
||||
- First line must start exactly with: Welcome to the game
|
||||
- On that same line include the full official title: Zork I: The Great Underground Empire
|
||||
- Second line must start exactly with: You are
|
||||
- Continue with the full persona in flowing prose.
|
||||
- Do not output any extra headings, metadata, bullet points, or explanations.
|
||||
|
||||
Ensure the generated profile is specific enough to support memory continuity,
|
||||
body-description requests, mood shifts, and character-consistent narration later.
|
||||
|
||||
user_template: ""
|
||||
@@ -0,0 +1,112 @@
|
||||
# Command Translator Prompt
|
||||
# Called for every player input. Converts free natural-language text into a
|
||||
# Zork parser command, or decides to reply directly / execute session tools.
|
||||
# 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 Zork 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.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Output Evaluator Prompt
|
||||
# Called after each Z-machine response. Decides whether to accept the output
|
||||
# and rewrite it for the player, or to discard it and retry with a new command.
|
||||
# Expected output: a JSON object (see schema below).
|
||||
|
||||
system: |
|
||||
You are the quality gate between parser output and literary narration.
|
||||
|
||||
Decide whether to accept parser output or retry with a better command.
|
||||
|
||||
Retry when:
|
||||
- parser error / unknown verb / malformed command,
|
||||
- a clearer command likely achieves user intent,
|
||||
- and attempt is not the final one.
|
||||
|
||||
Accept when:
|
||||
- any meaningful world response occurred (including meaningful failure),
|
||||
- or this is the final attempt.
|
||||
|
||||
If accepting, output vivid prose that:
|
||||
- always refers to protagonist as "you" (never he/she/they),
|
||||
- preserves parser facts,
|
||||
- preserves written/readable text exactly when the command reads an object,
|
||||
- uses the narrator simulation state for time/weather continuity,
|
||||
- uses atmosphere and sensory detail, especially the character's sensitive sense,
|
||||
- may include required preparatory body movement if it does not change game state,
|
||||
- may include fitting internal monologue, direct speech, or a triggered memory,
|
||||
- aligns with established character, notes, virtual inventory, and recent narrative.
|
||||
|
||||
Keep output concrete and scene-rooted.
|
||||
Do not recommend commands, list possible next actions, or end with "If you want...".
|
||||
Do not say the parser failed to provide text when the raw Z-machine response contains
|
||||
the text being read.
|
||||
|
||||
Output JSON only:
|
||||
- Accept:
|
||||
{ "decision": "accept", "text": "..." }
|
||||
- Retry:
|
||||
{ "decision": "retry", "command": "..." }
|
||||
|
||||
user_template: |
|
||||
Player character:
|
||||
{{characterDescription}}
|
||||
|
||||
Narrator's notes:
|
||||
{{notes}}
|
||||
|
||||
Character-side virtual inventory:
|
||||
{{virtualInventory}}
|
||||
|
||||
Narrator simulation state:
|
||||
{{narratorState}}
|
||||
|
||||
Current location: {{currentRoom}}
|
||||
|
||||
What the player has seen here recently:
|
||||
{{roomHistory}}
|
||||
|
||||
Most recent narrative paragraphs across scenes (up to 10, newest last):
|
||||
{{recentNarrative}}
|
||||
|
||||
Recent raw parser transcript for factual anchoring:
|
||||
{{rawTranscript}}
|
||||
|
||||
---
|
||||
Original player intent: "{{userIntent}}"
|
||||
Command tried: {{commandTried}}
|
||||
Attempt: {{attempt}} of {{maxAttempts}}
|
||||
|
||||
Raw Z-machine response:
|
||||
---
|
||||
{{zorkOutput}}
|
||||
---
|
||||
|
||||
Decide now: accept and rewrite, or retry with a new command?
|
||||
Respond with the appropriate JSON.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Text Rewriter Prompt
|
||||
# Called for the game's opening text, and for re-entry into rooms that have
|
||||
# no prior player-facing history yet.
|
||||
# Expected output: polished prose. No JSON.
|
||||
|
||||
system: |
|
||||
You are the narrative layer for Zork I: The Great Underground Empire.
|
||||
Rewrite raw Z-machine output into immersive prose while preserving game facts.
|
||||
|
||||
Core stance:
|
||||
- Always narrate the player-character in second person: "you".
|
||||
- Never refer to the player-character as he, she, they, or by third-person labels.
|
||||
- Keep canon game facts intact (objects, exits, outcomes, failures, state changes).
|
||||
- Do not invent gameplay-critical facts that contradict Zork 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 Zork 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:
|
||||
---
|
||||
{{zorkOutput}}
|
||||
---
|
||||
|
||||
Rewrite the above as prose for the player now.
|
||||
Vendored
+90
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Zork LLM Engine
|
||||
*
|
||||
* Runs Zork I (or any Z-machine story file) as a headless subprocess via the
|
||||
* `ifvms` CLI, and wraps every I/O exchange with OpenRouter LLM calls that
|
||||
* translate free natural-language player input into parser commands and
|
||||
* re-voice the Z-machine's raw output as polished narrative prose.
|
||||
*
|
||||
* Configuration (environment variables):
|
||||
* ZORK_STORY_FILE – path to the .z5/.z8/.bin story file (default: ./data/z-code/zork1.bin)
|
||||
* ZORK_MAX_RETRIES – maximum command retry attempts per turn (default: 3)
|
||||
* ZORK_HISTORY_SIZE – player-facing outputs stored per room (default: 5)
|
||||
* OPENROUTER_API_KEY, OPENROUTER_MODEL – required
|
||||
*/
|
||||
export interface ZorkSession {
|
||||
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;
|
||||
}
|
||||
/** Subset of the unified TurnResult protocol understood by the client. */
|
||||
export interface ZorkTurnResult {
|
||||
paragraphs: Array<{
|
||||
text: string;
|
||||
tags: unknown[];
|
||||
}>;
|
||||
choices: unknown[];
|
||||
inputMode: 'text' | 'end';
|
||||
gameState?: {
|
||||
statusLine?: string;
|
||||
};
|
||||
}
|
||||
export declare class ZorkLlmEngine {
|
||||
private zork;
|
||||
private session;
|
||||
private prompts;
|
||||
private llm;
|
||||
private model;
|
||||
private resolvedFallbackModel;
|
||||
private llmCallCounter;
|
||||
private maxRetries;
|
||||
private historySize;
|
||||
private storyPath;
|
||||
private static readonly DEPRECATED_MODEL_REPLACEMENTS;
|
||||
constructor();
|
||||
private createCompletion;
|
||||
private resolveFallbackModel;
|
||||
isRunning(): boolean;
|
||||
/**
|
||||
* Start a new game: launch Zork, generate the player character, rewrite the
|
||||
* intro text, and return the first TurnResult for the client.
|
||||
*/
|
||||
newGame(): Promise<ZorkTurnResult>;
|
||||
/**
|
||||
* Process player free-text input. Returns the next TurnResult.
|
||||
*/
|
||||
processInput(userInput: string): Promise<ZorkTurnResult>;
|
||||
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<ZorkTurnResult>;
|
||||
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
+984
@@ -0,0 +1,984 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Zork LLM Engine
|
||||
*
|
||||
* Runs Zork I (or any Z-machine story file) as a headless subprocess via the
|
||||
* `ifvms` CLI, and wraps every I/O exchange with OpenRouter LLM calls that
|
||||
* translate free natural-language player input into parser commands and
|
||||
* re-voice the Z-machine's raw output as polished narrative prose.
|
||||
*
|
||||
* Configuration (environment variables):
|
||||
* ZORK_STORY_FILE – path to the .z5/.z8/.bin story file (default: ./data/z-code/zork1.bin)
|
||||
* ZORK_MAX_RETRIES – maximum command retry attempts per turn (default: 3)
|
||||
* ZORK_HISTORY_SIZE – player-facing outputs stored per room (default: 5)
|
||||
* OPENROUTER_API_KEY, OPENROUTER_MODEL – required
|
||||
*/
|
||||
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.ZorkLlmEngine = 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"));
|
||||
dotenv.config();
|
||||
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZORK_DEBUG ?? '');
|
||||
function debugLog(message, details) {
|
||||
if (!DEBUG_ENABLED)
|
||||
return;
|
||||
if (typeof details === 'undefined') {
|
||||
console.log(`[ZorkLlm:debug] ${message}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[ZorkLlm: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, zorkOutput) {
|
||||
const object = command.replace(/^READ\s+/i, '').trim().toLowerCase();
|
||||
const label = object ? `the ${object}` : 'it';
|
||||
const cleanedOutput = zorkOutput
|
||||
.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];
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// ZorkProcess – manages the ifvms zvm child process
|
||||
// ---------------------------------------------------------------------------
|
||||
class ZorkProcess {
|
||||
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 Zork's '>' 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(`[ZorkLlm] ${scope} failed: ${ax.message}`);
|
||||
if (ax.response) {
|
||||
console.error(`[ZorkLlm] ${scope} status=${ax.response.status} data=`, ax.response.data);
|
||||
if (ax.response.status === 404) {
|
||||
console.error('[ZorkLlm] Hint: OPENROUTER_MODEL is likely invalid or unavailable for your API key.');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error(`[ZorkLlm] ${scope} failed:`, err);
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// ZorkLlmEngine
|
||||
// ---------------------------------------------------------------------------
|
||||
class ZorkLlmEngine {
|
||||
constructor() {
|
||||
this.zork = new ZorkProcess();
|
||||
this.session = null;
|
||||
this.resolvedFallbackModel = null;
|
||||
this.llmCallCounter = 0;
|
||||
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 = ZorkLlmEngine.DEPRECATED_MODEL_REPLACEMENTS[model] ?? null;
|
||||
if (replacement) {
|
||||
this.model = replacement;
|
||||
console.warn(`[ZorkLlm] 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.ZORK_MAX_RETRIES ?? '3', 10);
|
||||
this.historySize = parseInt(process.env.ZORK_HISTORY_SIZE ?? '5', 10);
|
||||
this.storyPath = path.resolve(process.env.ZORK_STORY_FILE ?? './data/z-code/zork1.bin');
|
||||
const promptDir = path.resolve('./data/zork-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(`[ZorkLlm] 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.zork.isAlive();
|
||||
}
|
||||
/**
|
||||
* Start a new game: launch Zork, generate the player character, rewrite the
|
||||
* intro text, and return the first TurnResult for the client.
|
||||
*/
|
||||
async newGame() {
|
||||
// Kill any existing game
|
||||
if (this.zork.isAlive())
|
||||
this.zork.kill();
|
||||
if (!fs.existsSync(this.storyPath)) {
|
||||
throw new Error(`Story file not found: ${this.storyPath}\n` +
|
||||
'Place zork1.bin in ./data/z-code/ (see README in that folder).');
|
||||
}
|
||||
debugLog('launching Z-machine', { storyPath: this.storyPath });
|
||||
const rawIntro = await this.zork.launch(this.storyPath);
|
||||
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 Zork 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(), `zork-save-${Date.now()}.qzl`);
|
||||
try {
|
||||
// Ask Zork to save, supply the temp file path, and discard the output
|
||||
await this.zork.sendLine('SAVE');
|
||||
await this.zork.sendLine(tmpFile);
|
||||
let zorkSave = '';
|
||||
if (fs.existsSync(tmpFile)) {
|
||||
zorkSave = fs.readFileSync(tmpFile).toString('base64');
|
||||
}
|
||||
return JSON.stringify({ session: this.session, zorkSave });
|
||||
}
|
||||
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, zorkSave } = JSON.parse(savedJson);
|
||||
if (this.zork.isAlive())
|
||||
this.zork.kill();
|
||||
const tmpFile = path.join(os.tmpdir(), `zork-restore-${Date.now()}.qzl`);
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, Buffer.from(zorkSave, 'base64'));
|
||||
await this.zork.launch(this.storyPath);
|
||||
await this.zork.sendLine('RESTORE');
|
||||
const restoreOutput = await this.zork.sendLine(tmpFile);
|
||||
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.zork.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(zorkOutput) {
|
||||
const cfg = this.prompts.textRewriter;
|
||||
const vars = this.buildCommonVars();
|
||||
vars['zorkOutput'] = zorkOutput;
|
||||
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 zorkOutput;
|
||||
}
|
||||
}
|
||||
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 Zork parser
|
||||
return { type: 'command', command: userInput.toUpperCase() };
|
||||
}
|
||||
}
|
||||
async evaluateOutput(userIntent, commandTried, zorkOutput, attempt) {
|
||||
const cfg = this.prompts.outputEvaluator;
|
||||
const vars = this.buildCommonVars();
|
||||
vars['userIntent'] = userIntent;
|
||||
vars['commandTried'] = commandTried;
|
||||
vars['zorkOutput'] = zorkOutput;
|
||||
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: zorkOutput };
|
||||
}
|
||||
}
|
||||
// ---- 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.zork.isAlive();
|
||||
if (!alive && this.session)
|
||||
this.session.running = false;
|
||||
return {
|
||||
paragraphs: [{ text, tags: [] }],
|
||||
choices: [],
|
||||
inputMode: alive ? 'text' : 'end',
|
||||
gameState: { statusLine: this.session?.currentRoom },
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.ZorkLlmEngine = ZorkLlmEngine;
|
||||
ZorkLlmEngine.DEPRECATED_MODEL_REPLACEMENTS = {
|
||||
'anthropic/claude-3-opus-20240229': 'openai/gpt-5.5',
|
||||
'openai/gpt-5.4-mini': 'openai/gpt-5.5',
|
||||
};
|
||||
//# sourceMappingURL=zork-llm-engine.js.map
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Zork LLM Server
|
||||
*
|
||||
* Starts an Express + Socket.IO server that runs Zork I through the
|
||||
* ZorkLlmEngine and serves the same shared client UI as the YAML engine.
|
||||
*
|
||||
* Usage:
|
||||
* npm run dev:zork (development, with file watching)
|
||||
* npm run start:zork (production, from compiled dist/)
|
||||
*
|
||||
* Environment variables:
|
||||
* PORT – HTTP port (default: 3002)
|
||||
* ZORK_STORY_FILE – path to the story file (default: ./data/z-code/zork1.bin)
|
||||
* OPENROUTER_API_KEY, OPENROUTER_MODEL – required
|
||||
*/
|
||||
export {};
|
||||
Vendored
+343
@@ -0,0 +1,343 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Zork LLM Server
|
||||
*
|
||||
* Starts an Express + Socket.IO server that runs Zork I through the
|
||||
* ZorkLlmEngine and serves the same shared client UI as the YAML engine.
|
||||
*
|
||||
* Usage:
|
||||
* npm run dev:zork (development, with file watching)
|
||||
* npm run start:zork (production, from compiled dist/)
|
||||
*
|
||||
* Environment variables:
|
||||
* PORT – HTTP port (default: 3002)
|
||||
* ZORK_STORY_FILE – path to the story file (default: ./data/z-code/zork1.bin)
|
||||
* OPENROUTER_API_KEY, OPENROUTER_MODEL – required
|
||||
*/
|
||||
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 zork_llm_engine_1 = require("./engine/zork-llm-engine");
|
||||
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 = 10;
|
||||
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZORK_DEBUG ?? '');
|
||||
function debugLog(message, details) {
|
||||
if (!DEBUG_ENABLED)
|
||||
return;
|
||||
if (typeof details === 'undefined') {
|
||||
console.log(`[zork:debug] ${message}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[zork: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');
|
||||
},
|
||||
}));
|
||||
// One engine instance per connected socket
|
||||
const sessions = new Map();
|
||||
// Save-game slot maps: socketId → Map<slotNumber, serialisedJson>
|
||||
const saveSlots = new Map();
|
||||
function toLegacyNarrative(turn) {
|
||||
const text = (turn.paragraphs ?? [])
|
||||
.map((p) => String(p?.text ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
return {
|
||||
text,
|
||||
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 zork_llm_engine_1.ZorkLlmEngine();
|
||||
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;
|
||||
}
|
||||
async function handleGameApi(socket, method, args) {
|
||||
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', toLegacyNarrative(turn));
|
||||
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', toLegacyNarrative(turn));
|
||||
socket.emit('gameLoaded', { slot });
|
||||
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 = path_1.default.resolve(process.env.ZORK_STORY_FILE ?? './data/z-code/zork1.bin');
|
||||
const promptDir = path_1.default.resolve('./data/zork-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('[zork] Missing OPENROUTER_API_KEY in environment.');
|
||||
}
|
||||
if (!process.env.OPENROUTER_MODEL) {
|
||||
console.error('[zork] Missing OPENROUTER_MODEL in environment.');
|
||||
}
|
||||
if (!(0, fs_1.existsSync)(storyPath)) {
|
||||
console.error(`[zork] Story file missing: ${storyPath}`);
|
||||
console.error('[zork] Place zork1.bin in ./data/z-code/ or set ZORK_STORY_FILE.');
|
||||
}
|
||||
if (missingPrompts.length > 0) {
|
||||
console.error('[zork] 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(`[zork] Client connected: ${socket.id}`);
|
||||
socket.on('gameApi', async (request, respond) => {
|
||||
try {
|
||||
const result = await handleGameApi(socket, String(request?.method ?? ''), Array.isArray(request?.args) ? request.args : []);
|
||||
debugLog(`gameApi response to ${socket.id}`, result);
|
||||
if (typeof respond === 'function')
|
||||
respond(result);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[zork] 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', toLegacyNarrative(turn));
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[zork] playerCommand error:', error);
|
||||
socket.emit('error', {
|
||||
message: error instanceof Error ? error.message : 'An error occurred.',
|
||||
});
|
||||
}
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`[zork] Client disconnected: ${socket.id}`);
|
||||
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/zork-prompts'),
|
||||
];
|
||||
for (const dir of dirs) {
|
||||
if (!(0, fs_1.existsSync)(dir))
|
||||
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
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.listen(port, () => {
|
||||
console.log(`[zork] Zork Narrator server running on http://localhost:${port}`);
|
||||
resolve();
|
||||
});
|
||||
server.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.log(`Port ${port} in use, trying ${port + 1}…`);
|
||||
server.close();
|
||||
port++;
|
||||
reject();
|
||||
}
|
||||
else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
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('[zork] Failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=server-zork.js.map
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+99
-31
@@ -62,30 +62,103 @@ exports.io = io;
|
||||
const DEFAULT_PORT = 3001;
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
|
||||
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
|
||||
// Serve static files from the public directory
|
||||
app.use(express_1.default.static(path_1.default.join(__dirname, '../public')));
|
||||
// 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');
|
||||
}
|
||||
}));
|
||||
// Set up game sessions
|
||||
const gameSessions = new Map();
|
||||
function normalizeSaveSlot(slot) {
|
||||
const value = Number(slot);
|
||||
return Number.isInteger(value) && value > 0 ? value : 1;
|
||||
}
|
||||
async function startDemoGameForSocket(socket) {
|
||||
const gameRunner = new game_runner_1.GameRunner();
|
||||
const worldFile = process.env.DEFAULT_WORLD_FILE || './data/worlds/example_world.yml';
|
||||
await gameRunner.initialize(worldFile);
|
||||
gameSessions.set(socket.id, gameRunner);
|
||||
const gameState = gameRunner.getGameState();
|
||||
socket.emit('gameIntroduction', {
|
||||
introduction: gameState.world.introduction,
|
||||
initialRoomDescription: gameRunner.getCurrentRoomDescription(),
|
||||
currentRoomId: gameState.currentRoomId
|
||||
});
|
||||
return gameRunner;
|
||||
}
|
||||
async function handleGameApi(socket, method, args = []) {
|
||||
const saveGames = socket.data.saveGames || new Map();
|
||||
socket.data.saveGames = saveGames;
|
||||
switch (method) {
|
||||
case 'newGame':
|
||||
case 'newGame()':
|
||||
await startDemoGameForSocket(socket);
|
||||
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);
|
||||
socket.emit('gameLoaded', { slot });
|
||||
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.data.saveGames = new Map();
|
||||
socket.on('gameApi', async (request, respond) => {
|
||||
try {
|
||||
const response = await handleGameApi(socket, String(request?.method || ''), Array.isArray(request?.args) ? request.args : []);
|
||||
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) });
|
||||
}
|
||||
}
|
||||
});
|
||||
// Start a new game
|
||||
socket.on('startGame', async () => {
|
||||
try {
|
||||
// Initialize game runner
|
||||
const gameRunner = new game_runner_1.GameRunner();
|
||||
const worldFile = process.env.DEFAULT_WORLD_FILE || './data/worlds/example_world.yml';
|
||||
// Initialize the game
|
||||
await gameRunner.initialize(worldFile);
|
||||
// Store game session
|
||||
gameSessions.set(socket.id, gameRunner);
|
||||
// Send introduction to client
|
||||
const gameState = gameRunner.getGameState();
|
||||
socket.emit('gameIntroduction', {
|
||||
introduction: gameState.world.introduction,
|
||||
initialRoomDescription: gameRunner.getCurrentRoomDescription(),
|
||||
currentRoomId: gameState.currentRoomId
|
||||
});
|
||||
await handleGameApi(socket, 'newGame', []);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error starting game:', error);
|
||||
@@ -100,11 +173,11 @@ io.on('connection', (socket) => {
|
||||
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
|
||||
return;
|
||||
}
|
||||
// Process command and get response
|
||||
const response = await gameRunner.processCommand(data.command);
|
||||
// Send narrative response to client
|
||||
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', {
|
||||
text: response,
|
||||
text: command,
|
||||
gameState: {
|
||||
currentRoomId: gameRunner.getGameState().currentRoomId
|
||||
},
|
||||
@@ -124,8 +197,7 @@ io.on('connection', (socket) => {
|
||||
socket.emit('error', { message: 'Game session not found. Please start a new game.' });
|
||||
return;
|
||||
}
|
||||
// Store save data in session
|
||||
socket.data.savedGame = gameRunner.getGameState();
|
||||
socket.data.saveGames.set(1, gameRunner.getGameState());
|
||||
socket.emit('gameSaved');
|
||||
}
|
||||
catch (error) {
|
||||
@@ -134,7 +206,7 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
// Load game state
|
||||
socket.on('loadGame', () => {
|
||||
socket.on('loadGame', async () => {
|
||||
try {
|
||||
const gameRunner = gameSessions.get(socket.id);
|
||||
if (!gameRunner) {
|
||||
@@ -142,17 +214,11 @@ io.on('connection', (socket) => {
|
||||
return;
|
||||
}
|
||||
// Check if there's a saved game
|
||||
if (!socket.data.savedGame) {
|
||||
if (!socket.data.saveGames?.has(1)) {
|
||||
socket.emit('error', { message: 'No saved game found.' });
|
||||
return;
|
||||
}
|
||||
// Load saved game
|
||||
gameRunner.loadGameState(socket.data.savedGame);
|
||||
// Send current state to client
|
||||
socket.emit('gameLoaded', {
|
||||
currentRoomDescription: gameRunner.getCurrentRoomDescription(),
|
||||
currentRoomId: gameRunner.getGameState().currentRoomId
|
||||
});
|
||||
await handleGameApi(socket, 'loadGame', [1]);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error loading game:', error);
|
||||
@@ -175,6 +241,8 @@ function ensureDirectories() {
|
||||
path_1.default.join(__dirname, '../public/js'),
|
||||
path_1.default.join(__dirname, '../public/css'),
|
||||
path_1.default.join(__dirname, '../public/images'),
|
||||
path_1.default.join(__dirname, '../public/music'),
|
||||
path_1.default.join(__dirname, '../public/sounds'),
|
||||
path_1.default.join(__dirname, '../public/fonts')
|
||||
];
|
||||
for (const dir of dirs) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+91
-11
@@ -60,8 +60,17 @@ exports.io = io;
|
||||
const DEFAULT_PORT = 3001;
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
|
||||
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
|
||||
// Serve static files from the public directory
|
||||
app.use(express_1.default.static(path_1.default.join(__dirname, '../public')));
|
||||
// Serve static files from the public directory. Keep browser modules uncached
|
||||
// during local development so fixes are visible without a hard cache clear.
|
||||
app.use(express_1.default.static(path_1.default.join(__dirname, '../public'), {
|
||||
etag: false,
|
||||
lastModified: false,
|
||||
setHeaders: (res) => {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
}
|
||||
}));
|
||||
// Test paragraphs to send to the client
|
||||
const TEST_PARAGRAPHS = [
|
||||
"You stand at the entrance of a mysterious cave. The air is cool and damp, carrying the scent of earth and ancient stone. Shadows dance on the walls as your torch flickers in the gentle breeze.",
|
||||
@@ -72,16 +81,87 @@ const TEST_PARAGRAPHS = [
|
||||
io.on('connection', (socket) => {
|
||||
console.log(`New client connected: ${socket.id}`);
|
||||
let currentParagraphIndex = 0;
|
||||
let gameRunning = false;
|
||||
const saveGames = new Set();
|
||||
const startDemoGame = () => {
|
||||
gameRunning = true;
|
||||
currentParagraphIndex = 0;
|
||||
socket.emit('gameIntroduction', {
|
||||
introduction: "::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.",
|
||||
initialRoomDescription: TEST_PARAGRAPHS[0],
|
||||
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) });
|
||||
}
|
||||
}
|
||||
});
|
||||
// Start a new game
|
||||
socket.on('startGame', async () => {
|
||||
try {
|
||||
console.log('Starting test game session');
|
||||
// Send introduction to client
|
||||
socket.emit('gameIntroduction', {
|
||||
introduction: "Welcome to the Interactive Fiction Test. This is a simplified version that sends predefined paragraphs instead of using an LLM.",
|
||||
initialRoomDescription: TEST_PARAGRAPHS[0],
|
||||
currentRoomId: "test-room"
|
||||
});
|
||||
startDemoGame();
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error starting game:', error);
|
||||
@@ -92,11 +172,9 @@ io.on('connection', (socket) => {
|
||||
socket.on('playerCommand', async (data) => {
|
||||
try {
|
||||
console.log(`Received command: ${data.command}`);
|
||||
// Move to the next paragraph
|
||||
currentParagraphIndex = (currentParagraphIndex + 1) % TEST_PARAGRAPHS.length;
|
||||
// Send narrative response to client
|
||||
socket.emit('narrativeResponse', {
|
||||
text: TEST_PARAGRAPHS[currentParagraphIndex],
|
||||
text: data.command,
|
||||
gameState: {
|
||||
currentRoomId: "test-room"
|
||||
},
|
||||
@@ -120,6 +198,8 @@ function ensureDirectories() {
|
||||
path_1.default.join(__dirname, '../public/js'),
|
||||
path_1.default.join(__dirname, '../public/css'),
|
||||
path_1.default.join(__dirname, '../public/images'),
|
||||
path_1.default.join(__dirname, '../public/music'),
|
||||
path_1.default.join(__dirname, '../public/sounds'),
|
||||
path_1.default.join(__dirname, '../public/fonts')
|
||||
];
|
||||
for (const dir of dirs) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Generated
+129
-17
@@ -14,6 +14,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.1.0",
|
||||
"hyphenopoly": "^6.0.0",
|
||||
"ifvms": "^1.1.6",
|
||||
"js-yaml": "^4.1.0",
|
||||
"kokoro-js": "^1.2.0",
|
||||
"openai": "^4.91.0",
|
||||
@@ -32,6 +33,9 @@
|
||||
"ts-jest": "^29.3.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
@@ -2314,7 +2318,6 @@
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
|
||||
"integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"type-fest": "^0.21.3"
|
||||
@@ -2330,7 +2333,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -2340,7 +2342,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -2707,7 +2708,6 @@
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -3031,6 +3031,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz",
|
||||
@@ -3192,7 +3201,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
@@ -3913,7 +3921,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
@@ -4088,7 +4095,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -4154,6 +4160,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/glkote-term": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/glkote-term/-/glkote-term-0.4.4.tgz",
|
||||
"integrity": "sha512-5l2t4QC9Pr4DgMz/OBGojgaAZJ3p0yf+e8pIYuz63kT0gBaHqsAuASYWQVqSkj60v6nUxKYJRzE0GQucf9PDxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-escapes": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
@@ -4343,6 +4358,89 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ifvms": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/ifvms/-/ifvms-1.1.6.tgz",
|
||||
"integrity": "sha512-4OPV23gHu/YsyqcUuV4oqVBkicz6KsFdwKyMQkaUeN6nvv4maGcYA5qgjDse/iEdvsqSijLHRbx5VuM0zuXEMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"glkote-term": "^0.4.0",
|
||||
"mute-stream": "0.0.8",
|
||||
"yargs": "^15.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"zvm": "bin/zvm.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ifvms/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ifvms/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ifvms/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ifvms/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ifvms/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -4494,7 +4592,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -5396,7 +5493,6 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
@@ -5628,6 +5724,12 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mute-stream": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
|
||||
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
@@ -5968,7 +6070,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
@@ -5981,7 +6082,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
@@ -5997,7 +6097,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -6048,7 +6147,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6358,12 +6456,17 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
@@ -6542,6 +6645,12 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -6983,7 +7092,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -6998,7 +7106,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -7319,7 +7426,6 @@
|
||||
"version": "0.21.3",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
|
||||
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -7501,6 +7607,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
+23
-3
@@ -4,18 +4,37 @@
|
||||
"description": "A modern take on classic text adventures that combines traditional world modeling with Large Language Models (LLMs) to create natural language interactive fiction experiences.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"check:node": "node scripts/check-node-version.js",
|
||||
"prestart": "npm run check:node",
|
||||
"start": "node dist/index.js",
|
||||
"prestart:web": "npm run check:node",
|
||||
"start:web": "node dist/index.js",
|
||||
"prestart:cli": "npm run check:node",
|
||||
"start:cli": "node dist/index.js --cli",
|
||||
"dev": "nodemon --watch 'src/**' --ext 'ts,json' --exec 'ts-node src/index.ts'",
|
||||
"dev:web": "nodemon --watch 'src/**' --ext 'ts,json' --exec 'ts-node src/index.ts'",
|
||||
"dev:cli": "nodemon --watch 'src/**' --ext 'ts,json' --exec 'ts-node src/index.ts --cli'",
|
||||
"predev": "npm run check:node",
|
||||
"dev": "nodemon --watch src --ext ts,json --exec \"ts-node src/index.ts\"",
|
||||
"predev:web": "npm run check:node",
|
||||
"dev:web": "nodemon --watch src --ext ts,json --exec \"ts-node src/index.ts\"",
|
||||
"predev:cli": "npm run check:node",
|
||||
"dev:cli": "nodemon --watch src --ext ts,json --exec \"ts-node src/index.ts --cli\"",
|
||||
"predev:zork": "npm run check:node",
|
||||
"dev:zork": "nodemon --watch src --watch data/zork-prompts --ext ts,json,yml --exec \"ts-node src/server-zork.ts\"",
|
||||
"dev:zork:debug": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; npm run dev:zork\"",
|
||||
"dev:zork:inspect": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; nodemon --watch src --watch data/zork-prompts --ext ts,json,yml --exec \\\"node --inspect=127.0.0.1:9229 -r ts-node/register src/server-zork.ts\\\"\"",
|
||||
"prestart:zork": "npm run check:node && npm run build",
|
||||
"start:zork": "node dist/server-zork.js",
|
||||
"start:zork:debug": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; npm run start:zork\"",
|
||||
"start:zork:inspect": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; node --inspect=127.0.0.1:9229 dist/server-zork.js\"",
|
||||
"pretest-server": "npm run check:node",
|
||||
"test-server": "ts-node src/test-server.ts",
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"lint": "eslint --ext .ts src/",
|
||||
"lint:fix": "eslint --ext .ts src/ --fix"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
@@ -40,6 +59,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.1.0",
|
||||
"hyphenopoly": "^6.0.0",
|
||||
"ifvms": "^1.1.6",
|
||||
"js-yaml": "^4.1.0",
|
||||
"kokoro-js": "^1.2.0",
|
||||
"openai": "^4.91.0",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
speech_cache
|
||||
@@ -0,0 +1,2 @@
|
||||
GET https://api.elevenlabs.io/v1/voices
|
||||
xi-api-key: d191e27c2e5b07573b39fe70f0783f48
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+931
@@ -0,0 +1,931 @@
|
||||
/**
|
||||
* @license Hyphenopoly 5.2.0-beta.1 - client side hyphenation for webbrowsers
|
||||
* ©2023 Mathias Nater, Güttingen (mathiasnater at gmail dot com)
|
||||
* https://github.com/mnater/Hyphenopoly
|
||||
*
|
||||
* Released under the MIT license
|
||||
* http://mnater.github.io/Hyphenopoly/LICENSE
|
||||
*/
|
||||
|
||||
/* globals Hyphenopoly:readonly */
|
||||
((w, o) => {
|
||||
"use strict";
|
||||
const SOFTHYPHEN = "\u00AD";
|
||||
|
||||
/**
|
||||
* Event
|
||||
*/
|
||||
const event = ((H) => {
|
||||
const knownEvents = new Map([
|
||||
["afterElementHyphenation", []],
|
||||
["beforeElementHyphenation", []],
|
||||
["engineReady", []],
|
||||
[
|
||||
"error", [
|
||||
(e) => {
|
||||
if (e.runDefault) {
|
||||
w.console.warn(e);
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
["hyphenopolyEnd", []],
|
||||
["hyphenopolyStart", []]
|
||||
]);
|
||||
if (H.hev) {
|
||||
const userEvents = new Map(o.entries(H.hev));
|
||||
knownEvents.forEach((eventFuncs, eventName) => {
|
||||
if (userEvents.has(eventName)) {
|
||||
eventFuncs.unshift(userEvents.get(eventName));
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
"fire": ((eventName, eventData) => {
|
||||
eventData.runDefault = true;
|
||||
eventData.preventDefault = () => {
|
||||
eventData.runDefault = false;
|
||||
};
|
||||
knownEvents.get(eventName).forEach((eventFn) => {
|
||||
eventFn(eventData);
|
||||
});
|
||||
})
|
||||
};
|
||||
})(Hyphenopoly);
|
||||
|
||||
/**
|
||||
* Register copy event on element
|
||||
* @param {Object} el The element
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function registerOnCopy(el) {
|
||||
el.addEventListener(
|
||||
"copy",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const sel = w.getSelection();
|
||||
const div = document.createElement("div");
|
||||
div.appendChild(sel.getRangeAt(0).cloneContents());
|
||||
e.clipboardData.setData("text/plain", sel.toString().replace(RegExp(SOFTHYPHEN, "g"), ""));
|
||||
e.clipboardData.setData("text/html", div.innerHTML.replace(RegExp(SOFTHYPHEN, "g"), ""));
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert settings from H.setup-Object to Map
|
||||
* This is a IIFE to keep complexity low.
|
||||
*/
|
||||
((H) => {
|
||||
/**
|
||||
* Create a Map with a default Map behind the scenes. This mimics
|
||||
* kind of a prototype chain of an object, but without the object-
|
||||
* injection security risk.
|
||||
*
|
||||
* @param {Map} defaultsMap - A Map with default values
|
||||
* @returns {Proxy} - A Proxy for the Map (dot-notation or get/set)
|
||||
*/
|
||||
function createMapWithDefaults(defaultsMap) {
|
||||
const userMap = new Map();
|
||||
|
||||
/**
|
||||
* The get-trap: get the value from userMap or else from defaults
|
||||
* @param {Sring} key - The key to retrieve the value for
|
||||
* @returns {*}
|
||||
*/
|
||||
function get(key) {
|
||||
return (userMap.has(key))
|
||||
? userMap.get(key)
|
||||
: defaultsMap.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The set-trap: set the value to userMap and don't touch defaults
|
||||
* @param {Sring} key - The key for the value
|
||||
* @param {*} value - The value
|
||||
* @returns {*}
|
||||
*/
|
||||
function set(key, value) {
|
||||
userMap.set(key, value);
|
||||
}
|
||||
return new Proxy(defaultsMap, {
|
||||
"get": (_target, prop) => {
|
||||
if (prop === "set") {
|
||||
return set;
|
||||
}
|
||||
if (prop === "get") {
|
||||
return get;
|
||||
}
|
||||
return get(prop);
|
||||
},
|
||||
"ownKeys": () => {
|
||||
return [
|
||||
...new Set(
|
||||
[...defaultsMap.keys(), ...userMap.keys()]
|
||||
)
|
||||
];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const settings = createMapWithDefaults(new Map([
|
||||
["defaultLanguage", "en-us"],
|
||||
[
|
||||
"dontHyphenate", (() => {
|
||||
const list = "abbr,acronym,audio,br,button,code,img,input,kbd,label,math,option,pre,samp,script,style,sub,sup,svg,textarea,var,video";
|
||||
return createMapWithDefaults(
|
||||
new Map(list.split(",").map((val) => {
|
||||
return [val, true];
|
||||
}))
|
||||
);
|
||||
})()
|
||||
],
|
||||
["dontHyphenateClass", "donthyphenate"],
|
||||
["exceptions", new Map()],
|
||||
["keepAlive", true],
|
||||
["normalize", false],
|
||||
["processShadows", false],
|
||||
["safeCopy", true],
|
||||
["substitute", new Map()],
|
||||
["timeout", 1000]
|
||||
]));
|
||||
o.entries(H.s).forEach(([key, value]) => {
|
||||
switch (key) {
|
||||
case "selectors":
|
||||
// Set settings.selectors to array of selectors
|
||||
settings.set("selectors", o.keys(value));
|
||||
|
||||
/*
|
||||
* For each selector add a property to settings with
|
||||
* selector specific settings
|
||||
*/
|
||||
o.entries(value).forEach(([sel, selSettings]) => {
|
||||
const selectorSettings = createMapWithDefaults(new Map([
|
||||
["compound", "hyphen"],
|
||||
["hyphen", SOFTHYPHEN],
|
||||
["leftmin", 0],
|
||||
["leftminPerLang", 0],
|
||||
["minWordLength", 6],
|
||||
["mixedCase", true],
|
||||
["orphanControl", 1],
|
||||
["rightmin", 0],
|
||||
["rightminPerLang", 0]
|
||||
]));
|
||||
o.entries(selSettings).forEach(
|
||||
([selSetting, setVal]) => {
|
||||
if (typeof setVal === "object") {
|
||||
selectorSettings.set(
|
||||
selSetting,
|
||||
new Map(o.entries(setVal))
|
||||
);
|
||||
} else {
|
||||
selectorSettings.set(selSetting, setVal);
|
||||
}
|
||||
}
|
||||
);
|
||||
settings.set(sel, selectorSettings);
|
||||
});
|
||||
break;
|
||||
case "dontHyphenate":
|
||||
case "exceptions":
|
||||
o.entries(value).forEach(([k, v]) => {
|
||||
settings.get(key).set(k, v);
|
||||
});
|
||||
break;
|
||||
case "substitute":
|
||||
o.entries(value).forEach(([lang, subst]) => {
|
||||
settings.substitute.set(
|
||||
lang,
|
||||
new Map(o.entries(subst))
|
||||
);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
settings.set(key, value);
|
||||
}
|
||||
});
|
||||
H.c = settings;
|
||||
})(Hyphenopoly);
|
||||
|
||||
((H) => {
|
||||
const C = H.c;
|
||||
let mainLanguage = null;
|
||||
|
||||
event.fire(
|
||||
"hyphenopolyStart",
|
||||
{
|
||||
"msg": "hyphenopolyStart"
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Factory for elements
|
||||
* @returns {Object} elements-object
|
||||
*/
|
||||
function makeElementCollection() {
|
||||
const list = new Map();
|
||||
|
||||
/*
|
||||
* Counter counts the elements to be hyphenated.
|
||||
* Needs to be an object (Pass by reference)
|
||||
*/
|
||||
const counter = [0];
|
||||
|
||||
/**
|
||||
* Add element to elements
|
||||
* @param {object} el The element
|
||||
* @param {string} lang The language of the element
|
||||
* @param {string} sel The selector of the element
|
||||
* @returns {Object} An element-object
|
||||
*/
|
||||
function add(el, lang, sel) {
|
||||
const elo = {
|
||||
"element": el,
|
||||
"selector": sel
|
||||
};
|
||||
if (!list.has(lang)) {
|
||||
list.set(lang, []);
|
||||
}
|
||||
list.get(lang).push(elo);
|
||||
counter[0] += 1;
|
||||
return elo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes elements from the list and updates the counter
|
||||
* @param {string} lang - The lang of the elements to remove
|
||||
*/
|
||||
function rem(lang) {
|
||||
let langCount = 0;
|
||||
if (list.has(lang)) {
|
||||
langCount = list.get(lang).length;
|
||||
list.delete(lang);
|
||||
counter[0] -= langCount;
|
||||
if (counter[0] === 0) {
|
||||
event.fire(
|
||||
"hyphenopolyEnd",
|
||||
{
|
||||
"msg": "hyphenopolyEnd"
|
||||
}
|
||||
);
|
||||
if (!C.keepAlive) {
|
||||
window.Hyphenopoly = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
add,
|
||||
counter,
|
||||
list,
|
||||
rem
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language of element by searching its parents or fallback
|
||||
* @param {Object} el The element
|
||||
* @param {string} parentLang Lang of parent if available
|
||||
* @param {boolean} fallback Will falback to mainlanguage
|
||||
* @returns {string|null} The language or null
|
||||
*/
|
||||
function getLang(el, parentLang = "", fallback = true) {
|
||||
// Find closest el with lang attr not empty
|
||||
el = el.closest("[lang]:not([lang=''])");
|
||||
if (el && el.lang) {
|
||||
return el.lang.toLowerCase();
|
||||
}
|
||||
if (parentLang) {
|
||||
return parentLang;
|
||||
}
|
||||
return (fallback)
|
||||
? mainLanguage
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect elements that have a selector defined in C.selectors
|
||||
* and add them to elements.
|
||||
* @param {Object} [parent = null] The start point element
|
||||
* @param {string} [selector = null] The selector matching the parent
|
||||
* @returns {Object} elements-object
|
||||
*/
|
||||
function collectElements(parent = null, selector = null) {
|
||||
const elements = makeElementCollection();
|
||||
|
||||
const dontHyphenateSelector = (() => {
|
||||
let s = "." + C.dontHyphenateClass;
|
||||
o.getOwnPropertyNames(C.dontHyphenate).forEach((tag) => {
|
||||
if (C.dontHyphenate.get(tag)) {
|
||||
s += "," + tag;
|
||||
}
|
||||
});
|
||||
return s;
|
||||
})();
|
||||
const matchingSelectors = C.selectors.join(",") + "," + dontHyphenateSelector;
|
||||
|
||||
/**
|
||||
* Recursively walk all elements in el, lending lang and selName
|
||||
* add them to elements if necessary.
|
||||
* @param {Object} el The element to scan
|
||||
* @param {string} pLang The language of the parent element
|
||||
* @param {string} sel The selector of the parent element
|
||||
* @param {boolean} isChild If el is a child element
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function processElements(el, pLang, sel, isChild = false) {
|
||||
const eLang = getLang(el, pLang);
|
||||
const langDef = H.cf.langs.get(eLang);
|
||||
if (langDef === "H9Y") {
|
||||
elements.add(el, eLang, sel);
|
||||
if (!isChild && C.safeCopy) {
|
||||
registerOnCopy(el);
|
||||
}
|
||||
} else if (!langDef && eLang !== "zxx") {
|
||||
event.fire(
|
||||
"error",
|
||||
Error(`Element with '${eLang}' found, but '${eLang}.wasm' not loaded. Check language tags!`)
|
||||
);
|
||||
}
|
||||
el.childNodes.forEach((n) => {
|
||||
if (n.nodeType === 1 && !n.matches(matchingSelectors)) {
|
||||
processElements(n, eLang, sel, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches the DOM for each sel
|
||||
* @param {object} root The DOM root
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function getElems(root) {
|
||||
C.selectors.forEach((sel) => {
|
||||
root.querySelectorAll(sel).forEach((n) => {
|
||||
processElements(n, getLang(n), sel, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (parent === null) {
|
||||
if (C.processShadows) {
|
||||
w.document.querySelectorAll("*").forEach((m) => {
|
||||
if (m.shadowRoot) {
|
||||
getElems(m.shadowRoot);
|
||||
}
|
||||
});
|
||||
}
|
||||
getElems(w.document);
|
||||
} else {
|
||||
processElements(parent, getLang(parent), selector);
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
const wordHyphenatorPool = new Map();
|
||||
|
||||
/**
|
||||
* Factory for hyphenatorFunctions for a specific language and selector
|
||||
* @param {Object} lo Language-Object
|
||||
* @param {string} lang The language
|
||||
* @param {string} sel The selector
|
||||
* @returns {function} The hyphenate function
|
||||
*/
|
||||
function createWordHyphenator(lo, lang, sel) {
|
||||
const poolKey = lang + "-" + sel;
|
||||
if (wordHyphenatorPool.has(poolKey)) {
|
||||
return wordHyphenatorPool.get(poolKey);
|
||||
}
|
||||
|
||||
const selSettings = C.get(sel);
|
||||
lo.cache.set(sel, new Map());
|
||||
|
||||
/**
|
||||
* HyphenateFunction for non-compound words
|
||||
* @param {string} word The word
|
||||
* @returns {string} The hyphenated word
|
||||
*/
|
||||
function hyphenateNormal(word) {
|
||||
if (word.length > 61) {
|
||||
event.fire(
|
||||
"error",
|
||||
Error("Found word longer than 61 characters")
|
||||
);
|
||||
} else if (!lo.reNotAlphabet.test(word)) {
|
||||
return lo.hyphenate(
|
||||
word,
|
||||
selSettings.hyphen.charCodeAt(0),
|
||||
selSettings.leftminPerLang.get(lang),
|
||||
selSettings.rightminPerLang.get(lang)
|
||||
);
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
/**
|
||||
* HyphenateFunction for compound words
|
||||
* @param {string} word The word
|
||||
* @returns {string} The hyphenated compound word
|
||||
*/
|
||||
function hyphenateCompound(word) {
|
||||
const zeroWidthSpace = "\u200B";
|
||||
let parts = null;
|
||||
let wordHyphenator = null;
|
||||
if (selSettings.compound === "auto" ||
|
||||
selSettings.compound === "all") {
|
||||
wordHyphenator = createWordHyphenator(lo, lang, sel);
|
||||
parts = word.split("-").map((p) => {
|
||||
if (p.length >= selSettings.minWordLength) {
|
||||
return wordHyphenator(p);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
if (selSettings.compound === "auto") {
|
||||
word = parts.join("-");
|
||||
} else {
|
||||
word = parts.join("-" + zeroWidthSpace);
|
||||
}
|
||||
} else {
|
||||
word = word.replace("-", "-" + zeroWidthSpace);
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is mixed case
|
||||
* @param {string} s The string
|
||||
* @returns {boolean} true if s is mixed case
|
||||
*/
|
||||
function isMixedCase(s) {
|
||||
return [...s].map((c) => {
|
||||
return (c === c.toLowerCase());
|
||||
}).some((v, i, a) => {
|
||||
return (v !== a[0]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HyphenateFunction for words (compound or not)
|
||||
* @param {string} word The word
|
||||
* @returns {string} The hyphenated word
|
||||
*/
|
||||
function hyphenator(word) {
|
||||
let hw = lo.cache.get(sel).get(word);
|
||||
if (!hw) {
|
||||
if (lo.exc.has(word)) {
|
||||
hw = lo.exc.get(word).replace(
|
||||
/-/g,
|
||||
selSettings.hyphen
|
||||
);
|
||||
} else if (!selSettings.mixedCase && isMixedCase(word)) {
|
||||
hw = word;
|
||||
} else if (word.indexOf("-") === -1) {
|
||||
hw = hyphenateNormal(word);
|
||||
} else {
|
||||
hw = hyphenateCompound(word);
|
||||
}
|
||||
lo.cache.get(sel).set(word, hw);
|
||||
}
|
||||
return hw;
|
||||
}
|
||||
wordHyphenatorPool.set(poolKey, hyphenator);
|
||||
return hyphenator;
|
||||
}
|
||||
|
||||
const orphanControllerPool = new Map();
|
||||
|
||||
/**
|
||||
* Factory for function that handles orphans
|
||||
* @param {string} sel The selector
|
||||
* @returns {function} The function created
|
||||
*/
|
||||
function createOrphanController(sel) {
|
||||
if (orphanControllerPool.has(sel)) {
|
||||
return orphanControllerPool.get(sel);
|
||||
}
|
||||
const selSettings = C.get(sel);
|
||||
|
||||
/**
|
||||
* Function template
|
||||
* @param {string} ignore unused result of replace
|
||||
* @param {string} leadingWhiteSpace The leading whiteSpace
|
||||
* @param {string} lastWord The last word
|
||||
* @param {string} trailingWhiteSpace The trailing whiteSpace
|
||||
* @returns {string} Treated end of text
|
||||
*/
|
||||
function controlOrphans(
|
||||
ignore,
|
||||
leadingWhiteSpace,
|
||||
lastWord,
|
||||
trailingWhiteSpace
|
||||
) {
|
||||
if (selSettings.orphanControl === 3 && leadingWhiteSpace === " ") {
|
||||
// \u00A0 = no-break space (nbsp)
|
||||
leadingWhiteSpace = "\u00A0";
|
||||
}
|
||||
return leadingWhiteSpace + lastWord.replace(RegExp(selSettings.hyphen, "g"), "") + trailingWhiteSpace;
|
||||
}
|
||||
orphanControllerPool.set(sel, controlOrphans);
|
||||
return controlOrphans;
|
||||
}
|
||||
|
||||
const wordRegExpPool = new Map();
|
||||
|
||||
/**
|
||||
* Hyphenate an entitiy (text string or Element-Object)
|
||||
* @param {string} lang - the language of the string
|
||||
* @param {string} sel - the selectorName of settings
|
||||
* @param {string} entity - the entity to be hyphenated
|
||||
* @returns {string | null} hyphenated str according to setting of sel
|
||||
*/
|
||||
function hyphenate(lang, sel, entity) {
|
||||
const lo = H.languages.get(lang);
|
||||
const selSettings = C.get(sel);
|
||||
const minWordLength = selSettings.minWordLength;
|
||||
|
||||
|
||||
const regExpWord = (() => {
|
||||
const key = lang + minWordLength;
|
||||
if (wordRegExpPool.has(key)) {
|
||||
return wordRegExpPool.get(key);
|
||||
}
|
||||
|
||||
/*
|
||||
* Transpiled RegExp of
|
||||
* /[${alphabet}\p{Mn}Subset\p{Letter}\00AD-]
|
||||
* {${minwordlength},}/gui
|
||||
*/
|
||||
const reWord = RegExp(
|
||||
`[${lo.alphabet}a-z\u0300-\u036F\u0483-\u0487\u00DF-\u00F6\u00F8-\u00FE\u0101\u0103\u0105\u0107\u0109\u010D\u010F\u0111\u0113\u0117\u0119\u011B\u011D\u011F\u0123\u0125\u012B\u012F\u0131\u0135\u0137\u013C\u013E\u0142\u0144\u0146\u0148\u014D\u0151\u0153\u0155\u0159\u015B\u015D\u015F\u0161\u0165\u016B\u016D\u016F\u0171\u0173\u017A\u017C\u017E\u017F\u01CE\u01D0\u01D2\u01D4\u01D6\u01D8\u01DA\u01DC\u0219\u021B\u02BC\u0390\u03AC-\u03CE\u03D0\u03E3\u03E5\u03E7\u03E9\u03EB\u03ED\u03EF\u03F2\u0430-\u044F\u0451-\u045C\u045E\u045F\u0491\u04AF\u04E9\u0561-\u0585\u0587\u0905-\u090C\u090F\u0910\u0913-\u0928\u092A-\u0930\u0932\u0933\u0935-\u0939\u093D\u0960\u0961\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A85-\u0A8B\u0A8F\u0A90\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AE0\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B60\u0B61\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB5\u0BB7-\u0BB9\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D28\u0D2A-\u0D39\u0D60\u0D61\u0D7A-\u0D7F\u0E01-\u0E2E\u0E30\u0E32\u0E33\u0E40-\u0E45\u10D0-\u10F0\u1200-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u1E0D\u1E37\u1E41\u1E43\u1E45\u1E47\u1E6D\u1F00-\u1F07\u1F10-\u1F15\u1F20-\u1F27\u1F30-\u1F37\u1F40-\u1F45\u1F50-\u1F57\u1F60-\u1F67\u1F70-\u1F7D\u1F80-\u1F87\u1F90-\u1F97\u1FA0-\u1FA7\u1FB2-\u1FB4\u1FB6\u1FB7\u1FC2-\u1FC4\u1FC6\u1FC7\u1FD2\u1FD3\u1FD6\u1FD7\u1FE2-\u1FE7\u1FF2-\u1FF4\u1FF6\u1FF7\u2C81\u2C83\u2C85\u2C87\u2C89\u2C8D\u2C8F\u2C91\u2C93\u2C95\u2C97\u2C99\u2C9B\u2C9D\u2C9F\u2CA1\u2CA3\u2CA5\u2CA7\u2CA9\u2CAB\u2CAD\u2CAF\u2CB1\u2CC9\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\u00AD\u200B-\u200D-]{${minWordLength},}`, "gui"
|
||||
);
|
||||
wordRegExpPool.set(key, reWord);
|
||||
return reWord;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Hyphenate text according to setting in sel
|
||||
* @param {string} text - the strint to be hyphenated
|
||||
* @returns {string} hyphenated string according to setting of sel
|
||||
*/
|
||||
function hyphenateText(text) {
|
||||
if (C.normalize) {
|
||||
text = text.normalize("NFC");
|
||||
}
|
||||
let tn = text.replace(
|
||||
regExpWord,
|
||||
createWordHyphenator(lo, lang, sel)
|
||||
);
|
||||
if (selSettings.orphanControl !== 1) {
|
||||
tn = tn.replace(
|
||||
/(\u0020*)(\S+)(\s*)$/,
|
||||
createOrphanController(sel)
|
||||
);
|
||||
}
|
||||
return tn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hyphenate element according to setting in sel
|
||||
* @param {object} el - the HTMLElement to be hyphenated
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function hyphenateElement(el) {
|
||||
event.fire(
|
||||
"beforeElementHyphenation",
|
||||
{
|
||||
el,
|
||||
lang
|
||||
}
|
||||
);
|
||||
el.childNodes.forEach((n) => {
|
||||
if (
|
||||
n.nodeType === 3 &&
|
||||
(/\S/).test(n.data) &&
|
||||
n.data.length >= minWordLength
|
||||
) {
|
||||
n.data = hyphenateText(n.data);
|
||||
}
|
||||
});
|
||||
H.res.els.counter[0] -= 1;
|
||||
event.fire(
|
||||
"afterElementHyphenation",
|
||||
{
|
||||
el,
|
||||
lang
|
||||
}
|
||||
);
|
||||
}
|
||||
let r = null;
|
||||
if (typeof entity === "string") {
|
||||
r = hyphenateText(entity);
|
||||
} else if (entity instanceof HTMLElement) {
|
||||
hyphenateElement(entity);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a language-specific string hyphenator
|
||||
* @param {String} lang - The language this hyphenator hyphenates
|
||||
*/
|
||||
function createStringHyphenator(lang) {
|
||||
return ((entity, sel = ".hyphenate") => {
|
||||
if (typeof entity !== "string") {
|
||||
event.fire(
|
||||
"error",
|
||||
Error("This use of hyphenators is deprecated. See https://mnater.github.io/Hyphenopoly/Hyphenators.html")
|
||||
);
|
||||
}
|
||||
return hyphenate(lang, sel, entity);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a polyglot HTML hyphenator
|
||||
*/
|
||||
function createDOMHyphenator() {
|
||||
return ((entity, sel = ".hyphenate") => {
|
||||
collectElements(entity, sel).list.forEach((els, l) => {
|
||||
els.forEach((elo) => {
|
||||
hyphenate(l, elo.selector, elo.element);
|
||||
});
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
H.unhyphenate = () => {
|
||||
H.res.els.list.forEach((els) => {
|
||||
els.forEach((elo) => {
|
||||
const n = elo.element.firstChild;
|
||||
n.data = n.data.replace(RegExp(C[elo.selector].hyphen, "g"), "");
|
||||
});
|
||||
});
|
||||
return Promise.resolve(H.res.els);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hyphenate all elements with a given language
|
||||
* @param {string} lang The language
|
||||
* @param {Array} elArr Array of elements
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function hyphenateLangElements(lang, elements) {
|
||||
const elArr = elements.list.get(lang);
|
||||
if (elArr) {
|
||||
elArr.forEach((elo) => {
|
||||
hyphenate(lang, elo.selector, elo.element);
|
||||
});
|
||||
} else {
|
||||
event.fire(
|
||||
"error",
|
||||
Error(`Engine for language '${lang}' loaded, but no elements found.`)
|
||||
);
|
||||
}
|
||||
if (elements.counter[0] === 0) {
|
||||
w.clearTimeout(H.timeOutHandler);
|
||||
H.hide(0, null);
|
||||
event.fire(
|
||||
"hyphenopolyEnd",
|
||||
{
|
||||
"msg": "hyphenopolyEnd"
|
||||
}
|
||||
);
|
||||
if (!C.keepAlive) {
|
||||
window.Hyphenopoly = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the exceptions from user input to Map
|
||||
* @param {string} lang - The language for which the Map is created
|
||||
* @return {Map}
|
||||
*/
|
||||
function createExceptionMap(lang) {
|
||||
let exc = "";
|
||||
if (C.exceptions.has(lang)) {
|
||||
exc = C.exceptions.get(lang);
|
||||
}
|
||||
if (C.exceptions.has("global")) {
|
||||
if (exc === "") {
|
||||
exc = C.exceptions.get("global");
|
||||
} else {
|
||||
exc += ", " + C.exceptions.get("global");
|
||||
}
|
||||
}
|
||||
if (exc === "") {
|
||||
return new Map();
|
||||
}
|
||||
return new Map(exc.split(", ").map((e) => {
|
||||
return [e.replace(/-/g, ""), e];
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup lo
|
||||
* @param {string} lang The language
|
||||
* @param {function} hyphenateFunction The hyphenateFunction
|
||||
* @param {string} alphabet List of used characters
|
||||
* @param {number} leftmin leftmin
|
||||
* @param {number} rightmin rightmin
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function prepareLanguagesObj(
|
||||
lang,
|
||||
hyphenateFunction,
|
||||
alphabet,
|
||||
patternLeftmin,
|
||||
patternRightmin
|
||||
) {
|
||||
C.selectors.forEach((sel) => {
|
||||
const selSettings = C.get(sel);
|
||||
if (selSettings.leftminPerLang === 0) {
|
||||
selSettings.set("leftminPerLang", new Map());
|
||||
}
|
||||
if (selSettings.rightminPerLang === 0) {
|
||||
selSettings.set("rightminPerLang", new Map());
|
||||
}
|
||||
selSettings.leftminPerLang.set(lang, Math.max(
|
||||
patternLeftmin,
|
||||
selSettings.leftmin,
|
||||
Number(selSettings.leftminPerLang.get(lang)) || 0
|
||||
));
|
||||
|
||||
selSettings.rightminPerLang.set(lang, Math.max(
|
||||
patternRightmin,
|
||||
selSettings.rightmin,
|
||||
Number(selSettings.rightminPerLang.get(lang)) || 0
|
||||
));
|
||||
});
|
||||
if (!H.languages) {
|
||||
H.languages = new Map();
|
||||
}
|
||||
alphabet = alphabet.replace(/\\*-/g, "\\-");
|
||||
H.languages.set(lang, {
|
||||
alphabet,
|
||||
"cache": new Map(),
|
||||
"exc": createExceptionMap(lang),
|
||||
"hyphenate": hyphenateFunction,
|
||||
"ready": true,
|
||||
"reNotAlphabet": RegExp(`[^${alphabet}]`, "i")
|
||||
});
|
||||
H.hy6ors.get(lang).resolve(createStringHyphenator(lang));
|
||||
event.fire(
|
||||
"engineReady",
|
||||
{
|
||||
lang
|
||||
}
|
||||
);
|
||||
if (H.res.els) {
|
||||
hyphenateLangElements(lang, H.res.els);
|
||||
}
|
||||
}
|
||||
|
||||
const decode = (() => {
|
||||
const utf16ledecoder = new TextDecoder("utf-16le");
|
||||
return ((ui16) => {
|
||||
return utf16ledecoder.decode(ui16);
|
||||
});
|
||||
})();
|
||||
|
||||
/**
|
||||
* Setup env for hyphenateFunction
|
||||
* @param {ArrayBuffer} buf Memory buffer
|
||||
* @param {function} hyphenateFunc hyphenateFunction
|
||||
* @returns {function} hyphenateFunction with closured environment
|
||||
*/
|
||||
function encloseHyphenateFunction(buf, hyphenateFunc) {
|
||||
const wordStore = new Uint16Array(buf, 0, 64);
|
||||
return ((word, hyphencc, leftmin, rightmin) => {
|
||||
wordStore.set([
|
||||
...[...word].map((c) => {
|
||||
return c.charCodeAt(0);
|
||||
}),
|
||||
0
|
||||
]);
|
||||
const len = hyphenateFunc(leftmin, rightmin, hyphencc);
|
||||
if (len > 0) {
|
||||
word = decode(
|
||||
new Uint16Array(buf, 0, len)
|
||||
);
|
||||
}
|
||||
return word;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate Wasm Engine
|
||||
* @param {string} lang The language
|
||||
* @returns {undefined}
|
||||
*/
|
||||
function instantiateWasmEngine(heProm, lang) {
|
||||
const wa = window.WebAssembly;
|
||||
|
||||
/**
|
||||
* Register character substitutions in the .wasm-hyphenEngine
|
||||
* @param {number} alphalen - The length of the alphabet
|
||||
* @param {object} exp - Export-object of the hyphenEngine
|
||||
*/
|
||||
function registerSubstitutions(alphalen, exp) {
|
||||
if (C.substitute.has(lang)) {
|
||||
const subst = C.substitute.get(lang);
|
||||
subst.forEach((substituer, substituted) => {
|
||||
const substitutedU = substituted.toUpperCase();
|
||||
const substitutedUcc = (substitutedU === substituted)
|
||||
? 0
|
||||
: substitutedU.charCodeAt(0);
|
||||
alphalen = exp.subst(
|
||||
substituted.charCodeAt(0),
|
||||
substitutedUcc,
|
||||
substituer.charCodeAt(0)
|
||||
);
|
||||
});
|
||||
}
|
||||
return alphalen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate the hyphenEngine
|
||||
* @param {object} res - The fetched ressource
|
||||
*/
|
||||
function handleWasm(res) {
|
||||
const exp = res.instance.exports;
|
||||
// eslint-disable-next-line multiline-ternary
|
||||
let alphalen = (wa.Global) ? exp.lct.value : exp.lct;
|
||||
alphalen = registerSubstitutions(alphalen, exp);
|
||||
heProm.l.forEach((l) => {
|
||||
prepareLanguagesObj(
|
||||
l,
|
||||
encloseHyphenateFunction(
|
||||
exp.mem.buffer,
|
||||
exp.hyphenate
|
||||
),
|
||||
decode(new Uint16Array(exp.mem.buffer, 1408, alphalen)),
|
||||
/* eslint-disable multiline-ternary */
|
||||
(wa.Global) ? exp.lmi.value : exp.lmi,
|
||||
(wa.Global) ? exp.rmi.value : exp.rmi
|
||||
/* eslint-enable multiline-ternary */
|
||||
);
|
||||
});
|
||||
}
|
||||
heProm.w.then((response) => {
|
||||
if (response.ok) {
|
||||
if (
|
||||
wa.instantiateStreaming &&
|
||||
(response.headers.get("Content-Type") === "application/wasm")
|
||||
) {
|
||||
return wa.instantiateStreaming(response);
|
||||
}
|
||||
return response.arrayBuffer().then((ab) => {
|
||||
return wa.instantiate(ab);
|
||||
});
|
||||
}
|
||||
return Promise.reject(Error(`File ${lang}.wasm can't be loaded from ${H.paths.patterndir}`));
|
||||
}).then(handleWasm, (e) => {
|
||||
event.fire("error", e);
|
||||
H.res.els.rem(lang);
|
||||
});
|
||||
}
|
||||
|
||||
H.main = () => {
|
||||
H.res.DOM.then(() => {
|
||||
mainLanguage = getLang(w.document.documentElement, "", false);
|
||||
if (!mainLanguage && C.defaultLanguage !== "") {
|
||||
mainLanguage = C.defaultLanguage;
|
||||
}
|
||||
const elements = collectElements();
|
||||
H.res.els = elements;
|
||||
elements.list.forEach((ignore, lang) => {
|
||||
if (H.languages &&
|
||||
H.languages.has(lang) &&
|
||||
H.languages.get(lang).ready
|
||||
) {
|
||||
hyphenateLangElements(lang, elements);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
H.res.he.forEach(instantiateWasmEngine);
|
||||
|
||||
Promise.all(
|
||||
// Make sure all lang specific hyphenators and DOM are ready
|
||||
[...H.hy6ors.entries()].
|
||||
reduce((accumulator, value) => {
|
||||
if (value[0] !== "HTML") {
|
||||
return accumulator.concat(value[1]);
|
||||
}
|
||||
return accumulator;
|
||||
}, []).
|
||||
concat(H.res.DOM)
|
||||
).then(() => {
|
||||
H.hy6ors.get("HTML").resolve(createDOMHyphenator());
|
||||
}, (e) => {
|
||||
event.fire("error", e);
|
||||
});
|
||||
};
|
||||
H.main();
|
||||
})(Hyphenopoly);
|
||||
})(window, Object);
|
||||
Vendored
+347
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* @license Hyphenopoly_Loader 5.2.0-beta.1 - client side hyphenation
|
||||
* ©2023 Mathias Nater, Güttingen (mathiasnater at gmail dot com)
|
||||
* https://github.com/mnater/Hyphenopoly
|
||||
*
|
||||
* Released under the MIT license
|
||||
* http://mnater.github.io/Hyphenopoly/LICENSE
|
||||
*/
|
||||
/* globals Hyphenopoly:readonly */
|
||||
window.Hyphenopoly = {};
|
||||
|
||||
((w, d, H, o) => {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Shortcut for new Map
|
||||
* @param {any} init - initialiser for new Map
|
||||
* @returns {Map}
|
||||
*/
|
||||
const mp = (init) => {
|
||||
return new Map(init);
|
||||
};
|
||||
|
||||
const scriptName = "Hyphenopoly_Loader.js";
|
||||
const thisScript = d.currentScript.src;
|
||||
const store = sessionStorage;
|
||||
let mainScriptLoaded = false;
|
||||
|
||||
/**
|
||||
* The main function runs the feature test and loads Hyphenopoly if
|
||||
* necessary.
|
||||
*/
|
||||
const main = (() => {
|
||||
const shortcuts = {
|
||||
"ac": "appendChild",
|
||||
"ce": "createElement",
|
||||
"ct": "createTextNode"
|
||||
};
|
||||
|
||||
/**
|
||||
* Create deferred Promise
|
||||
*
|
||||
* From http://lea.verou.me/2016/12/resolve-promises-externally-with-
|
||||
* this-one-weird-trick/
|
||||
* @return {promise}
|
||||
*/
|
||||
const defProm = () => {
|
||||
let res = null;
|
||||
let rej = null;
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
res = resolve;
|
||||
rej = reject;
|
||||
});
|
||||
promise.resolve = res;
|
||||
promise.reject = rej;
|
||||
return promise;
|
||||
};
|
||||
|
||||
H.ac = new AbortController();
|
||||
const fetchOptions = {
|
||||
"credentials": H.s.CORScredentials,
|
||||
"signal": H.ac.signal
|
||||
};
|
||||
|
||||
let stylesNode = null;
|
||||
|
||||
/**
|
||||
* Define function H.hide.
|
||||
* This function hides (state = 1) or unhides (state = 0)
|
||||
* the whole document (mode == 0) or
|
||||
* each selected element (mode == 1) or
|
||||
* text of each selected element (mode == 2) or
|
||||
* nothing (mode == -1)
|
||||
* @param {integer} state - State
|
||||
* @param {integer} mode - Mode
|
||||
*/
|
||||
H.hide = (state, mode) => {
|
||||
if (state) {
|
||||
let vis = "{visibility:hidden!important}";
|
||||
stylesNode = d[shortcuts.ce]("style");
|
||||
let myStyle = "";
|
||||
if (mode === 0) {
|
||||
myStyle = "html" + vis;
|
||||
} else if (mode !== -1) {
|
||||
if (mode === 2) {
|
||||
vis = "{color:transparent!important}";
|
||||
}
|
||||
o.keys(H.s.selectors).forEach((sel) => {
|
||||
myStyle += sel + vis;
|
||||
});
|
||||
}
|
||||
stylesNode[shortcuts.ac](d[shortcuts.ct](myStyle));
|
||||
d.head[shortcuts.ac](stylesNode);
|
||||
} else if (stylesNode) {
|
||||
stylesNode.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const tester = (() => {
|
||||
let fakeBody = null;
|
||||
return {
|
||||
|
||||
/**
|
||||
* Append fakeBody with tests to document
|
||||
* @returns {Object|null} The body element or null, if no tests
|
||||
*/
|
||||
"ap": () => {
|
||||
if (fakeBody) {
|
||||
d.documentElement[shortcuts.ac](fakeBody);
|
||||
return fakeBody;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove fakeBody
|
||||
* @returns {undefined}
|
||||
*/
|
||||
"cl": () => {
|
||||
if (fakeBody) {
|
||||
fakeBody.remove();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create and append div with CSS-hyphenated word
|
||||
* @param {string} lang Language
|
||||
* @returns {undefined}
|
||||
*/
|
||||
"cr": (lang) => {
|
||||
if (H.cf.langs.has(lang)) {
|
||||
return;
|
||||
}
|
||||
fakeBody = fakeBody || d[shortcuts.ce]("body");
|
||||
const testDiv = d[shortcuts.ce]("div");
|
||||
const ha = "hyphens:auto";
|
||||
testDiv.lang = lang;
|
||||
testDiv.style.cssText = `visibility:hidden;-webkit-${ha};-ms-${ha};${ha};width:48px;font-size:12px;line-height:12px;border:none;padding:0;word-wrap:normal`;
|
||||
testDiv[shortcuts.ac](
|
||||
d[shortcuts.ct](H.lrq.get(lang).wo.toLowerCase())
|
||||
);
|
||||
fakeBody[shortcuts.ac](testDiv);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
/**
|
||||
* Checks if hyphens (ev.prefixed) is set to auto for the element.
|
||||
* @param {Object} elm - the element
|
||||
* @returns {Boolean} result of the check
|
||||
*/
|
||||
const checkCSSHyphensSupport = (elmStyle) => {
|
||||
const h = elmStyle.hyphens ||
|
||||
elmStyle.webkitHyphens ||
|
||||
elmStyle.msHyphens;
|
||||
return (h === "auto");
|
||||
};
|
||||
|
||||
H.res = {
|
||||
"he": mp()
|
||||
};
|
||||
|
||||
/**
|
||||
* Load hyphenEngines to H.res.he
|
||||
*
|
||||
* Make sure each .wasm is loaded exactly once, even for fallbacks
|
||||
* Store a list of languages to by hyphenated with each .wasm
|
||||
* @param {string} lang The language
|
||||
* @returns {undefined}
|
||||
*/
|
||||
const loadhyphenEngine = (lang) => {
|
||||
const fn = H.lrq.get(lang).fn;
|
||||
H.cf.pf = true;
|
||||
H.cf.langs.set(lang, "H9Y");
|
||||
if (H.res.he.has(fn)) {
|
||||
H.res.he.get(fn).l.push(lang);
|
||||
} else {
|
||||
H.res.he.set(
|
||||
fn,
|
||||
{
|
||||
"l": [lang],
|
||||
"w": w.fetch(H.paths.patterndir + fn + ".wasm", fetchOptions)
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
H.lrq.forEach((value, lang) => {
|
||||
if (value.wo === "FORCEHYPHENOPOLY" || H.cf.langs.get(lang) === "H9Y") {
|
||||
loadhyphenEngine(lang);
|
||||
} else {
|
||||
tester.cr(lang);
|
||||
}
|
||||
});
|
||||
const testContainer = tester.ap();
|
||||
if (testContainer) {
|
||||
testContainer.querySelectorAll("div").forEach((n) => {
|
||||
if (checkCSSHyphensSupport(n.style) && n.offsetHeight > 12) {
|
||||
H.cf.langs.set(n.lang, "CSS");
|
||||
} else {
|
||||
loadhyphenEngine(n.lang);
|
||||
}
|
||||
});
|
||||
tester.cl();
|
||||
}
|
||||
const hev = H.hev;
|
||||
if (H.cf.pf) {
|
||||
H.res.DOM = new Promise((res) => {
|
||||
if (d.readyState === "loading") {
|
||||
d.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
res,
|
||||
{
|
||||
"once": true,
|
||||
"passive": true
|
||||
}
|
||||
);
|
||||
} else {
|
||||
res();
|
||||
}
|
||||
});
|
||||
H.hide(1, H.s.hide);
|
||||
H.timeOutHandler = w.setTimeout(() => {
|
||||
H.hide(0, null);
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if (H.s.timeout & 1) {
|
||||
H.ac.abort();
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(scriptName + " timed out.");
|
||||
}, H.s.timeout);
|
||||
if (mainScriptLoaded) {
|
||||
H.main();
|
||||
} else {
|
||||
// Load main script
|
||||
fetch(H.paths.maindir + "Hyphenopoly.js", fetchOptions).
|
||||
then((response) => {
|
||||
if (response.ok) {
|
||||
response.blob().then((blb) => {
|
||||
const script = d[shortcuts.ce]("script");
|
||||
script.src = URL.createObjectURL(blb);
|
||||
d.head[shortcuts.ac](script);
|
||||
mainScriptLoaded = true;
|
||||
URL.revokeObjectURL(script.src);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
H.hy6ors = mp();
|
||||
H.cf.langs.forEach((langDef, lang) => {
|
||||
if (langDef === "H9Y") {
|
||||
H.hy6ors.set(lang, defProm());
|
||||
}
|
||||
});
|
||||
H.hy6ors.set("HTML", defProm());
|
||||
H.hyphenators = new Proxy(H.hy6ors, {
|
||||
"get": (target, key) => {
|
||||
return target.get(key);
|
||||
},
|
||||
"set": () => {
|
||||
// Inhibit setting of hyphenators
|
||||
return true;
|
||||
}
|
||||
});
|
||||
(() => {
|
||||
if (hev && hev.polyfill) {
|
||||
hev.polyfill();
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
(() => {
|
||||
if (hev && hev.tearDown) {
|
||||
hev.tearDown();
|
||||
}
|
||||
w.Hyphenopoly = null;
|
||||
})();
|
||||
}
|
||||
(() => {
|
||||
if (H.cft) {
|
||||
store.setItem(scriptName, JSON.stringify(
|
||||
{
|
||||
"langs": [...H.cf.langs.entries()],
|
||||
"pf": H.cf.pf
|
||||
}
|
||||
));
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
H.config = (c) => {
|
||||
/**
|
||||
* Sets default properties for an Object
|
||||
* @param {object} obj - The object to set defaults to
|
||||
* @param {object} defaults - The defaults to set
|
||||
* @returns {object}
|
||||
*/
|
||||
const setDefaults = (obj, defaults) => {
|
||||
if (obj) {
|
||||
o.entries(defaults).forEach(([k, v]) => {
|
||||
// eslint-disable-next-line security/detect-object-injection
|
||||
obj[k] = obj[k] || v;
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
return defaults;
|
||||
};
|
||||
|
||||
H.cft = Boolean(c.cacheFeatureTests);
|
||||
if (H.cft && store.getItem(scriptName)) {
|
||||
H.cf = JSON.parse(store.getItem(scriptName));
|
||||
H.cf.langs = mp(H.cf.langs);
|
||||
} else {
|
||||
H.cf = {
|
||||
"langs": mp(),
|
||||
"pf": false
|
||||
};
|
||||
}
|
||||
|
||||
const maindir = thisScript.slice(0, (thisScript.lastIndexOf("/") + 1));
|
||||
const patterndir = maindir + "patterns/";
|
||||
H.paths = setDefaults(c.paths, {
|
||||
maindir,
|
||||
patterndir
|
||||
});
|
||||
H.s = setDefaults(c.setup, {
|
||||
"CORScredentials": "include",
|
||||
"hide": "all",
|
||||
"selectors": {".hyphenate": {}},
|
||||
"timeout": 1000
|
||||
});
|
||||
// Change mode string to mode int
|
||||
H.s.hide = ["all", "element", "text"].indexOf(H.s.hide);
|
||||
if (c.handleEvent) {
|
||||
H.hev = c.handleEvent;
|
||||
}
|
||||
|
||||
const fallbacks = mp(o.entries(c.fallbacks || {}));
|
||||
H.lrq = mp();
|
||||
o.entries(c.require).forEach(([lang, wo]) => {
|
||||
H.lrq.set(lang.toLowerCase(), {
|
||||
"fn": fallbacks.get(lang) || lang,
|
||||
wo
|
||||
});
|
||||
});
|
||||
|
||||
main();
|
||||
};
|
||||
})(window, document, Hyphenopoly, Object);
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
CC0 1.0 Universal
|
||||
==================
|
||||
|
||||
Statement of Purpose
|
||||
---------------------
|
||||
|
||||
The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work").
|
||||
|
||||
Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others.
|
||||
|
||||
For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights.
|
||||
|
||||
1. Copyright and Related Rights.
|
||||
--------------------------------
|
||||
A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following:
|
||||
|
||||
i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work;
|
||||
ii. moral rights retained by the original author(s) and/or performer(s);
|
||||
iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work;
|
||||
iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below;
|
||||
v. rights protecting the extraction, dissemination, use and reuse of data in a Work;
|
||||
vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and
|
||||
vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof.
|
||||
|
||||
2. Waiver.
|
||||
-----------
|
||||
To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose.
|
||||
|
||||
3. Public License Fallback.
|
||||
----------------------------
|
||||
Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose.
|
||||
|
||||
4. Limitations and Disclaimers.
|
||||
--------------------------------
|
||||
|
||||
a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document.
|
||||
b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law.
|
||||
c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work.
|
||||
d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,40 @@
|
||||
# electron-quick-start
|
||||
|
||||
**Clone and run for a quick way to see Electron in action.**
|
||||
|
||||
This is a minimal Electron application based on the [Quick Start Guide](https://electronjs.org/docs/latest/tutorial/quick-start) within the Electron documentation.
|
||||
|
||||
A basic Electron application needs just these files:
|
||||
|
||||
- `package.json` - Points to the app's main file and lists its details and dependencies.
|
||||
- `main.js` - Starts the app and creates a browser window to render HTML. This is the app's **main process**.
|
||||
- `index.html` - A web page to render. This is the app's **renderer process**.
|
||||
- `preload.js` - A content script that runs before the renderer process loads.
|
||||
|
||||
You can learn more about each of these components in depth within the [Tutorial](https://electronjs.org/docs/latest/tutorial/tutorial-prerequisites).
|
||||
|
||||
## To Use
|
||||
|
||||
To clone and run this repository you'll need [Git](https://git-scm.com) and [Node.js](https://nodejs.org/en/download/) (which comes with [npm](http://npmjs.com)) installed on your computer. From your command line:
|
||||
|
||||
```bash
|
||||
# Clone this repository
|
||||
git clone https://github.com/electron/electron-quick-start
|
||||
# Go into the repository
|
||||
cd electron-quick-start
|
||||
# Install dependencies
|
||||
npm install
|
||||
# Run the app
|
||||
npm start
|
||||
```
|
||||
|
||||
Note: If you're using Linux Bash for Windows, [see this guide](https://www.howtogeek.com/261575/how-to-run-graphical-linux-desktop-applications-from-windows-10s-bash-shell/) or use `node` from the command prompt.
|
||||
|
||||
## Resources for Learning Electron
|
||||
|
||||
- [electronjs.org/docs](https://electronjs.org/docs) - all of Electron's documentation
|
||||
- [Electron Fiddle](https://electronjs.org/fiddle) - Electron Fiddle, an app to test small Electron experiments
|
||||
|
||||
## License
|
||||
|
||||
[CC0 1.0 (Public Domain)](LICENSE.md)
|
||||
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
@@ -0,0 +1,16 @@
|
||||
# title: Das Herrenhaus
|
||||
# author: Georg Tomitsch
|
||||
|
||||
VAR location = 0
|
||||
|
||||
INCLUDE Stats.ink
|
||||
|
||||
-> Herrenhaus
|
||||
|
||||
=== Herrenhaus ===
|
||||
~ location = Herrenhaus
|
||||
Du stehst vor einem Herrenhaus.
|
||||
* Aktion 1 # 1
|
||||
* Aktion 2 # 2
|
||||
* Aktion 3 # 3
|
||||
- -> END
|
||||
@@ -0,0 +1,79 @@
|
||||
// Quest tracking functions as described by Jon Ingold in his GDC 2017 talk.
|
||||
|
||||
// Every state implies the states before.
|
||||
|
||||
=== function state_reached(state) ===
|
||||
~ return
|
||||
|
||||
// The state is never rolled back. If a lower or the same state is moved to nothing happens.
|
||||
=== function move_to_state(state) ===
|
||||
~ return
|
||||
|
||||
// Often the state is checked as a range (excluding first and last state)
|
||||
=== function state_between(first_state, last_state) ===
|
||||
~ return
|
||||
|
||||
// Implementation of ChoiceScript's Fairmath system
|
||||
|
||||
// Adjust the variable by adding amount percent of the current value
|
||||
=== function set(ref variable, amount) ===
|
||||
~ variable = MIN(100, variable + variable * amount / 100)
|
||||
~ return variable
|
||||
|
||||
// Implementation of relative opposed pair stats
|
||||
=== function opposed(positive, negative) ===
|
||||
~ return positive / negative * 100 // TODO Check if this calculation is correct
|
||||
|
||||
|
||||
// Inkle's default number writing function
|
||||
=== function print_num(x)
|
||||
{
|
||||
- x >= 1000:
|
||||
{print_num(x / 1000)} thousand { x mod 1000 > 0:{print_num(x mod 1000)}}
|
||||
- x >= 100:
|
||||
{print_num(x / 100)} hundred { x mod 100 > 0:and {print_num(x mod 100)}}
|
||||
- x == 0:
|
||||
zero
|
||||
- else:
|
||||
{ x >= 20:
|
||||
{ x / 10:
|
||||
- 2: twenty
|
||||
- 3: thirty
|
||||
- 4: forty
|
||||
- 5: fifty
|
||||
- 6: sixty
|
||||
- 7: seventy
|
||||
- 8: eighty
|
||||
- 9: ninety
|
||||
}
|
||||
{ x mod 10 > 0:
|
||||
<>-<>
|
||||
}
|
||||
}
|
||||
{ x < 10 || x > 20:
|
||||
{ x mod 10:
|
||||
- 1: one
|
||||
- 2: two
|
||||
- 3: three
|
||||
- 4: four
|
||||
- 5: five
|
||||
- 6: six
|
||||
- 7: seven
|
||||
- 8: eight
|
||||
- 9: nine
|
||||
}
|
||||
- else:
|
||||
{ x:
|
||||
- 10: ten
|
||||
- 11: eleven
|
||||
- 12: twelve
|
||||
- 13: thirteen
|
||||
- 14: fourteen
|
||||
- 15: fifteen
|
||||
- 16: sixteen
|
||||
- 17: seventeen
|
||||
- 18: eighteen
|
||||
- 19: nineteen
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+566
@@ -0,0 +1,566 @@
|
||||
(function(storyContent) {
|
||||
// Create ink story from the content using inkjs
|
||||
var story = new inkjs.Story(storyContent);
|
||||
|
||||
var savePoint = "";
|
||||
|
||||
let fade_in = true;
|
||||
|
||||
// Global tags - those at the top of the ink file
|
||||
// We support:
|
||||
// # theme: dark
|
||||
// # author: Your Name
|
||||
var globalTags = story.globalTags;
|
||||
if( globalTags ) {
|
||||
for(var i=0; i<story.globalTags.length; i++) {
|
||||
var globalTag = story.globalTags[i];
|
||||
var splitTag = splitPropertyTag(globalTag);
|
||||
|
||||
// THEME: dark
|
||||
if( splitTag && splitTag.property == "title" ) {
|
||||
var title = document.querySelector('.title');
|
||||
title.innerHTML = splitTag.val;
|
||||
}
|
||||
|
||||
// author: Your Name
|
||||
else if( splitTag && splitTag.property == "author" ) {
|
||||
var byline = document.querySelector('.byline');
|
||||
byline.innerHTML = "by "+splitTag.val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var storyContainer = document.querySelector('#story');
|
||||
var choiceContainer = document.querySelector('#choices');
|
||||
var outerScrollContainer = document.querySelector('#book');
|
||||
|
||||
function updateParagraphPreview(paragraph_data, indent_width, preview_width) {
|
||||
var old_preview = document.getElementById("preview");
|
||||
var preview = document.createElement("div");
|
||||
preview.id = "preview";
|
||||
preview.style.width = preview_width + 'px';
|
||||
if(old_preview) {
|
||||
old_preview.replaceWith(preview);
|
||||
// preview = old_preview;
|
||||
} else {
|
||||
document.body.appendChild(preview);
|
||||
}
|
||||
// p = typesetParagraph(paragraph_data, indent_width);
|
||||
// preview.appendChild(p);
|
||||
}
|
||||
|
||||
let timeoutQueue = [];
|
||||
|
||||
function scheduleTimeout(func, delay, ...args) {
|
||||
const timeoutObject = {
|
||||
execute: () => func(...args),
|
||||
timeoutId: null
|
||||
};
|
||||
|
||||
timeoutObject.timeoutId = setTimeout(() => {
|
||||
timeoutObject.execute();
|
||||
timeoutQueue = timeoutQueue.filter(t => t !== timeoutObject);
|
||||
}, delay);
|
||||
|
||||
timeoutQueue.push(timeoutObject);
|
||||
|
||||
return timeoutObject.timeoutId;
|
||||
}
|
||||
|
||||
function fastForward() {
|
||||
// Sort the queue based on timeoutId (assuming that smaller ids are scheduled earlier)
|
||||
timeoutQueue.sort((a, b) => a.timeoutId - b.timeoutId);
|
||||
// Clear and execute all timeouts
|
||||
timeoutQueue.forEach(timeoutObject => {
|
||||
clearTimeout(timeoutObject.timeoutId);
|
||||
timeoutObject.execute();
|
||||
});
|
||||
|
||||
timeoutQueue = [];
|
||||
document.getElementById("page_right").scrollTo({top: document.getElementById("page_right").scrollHeight, behavior: 'smooth'});
|
||||
}
|
||||
|
||||
// var numberOfPreviewLines = 0;
|
||||
|
||||
function typesetParagraph(paragraph_data, indent_width, delay = 0) {
|
||||
console.log("Typesetting Paragraph with: ", paragraph_data, indent_width);
|
||||
var left = indent_width;
|
||||
var p = document.createElement("p");
|
||||
p.style.position = 'relative';
|
||||
var line_height = parseFloat(window.getComputedStyle(document.querySelector("#ruler")).lineHeight);
|
||||
// numberOfPreviewLines += paragraph_data.breaks.length - 1;
|
||||
// console.log("Calculated line height:", line_height);
|
||||
p.style.height = line_height * (paragraph_data.breaks.length - 1) + 'px';
|
||||
p.style.marginBlockEnd = 0;
|
||||
for(let i = 1; i < paragraph_data.breaks.length; i++) {
|
||||
if(i > 1)
|
||||
left = 0;
|
||||
for(let j = paragraph_data.breaks[i-1].position; j <= paragraph_data.breaks[i].position; j++) {
|
||||
// console.log("i =",i,"j =",j,"from =",paragraph_data.breaks[i-1].position,"to =",paragraph_data.breaks[i].position,"node_width =", paragraph_data.nodes[j].width, "left =", left, "type =", paragraph_data.nodes[j].type, "value =", paragraph_data.nodes[j].value);
|
||||
if(paragraph_data.nodes[j].type === 'box' && paragraph_data.nodes[j].value !== '' && j < paragraph_data.breaks[i].position) {
|
||||
if(j > paragraph_data.breaks[i-1].position + 1 && paragraph_data.nodes[j-1].type === 'penalty' && p.lastChild) {
|
||||
p.lastChild.textContent += paragraph_data.nodes[j].value;
|
||||
left += paragraph_data.nodes[j].width;
|
||||
} else {
|
||||
let word = document.createElement("span");
|
||||
word.style.position = 'absolute';
|
||||
word.classList.add("fade-in");
|
||||
word.style.top = line_height * (i - 1) + 'px';
|
||||
word.style.left = left + 'px';
|
||||
word.innerHTML = paragraph_data.nodes[j].value;
|
||||
insertAfter(delay, p, word);
|
||||
delay += 100.0;
|
||||
// p.appendChild(word);
|
||||
if(j > 0)
|
||||
left += paragraph_data.nodes[j].width;
|
||||
else
|
||||
left += paragraph_data.nodes[j].width - indent_width;
|
||||
}
|
||||
} else if(j > paragraph_data.breaks[i-1].position && paragraph_data.nodes[j].type === 'glue' && paragraph_data.nodes[j].width !== 0 && j <= paragraph_data.breaks[i].position) {
|
||||
// Insert space character
|
||||
if(paragraph_data.breaks[i].ratio > 0) {
|
||||
left += paragraph_data.nodes[j].width + paragraph_data.breaks[i].ratio * paragraph_data.nodes[j].stretch;
|
||||
} else {
|
||||
left += paragraph_data.nodes[j].width + paragraph_data.breaks[i].ratio * paragraph_data.nodes[j].shrink;
|
||||
}
|
||||
} else if(paragraph_data.nodes[j].type === 'penalty' && paragraph_data.nodes[j].penalty === 100 && j === paragraph_data.breaks[i].position) {
|
||||
let word = document.createElement("span");
|
||||
word.style.position = 'absolute';
|
||||
word.style.top = line_height * (i - 1) + 'px';
|
||||
word.style.left = left + 'px';
|
||||
word.innerHTML = "-";
|
||||
insertAfter(delay, p, word);
|
||||
delay += 100;
|
||||
// p.appendChild(word);
|
||||
// left += paragraph_data.nodes[j].width;
|
||||
}
|
||||
}
|
||||
};
|
||||
return [p, delay];
|
||||
}
|
||||
|
||||
function measureText(str) {
|
||||
if (str === ' ') {
|
||||
str = '\u00A0';
|
||||
}
|
||||
ruler.textContent = str;
|
||||
return ruler.getClientRects()[0].width;
|
||||
}
|
||||
|
||||
function updateBookDimensions() {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const viewportAspectRatio = vw / vh;
|
||||
const imageAspectRatio = 2727 / 1691;
|
||||
|
||||
let bookWidth, bookHeight;
|
||||
|
||||
if (viewportAspectRatio > imageAspectRatio) {
|
||||
bookWidth = vh * imageAspectRatio;
|
||||
bookHeight = vh;
|
||||
} else {
|
||||
bookWidth = vw;
|
||||
bookHeight = vw / imageAspectRatio;
|
||||
}
|
||||
|
||||
document.documentElement.style.setProperty('--book-width', `${bookWidth}px`);
|
||||
document.documentElement.style.setProperty('--book-height', `${bookHeight}px`);
|
||||
|
||||
// Setting a CSS variable that will be either vw or vh depending on the viewport aspect ratio
|
||||
document.documentElement.style.setProperty(
|
||||
"--viewport-dimension",
|
||||
viewportAspectRatio > imageAspectRatio ? 'vw' : 'vh'
|
||||
);
|
||||
document.documentElement.style.setProperty('--viewport-aspect-ratio', viewportAspectRatio);
|
||||
let story = document.getElementById("story");
|
||||
let paddingTop = window.getComputedStyle(story).paddingTop;
|
||||
let paddingBottom = window.getComputedStyle(story).paddingBottom;
|
||||
document.documentElement.style.setProperty('--story-line-height', (story.clientHeight - paddingTop - paddingBottom) / 28)
|
||||
}
|
||||
|
||||
// Update the aspect ratio when the page loads
|
||||
updateBookDimensions();
|
||||
|
||||
// Update the aspect ratio whenever the window is resized
|
||||
window.addEventListener('resize', updateBookDimensions);
|
||||
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.code === 'Space') {
|
||||
fade_in = false;
|
||||
fastForward();
|
||||
}
|
||||
});
|
||||
|
||||
// page features setup
|
||||
var hasSave = loadSavePoint();
|
||||
setupButtons(hasSave);
|
||||
|
||||
// Set initial save point
|
||||
savePoint = story.state.toJson();
|
||||
|
||||
// Kick off the start of the story!
|
||||
continueStory(true);
|
||||
|
||||
// Main story processing function. Each time this is called it generates
|
||||
// all the next content up as far as the next set of choices.
|
||||
function continueStory(firstTime) {
|
||||
|
||||
var paragraphIndex = 0;
|
||||
var delay = 0.0;
|
||||
|
||||
// Don't over-scroll past new content
|
||||
var previousBottomEdge = firstTime ? 0 : contentBottomEdgeY();
|
||||
|
||||
var fade_in = true
|
||||
|
||||
// Generate story text - loop through available content
|
||||
while(story.canContinue) {
|
||||
// Get ink to generate the next paragraph
|
||||
var paragraphText = story.Continue();
|
||||
var tags = story.currentTags;
|
||||
|
||||
// Any special tags included with this line
|
||||
var customClasses = [];
|
||||
for(var i=0; i<tags.length; i++) {
|
||||
var tag = tags[i];
|
||||
|
||||
// Detect tags of the form "X: Y". Currently used for IMAGE and CLASS but could be
|
||||
// customised to be used for other things too.
|
||||
var splitTag = splitPropertyTag(tag);
|
||||
|
||||
// AUDIO: src
|
||||
if( splitTag && splitTag.property == "AUDIO" ) {
|
||||
if('audio' in this) {
|
||||
this.audio.pause();
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
}
|
||||
this.audio = new Audio(splitTag.val);
|
||||
this.audio.play();
|
||||
}
|
||||
|
||||
// AUDIOLOOP: src
|
||||
else if( splitTag && splitTag.property == "AUDIOLOOP" ) {
|
||||
if('audioLoop' in this) {
|
||||
this.audioLoop.pause();
|
||||
this.audioLoop.removeAttribute('src');
|
||||
this.audioLoop.load();
|
||||
}
|
||||
this.audioLoop = new Audio(splitTag.val);
|
||||
this.audioLoop.play();
|
||||
this.audioLoop.loop = true;
|
||||
}
|
||||
|
||||
// IMAGE: src
|
||||
if( splitTag && splitTag.property == "IMAGE" ) {
|
||||
var imageElement = document.createElement('img');
|
||||
imageElement.src = splitTag.val;
|
||||
storyContainer.appendChild(imageElement);
|
||||
|
||||
showAfter(delay, imageElement);
|
||||
delay += 200.0;
|
||||
}
|
||||
|
||||
// LINK: url
|
||||
else if( splitTag && splitTag.property == "LINK" ) {
|
||||
window.location.href = splitTag.val;
|
||||
}
|
||||
|
||||
// LINKOPEN: url
|
||||
else if( splitTag && splitTag.property == "LINKOPEN" ) {
|
||||
window.open(splitTag.val);
|
||||
}
|
||||
|
||||
// BACKGROUND: src
|
||||
else if( splitTag && splitTag.property == "BACKGROUND" ) {
|
||||
outerScrollContainer.style.backgroundImage = 'url('+splitTag.val+')';
|
||||
}
|
||||
|
||||
// CLASS: className
|
||||
else if( splitTag && splitTag.property == "CLASS" ) {
|
||||
customClasses.push(splitTag.val);
|
||||
}
|
||||
|
||||
// CLEAR - removes all existing content.
|
||||
// RESTART - clears everything and restarts the story from the beginning
|
||||
else if( tag == "CLEAR" || tag == "RESTART" ) {
|
||||
removeAll("p");
|
||||
removeAll("img");
|
||||
|
||||
// Comment out this line if you want to leave the header visible when clearing
|
||||
// setVisible(".header", false);
|
||||
|
||||
if( tag == "RESTART" ) {
|
||||
restart();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create paragraph element (initially hidden)
|
||||
(function(text) {
|
||||
if(text.trim().length === 0)
|
||||
return;
|
||||
console.log("Hyphenating:", text);
|
||||
let hyphenator_promise = Hyphenopoly.hyphenators["en-us"].then((hyphenator_en) => {
|
||||
var measure = parseFloat(window.getComputedStyle(document.getElementById("story")).width);
|
||||
var indentWidth = parseFloat(window.getComputedStyle(document.querySelector("#indent")).textIndent);
|
||||
var previewWidth = measure;
|
||||
var preview_data = kap(hyphenator_en(text, '.hyphenatePipe'), measureText, 'align-justify', measure, true, indentWidth);
|
||||
return { preview_data, indentWidth, previewWidth};
|
||||
});
|
||||
hyphenator_promise.then(({ preview_data, indentWidth, previewWidth }) => {
|
||||
// updateParagraphPreview(preview_data, indentWidth, previewWidth);
|
||||
var p, d;
|
||||
[p, d] = typesetParagraph(preview_data, indentWidth, delay);
|
||||
delay = d;
|
||||
// Add any custom classes derived from ink tags
|
||||
for(var i=0; i<customClasses.length; i++)
|
||||
p.classList.add(customClasses[i]);
|
||||
storyContainer.appendChild(p);
|
||||
p.scrollIntoView({ behavior: 'smooth'});
|
||||
});
|
||||
})(paragraphText);
|
||||
|
||||
// var paragraphElement = document.createElement('p');
|
||||
// var words = paragraphText.split(" ");
|
||||
// words.forEach(word => {
|
||||
// var wordElement = document.createElement('span');
|
||||
// Hyphenopoly.hyphenators["en-us"].then((hyphenator_en) => {
|
||||
// wordElement.innerHTML = hyphenator_en(word);
|
||||
// });
|
||||
// // showAfter(delay, wordElement);
|
||||
// insertAfter(delay, paragraphElement, wordElement, fade_in);
|
||||
// insertAfter(delay, paragraphElement, document.createTextNode(" "), false);
|
||||
// delay +=100.0;
|
||||
// // paragraphElement.appendChild(wordElement);
|
||||
// // paragraphElement.appendChild(document.createTextNode(" "));
|
||||
// });
|
||||
// // paragraphElement.innerHTML = paragraphText;
|
||||
// storyContainer.appendChild(paragraphElement);
|
||||
|
||||
|
||||
// Fade in paragraph after a short delay
|
||||
// showAfter(delay, paragraphElement);
|
||||
// delay += 200.0;
|
||||
}
|
||||
|
||||
// Create HTML choices from ink choices
|
||||
story.currentChoices.forEach(function(choice) {
|
||||
|
||||
// Create paragraph with anchor element
|
||||
var choiceParagraphElement = document.createElement('p');
|
||||
choiceParagraphElement.classList.add("choice");
|
||||
choiceParagraphElement.innerHTML = `<a href='#'>${choice.text}</a>`
|
||||
// choiceContainer.appendChild(choiceParagraphElement);
|
||||
insertAfter(delay, choiceContainer, choiceParagraphElement, fade_in);
|
||||
// Fade choice in after a short delay
|
||||
// showAfter(delay, choiceParagraphElement);
|
||||
delay += 200.0;
|
||||
|
||||
// Click on choice
|
||||
var choiceAnchorEl = choiceParagraphElement.querySelectorAll("a")[0];
|
||||
choiceAnchorEl.addEventListener("click", function(event) {
|
||||
|
||||
// Don't follow <a> link
|
||||
event.preventDefault();
|
||||
|
||||
// Remove all existing choices
|
||||
removeAll(".choice", true);
|
||||
|
||||
// Tell the story where to go next
|
||||
story.ChooseChoiceIndex(choice.index);
|
||||
|
||||
// This is where the save button will save from
|
||||
savePoint = story.state.toJson();
|
||||
|
||||
// Aaand loop
|
||||
continueStory();
|
||||
});
|
||||
});
|
||||
|
||||
// Extend height to fit
|
||||
// We do this manually so that removing elements and creating new ones doesn't
|
||||
// cause the height (and therefore scroll) to jump backwards temporarily.
|
||||
// storyContainer.style.height = contentBottomEdgeY()+"px";
|
||||
|
||||
if( !firstTime )
|
||||
scrollDown(previousBottomEdge);
|
||||
|
||||
}
|
||||
|
||||
function restart() {
|
||||
story.ResetState();
|
||||
|
||||
setVisible(".header", true);
|
||||
removeAll(".choice", true);
|
||||
|
||||
// set save point to here
|
||||
savePoint = story.state.toJson();
|
||||
|
||||
continueStory(true);
|
||||
|
||||
outerScrollContainer.scrollTo({ top: 0, left: 0, behavior: 'smooth'});
|
||||
}
|
||||
|
||||
// -----------------------------------
|
||||
// Various Helper functions
|
||||
// -----------------------------------
|
||||
|
||||
// Fades in an element after a specified delay
|
||||
function showAfter(delay, el) {
|
||||
el.classList.add("hide");
|
||||
setTimeout(function() {
|
||||
setTimeout(function() { el.classList.remove("hide") }, delay);
|
||||
});
|
||||
}
|
||||
|
||||
function insertAfter(delay, target, el, fade_in = true) {
|
||||
if(fade_in) {
|
||||
el.classList.add("fade-in");
|
||||
scheduleTimeout(function() {
|
||||
target.appendChild(el);
|
||||
el.scrollIntoView({ behavior: 'smooth'});
|
||||
}, delay);
|
||||
} else {
|
||||
scheduleTimeout(function() {
|
||||
target.appendChild(el);
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
// Scrolls the page down, but no further than the bottom edge of what you could
|
||||
// see previously, so it doesn't go too far.
|
||||
function scrollDown(previousBottomEdge) {
|
||||
return; // TODO: Fix or remove function
|
||||
// Line up top of screen with the bottom of where the previous content ended
|
||||
var target = previousBottomEdge;
|
||||
|
||||
// Can't go further than the very bottom of the page
|
||||
var limit = outerScrollContainer.scrollHeight - outerScrollContainer.clientHeight;
|
||||
if( target > limit ) target = limit;
|
||||
|
||||
var start = outerScrollContainer.scrollTop;
|
||||
|
||||
var dist = target - start;
|
||||
var duration = 300 + 300*dist/100;
|
||||
var startTime = null;
|
||||
function step(time) {
|
||||
if( startTime == null ) startTime = time;
|
||||
var t = (time-startTime) / duration;
|
||||
var lerp = 3*t*t - 2*t*t*t; // ease in/out
|
||||
outerScrollContainer.scrollTo({ left: 0, top: (1.0-lerp)*start + lerp*target, behavior: 'smooth'});
|
||||
if( t < 1 ) requestAnimationFrame(step);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
// The Y coordinate of the bottom end of all the story content, used
|
||||
// for growing the container, and deciding how far to scroll.
|
||||
function contentBottomEdgeY() {
|
||||
var bottomElement = storyContainer.lastElementChild;
|
||||
return bottomElement ? bottomElement.offsetTop + bottomElement.offsetHeight : 0;
|
||||
}
|
||||
|
||||
// Remove all elements that match the given selector. Used for removing choices after
|
||||
// you've picked one, as well as for the CLEAR and RESTART tags.
|
||||
function removeAll(selector, choices = false)
|
||||
{
|
||||
if(choices)
|
||||
var allElements = choiceContainer.querySelectorAll(selector);
|
||||
else
|
||||
var allElements = storyContainer.querySelectorAll(selector);
|
||||
for(var i=0; i<allElements.length; i++) {
|
||||
var el = allElements[i];
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
// Used for hiding and showing the header when you CLEAR or RESTART the story respectively.
|
||||
function setVisible(selector, visible)
|
||||
{
|
||||
var allElements = storyContainer.querySelectorAll(selector);
|
||||
for(var i=0; i<allElements.length; i++) {
|
||||
var el = allElements[i];
|
||||
if( !visible )
|
||||
el.classList.add("invisible");
|
||||
else
|
||||
el.classList.remove("invisible");
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for parsing out tags of the form:
|
||||
// # PROPERTY: value
|
||||
// e.g. IMAGE: source path
|
||||
function splitPropertyTag(tag) {
|
||||
var propertySplitIdx = tag.indexOf(":");
|
||||
if( propertySplitIdx != null ) {
|
||||
var property = tag.substr(0, propertySplitIdx).trim();
|
||||
var val = tag.substr(propertySplitIdx+1).trim();
|
||||
return {
|
||||
property: property,
|
||||
val: val
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Loads save state if exists in the browser memory
|
||||
function loadSavePoint() {
|
||||
try {
|
||||
let savedState = window.localStorage.getItem('save-state');
|
||||
if (savedState) {
|
||||
story.state.LoadJson(savedState);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug("Couldn't load save state");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Used to hook up the functionality for global functionality buttons
|
||||
function setupButtons(hasSave) {
|
||||
|
||||
let rewindEl = document.getElementById("rewind");
|
||||
if (rewindEl) rewindEl.addEventListener("click", function(event) {
|
||||
removeAll("p");
|
||||
removeAll("img");
|
||||
setVisible(".header", false);
|
||||
restart();
|
||||
});
|
||||
|
||||
let saveEl = document.getElementById("save");
|
||||
if (saveEl) saveEl.addEventListener("click", function(event) {
|
||||
try {
|
||||
window.localStorage.setItem('save-state', savePoint);
|
||||
document.getElementById("reload").removeAttribute("disabled");
|
||||
} catch (e) {
|
||||
console.warn("Couldn't save state");
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
let reloadEl = document.getElementById("reload");
|
||||
if (!hasSave) {
|
||||
reloadEl.setAttribute("disabled", "disabled");
|
||||
}
|
||||
reloadEl.addEventListener("click", function(event) {
|
||||
if (reloadEl.getAttribute("disabled"))
|
||||
return;
|
||||
|
||||
removeAll("p");
|
||||
removeAll("img");
|
||||
removeAll(".choice", true);
|
||||
try {
|
||||
let savedState = window.localStorage.getItem('save-state');
|
||||
if (savedState) story.state.LoadJson(savedState);
|
||||
} catch (e) {
|
||||
console.debug("Couldn't load save state");
|
||||
}
|
||||
continueStory(true);
|
||||
});
|
||||
}
|
||||
|
||||
})(storyContent);
|
||||
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<!-- meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'blob'; style-src 'self' 'unsafe-inline'" -->
|
||||
<title>Ink.js Book Runtime</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<p id="versions">We are using Node.js <span id="node-version"></span>,
|
||||
Chromium <span id="chrome-version"></span>,
|
||||
and Electron <span id="electron-version"></span>.</p>
|
||||
<div id="book">
|
||||
<div id="page_left">
|
||||
<div class="header">
|
||||
<h2 class="byline l10n-by">by </h2>
|
||||
<h1 class="title"></h1>
|
||||
<h3 class="subtitle"></h3>
|
||||
<div class="separator"><double>❦</double></div>
|
||||
</div>
|
||||
<div id="controls" class="buttons">
|
||||
<a class="l10n-speech" id="speech" title="Toggle text to speech" disabled="disabled">speech</a>
|
||||
<span><a id="speed_reset"><span class="l10n-speed">speed<sup>*</sup></span></a><input type="range" min="0" max="100" value="50" id="speed" name="speed" /></span>
|
||||
<a class="l10n-restart" id="rewind" title="Restart story from beginning" disabled="disabled">restart</a>
|
||||
<a class="l10n-save" id="save" title="Save progress">save</a>
|
||||
<a class="l10n-load" id="reload" title="Reload from save point" disabled="disabled">load</a>
|
||||
</div>
|
||||
<div id="choices" class="container">
|
||||
</div>
|
||||
<div class="l10n-remark" id="remark"><i><sup>*</sup>click on page or press spacebar to fast forward text animation</i></div>
|
||||
</div>
|
||||
<div id="page_right">
|
||||
<div id="story" class="container">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ruler"></div>
|
||||
<div class="l10n-prompt" id="indent">What do you want to do next?</div>
|
||||
<div id="lighting" />
|
||||
|
||||
<!-- You can also require other files to run in this process -->
|
||||
<script src="smartypants.js"></script>
|
||||
<script src="linked-list.js"></script>
|
||||
<script src="linebreak.js"></script>
|
||||
<script src="knuth-and-plass.js"></script>
|
||||
<script src="ink-full.js"></script>
|
||||
<!-- <script src="TheIntercept.js"></script> -->
|
||||
<script src="Hyphenopoly_Loader.js"></script>
|
||||
<script>
|
||||
var locale = "de";
|
||||
</script>
|
||||
<script src="game.js"></script>
|
||||
<script src="./renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+2
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
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"CodeGPT.apiKey": "Ollama"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
"use strict";Object.defineProperty(exports,"__esModule",{value:!0});var e=require("path"),t=require("fs");function r(e){if(e&&e.__esModule)return e;var t=Object.create(null);return e&&Object.keys(e).forEach((function(r){if("default"!==r){var n=Object.getOwnPropertyDescriptor(e,r);Object.defineProperty(t,r,n.get?n:{enumerable:!0,get:function(){return e[r]}})}})),t.default=e,Object.freeze(t)}var n=r(e),o=r(t);function i(e,t){for(var r=0;r<t.length;r++){var n=t[r];n.enumerable=n.enumerable||!1,n.configurable=!0,"value"in n&&(n.writable=!0),Object.defineProperty(e,n.key,n)}}function a(e,t,r){return t&&i(e.prototype,t),r&&i(e,r),Object.defineProperty(e,"prototype",{writable:!1}),e}var u=a((function e(t){var r=this;!function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}(this,e),this.rootPath=t,this.ResolveInkFilename=function(e){if(void 0!==r.rootPath&&""!==r.rootPath)return n.join(r.rootPath,e);var t=process.cwd();return n.join(t,e)},this.LoadInkFileContents=function(e){return o.readFileSync(e,"utf-8")}}));exports.PosixFileHandler=u;
|
||||
Vendored
+56
@@ -0,0 +1,56 @@
|
||||
function kap(text, measureText, measure, hyphenation) {
|
||||
console.log("Typesetting hyphenated text:", text, measure);
|
||||
if (!hyphenation) {
|
||||
text = text.replace(/\|/g, '');
|
||||
}
|
||||
|
||||
let hyphenWidth = measureText('-');
|
||||
let spaceWidth = measureText('\u00A0');
|
||||
let nodes = [];
|
||||
|
||||
text.split(/([.,:;!?] |\s|\||<.*?>)/u).forEach(function (fragment) {
|
||||
let fragmentWidth = measureText(fragment);
|
||||
|
||||
if (fragment === ' ') {
|
||||
let stretch = (spaceWidth * 3) / 6;
|
||||
let shrink = (spaceWidth * 3) / 9;
|
||||
|
||||
nodes.push(linebreak.glue(spaceWidth, stretch, shrink));
|
||||
} else if (fragment === '|') {
|
||||
// nodes.push(linebreak.penalty(hyphenWidth, 100, 1));
|
||||
nodes.push(linebreak.penalty(hyphenWidth * 0.25, 100, 1));
|
||||
} else if (fragment.match(/(<.*?>)/u)) {
|
||||
nodes.push(linebreak.tag(fragmentWidth, fragment));
|
||||
} else if (fragment.match(/[.,:;!?] /u)) {
|
||||
let punctuation = fragment.match(/([.,:;!?])( )/u);
|
||||
let punctuationSymbolWidth = measureText(punctuation[1]) * 0.25;
|
||||
let punctuationWidth = measureText(punctuation[1]) * 0.75 + spaceWidth;
|
||||
nodes.push(linebreak.box(punctuationSymbolWidth, punctuation[1]));
|
||||
let stretch = (punctuationWidth * 3) / 6;
|
||||
let shrink = (punctuationWidth * 3) / 9;
|
||||
|
||||
nodes.push(linebreak.glue(punctuationWidth, stretch, shrink));
|
||||
} else if (fragment.match(/(\s+)/u)) {
|
||||
|
||||
} else {
|
||||
nodes.push(linebreak.box(fragmentWidth, fragment));
|
||||
}
|
||||
});
|
||||
|
||||
nodes.push(linebreak.glue(0, linebreak.infinity, 0));
|
||||
nodes.push(linebreak.penalty(0, -linebreak.infinity, 1));
|
||||
|
||||
let demerits = {
|
||||
line: 10,
|
||||
flagged: 100,
|
||||
fitness: 3000
|
||||
};
|
||||
|
||||
let breaks = linebreak(nodes, measure, { tolerance: 3, demerits });
|
||||
|
||||
if (!breaks.length) {
|
||||
breaks = linebreak(nodes, measure, { tolerance: 10, demerits });
|
||||
}
|
||||
|
||||
return { nodes, breaks };
|
||||
}
|
||||
Vendored
+334
@@ -0,0 +1,334 @@
|
||||
var linebreak = function (nodes, lines, settings = {
|
||||
demerits: {
|
||||
line: 10,
|
||||
flagged: 100,
|
||||
fitness: 3000
|
||||
},
|
||||
tolerance: 2
|
||||
}) {
|
||||
const options = settings;
|
||||
activeNodes = new LinkedList(),
|
||||
sum = {
|
||||
width: 0,
|
||||
stretch: 0,
|
||||
shrink: 0
|
||||
},
|
||||
lineLengths = lines,
|
||||
breaks = [],
|
||||
tmp = {
|
||||
data: {
|
||||
demerits: Infinity
|
||||
}
|
||||
};
|
||||
|
||||
function breakpoint(position, demerits, ratio, line, fitnessClass, totals, previous) {
|
||||
return {
|
||||
position: position,
|
||||
demerits: demerits,
|
||||
ratio: ratio,
|
||||
line: line,
|
||||
fitnessClass: fitnessClass,
|
||||
totals: totals || {
|
||||
width: 0,
|
||||
stretch: 0,
|
||||
shrink: 0
|
||||
},
|
||||
previous: previous
|
||||
};
|
||||
}
|
||||
|
||||
function computeCost(start, end, active, currentLine) {
|
||||
var width = sum.width - active.totals.width,
|
||||
stretch = 0,
|
||||
shrink = 0,
|
||||
// If the current line index is within the list of linelengths, use it, otherwise use
|
||||
// the last line length of the list.
|
||||
lineLength = currentLine < lineLengths.length ? lineLengths[currentLine - 1] : lineLengths[lineLengths.length - 1];
|
||||
|
||||
if (nodes[end].type === 'penalty') {
|
||||
width += nodes[end].width;
|
||||
}
|
||||
|
||||
if (width < lineLength) {
|
||||
// Calculate the stretch ratio
|
||||
stretch = sum.stretch - active.totals.stretch;
|
||||
|
||||
if (stretch > 0) {
|
||||
return (lineLength - width) / stretch;
|
||||
} else {
|
||||
return linebreak.infinity;
|
||||
}
|
||||
|
||||
} else if (width > lineLength) {
|
||||
// Calculate the shrink ratio
|
||||
shrink = sum.shrink - active.totals.shrink;
|
||||
|
||||
if (shrink > 0) {
|
||||
return (lineLength - width) / shrink;
|
||||
} else {
|
||||
return linebreak.infinity;
|
||||
}
|
||||
} else {
|
||||
// perfect match
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add width, stretch and shrink values from the current
|
||||
// break point up to the next box or forced penalty.
|
||||
function computeSum(breakPointIndex) {
|
||||
var result = {
|
||||
width: sum.width,
|
||||
stretch: sum.stretch,
|
||||
shrink: sum.shrink
|
||||
},
|
||||
i = 0;
|
||||
|
||||
for (i = breakPointIndex; i < nodes.length; i += 1) {
|
||||
if (nodes[i].type === 'glue') {
|
||||
result.width += nodes[i].width;
|
||||
result.stretch += nodes[i].stretch;
|
||||
result.shrink += nodes[i].shrink;
|
||||
} else if (nodes[i].type === 'box' || (nodes[i].type === 'penalty' && nodes[i].penalty === -linebreak.infinity && i > breakPointIndex)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
let graphNodes = [];
|
||||
let graphEdges = [];
|
||||
|
||||
// The main loop of the algorithm
|
||||
function mainLoop(node, index, nodes) {
|
||||
var active = activeNodes.first,
|
||||
next = null,
|
||||
ratio = 0,
|
||||
demerits = 0,
|
||||
candidates = [],
|
||||
badness,
|
||||
currentLine = 0,
|
||||
tmpSum,
|
||||
currentClass = 0,
|
||||
fitnessClass,
|
||||
candidate,
|
||||
newNode;
|
||||
|
||||
// The inner loop iterates through all the active nodes with line < currentLine and then
|
||||
// breaks out to insert the new active node candidates before looking at the next active
|
||||
// nodes for the next lines. The result of this is that the active node list is always
|
||||
// sorted by line number.
|
||||
while (active !== null) {
|
||||
|
||||
candidates = [{
|
||||
demerits: Infinity
|
||||
}, {
|
||||
demerits: Infinity
|
||||
}, {
|
||||
demerits: Infinity
|
||||
}, {
|
||||
demerits: Infinity
|
||||
}];
|
||||
|
||||
// Iterate through the linked list of active nodes to find new potential active nodes
|
||||
// and deactivate current active nodes.
|
||||
while (active !== null) {
|
||||
next = active.next;
|
||||
currentLine = active.data.line + 1;
|
||||
ratio = computeCost(active.data.position, index, active.data, currentLine);
|
||||
|
||||
// Deactive nodes when the distance between the current active node and the
|
||||
// current node becomes too large (i.e. it exceeds the stretch limit and the stretch
|
||||
// ratio becomes negative) or when the current node is a forced break (i.e. the end
|
||||
// of the paragraph when we want to remove all active nodes, but possibly have a final
|
||||
// candidate active node---if the paragraph can be set using the given tolerance value.)
|
||||
if (ratio < -1 || (node.type === 'penalty' && node.penalty === -linebreak.infinity)) {
|
||||
activeNodes.remove(active);
|
||||
}
|
||||
|
||||
// If the ratio is within the valid range of -1 <= ratio <= tolerance calculate the
|
||||
// total demerits and record a candidate active node.
|
||||
if (-1 <= ratio && ratio <= options.tolerance) {
|
||||
badness = 100 * Math.pow(Math.abs(ratio), 3);
|
||||
|
||||
// Positive penalty
|
||||
if (node.type === 'penalty' && node.penalty >= 0) {
|
||||
demerits = Math.pow(options.demerits.line + badness, 2) + Math.pow(node.penalty, 2);
|
||||
// Negative penalty but not a forced break
|
||||
} else if (node.type === 'penalty' && node.penalty !== -linebreak.infinity) {
|
||||
demerits = Math.pow(options.demerits.line + badness, 2) - Math.pow(node.penalty, 2);
|
||||
// All other cases
|
||||
} else {
|
||||
demerits = Math.pow(options.demerits.line + badness, 2);
|
||||
}
|
||||
|
||||
if (node.type === 'penalty' && nodes[active.data.position].type === 'penalty') {
|
||||
demerits += options.demerits.flagged * node.flagged * nodes[active.data.position].flagged;
|
||||
}
|
||||
|
||||
// Calculate the fitness class for this candidate active node.
|
||||
if (ratio < -0.5) {
|
||||
currentClass = 0;
|
||||
} else if (ratio <= 0.5) {
|
||||
currentClass = 1;
|
||||
} else if (ratio <= 1) {
|
||||
currentClass = 2;
|
||||
} else {
|
||||
currentClass = 3;
|
||||
}
|
||||
|
||||
// Add a fitness penalty to the demerits if the fitness classes of two adjacent lines
|
||||
// differ too much.
|
||||
if (Math.abs(currentClass - active.data.fitnessClass) > 1) {
|
||||
demerits += options.demerits.fitness;
|
||||
}
|
||||
|
||||
// Add the total demerits of the active node to get the total demerits of this candidate node.
|
||||
demerits += active.data.demerits;
|
||||
|
||||
// Only store the best candidate for each fitness class
|
||||
if (demerits < candidates[currentClass].demerits) {
|
||||
candidates[currentClass] = {
|
||||
active: active,
|
||||
demerits: demerits,
|
||||
ratio: ratio
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
active = next;
|
||||
|
||||
// Stop iterating through active nodes to insert new candidate active nodes in the active list
|
||||
// before moving on to the active nodes for the next line.
|
||||
// TODO: The Knuth and Plass paper suggests a conditional for currentLine < j0. This means paragraphs
|
||||
// with identical line lengths will not be sorted by line number. Find out if that is a desirable outcome.
|
||||
// For now I left this out, as it only adds minimal overhead to the algorithm and keeping the active node
|
||||
// list sorted has a higher priority.
|
||||
if (active !== null && active.data.line >= currentLine) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
tmpSum = computeSum(index);
|
||||
|
||||
for (fitnessClass = 0; fitnessClass < candidates.length; fitnessClass += 1) {
|
||||
candidate = candidates[fitnessClass];
|
||||
|
||||
if (candidate.demerits < Infinity) {
|
||||
newNode = new Node(breakpoint(index, candidate.demerits, candidate.ratio,
|
||||
candidate.active.data.line + 1, fitnessClass, tmpSum, candidate.active));
|
||||
|
||||
graphNodes.push({
|
||||
id: index
|
||||
});
|
||||
|
||||
graphEdges.push({
|
||||
from: index,
|
||||
to: candidate.active.data.position,
|
||||
label: candidate.ratio.toFixed(2)
|
||||
});
|
||||
|
||||
if (active !== null) {
|
||||
activeNodes.insertBefore(active, newNode);
|
||||
} else {
|
||||
activeNodes.push(newNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add an active node for the start of the paragraph.
|
||||
activeNodes.push(new Node(breakpoint(0, 0, 0, 0, 0, undefined, null)));
|
||||
|
||||
graphNodes.push({
|
||||
id: 0
|
||||
});
|
||||
|
||||
nodes.forEach(function (node, index, nodes) {
|
||||
if (node.type === 'box') {
|
||||
sum.width += node.width;
|
||||
} else if (node.type === 'glue') {
|
||||
if (index > 0 && nodes[index - 1].type === 'box') {
|
||||
mainLoop(node, index, nodes);
|
||||
}
|
||||
sum.width += node.width;
|
||||
sum.stretch += node.stretch;
|
||||
sum.shrink += node.shrink;
|
||||
} else if (node.type === 'penalty' && node.penalty !== linebreak.infinity) {
|
||||
mainLoop(node, index, nodes);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (activeNodes.size !== 0) {
|
||||
// Find the best active node (the one with the least total demerits.)
|
||||
activeNodes.forEach(function (node) {
|
||||
if (node.data.demerits < tmp.data.demerits) {
|
||||
tmp = node;
|
||||
}
|
||||
});
|
||||
|
||||
graphNodes.forEach(function (n) {
|
||||
let label = nodes[n.id].value;
|
||||
|
||||
if (nodes[n.id].type === 'glue') {
|
||||
label = nodes[n.id - 1].value;
|
||||
} else if (nodes[n.id].type === 'penalty') {
|
||||
label = nodes[n.id - 1].value;
|
||||
} else {
|
||||
label = nodes[n.id].value;
|
||||
}
|
||||
n.label = label;
|
||||
});
|
||||
|
||||
while (tmp !== null) {
|
||||
breaks.push({
|
||||
position: tmp.data.position,
|
||||
ratio: tmp.data.ratio
|
||||
});
|
||||
tmp = tmp.data.previous;
|
||||
}
|
||||
return breaks.reverse();
|
||||
} else {
|
||||
console.warn('Overfull paragraph.');
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
linebreak.infinity = 10000;
|
||||
|
||||
linebreak.glue = function (width, stretch, shrink) {
|
||||
return {
|
||||
type: 'glue',
|
||||
width: width,
|
||||
stretch: stretch,
|
||||
shrink: shrink
|
||||
};
|
||||
};
|
||||
|
||||
linebreak.box = function (width, value) {
|
||||
return {
|
||||
type: 'box',
|
||||
width: width,
|
||||
value: value
|
||||
};
|
||||
};
|
||||
|
||||
linebreak.tag = function (width, value) {
|
||||
return {
|
||||
type: 'tag',
|
||||
width: width,
|
||||
value: value
|
||||
}
|
||||
}
|
||||
|
||||
linebreak.penalty = function (width, penalty, flagged) {
|
||||
return {
|
||||
type: 'penalty',
|
||||
width: width,
|
||||
penalty: penalty,
|
||||
flagged: flagged
|
||||
};
|
||||
};
|
||||
Vendored
+187
@@ -0,0 +1,187 @@
|
||||
class LinkedList {
|
||||
constructor() {
|
||||
this.head = null;
|
||||
this.tail = null;
|
||||
this.listSize = 0;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.listSize;
|
||||
}
|
||||
|
||||
isLinked(node) {
|
||||
return !((node && node.prev === null && node.next === null && this.tail !== node && this.head !== node) || this.isEmpty());
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.listSize === 0;
|
||||
}
|
||||
|
||||
get first() {
|
||||
return this.head;
|
||||
}
|
||||
|
||||
get last() {
|
||||
return this.last;
|
||||
}
|
||||
|
||||
|
||||
toString() {
|
||||
return this.toArray().toString();
|
||||
}
|
||||
|
||||
toArray() {
|
||||
var node = this.head,
|
||||
result = [];
|
||||
while (node !== null) {
|
||||
result.push(node);
|
||||
node = node.next;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Note that modifying the list during
|
||||
// iteration is not safe.
|
||||
forEach(fun) {
|
||||
var node = this.head;
|
||||
while (node !== null) {
|
||||
fun(node);
|
||||
node = node.next;
|
||||
}
|
||||
}
|
||||
|
||||
contains(n) {
|
||||
var node = this.head;
|
||||
if (!this.isLinked(n)) {
|
||||
return false;
|
||||
}
|
||||
while (node !== null) {
|
||||
if (node === n) {
|
||||
return true;
|
||||
}
|
||||
node = node.next;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
at(i) {
|
||||
var node = this.head, index = 0;
|
||||
|
||||
if (i >= this.listLength || i < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
while (node !== null) {
|
||||
if (i === index) {
|
||||
return node;
|
||||
}
|
||||
node = node.next;
|
||||
index += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
insertAfter(node, newNode) {
|
||||
if (!this.isLinked(node)) {
|
||||
return this;
|
||||
}
|
||||
newNode.prev = node;
|
||||
newNode.next = node.next;
|
||||
if (node.next === null) {
|
||||
this.tail = newNode;
|
||||
} else {
|
||||
node.next.prev = newNode;
|
||||
}
|
||||
node.next = newNode;
|
||||
this.listSize += 1;
|
||||
return this;
|
||||
}
|
||||
|
||||
insertBefore(node, newNode) {
|
||||
if (!this.isLinked(node)) {
|
||||
return this;
|
||||
}
|
||||
newNode.prev = node.prev;
|
||||
newNode.next = node;
|
||||
if (node.prev === null) {
|
||||
this.head = newNode;
|
||||
} else {
|
||||
node.prev.next = newNode;
|
||||
}
|
||||
node.prev = newNode;
|
||||
this.listSize += 1;
|
||||
return this;
|
||||
}
|
||||
|
||||
push(node) {
|
||||
if (this.head === null) {
|
||||
this.unshift(node);
|
||||
} else {
|
||||
this.insertAfter(this.tail, node);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
unshift(node) {
|
||||
if (this.head === null) {
|
||||
this.head = node;
|
||||
this.tail = node;
|
||||
node.prev = null;
|
||||
node.next = null;
|
||||
this.listSize += 1;
|
||||
} else {
|
||||
this.insertBefore(this.head, node);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
remove(node) {
|
||||
if (!this.isLinked(node)) {
|
||||
return this;
|
||||
}
|
||||
if (node.prev === null) {
|
||||
this.head = node.next;
|
||||
} else {
|
||||
node.prev.next = node.next;
|
||||
}
|
||||
if (node.next === null) {
|
||||
this.tail = node.prev;
|
||||
} else {
|
||||
node.next.prev = node.prev;
|
||||
}
|
||||
this.listSize -= 1;
|
||||
return this;
|
||||
}
|
||||
|
||||
pop() {
|
||||
var node = this.tail;
|
||||
this.tail.prev.next = null;
|
||||
this.tail = this.tail.prev;
|
||||
this.listSize -= 1;
|
||||
node.prev = null;
|
||||
node.next = null;
|
||||
return node;
|
||||
}
|
||||
|
||||
shift() {
|
||||
var node = this.head;
|
||||
this.head.next.prev = null;
|
||||
this.head = this.head.next;
|
||||
this.listSize -= 1;
|
||||
node.prev = null;
|
||||
node.next = null;
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
class Node {
|
||||
constructor(data) {
|
||||
this.prev = null;
|
||||
this.next = null;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.data.toString();
|
||||
}
|
||||
}
|
||||
Vendored
+108
@@ -0,0 +1,108 @@
|
||||
// Modules to control application life and create native browser window
|
||||
const { contextBridge, ipcMain, session, app, BrowserWindow } = require('electron')
|
||||
const path = require('path')
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
require('./speech');
|
||||
|
||||
// const fetch = require('node-fetch');
|
||||
|
||||
// // Use a polyfill for fetch
|
||||
// if (!globalThis.fetch) {
|
||||
// globalThis.fetch = fetch;
|
||||
// }
|
||||
|
||||
// const hyphenopolyScript = fs.readFileSync(require.resolve('./Hyphenopoly_Loader.js'), 'utf-8');
|
||||
// vm.runInThisContext(hyphenopolyScript, { filename: 'Hyphenopoly_Loader.js' });
|
||||
|
||||
// Hyphenopoly.config({
|
||||
// require: {
|
||||
// 'en-us': 'FORCEHYPHENOPOLY',
|
||||
// 'de': 'Silbentrennungsalgorithmus',
|
||||
// },
|
||||
// paths: {
|
||||
// maindir: './',
|
||||
// patterndir: './patterns/',
|
||||
// },
|
||||
// setup: {
|
||||
// selectors: {
|
||||
// '.hyphenate': {
|
||||
// hyphen: '­',
|
||||
// },
|
||||
// '.hyphenatePipe': {
|
||||
// hyphen: '|',
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
// contextBridge.exposeInMainWorld('api', {
|
||||
// hyphenateWord: async (word, selector = '.hyphenate') => {
|
||||
// const hyphenator = await Hyphenopoly.hyphenators['en-us'];
|
||||
// return hyphenator(word, selector);
|
||||
// },
|
||||
// });
|
||||
|
||||
const debug = true;
|
||||
|
||||
function createWindow () {
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
// fullscreen: true,
|
||||
// frame: false,
|
||||
// titleBarStyle: 'hidden',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
enableRemoteModule: false,
|
||||
// contentSecurityPolicy: "script-src 'self' 'unsafe-inline';",
|
||||
preload: path.join(__dirname, 'preload.js')
|
||||
}
|
||||
})
|
||||
|
||||
if(!debug)
|
||||
mainWindow.removeMenu();
|
||||
|
||||
// and load the index.html of the app.
|
||||
mainWindow.loadFile('index.html')
|
||||
|
||||
mainWindow.maximize()
|
||||
// Open the DevTools.
|
||||
// mainWindow.webContents.openDevTools()
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(() => {
|
||||
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
'Content-Security-Policy': ['default-src \'self\'; script-src \'self\' \'unsafe-inline\' \'unsafe-eval\' blob:; style-src \'self\' \'unsafe-inline\'']
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', function () {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on('window-all-closed', function () {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
||||
Generated
+1754
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "electron-quick-start",
|
||||
"version": "1.0.0",
|
||||
"description": "A minimal Electron application",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron ."
|
||||
},
|
||||
"repository": "https://github.com/electron/electron-quick-start",
|
||||
"keywords": [
|
||||
"Electron",
|
||||
"quick",
|
||||
"start",
|
||||
"tutorial",
|
||||
"demo"
|
||||
],
|
||||
"author": "GitHub",
|
||||
"license": "CC0-1.0",
|
||||
"devDependencies": {
|
||||
"electron": "^25.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.4.0",
|
||||
"crypto": "^1.0.1",
|
||||
"fs": "^0.0.1-security",
|
||||
"node-fetch": "^3.3.1",
|
||||
"play-sound": "^1.1.5"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user