Add ink integration UI and media playback

This commit is contained in:
2026-05-15 21:23:46 +02:00
parent 44dc64f830
commit f2e786d5bc
89 changed files with 6561 additions and 556 deletions
+22 -12
View File
@@ -12,7 +12,7 @@ The production client must tolerate TTS being unavailable. The safe default TTS
- 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: story parser/protocol bridge for Ink-style `#` tags, chapters, text blocks, Markdown emphasis, image blocks, sound effect cues, and music cues.
- Done: SmartyPants punctuation, language-aware Hyphenopoly integration, and Knuth-Plass paragraph line breaking.
- Done: paragraph rules for normal paragraphs, chapter-first paragraphs, textblock-first paragraphs, drop caps, and first-line indentation.
- Done: sentence queue and playback coordinator for preparing text and TTS before synchronized playback.
@@ -124,6 +124,13 @@ Known inherited implementation note: the old `linked-list.js` analysis identifie
## Story Markup Specification
Canonical structural and media tags use Ink-style `#` syntax:
- Ink engines write native Ink tags such as `# chapter[Title]`, `# image[file.png](landscape)`, or `# music[track.ogg](crossfade loop)`.
- inkjs exposes those tags without the leading `#`; the server parses them into `StoryTag` objects.
- YAML and Zork narrative output use the same leading `#...` syntax, parsed by the server into `StoryTag` objects before the client sees them.
- The browser protocol is structured `TurnResult` objects with structured tags and render blocks, not raw story markup.
Markdown emphasis:
```text
@@ -135,7 +142,7 @@ Markdown emphasis:
Chapter:
```text
::chapter[The Mysterious Mansion]
#chapter[The Mysterious Mansion]
The first paragraph has a drop cap and no first-line indent.
```
@@ -145,35 +152,36 @@ The heading is centered, italic, and uses the body font size. Following ordinary
Section or text block:
```text
::section
#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.
`#textblock` is an alias. Following ordinary paragraphs return to normal indentation.
Images:
```text
::image[widescreen](file-name.jpg)
::image[portrait](file-name.jpg)
#image[file-name.jpg](landscape)
#image[file-name.jpg](portrait pause=2)
#image[file-name.jpg](square delay=1.5)
```
File names resolve relative to `public/images/`. `widescreen` means full page width and half page height. `portrait` means full page width and full page height. Parsing is implemented; visual rendering is pending.
File names resolve relative to `public/images/`. `widescreen` is still accepted as an alias for `landscape`. Landscape and square images are centered and rendered near full page width with heights snapped to whole text lines. Portrait images float at half page width and following prose is narrowed for the number of lines the image covers. Image pauses accept the same timing style as music (`pause=2`, `delay=2`, `lead=2`, or `2s`); pauses are skippable with click/space and do not prevent the next TTS item from being prepared in the background.
Sound effects:
```text
The old door opens {{sfx:squeaky-door.ogg}} into the dark.
#sfx[squeaky-door.ogg]
The old door opens 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.
File names resolve relative to `public/sounds/`. The server parses the tag into a `StoryTag`; the tag is not displayed and is not sent to TTS.
Music:
```text
::music[crossfade, loop, lead=4](track.ogg)
{{music:cut:track.ogg}}
#music[track.ogg](crossfade, loop, lead=4)
```
File names resolve relative to `public/music/`. Modes:
@@ -185,6 +193,8 @@ File names resolve relative to `public/music/`. Modes:
- `once`: do not repeat the track.
- `lead=<seconds>`: for block music, let music play alone before the following text/TTS paragraph starts.
For chapter openings, authors can place `#music[file](..., lead=N)` after `#chapter[...]` and before the first prose paragraph. The heading is rendered/spoken first, then music starts and plays alone for the lead duration, then the dropcapped paragraph continues. Music and image pauses are playback gates only; they must not stop the queue from preparing upcoming TTS in the background.
## TTS And Playback Specification
The playback system must keep text animation and audio synchronized.
@@ -354,7 +364,7 @@ Longer-term goal:
### Pending
- [ ] Implement image rendering for `::image[widescreen]` and `::image[portrait]`.
- [x] Implement image rendering for `#image[file](landscape)`, `#image[file](portrait)`, and `#image[file](square)`.
- [ ] Replace placeholder save implementation with durable save files or server-side save storage.
- [ ] Replace command mirroring with the full LLM/world-model command loop when typography/audio testing no longer needs mirroring.
- [ ] Add optical margin alignment or punctuation protrusion support for line endings.
+18 -21
View File
@@ -62,7 +62,7 @@ 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.
- `markup-parser-module.js`: story markup fallback 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.
@@ -82,10 +82,12 @@ Inline Markdown emphasis:
***bold italic*** or ___bold italic___
```
Canonical block/media tags use Ink-style `#` syntax. In Ink these are real Ink tags. In YAML and Zork narrative output, leading `#...` lines are parsed by the server into the same structured `StoryTag` objects before reaching the client. The browser only consumes structured `TurnResult` objects.
Chapter:
```text
::chapter[The Mysterious Mansion]
#chapter[The Mysterious Mansion]
The first paragraph uses a drop cap and no first-line indent.
@@ -97,51 +99,46 @@ The heading is centered, italic, and uses the same text face as the body. The fi
Section or text block:
```text
::section
#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.
`#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[mansion-rain.jpg](landscape)
#image[portrait-letter.jpg](portrait pause=2)
```
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.
Image file names are relative to `public/images/`. `widescreen` is accepted as an alias for `landscape`.
Sound effects can be placed inline:
Sound effects are story tags:
```text
The door opens {{sfx:squeaky-door.ogg}} and the hall exhales.
#sfx[squeaky-door.ogg]
The door opens 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/`.
The tag is parsed by the server into a `StoryTag` object. 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[rain-theme.ogg](crossfade, loop, lead=4)
```
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.
Music paths are relative to `public/music/`. Supported modes are `queue`, `crossfade`, and `cut`. Use `loop` or `once` to control repetition. `lead=<seconds>` delays the following text/TTS paragraph so the music can play alone before narration continues. To place that pause between a chapter heading and the dropcapped first paragraph, put the music tag after the chapter tag and before the first prose paragraph; TTS generation for the next spoken paragraph continues during the lead pause.
## 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/sounds/`: sound effects referenced by `#sfx[file]` tags.
- `public/music/`: background music referenced by `#music[file](...)` tags.
- `public/images/`: story images referenced by `#image[file](...)`.
- `public/fonts/`: font assets used by the book UI.
Keep third-party assets licensed for local redistribution, and document source and license in the folder README or alongside the file.
+19
View File
@@ -0,0 +1,19 @@
{
"engine": "ink",
"locale": "en_US",
"paths": {
"mainGameFile": "data/ink/kaiserpunk.ink.json",
"inkSource": "data/ink-src/kaiserpunk.ink",
"inkCompiled": "data/ink/kaiserpunk.ink.json",
"music": "public/music",
"sfx": "public/sounds",
"images": "public/images"
},
"metadata": {
"title": "Eibenreith",
"author": "Georg Tomitsch",
"subtitle": "A Kaiserpunk Adventure",
"version": "0.0.1",
"copyright": "2026 by Bad Tools Studio"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"engine": "yaml",
"locale": "en_US",
"paths": {
"mainGameFile": "data/worlds/example_world.yml",
"music": "public/music",
"sfx": "public/sounds",
"images": "public/images"
},
"metadata": {
"title": "The Mysterious Mansion",
"author": "AI Interactive Fiction",
"subtitle": "An open-world text adventure",
"version": "1.0.0",
"copyright": "Prototype content for local development."
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"engine": "zork",
"locale": "en_US",
"paths": {
"mainGameFile": "data/z-code/zork1.bin",
"promptDir": "data/zork-prompts",
"music": "public/music",
"sfx": "public/sounds",
"images": "public/images"
},
"metadata": {
"title": "Zork I",
"author": "Infocom",
"subtitle": "A narrated Z-code adventure",
"version": "1.0.0",
"copyright": "Use only with a legally supplied Z-code story file."
}
}
+795
View File
@@ -0,0 +1,795 @@
// Kaiserpunk Horror Intro
// Ink source file draft
// Covers: opening train journey, class selection, name builder, supernatural stance,
// spiritual-sense selection, first Viktor relationship choices, and arrival at Eibenreith.
VAR birth_class = "unset"
VAR title_part = ""
VAR given_names = ""
VAR common_name = ""
VAR surname = ""
VAR full_name = ""
VAR supernatural_belief = "unset" // believer, sceptic, performer, undecided
VAR supernatural_senses = "unset" // genuine, faked, repressed, ambiguous
VAR viktor_relation = "unset" // trust, tension, utility, dependence, provocation
VAR lover = 0
VAR sapphic = 0
VAR detective = 0
VAR careless = 0
VAR eccentric = 0
VAR class_confidence = 0
VAR medium_reputation = 0
VAR court_loyalty = 0
VAR viktor_trust = 0
VAR viktor_suspicion = 0
VAR supernatural_exposure = 0
-> intro_train
=== intro_train ===
The train has left Vienna behind, though Vienna has not yet left you. #chapter[Eibenreith] #music[Kaiserpunk Waltz.mp3](crossfade, loop, lead=8)
It clings to the black gloss of your travelling boots, to the cut of your coat, to the stiff little prison of your gloves. It lives in the seal upon the letter folded inside your reticule, in the thin scent of coal smoke that has insinuated itself even into first-class upholstery, in the fact that Herr Viktor Nowak sits opposite you as if the carriage were a field office and not a compartment lined in velvet, polished wood, and brass.
Outside the window, the last outskirts of the capital have broken apart into winter-browned fields and villages with church towers too small to compete with the engine's whistle. The rails take the land without asking permission. Embankments cut through orchards. Telegraph poles pass at regular intervals, each one vanishing behind you like a thought dismissed too quickly. #sfx[steam-whistle.ogg]
You had expected the train to feel like a triumph of the age.
Instead it feels like an argument. #image[suedbahn.png](landscape)
The machine throws itself southward with a violence that polite society would never admit to admiring. The lamps tremble in their fittings. Your cup rattles against its saucer. Beyond the glass, the country begins to rise, first gently, then with a firmer will, until the line itself seems to negotiate with the mountains through stone arches, black tunnels, and viaducts thrown across ravines with all the confidence of imperial engineering.
Viktor has not looked impressed once.
His civilian clothes are correct enough to pass without comment: dark frock coat, sober waistcoat, gloves, collar immaculate, the posture of a man who has never truly sat at ease in his life. But no tailor can disguise discipline. It remains in his shoulders, in the economy of his movements, in the way his eyes measure doors, windows, luggage rack, corridor, your face, then the door again.
On the paperwork he is your secretary and travelling companion.
In truth, he is an officer lent to a delicate matter by channels that prefer not to be named. Rittmeister Viktor Alois Nowak, though no one at Jagdhaus Hohenreith is expected to call him that. Your hosts have asked for a medium. The Cabinet has sent them you. The military has sent him to make certain that you do not become a scandal before you become useful.
He folds the newspaper, though you are quite certain he had not been reading it.
"You have been very quiet, gnädiges Fräulein."
The form of address is technically correct if you are noble, excessive if you are not, and perfectly chosen because he does not yet know which part of you is useful, which part is costume, and which part is threat.
"For a lady on her first official journey," he adds, "you show remarkable restraint."
You look around the compartment before you answer. The answer comes from somewhere older than the letter in your reticule. It comes from the place you began.
* [The compartment seems built for people who never wonder whether they belong in it.] #class:noble
~ birth_class = "noble"
~ class_confidence += 2
~ court_loyalty += 1
It is not luxury that unsettles you. Luxury is only wood, cloth, brass, service, silence. What matters is whether the servants glance twice, whether the guard lowers his voice, whether another passenger weighs your gloves and decides not to ask your business.
You were born among people who understood such things before they understood kindness.
-> class_noble_background
* [You count the cost of each detail before you can stop yourself.] #class:middle
~ birth_class = "middle"
~ class_confidence += 1
The upholstery, the lamps, the polished veneer, the quiet attendance at stations: none of it is magical. It is paid for. Accounted for. Itemised somewhere by someone with ink on his cuffs and a wife who knows how long candles may be burned before the household budget complains.
You were not born to this compartment, but you were born close enough to study its rules.
-> class_middle_background
* [You notice first how clean everything is, and how carefully one must sit so as not to betray noticing.] #class:working
~ birth_class = "working"
~ class_confidence -= 1
The velvet looks soft enough to swallow fingerprints. The brass fittings have been polished by hands that will never sit here. The little curtain strap is worn where other travellers, all of them more certain than you, have touched it without gratitude.
You were not born on this side of service.
-> class_working_background
=== class_noble_background ===
"Restraint is not a virtue, Herr Nowak," you say. "It is often only good breeding with its mouth shut."
His brows move almost imperceptibly.
You learned young that every room contains a court, even when no emperor is present. A girl of your rank is trained to enter, to bow, to be introduced, to be placed, to speak only enough, to understand more than she admits, and to know that a family name can be both a key and a chain.
Your own family possesses no grand seat, no army of retainers, no ancient right to command provinces. But your name opened drawing-room doors in Vienna, and once inside those rooms you learned to make people repeat stories they had meant only to hint at. You learned how widows speak when priests are absent, how officers lie when flattered, how old men confess when they believe themselves admired, and how a young woman may be underestimated so consistently that underestimation becomes a profession.
Your reputation as a medium did not descend from heaven. It was assembled from half-lights, correct guesses, careful silences, and the willingness of better-born fools to mistake performance for revelation.
Before the court could use you, society had first to invent you.
Now choose the name by which Vienna invented you.
-> choose_name_noble
=== class_middle_background ===
"Restraint," you say, "is easier when one has learned that every mistake is remembered by someone better placed."
Viktor watches you more closely.
You were born in that broad, anxious territory between deference and ambition. Your family had books, invoices, respectability, perhaps a piano no one played well enough, perhaps a father with an office, a mother with callers, brothers who were expected to advance, and daughters who were expected not to make advancement look hungry.
You learned accounts before etiquette, etiquette before French, and French before you learned how easily a woman with a calm voice could make men explain themselves. You rose because you listened. You rose because you understood that fraud, faith, medicine, gossip, politics, and grief all use the same doors into the human mind.
The court does not like to admit that it needs middle-class competence. It prefers to borrow it, dress it properly, and call it discretion.
Your reputation as a medium gave them a word that sounded less dangerous than investigator.
Now choose the name under which you entered the salons that first laughed at you, then invited you back.
-> choose_name_middle
=== class_working_background ===
"Restraint," you say, "is what people praise when they prefer not to see the effort."
The newspaper in Viktor's hand creases once.
You were born among people who owned little but obligations. Work had a sound before it had a meaning: water, broom, bootsteps, breath, the clatter of dishes, the cough of men coming in from cold yards, women counting coins under their breath. You learned early that the high-born are not more observant than others. They are merely less often required to observe.
That was your first advantage.
A servant knows which door matters because she uses the others. A seamstress learns bodies because she measures them. A maid learns secrets because fine people leave their souls lying about like gloves, certain that no one beneath them has hands.
You rose by talent, patronage, imitation, nerve, and the terrible convenience of being believed harmless. By the time Vienna began whispering that you saw more than respectable people saw, you had already spent years seeing what respectable people missed.
The court has placed you in first class because it needs what birth did not give you.
Now choose the name you carried upward, altered perhaps in pronunciation, never quite cleansed of where it began.
-> choose_name_working
=== choose_name_noble ===
// Noble title is fixed for the canonical lower-noble route.
// Full names are formal; common_name is used for narration and dialogue.
* [Valerie Eleonore Josepha]
~ given_names = "Valerie Eleonore Josepha"
~ common_name = "Valerie"
-> choose_surname_noble
* [Helene Cäcilie Franziska]
~ given_names = "Helene Cäcilie Franziska"
~ common_name = "Helene"
-> choose_surname_noble
* [Clara Theresia Leopoldine]
~ given_names = "Clara Theresia Leopoldine"
~ common_name = "Clara"
-> choose_surname_noble
* [Sophie Eleonore Auguste]
~ given_names = "Sophie Eleonore Auguste"
~ common_name = "Sophie"
-> choose_surname_noble
* [Mathilde Josepha Henriette]
~ given_names = "Mathilde Josepha Henriette"
~ common_name = "Mathilde"
-> choose_surname_noble
* [Therese Valerie Franziska]
~ given_names = "Therese Valerie Franziska"
~ common_name = "Therese"
-> choose_surname_noble
* [Ilona Theresia Eleonore]
~ given_names = "Ilona Theresia Eleonore"
~ common_name = "Ilona"
-> choose_surname_noble
* [Zdenka Eleonore Josepha]
~ given_names = "Zdenka Eleonore Josepha"
~ common_name = "Zdenka"
-> choose_surname_noble
=== choose_surname_noble ===
Your title is fixed by birth and by the careful modesty of your family: not countess, not princess, not one of the brilliant names that gather ambassadors and creditors like dust.
A Freiin. Baronial. Usable. Admitted, but not enthroned.
* [Freiin von Rauhenfels]
~ title_part = "Freiin von"
~ surname = "Rauhenfels"
-> assemble_full_name
* [Freiin von Traunegg]
~ title_part = "Freiin von"
~ surname = "Traunegg"
-> assemble_full_name
* [Freiin von Ebenwald]
~ title_part = "Freiin von"
~ surname = "Ebenwald"
-> assemble_full_name
* [Freiin von Arnsberg]
~ title_part = "Freiin von"
~ surname = "Arnsberg"
-> assemble_full_name
* [Freiin von Reichenau]
~ title_part = "Freiin von"
~ surname = "Reichenau"
-> assemble_full_name
* [Freiin von Waldstätten]
~ title_part = "Freiin von"
~ surname = "Waldstätten"
-> assemble_full_name
=== choose_name_middle ===
* [Clara Eleonore]
~ given_names = "Clara Eleonore"
~ common_name = "Clara"
-> choose_surname_middle
* [Anna Katharina]
~ given_names = "Anna Katharina"
~ common_name = "Anna"
-> choose_surname_middle
* [Helene Theresia]
~ given_names = "Helene Theresia"
~ common_name = "Helene"
-> choose_surname_middle
* [Rosa Franziska]
~ given_names = "Rosa Franziska"
~ common_name = "Rosa"
-> choose_surname_middle
* [Johanna Elise]
~ given_names = "Johanna Elise"
~ common_name = "Johanna"
-> choose_surname_middle
* [Katharina Sophie]
~ given_names = "Katharina Sophie"
~ common_name = "Katharina"
-> choose_surname_middle
* [Therese Leopoldine]
~ given_names = "Therese Leopoldine"
~ common_name = "Therese"
-> choose_surname_middle
* [Magdalena Cäcilie]
~ given_names = "Magdalena Cäcilie"
~ common_name = "Magdalena"
-> choose_surname_middle
=== choose_surname_middle ===
Your family name contains no particle to soften the ascent. It must stand upright by itself.
* [Leitner]
~ title_part = "Fräulein"
~ surname = "Leitner"
-> assemble_full_name
* [Wagner]
~ title_part = "Fräulein"
~ surname = "Wagner"
-> assemble_full_name
* [Kellner]
~ title_part = "Fräulein"
~ surname = "Kellner"
-> assemble_full_name
* [Baumgartner]
~ title_part = "Fräulein"
~ surname = "Baumgartner"
-> assemble_full_name
* [Fischer]
~ title_part = "Fräulein"
~ surname = "Fischer"
-> assemble_full_name
* [Schmid]
~ title_part = "Fräulein"
~ surname = "Schmid"
-> assemble_full_name
* [Pichler]
~ title_part = "Fräulein"
~ surname = "Pichler"
-> assemble_full_name
* [Rosenfeld]
~ title_part = "Fräulein"
~ surname = "Rosenfeld"
-> assemble_full_name
=== choose_name_working ===
* [Anna]
~ given_names = "Anna"
~ common_name = "Anna"
-> choose_surname_working
* [Klara]
~ given_names = "Klara"
~ common_name = "Klara"
-> choose_surname_working
* [Agnes]
~ given_names = "Agnes"
~ common_name = "Agnes"
-> choose_surname_working
* [Leni]
~ given_names = "Leni"
~ common_name = "Leni"
-> choose_surname_working
* [Rosa]
~ given_names = "Rosa"
~ common_name = "Rosa"
-> choose_surname_working
* [Gertrud]
~ given_names = "Gertrud"
~ common_name = "Gertrud"
-> choose_surname_working
* [Elisabeth]
~ given_names = "Elisabeth"
~ common_name = "Elisabeth"
-> choose_surname_working
* [Franziska]
~ given_names = "Franziska"
~ common_name = "Franziska"
-> choose_surname_working
=== choose_surname_working ===
A simple name can be a burden in Vienna. It tells people how little they must pretend to respect you before you have spoken.
* [Pichler]
~ title_part = "Fräulein"
~ surname = "Pichler"
-> assemble_full_name
* [Huber]
~ title_part = "Fräulein"
~ surname = "Huber"
-> assemble_full_name
* [Maier]
~ title_part = "Fräulein"
~ surname = "Maier"
-> assemble_full_name
* [Gruber]
~ title_part = "Fräulein"
~ surname = "Gruber"
-> assemble_full_name
* [Schuster]
~ title_part = "Fräulein"
~ surname = "Schuster"
-> assemble_full_name
* [Krenn]
~ title_part = "Fräulein"
~ surname = "Krenn"
-> assemble_full_name
* [Wolf]
~ title_part = "Fräulein"
~ surname = "Wolf"
-> assemble_full_name
* [Moser]
~ title_part = "Fräulein"
~ surname = "Moser"
-> assemble_full_name
=== assemble_full_name ===
{birth_class == "noble":
~ full_name = given_names + " " + title_part + " " + surname
- else:
~ full_name = title_part + " " + given_names + " " + surname
}
{birth_class == "noble":
On visiting cards, in letters, in the cautious mouths of servants, you are {full_name}.
- else:
On railway documents, hotel ledgers, and the tongues of people who have not yet decided how much respect you deserve, you are {full_name}.
}
But in the private chamber where a name is first answered before it is performed, you are {common_name}.
Viktor has waited through your silence with a soldier's patience and a jailer's courtesy. The train enters another tunnel. For several seconds the compartment window gives you back only your own reflection: your hat, your pale face above the dark collar, your eyes too steady or not steady enough.
When the mountains return, they seem closer.
-> supernatural_stance
=== supernatural_stance ===
The letter of commission in your reticule does not call you an investigator.
It calls you, in prose dry enough to pass through any number of offices, a woman whose unusual spiritual reputation has recommended her to a delicate household matter. The phrasing is exquisite. It neither affirms nor denies. It permits everyone involved to believe afterward that they had believed nothing improper.
The comital family at Jagdhaus Hohenreith has asked for discretion. Vienna has answered with a sealed letter, a woman reputed to speak with what is hidden, and a man opposite her who has orders of his own.
Before this journey, before this train, before the mountains began taking the sky piece by piece, what did you believe?
* [The dead are not silent. The living are merely poor listeners.] #supernatural:believer
~ supernatural_belief = "believer"
~ medium_reputation += 1
~ supernatural_exposure += 1
You have always thought disbelief a provincial arrogance of the educated. There are pressures in rooms where grief has been. There are words people speak before they know they have spoken. There are dreams that arrive with mud on their hems.
Perhaps the world is not haunted. Perhaps it is simply crowded.
-> spiritual_senses
* [The supernatural is usually pain, fraud, fever, inheritance, or bad ventilation.] #supernatural:sceptic #route:detective
~ supernatural_belief = "sceptic"
~ detective += 1
The word spirit covers too much and explains too little. You have watched respectable people call an echo a message, a coincidence a sign, a trembling hand an angelic visitation. Men of science can be fools, but fools with candles and planchettes are no improvement.
If Hohenreith has ghosts, you expect them to keep accounts, write letters, leave footprints, and benefit someone.
-> spiritual_senses
* [Belief is a costume. You wear it because men insist on dressing you in it.] #supernatural:performer
~ supernatural_belief = "performer"
~ medium_reputation += 2
You discovered early that men who distrust a woman's mind will sometimes worship her nerves. A conclusion from evidence irritates them. A vision, sighed through lowered lashes, makes them lean closer.
Very well. Let them lean.
-> spiritual_senses
* [You have learned not to decide too early.] #supernatural:undecided
~ supernatural_belief = "undecided"
There are things you can explain, things you cannot yet explain, and things that explanation damages before it helps. You have made a profession of standing at thresholds with a face composed enough for both sides to continue speaking.
Hohenreith will have to show you what kind of case it is.
-> spiritual_senses
=== spiritual_senses ===
Belief is one matter. Experience is another.
People call a woman sensitive when they want her perceptions to sound like an illness. They call her hysterical when those perceptions inconvenience them. They call her inspired when they need her, and unstable when they do not.
What, beneath reputation and performance, has truly happened to you?
* [There have been moments you cannot explain away.] #powers:genuine
~ supernatural_senses = "genuine"
~ supernatural_exposure += 2
Once, as a child, you knew before the telegram came. Once, in a crowded room, a stranger's grief entered you with such force that your own knees failed. Once, in a mirror, you saw a door behind you that was not in the room when you turned.
You learned caution after that. It is unwise for a woman to know things before a man has asked her opinion.
-> viktor_first_exchange
* [Everything you do can be explained by observation, timing, and nerve.] #powers:faked #route:detective
~ supernatural_senses = "faked"
~ detective += 1
You notice rings removed too recently, mourning gloves worn too carefully, letters folded and refolded until the crease gives away the reader's obsession. You hear servants misname guests, mothers pause before daughters' rooms, officers lie by becoming too exact.
The dead have never told you anything. The living cannot stop telling you everything.
-> viktor_first_exchange
* [Something happens, but never when summoned.] #powers:ambiguous
~ supernatural_senses = "ambiguous"
~ supernatural_exposure += 1
Your reputation depends upon command. The truth, if truth it is, has no respect for appointments.
Sometimes a room changes pressure around you. Sometimes a face acquires an old expression no living person taught it. Sometimes names arrive before introductions. But the harder you reach, the more ordinary the world becomes.
-> viktor_first_exchange
* [You buried the first signs so thoroughly that even you do not know what remains.] #powers:repressed #route:eccentric
~ supernatural_senses = "repressed"
~ eccentric += 1
There are childhood memories sealed behind politeness: a nursery mirror turned to the wall, a nurse dismissed without reference, your mother's hand tightening around your wrist until the bones complained.
You became strange afterward in ways society found easier to admire than understand.
-> viktor_first_exchange
=== viktor_first_exchange ===
The train emerges from the tunnel into a pale afternoon cut by dark firs and white rock. Far below, water shows itself only in flashes. The valley is no longer a view from a salon painting. It has depth enough to hide things.
Viktor opens a leather folder and removes a memorandum. He does not hand it to you at once.
"When we leave the railway," he says, "we will be met by a coach from Hohenreith. From that moment, appearances matter. Your hosts have been told that I assist with correspondence, travel, and practical arrangements. They need not be troubled with military definitions."
"And the villagers?" you ask.
"The villagers need not be troubled with anything."
There it is: the empire in miniature. A man, a folder, a locked sentence.
"You will be addressed according to the station you present," he continues. "The Graf's household will observe rank. Servants will observe what the household observes. Villagers may observe less and remember more. I advise restraint."
The advice is sound. That makes it no less irritating.
How do you answer him?
* ["If gentlemen were less easily led, Herr Nowak, ladies would require fewer methods."] #route:lover
~ lover += 1
~ viktor_relation = "provocation"
~ viktor_trust -= 1
~ viktor_suspicion += 1
For the first time, amusement almost reaches his mouth.
"A dangerous doctrine."
"A practical one."
"You intend to practice it at Hohenreith?"
"Only where patriotism requires sacrifice."
He looks down at the memorandum, but not quickly enough to conceal that he is reassessing you.
-> viktor_explains_orders
* ["If you wish me to pass as harmless, you must stop warning me like a gaoler."] #route:sapphic
~ sapphic += 1
~ viktor_relation = "tension"
~ viktor_suspicion += 1
His gaze sharpens.
"I am not your gaoler."
"No. A gaoler is at least honest about the key."
The words surprise you by leaving a mark. Not on him, perhaps. On yourself. The closer the train carries you to Amalia's world, though you do not yet know her face, the more intolerable it seems that every female life there might be guarded by men who call the guarding concern.
Viktor folds the memorandum once, precisely.
-> viktor_explains_orders
* ["Then let us be exact. What do they know, what do they suspect, and what am I permitted to verify?"] #route:detective
~ detective += 1
~ viktor_relation = "professional"
~ viktor_trust += 1
He gives the smallest nod, as if you have chosen the only answer fit for adults.
"They know that you come recommended. They suspect that you may be able to settle disturbances without police, priest, or press. You are permitted to verify fraud, coercion, threat to public order, or credible phenomena not presently classifiable."
"Credible phenomena not presently classifiable."
"That is the phrase."
"A bureaucratic ghost."
"The safest kind."
-> viktor_explains_orders
* ["I shall do my best not to faint unless it is useful."] #route:careless
~ careless += 1
~ viktor_relation = "dependence"
~ viktor_trust -= 1
Something in his expression tightens; not contempt exactly, but readiness.
"I would prefer you did not faint at all."
"How ungallant."
"How practical."
"Then you must be practical for both of us. I have never trusted the floor in strange houses."
His answer is delayed by half a breath.
"That, gnädiges Fräulein, is precisely what concerns me."
-> viktor_explains_orders
* ["Restraint is what timid people call obedience after they have forgotten who trained them."] #route:eccentric
~ eccentric += 1
~ viktor_relation = "challenge"
~ viktor_suspicion += 2
Viktor studies you as he might study an unfamiliar weapon found in luggage.
"You enjoy making enemies."
"No. I dislike the laziness of letting fools remain undecided."
"At Hohenreith, that dislike may become expensive."
"Then the Graf should have invited someone cheaper."
The wheels strike a curve. The compartment leans. For a moment the two of you are held in the same narrow imbalance.
-> viktor_explains_orders
=== viktor_explains_orders ===
Viktor gives you the memorandum at last.
The document is not long. That is part of its menace. Long documents invite argument; short ones carry authority.
A comital household. A hunting residence in Upper Styria, not the family's principal seat. Reports of disturbances among servants and villagers. No police action requested. No public ecclesiastical inquiry desired. No press. No correspondence beyond approved channels. Your presence to be explained as a discreet consultation requested by the family. Herr Nowak to assist in practical matters.
No one has written the word ghost.
No one has written the word fraud.
No one has written the word daughter.
Yet the omissions arrange themselves around the page like furniture around a corpse.
"There is another instruction," you say.
Viktor does not ask how you know.
"There is always another instruction," he says.
"For you."
"Yes."
"Concerning me?"
"Partly."
The train begins to slow. The rhythm changes first in the floor, then in the window, then in the body. Houses gather beside the line. A station roof appears between drifting smoke and the dark combs of forested slopes. #sfx[steam-whistle.ogg]
"Then I shall try to be worth the ink," you say.
"I sincerely hope so."
You cannot decide whether it is an insult, a prayer, or his first honest sentence.
-> railway_station
=== railway_station ===
The station is small enough that the train seems briefly embarrassed to stop there. #chapter[The Station] #image[muerzzuschlag.png](portrait)
A porter in a cap too large for him hurries along the platform. A woman with a basket steps back from the steam as if from an animal. Somewhere beyond the station building, a cart horse stamps at frozen mud. The signboard gives the place a name you have seen in the timetable but will not remember with affection.
Your luggage descends in stages: trunk, hatbox, smaller travelling case, dispatch case, folded rug, a narrow black case whose contents would embarrass both a priest and a conjurer if either searched it without imagination. Viktor oversees the transfer with clipped civility. He does not carry like a servant. He directs like a man pretending not to command.
The coach from Hohenreith waits beyond the station yard: dark green paint, black wheels, the comital crest discreetly worn on the door, and two horses already restless beneath harness. The driver removes his hat when he sees you. Not too deeply. Deep enough for rank, not deep enough for reverence. #sfx[horse-neigh.ogg]
"Gnädiges Fräulein? Herr Sekretär?"
{birth_class == "noble":
He has been told enough to place you. That is a courtesy. It is also a warning.
- else:
He hesitates over you by the smallest measure. The hesitation is not rudeness. It is calculation. First-class carriage, court letter, no title beyond Fräulein, and a man beside you who looks like he has arrested people for less than staring.
}
Viktor answers before you can.
"From Jagdhaus Hohenreith?"
"Jawohl, Herr Sekretär. The road is passable. If the mist holds, we should reach Eibenreith before dark."
The word enters the air without ceremony.
Eibenreith.
Not Hohenreith, the name printed on the invitation in a clean hand. Eibenreith: the village below it. A smaller name. Older in the mouth. A name with roots rather than stationery.
-> coach_journey
=== coach_journey ===
The coach leaves the station behind and with it the last easy evidence of empire. #chapter[The Graben] #music[Kaiserpunk Jodler.mp3](crossfade, loop, lead=4)
At first the road follows a valley where telegraph wire still keeps company with it and the river moves in a pale, stony bed. Sawmills, fenced meadows, and farmhouses appear and vanish behind stands of spruce. The mountains do not rise all at once. They advance by jurisdiction. A wooded slope claims the left-hand sky, then a grey wall of limestone closes the north, then another ridge gathers to the east until even the clouds seem to have entered service.
The driver names places when Viktor asks, but the names are local and practical, meant for men who know which bridge floods and which farm breeds stubborn horses. Somewhere beyond the visible ridges, he says, lies the great white back of the Hochschwab. Eastward, beyond forest and pass, the Hohe Veitsch keeps its own weather. He says this not as a guide would say it, but as a man explaining neighbours who may or may not be in a temper.
The main valley narrows.
The road turns from it into a side Graben, and the change is immediate. Sound alters. The wheels no longer ring against open distance but grind between banks, roots, and wet stone. The air smells of leaf mould, resin, and cold water. Yews appear among the firs in dark, improbable patience, their needles too black for the afternoon.
"Eibenreither Graben," the driver says, and crosses himself so quickly that the gesture might have been meant for a rut in the road.
Viktor notices. Of course he notices.
"Bad road?" he asks.
"Old road," the driver says.
No one speaks for a while.
You watch the trees.
There are forests that invite stories because they are pretty, and forests that reject stories because whatever happened there did not require witnesses. This one belongs to the second kind. Its trunks stand close, not wildly, but with the air of a crowd making room for something carried through it long ago. The snow that remains in hollows is not clean. It has gathered needles, bark, and a yellowish stain where water has risen underneath.
On a slope above the road, half swallowed by undergrowth, you glimpse stone.
A shrine, perhaps. A boundary marker. A figure. The coach has passed before your eyes can persuade themselves of its shape. For one instant you are left with the impression of a woman's head inclined not in prayer, but in listening. #image[statue.png](square)
{supernatural_senses == "genuine" or supernatural_senses == "ambiguous" or supernatural_senses == "repressed":
The back of your neck tightens.
Not fear. Recognition would be worse.
~ supernatural_exposure += 1
- else:
You tell yourself that old stone, seen through moving branches, will become whatever the mind is cowardly enough to supply.
}
Viktor has turned slightly toward the same slope.
"Did you see something?" he asks.
* ["A woman in the wood, perhaps. Or a stone that wanted to be one."] #route:eccentric #statue_hint
~ eccentric += 1
~ viktor_suspicion += 1
He studies the passing trees.
"A local shrine?"
"If it is a shrine, it has not been loved recently."
"You speak as if stones notice neglect."
"Do soldiers not?"
He does not answer.
-> coach_nears_village
* ["A marker. I would like to know where that path leads."] #route:detective #statue_hint
~ detective += 1
~ viktor_trust += 1
"You saw a path?"
"Not clearly. Enough to ask later."
Viktor looks back through the small rear window. The bend has already erased the slope.
"Ask carefully. Places people fail to mention are often more informative than those they recommend."
-> coach_nears_village
* ["Only trees. The sort that make one grateful for gentlemen with revolvers."] #route:careless
~ careless += 1
~ viktor_relation = "dependence"
His expression darkens by one official degree.
"A revolver is a poor instrument against trees."
"Then I shall rely on your conversation to intimidate them."
The driver pretends not to hear. His shoulders, however, hear everything.
-> coach_nears_village
* ["Would you believe me if I said I had?"] #route:lover
~ lover += 1
~ viktor_suspicion += 1
"That would depend on what advantage you expected from the answer."
"Herr Nowak. You wound me."
"Not yet."
It is the first thing he has said all day that almost sounds like flirtation, though perhaps only because danger has a talent for borrowing warmer clothes.
-> coach_nears_village
* ["No." ] #route:sapphic
~ sapphic += 1
The denial is too quick, and you both hear it.
You are not thinking of the stone now. You are thinking of the young woman waiting somewhere ahead: the Graf's daughter, the reason carefully not written into the memorandum, the stranger whose household has summoned you under a title both absurd and useful.
If this place keeps women in stone, you think, what does it do to them in houses?
-> coach_nears_village
=== coach_nears_village ===
The Graben opens reluctantly.
First comes the smell of smoke. Then a roof, low and dark with weather. Then another. Then a church tower, not high, not graceful, but thick-shouldered and pale against the slope behind it. Its walls look older than the village around them and less certain of victory. The windows are small. The churchyard wall holds the road at a distance, as if the dead require fortification from the living, or the living from something else. #chapter[Eibenreith Village] #sfx[church-bells.ogg](max=8, fade) #image[eibenreith.png](landscape)
Eibenreith appears not as a village in a picture appears, all at once and composed for admiration, but by fragments.
A woman in a dark kerchief pauses with a pail in her hand. A boy stops driving geese and lets them complain around his boots. Two men outside a shed end their conversation at the same moment without looking at each other. Curtains stir in windows where no one admits to standing. A blacksmith's sign moves slightly in air you cannot feel. Water runs somewhere under boards, under stone, under the road itself, quick and cold and hidden.
The houses are not poor, not exactly. Many are solid, whitewashed, shingled, kept with the stubborn decency of people who repair what they cannot replace. Yet something in their arrangement troubles the eye. They turn toward the church but not fully. They keep the road but lean from it. They leave, between yard and fence and woodpile, narrow passages where shadow gathers too early.
The coach slows.
No one runs to greet it.
No one needs to. News has already entered the village by means faster than railway, telegraph, or imperial seal.
You sit very straight as Eibenreith takes its first look at you.
Beside you, Viktor lowers his voice.
"Remember: at Hohenreith, every courtesy will mean something. Here, every silence will."
The horses draw the coach past the churchyard wall. Above it, on the old plaster beside the gate, a faded painted woman looks down from beneath a flaking blue mantle. Her hands are folded in prayer. Her eyes, damaged by weather, no longer point in the same direction.
For one breath, as the wheels pass over a buried runnel of water, the painted face seems less like the Holy Mother than like a mask put on something that had been waiting longer.
Then the coach enters the village proper, and the road bends toward the unseen height where Jagdhaus Hohenreith stands above Eibenreith under its newer name.
-> END
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5 -3
View File
@@ -2,9 +2,9 @@ title: The Mysterious Mansion
author: AI Interactive Fiction
version: 1.0.0
introduction: |
::music[crossfade, loop, lead=10](Dark Jodler.mp3)
::chapter[The Mysterious Mansion]
#chapter[The Mysterious Mansion]
#music[Dark Jodler.mp3](lead=10)
The last thing you remember is the letter: heavy paper, black wax, your name written in a hand you almost recognized.
It asked you to come after dusk, alone, and promised that the house would answer what the sender could not.
@@ -38,7 +38,9 @@ rooms:
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.
#sfx[squeaky-door.ogg]
When you reach for the handle, it turns before your fingers touch it, and the door opens with a long, complaining squeak.
exits:
- direction: north
targetRoomId: entrance_hall
+38
View File
@@ -0,0 +1,38 @@
export type EngineName = 'yaml' | 'ink' | 'zork' | string;
export interface GameMetadata {
title: string;
author?: string;
subtitle?: string;
version?: string;
copyright?: string;
}
export interface GamePaths {
mainGameFile: string;
inkSource?: string;
inkCompiled?: string;
promptDir?: string;
music?: string;
sfx?: string;
images?: string;
[key: string]: string | undefined;
}
export interface GameEngineConfig {
engine: EngineName;
locale: 'en_US' | 'de_DE' | string;
paths: GamePaths;
metadata: GameMetadata;
}
export declare function projectPath(relativeOrAbsolutePath: string): string;
export declare function loadGameConfig(configPath: string, engine: EngineName): GameEngineConfig;
export declare function ensureConfiguredAssetDirectories(config: GameEngineConfig): void;
export declare function clientGameConfig(config: GameEngineConfig): {
engine: string;
locale: string;
metadata: GameMetadata;
assets: {
music: string;
sfx: string;
sounds: string;
images: string;
};
};
+94
View File
@@ -0,0 +1,94 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.projectPath = projectPath;
exports.loadGameConfig = loadGameConfig;
exports.ensureConfiguredAssetDirectories = ensureConfiguredAssetDirectories;
exports.clientGameConfig = clientGameConfig;
const path_1 = __importDefault(require("path"));
const fs_1 = require("fs");
const PROJECT_ROOT = path_1.default.resolve(__dirname, '../..');
function fallbackConfig(engine) {
return {
engine,
locale: 'en_US',
paths: {
mainGameFile: engine === 'ink'
? 'data/ink/story.ink.json'
: engine === 'zork'
? 'data/z-code/zork1.bin'
: 'data/worlds/example_world.yml',
music: 'public/music',
sfx: 'public/sounds',
images: 'public/images',
},
metadata: {
title: 'AI Interactive Fiction',
author: 'Generative AI',
subtitle: 'An open-world text adventure',
version: '1.0.0',
copyright: '',
},
};
}
function projectPath(relativeOrAbsolutePath) {
return path_1.default.isAbsolute(relativeOrAbsolutePath)
? relativeOrAbsolutePath
: path_1.default.resolve(PROJECT_ROOT, relativeOrAbsolutePath);
}
function loadGameConfig(configPath, engine) {
const absolutePath = projectPath(configPath);
if (!(0, fs_1.existsSync)(absolutePath)) {
console.warn(`[config] Missing ${absolutePath}; using ${engine} defaults.`);
return fallbackConfig(engine);
}
const parsed = JSON.parse((0, fs_1.readFileSync)(absolutePath, 'utf8'));
const fallback = fallbackConfig(engine);
return {
engine: parsed.engine ?? fallback.engine,
locale: parsed.locale ?? fallback.locale,
paths: {
...fallback.paths,
...(parsed.paths ?? {}),
},
metadata: {
...fallback.metadata,
...(parsed.metadata ?? {}),
},
};
}
function ensureConfiguredAssetDirectories(config) {
const directories = [
config.paths.music,
config.paths.sfx,
config.paths.images,
config.paths.inkSource ? path_1.default.dirname(config.paths.inkSource) : undefined,
config.paths.inkCompiled ? path_1.default.dirname(config.paths.inkCompiled) : undefined,
config.paths.mainGameFile ? path_1.default.dirname(config.paths.mainGameFile) : undefined,
config.paths.promptDir,
];
for (const directory of directories) {
if (!directory)
continue;
const absolutePath = projectPath(directory);
if (!(0, fs_1.existsSync)(absolutePath)) {
(0, fs_1.mkdirSync)(absolutePath, { recursive: true });
}
}
}
function clientGameConfig(config) {
return {
engine: config.engine,
locale: config.locale,
metadata: config.metadata,
assets: {
music: '/music/',
sfx: '/sounds/',
sounds: '/sounds/',
images: '/images/',
},
};
}
//# sourceMappingURL=game-config.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"game-config.js","sourceRoot":"","sources":["../../src/config/game-config.ts"],"names":[],"mappings":";;;;;AA0DA,kCAIC;AAED,wCAqBC;AAED,4EAkBC;AAED,4CAYC;AAvHD,gDAAwB;AACxB,2BAAyD;AA8BzD,MAAM,YAAY,GAAG,cAAI,CAAC,OAAO,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;AAEtD,SAAS,cAAc,CAAC,MAAkB;IACxC,OAAO;QACL,MAAM;QACN,MAAM,EAAE,OAAO;QACf,KAAK,EAAE;YACL,YAAY,EACV,MAAM,KAAK,KAAK;gBACd,CAAC,CAAC,yBAAyB;gBAC3B,CAAC,CAAC,MAAM,KAAK,MAAM;oBACjB,CAAC,CAAC,uBAAuB;oBACzB,CAAC,CAAC,+BAA+B;YACvC,KAAK,EAAE,cAAc;YACrB,GAAG,EAAE,eAAe;YACpB,MAAM,EAAE,eAAe;SACxB;QACD,QAAQ,EAAE;YACR,KAAK,EAAE,wBAAwB;YAC/B,MAAM,EAAE,eAAe;YACvB,QAAQ,EAAE,8BAA8B;YACxC,OAAO,EAAE,OAAO;YAChB,SAAS,EAAE,EAAE;SACd;KACF,CAAC;AACJ,CAAC;AAED,SAAgB,WAAW,CAAC,sBAA8B;IACxD,OAAO,cAAI,CAAC,UAAU,CAAC,sBAAsB,CAAC;QAC5C,CAAC,CAAC,sBAAsB;QACxB,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,YAAY,EAAE,sBAAsB,CAAC,CAAC;AACzD,CAAC;AAED,SAAgB,cAAc,CAAC,UAAkB,EAAE,MAAkB;IACnE,MAAM,YAAY,GAAG,WAAW,CAAC,UAAU,CAAC,CAAC;IAC7C,IAAI,CAAC,IAAA,eAAU,EAAC,YAAY,CAAC,EAAE,CAAC;QAC9B,OAAO,CAAC,IAAI,CAAC,oBAAoB,YAAY,WAAW,MAAM,YAAY,CAAC,CAAC;QAC5E,OAAO,cAAc,CAAC,MAAM,CAAC,CAAC;IAChC,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAA,iBAAY,EAAC,YAAY,EAAE,MAAM,CAAC,CAA8B,CAAC;IAC3F,MAAM,QAAQ,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM;QACxC,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM;QACxC,KAAK,EAAE;YACL,GAAG,QAAQ,CAAC,KAAK;YACjB,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC;SACxB;QACD,QAAQ,EAAE;YACR,GAAG,QAAQ,CAAC,QAAQ;YACpB,GAAG,CAAC,MAAM,CAAC,QAAQ,IAAI,EAAE,CAAC;SAC3B;KACF,CAAC;AACJ,CAAC;AAED,SAAgB,gCAAgC,CAAC,MAAwB;IACvE,MAAM,WAAW,GAAG;QAClB,MAAM,CAAC,KAAK,CAAC,KAAK;QAClB,MAAM,CAAC,KAAK,CAAC,GAAG;QAChB,MAAM,CAAC,KAAK,CAAC,MAAM;QACnB,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS;QACzE,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,SAAS;QAC7E,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,SAAS;QAC/E,MAAM,CAAC,KAAK,CAAC,SAAS;KACvB,CAAC;IAEF,KAAK,MAAM,SAAS,IAAI,WAAW,EAAE,CAAC;QACpC,IAAI,CAAC,SAAS;YAAE,SAAS;QACzB,MAAM,YAAY,GAAG,WAAW,CAAC,SAAS,CAAC,CAAC;QAC5C,IAAI,CAAC,IAAA,eAAU,EAAC,YAAY,CAAC,EAAE,CAAC;YAC9B,IAAA,cAAS,EAAC,YAAY,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC/C,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAgB,gBAAgB,CAAC,MAAwB;IACvD,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,MAAM,EAAE;YACN,KAAK,EAAE,SAAS;YAChB,GAAG,EAAE,UAAU;YACf,MAAM,EAAE,UAAU;YAClB,MAAM,EAAE,UAAU;SACnB;KACF,CAAC;AACJ,CAAC"}
+20
View File
@@ -0,0 +1,20 @@
import { TurnResult } from '../interfaces/turn-result';
export interface InkCompileResult {
sourcePath: string;
outputPath: string;
warningCount: number;
}
export declare function compileInkSource(sourcePath: string, outputPath: string): InkCompileResult;
export declare class InkEngine {
private readonly storyPath;
private story;
private nextTurnId;
constructor(storyPath: string);
isRunning(): boolean;
newGame(): TurnResult;
chooseChoice(choiceIndex: number): TurnResult;
saveGame(): string;
loadGame(savedState: string): TurnResult;
private loadStory;
private continueStory;
}
+143
View File
@@ -0,0 +1,143 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.InkEngine = void 0;
exports.compileInkSource = compileInkSource;
const fs_1 = require("fs");
const path_1 = __importDefault(require("path"));
const inkjs_1 = require("inkjs");
const tag_parser_1 = require("../utils/tag-parser");
const { Compiler } = require('inkjs/full');
function compileInkSource(sourcePath, outputPath) {
const resolvedSource = path_1.default.resolve(sourcePath);
const resolvedOutput = path_1.default.resolve(outputPath);
if (!(0, fs_1.existsSync)(resolvedSource)) {
throw new Error(`Ink source file not found: ${resolvedSource}`);
}
const warnings = [];
const errors = [];
const source = (0, fs_1.readFileSync)(resolvedSource, 'utf8').replace(/^\uFEFF/, '');
const sourceDir = path_1.default.dirname(resolvedSource);
const fileHandler = {
ResolveInkFilename: (filename) => path_1.default.isAbsolute(filename) ? filename : path_1.default.resolve(sourceDir, filename),
LoadInkFileContents: (filename) => (0, fs_1.readFileSync)(path_1.default.isAbsolute(filename) ? filename : path_1.default.resolve(sourceDir, filename), 'utf8')
.replace(/^\uFEFF/, ''),
};
const compiler = new Compiler(source, {
sourceFilename: resolvedSource,
fileHandler,
errorHandler: (message, type) => {
if (type === 1) {
warnings.push(message);
}
else {
errors.push(message);
}
},
});
const story = compiler.Compile();
if (!story || errors.length > 0) {
throw new Error(`Ink compilation failed:\n${errors.join('\n')}`);
}
if (warnings.length > 0) {
warnings.forEach((warning) => console.warn(`[ink] ${warning}`));
}
(0, fs_1.mkdirSync)(path_1.default.dirname(resolvedOutput), { recursive: true });
(0, fs_1.writeFileSync)(resolvedOutput, story.ToJson(), 'utf8');
return {
sourcePath: resolvedSource,
outputPath: resolvedOutput,
warningCount: warnings.length,
};
}
class InkEngine {
constructor(storyPath) {
this.storyPath = storyPath;
this.story = null;
this.nextTurnId = 1;
}
isRunning() {
if (!this.story)
return false;
return this.story.canContinue || this.story.currentChoices.length > 0;
}
newGame() {
this.story = this.loadStory();
this.nextTurnId = 1;
return this.continueStory();
}
chooseChoice(choiceIndex) {
if (!this.story) {
throw new Error('No active Ink story');
}
const choice = this.story.currentChoices.find((item) => item.index === choiceIndex);
if (!choice) {
throw new Error(`Ink choice ${choiceIndex} is not available`);
}
this.story.ChooseChoiceIndex(choice.index);
return this.continueStory();
}
saveGame() {
if (!this.story) {
throw new Error('No active Ink story to save');
}
return this.story.state.toJson();
}
loadGame(savedState) {
this.story = this.loadStory();
this.story.state.LoadJson(savedState);
return this.continueStory();
}
loadStory() {
const resolvedPath = path_1.default.resolve(this.storyPath);
if (!(0, fs_1.existsSync)(resolvedPath)) {
throw new Error(`Ink story file not found: ${resolvedPath}`);
}
const storyJson = JSON.parse((0, fs_1.readFileSync)(resolvedPath, 'utf8'));
return new inkjs_1.Story(storyJson);
}
continueStory() {
if (!this.story) {
throw new Error('No active Ink story');
}
const paragraphs = [];
const globalTags = [];
while (this.story.canContinue) {
const rawText = this.story.Continue();
const text = String(rawText || '').trim();
const tags = (0, tag_parser_1.parseTags)(this.story.currentTags || []);
tags
.filter((tag) => tag.key === 'title' || tag.key === 'author')
.forEach((tag) => globalTags.push(tag));
if (text) {
paragraphs.push({ text, tags });
}
else {
tags.forEach((tag) => globalTags.push(tag));
}
}
const choices = this.story.currentChoices.map((choice) => {
const tags = (0, tag_parser_1.parseTags)(choice.tags || []);
const category = (0, tag_parser_1.getTagValue)(tags, 'action');
const letter = (0, tag_parser_1.getTagValue)(tags, 'letter');
return {
index: choice.index,
text: String(choice.text || '').trim(),
tags,
category,
letter,
};
});
return {
turnId: this.nextTurnId++,
paragraphs,
choices,
inputMode: choices.length > 0 ? 'choice' : 'end',
globalTags: globalTags.length > 0 ? globalTags : undefined,
};
}
}
exports.InkEngine = InkEngine;
//# sourceMappingURL=ink-engine.js.map
+1
View File
File diff suppressed because one or more lines are too long
+7 -13
View File
@@ -12,6 +12,7 @@
* ZORK_HISTORY_SIZE player-facing outputs stored per room (default: 5)
* OPENROUTER_API_KEY, OPENROUTER_MODEL required
*/
import { TurnResult } from '../interfaces/turn-result';
export interface ZorkSession {
characterDescription: string;
notes: string[];
@@ -26,18 +27,7 @@ export interface ZorkSession {
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 type ZorkTurnResult = TurnResult;
export declare class ZorkLlmEngine {
private zork;
private session;
@@ -48,9 +38,13 @@ export declare class ZorkLlmEngine {
private llmCallCounter;
private maxRetries;
private historySize;
private nextTurnId;
private storyPath;
private static readonly DEPRECATED_MODEL_REPLACEMENTS;
constructor();
constructor(options?: {
storyPath?: string;
promptDir?: string;
});
private createCompletion;
private resolveFallbackModel;
isRunning(): boolean;
+9 -4
View File
@@ -58,6 +58,7 @@ const os = __importStar(require("os"));
const yaml = __importStar(require("js-yaml"));
const axios_1 = __importDefault(require("axios"));
const dotenv = __importStar(require("dotenv"));
const turn_result_1 = require("../interfaces/turn-result");
dotenv.config();
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZORK_DEBUG ?? '');
function debugLog(message, details) {
@@ -348,11 +349,12 @@ function logLlmError(scope, err) {
// ZorkLlmEngine
// ---------------------------------------------------------------------------
class ZorkLlmEngine {
constructor() {
constructor(options = {}) {
this.zork = new ZorkProcess();
this.session = null;
this.resolvedFallbackModel = null;
this.llmCallCounter = 0;
this.nextTurnId = 1;
const apiKey = process.env.OPENROUTER_API_KEY;
const model = process.env.OPENROUTER_MODEL;
if (!apiKey || !model) {
@@ -372,8 +374,8 @@ class ZorkLlmEngine {
});
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.storyPath = path.resolve(options.storyPath ?? process.env.ZORK_STORY_FILE ?? './data/z-code/zork1.bin');
const promptDir = path.resolve(options.promptDir ?? './data/zork-prompts');
this.prompts = loadPrompts(promptDir);
this.llm = axios_1.default.create({
baseURL: 'https://openrouter.ai/api/v1',
@@ -486,6 +488,7 @@ class ZorkLlmEngine {
// Kill any existing game
if (this.zork.isAlive())
this.zork.kill();
this.nextTurnId = 1;
if (!fs.existsSync(this.storyPath)) {
throw new Error(`Story file not found: ${this.storyPath}\n` +
'Place zork1.bin in ./data/z-code/ (see README in that folder).');
@@ -968,8 +971,10 @@ class ZorkLlmEngine {
const alive = this.zork.isAlive();
if (!alive && this.session)
this.session.running = false;
const paragraphs = (0, turn_result_1.textToParagraphs)(text);
return {
paragraphs: [{ text, tags: [] }],
turnId: this.nextTurnId++,
paragraphs,
choices: [],
inputMode: alive ? 'text' : 'end',
gameState: { statusLine: this.session?.currentRoom },
+1 -1
View File
File diff suppressed because one or more lines are too long
+5 -3
View File
@@ -40,6 +40,7 @@ const dotenv = __importStar(require("dotenv"));
const game_runner_1 = require("./cli/game-runner");
// Import the server module and the startServer function for the web interface
const server_1 = require("./server");
const game_config_1 = require("./config/game-config");
// Load environment variables
console.log('Loading environment variables...');
try {
@@ -59,8 +60,9 @@ async function main() {
console.log('=== AI Interactive Fiction ===');
console.log('A modern take on classic text adventures with LLM-powered interactions');
console.log('');
// Get the world file path from environment variables or use default
const worldFile = process.env.DEFAULT_WORLD_FILE || './data/worlds/example_world.yml';
// Get the world file path from the YAML engine config, with environment override.
const engineConfig = (0, game_config_1.loadGameConfig)(process.env.YAML_CONFIG_FILE || './config/engines/yaml.json', 'yaml');
const worldFile = (0, game_config_1.projectPath)(process.env.DEFAULT_WORLD_FILE || engineConfig.paths.mainGameFile);
console.log(`Using world file: ${worldFile}`);
console.log(`OpenRouter API Key: ${process.env.OPENROUTER_API_KEY ? '✓ Found' : '✗ Missing'}`);
console.log(`OpenRouter Model: ${process.env.OPENROUTER_MODEL || '✗ Not specified'}`);
@@ -85,7 +87,7 @@ async function main() {
// Get port configuration
const DEFAULT_PORT = 3000;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10;
const PORT_RANGE = 300;
// Start the web server with port fallback
console.log('Starting web server...');
await (0, server_1.startServer)(PORT, PORT_RANGE);
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA;;GAEG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAGH,+CAAiC;AACjC,mDAA+C;AAC/C,8EAA8E;AAC9E,qCAAuC;AAEvC,6BAA6B;AAC7B,OAAO,CAAC,GAAG,CAAC,kCAAkC,CAAC,CAAC;AAChD,IAAI,CAAC;IACH,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;IAC/B,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QACjB,OAAO,CAAC,KAAK,CAAC,0BAA0B,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;IAC1D,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,GAAG,CAAC,2CAA2C,CAAC,CAAC;IAC3D,CAAC;AACH,CAAC;AAAC,OAAO,KAAK,EAAE,CAAC;IACf,OAAO,CAAC,KAAK,CAAC,6BAA6B,EAAE,KAAK,CAAC,CAAC;AACtD,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,IAAI,CAAC;QACH,OAAO,CAAC,GAAG,CAAC,gCAAgC,CAAC,CAAC;QAC9C,OAAO,CAAC,GAAG,CAAC,wEAAwE,CAAC,CAAC;QACtF,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAEhB,oEAAoE;QACpE,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,IAAI,iCAAiC,CAAC;QACtF,OAAO,CAAC,GAAG,CAAC,qBAAqB,SAAS,EAAE,CAAC,CAAC;QAC9C,OAAO,CAAC,GAAG,CAAC,uBAAuB,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;QAC/F,OAAO,CAAC,GAAG,CAAC,qBAAqB,OAAO,CAAC,GAAG,CAAC,gBAAgB,IAAI,iBAAiB,EAAE,CAAC,CAAC;QAEtF,qCAAqC;QACrC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACnC,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QAE9D,IAAI,OAAO,EAAE,CAAC;YACZ,WAAW;YACX,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAC;YAEvC,oCAAoC;YACpC,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAC;YACvC,MAAM,UAAU,GAAG,IAAI,wBAAU,EAAE,CAAC;YAEpC,OAAO,CAAC,GAAG,CAAC,sBAAsB,CAAC,CAAC;YACpC,MAAM,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;YAEvC,qBAAqB;YACrB,OAAO,CAAC,GAAG,CAAC,sBAAsB,CAAC,CAAC;YACpC,MAAM,UAAU,CAAC,KAAK,EAAE,CAAC;QAC3B,CAAC;aAAM,CAAC;YACN,sEAAsE;YACtE,OAAO,CAAC,GAAG,CAAC,mCAAmC,CAAC,CAAC;YAEjD,yBAAyB;YACzB,MAAM,YAAY,GAAG,IAAI,CAAC;YAC1B,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC;YAC1E,MAAM,UAAU,GAAG,EAAE,CAAC;YAEtB,0CAA0C;YAC1C,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;YACtC,MAAM,IAAA,oBAAW,EAAC,IAAI,EAAE,UAAU,CAAC,CAAC;QACtC,CAAC;IACH,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CAAC,kBAAkB,EAAE,KAAK,CAAC,CAAC;QACzC,IAAI,KAAK,YAAY,KAAK,EAAE,CAAC;YAC3B,OAAO,CAAC,KAAK,CAAC,aAAa,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;YACzC,OAAO,CAAC,KAAK,CAAC,gBAAgB,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;YAC/C,OAAO,CAAC,KAAK,CAAC,cAAc,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;QAC7C,CAAC;QACD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;AACH,CAAC;AAED,wBAAwB;AACxB,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAC;AACvC,IAAI,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,0BAA0B,EAAE,KAAK,CAAC,CAAC;IACjD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA;;GAEG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAGH,+CAAiC;AACjC,mDAA+C;AAC/C,8EAA8E;AAC9E,qCAAuC;AACvC,sDAAmE;AAEnE,6BAA6B;AAC7B,OAAO,CAAC,GAAG,CAAC,kCAAkC,CAAC,CAAC;AAChD,IAAI,CAAC;IACH,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;IAC/B,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QACjB,OAAO,CAAC,KAAK,CAAC,0BAA0B,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;IAC1D,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,GAAG,CAAC,2CAA2C,CAAC,CAAC;IAC3D,CAAC;AACH,CAAC;AAAC,OAAO,KAAK,EAAE,CAAC;IACf,OAAO,CAAC,KAAK,CAAC,6BAA6B,EAAE,KAAK,CAAC,CAAC;AACtD,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,IAAI,CAAC;QACH,OAAO,CAAC,GAAG,CAAC,gCAAgC,CAAC,CAAC;QAC9C,OAAO,CAAC,GAAG,CAAC,wEAAwE,CAAC,CAAC;QACtF,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAEhB,kFAAkF;QAClF,MAAM,YAAY,GAAG,IAAA,4BAAc,EACjC,OAAO,CAAC,GAAG,CAAC,gBAAgB,IAAI,4BAA4B,EAC5D,MAAM,CACP,CAAC;QACF,MAAM,SAAS,GAAG,IAAA,yBAAW,EAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,IAAI,YAAY,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;QACjG,OAAO,CAAC,GAAG,CAAC,qBAAqB,SAAS,EAAE,CAAC,CAAC;QAC9C,OAAO,CAAC,GAAG,CAAC,uBAAuB,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;QAC/F,OAAO,CAAC,GAAG,CAAC,qBAAqB,OAAO,CAAC,GAAG,CAAC,gBAAgB,IAAI,iBAAiB,EAAE,CAAC,CAAC;QAEtF,qCAAqC;QACrC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACnC,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QAE9D,IAAI,OAAO,EAAE,CAAC;YACZ,WAAW;YACX,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAC;YAEvC,oCAAoC;YACpC,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAC;YACvC,MAAM,UAAU,GAAG,IAAI,wBAAU,EAAE,CAAC;YAEpC,OAAO,CAAC,GAAG,CAAC,sBAAsB,CAAC,CAAC;YACpC,MAAM,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;YAEvC,qBAAqB;YACrB,OAAO,CAAC,GAAG,CAAC,sBAAsB,CAAC,CAAC;YACpC,MAAM,UAAU,CAAC,KAAK,EAAE,CAAC;QAC3B,CAAC;aAAM,CAAC;YACN,sEAAsE;YACtE,OAAO,CAAC,GAAG,CAAC,mCAAmC,CAAC,CAAC;YAEjD,yBAAyB;YACzB,MAAM,YAAY,GAAG,IAAI,CAAC;YAC1B,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC;YAC1E,MAAM,UAAU,GAAG,GAAG,CAAC;YAEvB,0CAA0C;YAC1C,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;YACtC,MAAM,IAAA,oBAAW,EAAC,IAAI,EAAE,UAAU,CAAC,CAAC;QACtC,CAAC;IACH,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CAAC,kBAAkB,EAAE,KAAK,CAAC,CAAC;QACzC,IAAI,KAAK,YAAY,KAAK,EAAE,CAAC;YAC3B,OAAO,CAAC,KAAK,CAAC,aAAa,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;YACzC,OAAO,CAAC,KAAK,CAAC,gBAAgB,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;YAC/C,OAAO,CAAC,KAAK,CAAC,cAAc,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;QAC7C,CAAC;QACD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;AACH,CAAC;AAED,wBAAwB;AACxB,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAC;AACvC,IAAI,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,0BAA0B,EAAE,KAAK,CAAC,CAAC;IACjD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
+32
View File
@@ -0,0 +1,32 @@
export type InputMode = 'text' | 'choice' | 'end';
export interface StoryTag {
key: string;
value?: string;
param?: string;
}
export interface ParagraphResult {
text: string;
tags: StoryTag[];
}
export interface ChoiceResult {
index: number;
text: string;
tags: StoryTag[];
category?: string;
letter?: string;
}
export interface TurnResult {
turnId: number;
paragraphs: ParagraphResult[];
choices: ChoiceResult[];
inputMode: InputMode;
globalTags?: StoryTag[];
gameState?: {
currentRoomId?: string;
score?: number;
moves?: number;
statusLine?: string;
};
suggestions?: string[];
}
export declare function textToParagraphs(text: string, tags?: StoryTag[]): ParagraphResult[];
+36
View File
@@ -0,0 +1,36 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.textToParagraphs = textToParagraphs;
/**
* Shared engine-to-client turn protocol.
*/
const tag_parser_1 = require("../utils/tag-parser");
function textToParagraphs(text, tags = []) {
return String(text || '')
.replace(/\r\n?/g, '\n')
.split(/\n{2,}/)
.map((paragraph) => paragraph.trim())
.filter(Boolean)
.map((paragraph) => {
const lines = paragraph.split('\n');
const paragraphTags = [...tags];
const textLines = [];
let tagPrefixOpen = true;
for (const line of lines) {
const trimmed = line.trim();
const maybeTag = tagPrefixOpen && trimmed.startsWith('#') ? (0, tag_parser_1.parseTag)(trimmed) : null;
if (maybeTag) {
paragraphTags.push(maybeTag);
}
else {
tagPrefixOpen = false;
textLines.push(line);
}
}
return {
text: textLines.join('\n').trim(),
tags: paragraphTags,
};
});
}
//# sourceMappingURL=turn-result.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"turn-result.js","sourceRoot":"","sources":["../../src/interfaces/turn-result.ts"],"names":[],"mappings":";;AAyCA,4CA6BC;AAtED;;GAEG;AACH,oDAA+C;AAsC/C,SAAgB,gBAAgB,CAAC,IAAY,EAAE,OAAmB,EAAE;IAClE,OAAO,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;SACtB,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC;SACvB,KAAK,CAAC,QAAQ,CAAC;SACf,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;SACpC,MAAM,CAAC,OAAO,CAAC;SACf,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE;QACjB,MAAM,KAAK,GAAG,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACpC,MAAM,aAAa,GAAe,CAAC,GAAG,IAAI,CAAC,CAAC;QAC5C,MAAM,SAAS,GAAa,EAAE,CAAC;QAC/B,IAAI,aAAa,GAAG,IAAI,CAAC;QAEzB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;YAC5B,MAAM,QAAQ,GAAG,aAAa,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAA,qBAAQ,EAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;YAErF,IAAI,QAAQ,EAAE,CAAC;gBACb,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YAC/B,CAAC;iBAAM,CAAC;gBACN,aAAa,GAAG,KAAK,CAAC;gBACtB,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACvB,CAAC;QACH,CAAC;QAED,OAAO;YACL,IAAI,EAAE,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE;YACjC,IAAI,EAAE,aAAa;SACpB,CAAC;IACJ,CAAC,CAAC,CAAC;AACP,CAAC"}
+13
View File
@@ -0,0 +1,13 @@
/**
* Ink Engine Server
*
* Serves the shared client UI and runs a compiled Ink JSON story through the
* unified TurnResult socket protocol.
*/
import http from 'http';
import { Server as SocketIOServer } from 'socket.io';
declare const app: import("express-serve-static-core").Express;
declare const server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
declare const io: SocketIOServer<import("socket.io").DefaultEventsMap, import("socket.io").DefaultEventsMap, import("socket.io").DefaultEventsMap, any>;
export declare function startServer(initialPort: number, range: number): Promise<void>;
export { app, server, io };
+272
View File
@@ -0,0 +1,272 @@
"use strict";
/**
* Ink Engine Server
*
* Serves the shared client UI and runs a compiled Ink JSON story through the
* unified TurnResult socket protocol.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.io = exports.server = exports.app = void 0;
exports.startServer = startServer;
const path_1 = __importDefault(require("path"));
const http_1 = __importDefault(require("http"));
const express_1 = __importDefault(require("express"));
const socket_io_1 = require("socket.io");
const dotenv = __importStar(require("dotenv"));
const fs_1 = require("fs");
const ink_engine_1 = require("./engine/ink-engine");
const game_config_1 = require("./config/game-config");
dotenv.config();
const app = (0, express_1.default)();
exports.app = app;
const server = http_1.default.createServer(app);
exports.server = server;
const io = new socket_io_1.Server(server);
exports.io = io;
const DEFAULT_PORT = 3003;
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
const PORT_RANGE = 300;
const engineConfig = (0, game_config_1.loadGameConfig)(process.env.INK_CONFIG_FILE || './config/engines/ink.json', 'ink');
app.use(express_1.default.static(path_1.default.join(__dirname, '../public'), {
etag: false,
lastModified: false,
setHeaders: (res) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
},
}));
app.get('/api/game-config', (_req, res) => {
res.json((0, game_config_1.clientGameConfig)(engineConfig));
});
const sessions = new Map();
const saveSlots = new Map();
function normalizeSaveSlot(slot) {
const n = Number(slot);
return Number.isInteger(n) && n > 0 ? n : 1;
}
function getStoryPath() {
return (0, game_config_1.projectPath)(process.env.INK_STORY_FILE ||
engineConfig.paths.inkCompiled ||
engineConfig.paths.mainGameFile);
}
function getSourcePath() {
return (0, game_config_1.projectPath)(process.env.INK_SOURCE_FILE || engineConfig.paths.inkSource || '');
}
function compileConfiguredStory() {
const sourcePath = getSourcePath();
const outputPath = getStoryPath();
const result = (0, ink_engine_1.compileInkSource)(sourcePath, outputPath);
console.log(`[ink] Compiled ${result.sourcePath} -> ${result.outputPath}` +
(result.warningCount > 0 ? ` (${result.warningCount} warnings)` : ''));
}
function getSlots(socketId) {
let slots = saveSlots.get(socketId);
if (!slots) {
slots = new Map();
saveSlots.set(socketId, slots);
}
return slots;
}
function getOrCreateEngine(socketId) {
let engine = sessions.get(socketId);
if (!engine) {
engine = new ink_engine_1.InkEngine(getStoryPath());
sessions.set(socketId, engine);
}
return engine;
}
async function handleGameApi(socket, method, args) {
const slots = getSlots(socket.id);
switch (method) {
case 'newGame':
case 'newGame()': {
const engine = new ink_engine_1.InkEngine(getStoryPath());
sessions.set(socket.id, engine);
socket.emit('narrativeResponse', engine.newGame());
return { success: true, result: true, running: true, canLoad: slots.size > 0 };
}
case 'chooseChoice':
case 'chooseChoice()': {
const engine = sessions.get(socket.id);
if (!engine?.isRunning()) {
return { success: false, error: 'game_not_running', result: false };
}
const choiceIndex = Number(args[0]);
if (!Number.isInteger(choiceIndex)) {
return { success: false, error: 'invalid_choice', result: false };
}
socket.emit('narrativeResponse', engine.chooseChoice(choiceIndex));
return { success: true, result: true };
}
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);
socket.emit('narrativeResponse', engine.loadGame(slots.get(slot)));
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]);
slots.set(slot, engine.saveGame());
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}` };
}
}
io.on('connection', (socket) => {
console.log(`[ink] Client connected: ${socket.id}`);
socket.emit('gameConfig', (0, game_config_1.clientGameConfig)(engineConfig));
socket.on('gameApi', async (request, respond) => {
try {
const result = await handleGameApi(socket, String(request?.method ?? ''), Array.isArray(request?.args) ? request.args : []);
if (typeof respond === 'function')
respond(result);
}
catch (error) {
console.error('[ink] gameApi error:', error);
if (typeof respond === 'function') {
respond({
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
});
socket.on('disconnect', () => {
console.log(`[ink] Client disconnected: ${socket.id}`);
sessions.delete(socket.id);
saveSlots.delete(socket.id);
});
});
function ensureDirectories() {
const dirs = [
path_1.default.join(__dirname, '../public'),
path_1.default.join(__dirname, '../public/js'),
path_1.default.join(__dirname, '../public/css'),
path_1.default.join(__dirname, '../public/images'),
path_1.default.join(__dirname, '../public/music'),
path_1.default.join(__dirname, '../public/sounds'),
path_1.default.join(__dirname, '../public/fonts'),
];
for (const dir of dirs) {
if (!(0, fs_1.existsSync)(dir))
(0, fs_1.mkdirSync)(dir, { recursive: true });
}
(0, game_config_1.ensureConfiguredAssetDirectories)(engineConfig);
}
function ensureKokoroJs() {
const source = path_1.default.join(__dirname, '../node_modules/kokoro-js/dist/index.js');
const destination = path_1.default.join(__dirname, '../public/js/kokoro-js.js');
if ((0, fs_1.existsSync)(source) && !(0, fs_1.existsSync)(destination)) {
(0, fs_1.copyFileSync)(source, destination);
}
}
async function startServer(initialPort, range) {
ensureDirectories();
try {
ensureKokoroJs();
}
catch { /* optional */ }
compileConfiguredStory();
if (!(0, fs_1.existsSync)(getStoryPath())) {
console.error(`[ink] Story file missing: ${getStoryPath()}`);
console.error('[ink] Set INK_SOURCE_FILE or configure paths.inkSource in config/engines/ink.json.');
}
let port = initialPort;
while (port < initialPort + range) {
try {
await new Promise((resolve, reject) => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
server.once('listening', () => {
console.log(`[ink] Ink server running on http://localhost:${port}`);
resolve();
});
server.once('error', (error) => {
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') {
console.log(`Port ${port} unavailable (${error.code}), trying ${port + 1}...`);
server.close();
port++;
reject();
}
else {
reject(error);
}
});
server.listen(port);
});
return;
}
catch {
if (port >= initialPort + range - 1) {
throw new Error(`Failed to start server on ports ${initialPort} to ${initialPort + range - 1}`);
}
}
}
}
if (require.main === module) {
startServer(PORT, PORT_RANGE).catch((error) => {
console.error('[ink] Failed to start:', error);
process.exit(1);
});
}
//# sourceMappingURL=server-ink.js.map
+1
View File
File diff suppressed because one or more lines are too long
+27 -17
View File
@@ -58,14 +58,16 @@ const socket_io_1 = require("socket.io");
const dotenv = __importStar(require("dotenv"));
const fs_1 = require("fs");
const zork_llm_engine_1 = require("./engine/zork-llm-engine");
const game_config_1 = require("./config/game-config");
dotenv.config();
const app = (0, express_1.default)();
const server = http_1.default.createServer(app);
const io = new socket_io_1.Server(server);
const DEFAULT_PORT = 3002;
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
const PORT_RANGE = 10;
const PORT_RANGE = 300;
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZORK_DEBUG ?? '');
const engineConfig = (0, game_config_1.loadGameConfig)(process.env.ZORK_CONFIG_FILE || './config/engines/zork.json', 'zork');
function debugLog(message, details) {
if (!DEBUG_ENABLED)
return;
@@ -85,18 +87,18 @@ app.use(express_1.default.static(path_1.default.join(__dirname, '../public'), {
res.setHeader('Expires', '0');
},
}));
app.get('/api/game-config', (_req, res) => {
res.json((0, game_config_1.clientGameConfig)(engineConfig));
});
// One engine instance per connected socket
const sessions = new Map();
// Save-game slot maps: socketId → Map<slotNumber, serialisedJson>
const saveSlots = new Map();
function toLegacyNarrative(turn) {
const text = (turn.paragraphs ?? [])
.map((p) => String(p?.text ?? '').trim())
.filter(Boolean)
.join('\n\n');
function toClientTurn(turn) {
return {
text,
...turn,
gameState: {
...turn.gameState,
currentRoomId: turn.gameState?.statusLine,
statusLine: turn.gameState?.statusLine,
},
@@ -109,7 +111,10 @@ function normalizeSaveSlot(slot) {
function getOrCreateEngine(socketId) {
let engine = sessions.get(socketId);
if (!engine) {
engine = new zork_llm_engine_1.ZorkLlmEngine();
engine = new zork_llm_engine_1.ZorkLlmEngine({
storyPath: (0, game_config_1.projectPath)(process.env.ZORK_STORY_FILE || engineConfig.paths.mainGameFile),
promptDir: (0, game_config_1.projectPath)(engineConfig.paths.promptDir || 'data/zork-prompts'),
});
sessions.set(socketId, engine);
}
return engine;
@@ -130,7 +135,7 @@ async function handleGameApi(socket, method, args) {
case 'newGame()': {
const engine = getOrCreateEngine(socket.id);
const turn = await engine.newGame();
socket.emit('narrativeResponse', toLegacyNarrative(turn));
socket.emit('narrativeResponse', toClientTurn(turn));
return {
success: true,
result: true,
@@ -146,7 +151,7 @@ async function handleGameApi(socket, method, args) {
}
const engine = getOrCreateEngine(socket.id);
const turn = await engine.loadGame(slots.get(slot));
socket.emit('narrativeResponse', toLegacyNarrative(turn));
socket.emit('narrativeResponse', toClientTurn(turn));
socket.emit('gameLoaded', { slot });
return { success: true, result: true, running: true, slot };
}
@@ -184,8 +189,8 @@ async function handleGameApi(socket, method, args) {
}
}
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 storyPath = (0, game_config_1.projectPath)(process.env.ZORK_STORY_FILE ?? engineConfig.paths.mainGameFile);
const promptDir = (0, game_config_1.projectPath)(engineConfig.paths.promptDir || 'data/zork-prompts');
const promptFiles = [
'character-generation.yml',
'text-rewriter.yml',
@@ -221,6 +226,7 @@ function checkRuntimeConfiguration() {
}
io.on('connection', (socket) => {
console.log(`[zork] Client connected: ${socket.id}`);
socket.emit('gameConfig', (0, game_config_1.clientGameConfig)(engineConfig));
socket.on('gameApi', async (request, respond) => {
try {
const result = await handleGameApi(socket, String(request?.method ?? ''), Array.isArray(request?.args) ? request.args : []);
@@ -257,7 +263,7 @@ io.on('connection', (socket) => {
paragraphs: turn.paragraphs.length,
statusLine: turn.gameState?.statusLine,
});
socket.emit('narrativeResponse', toLegacyNarrative(turn));
socket.emit('narrativeResponse', toClientTurn(turn));
}
catch (error) {
console.error('[zork] playerCommand error:', error);
@@ -291,6 +297,7 @@ function ensureDirectories() {
if (!(0, fs_1.existsSync)(dir))
(0, fs_1.mkdirSync)(dir, { recursive: true });
}
(0, game_config_1.ensureConfiguredAssetDirectories)(engineConfig);
}
function ensureKokoroJs() {
const src = path_1.default.join(__dirname, '../node_modules/kokoro-js/dist/index.js');
@@ -309,13 +316,15 @@ async function startServer(initialPort, range) {
while (port < initialPort + range) {
try {
await new Promise((resolve, reject) => {
server.listen(port, () => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
server.once('listening', () => {
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.once('error', (err) => {
if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
console.log(`Port ${port} unavailable (${err.code}), trying ${port + 1}...`);
server.close();
port++;
reject();
@@ -324,6 +333,7 @@ async function startServer(initialPort, range) {
reject(err);
}
});
server.listen(port);
});
return;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+51 -17
View File
@@ -49,6 +49,8 @@ const socket_io_1 = require("socket.io");
const dotenv = __importStar(require("dotenv"));
const game_runner_1 = require("./cli/game-runner");
const fs_1 = require("fs");
const turn_result_1 = require("./interfaces/turn-result");
const game_config_1 = require("./config/game-config");
// Load environment variables
dotenv.config();
// Create Express application
@@ -61,7 +63,8 @@ exports.io = io;
// Get port from environment variables or use default
const DEFAULT_PORT = 3001;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
const PORT_RANGE = 300; // Try enough ports to skip OS-excluded ranges.
const engineConfig = (0, game_config_1.loadGameConfig)(process.env.YAML_CONFIG_FILE || './config/engines/yaml.json', 'yaml');
// Serve static files from the public directory. During local development the
// browser must not keep stale ES modules, otherwise UI fixes appear to do
// nothing until a hard cache clear.
@@ -74,22 +77,51 @@ app.use(express_1.default.static(path_1.default.join(__dirname, '../public'), {
res.setHeader('Expires', '0');
}
}));
app.get('/api/game-config', (_req, res) => {
res.json((0, game_config_1.clientGameConfig)(engineConfig));
});
// Set up game sessions
const gameSessions = new Map();
const nextTurnIds = new Map();
function nextTurnId(socketId) {
const current = nextTurnIds.get(socketId) || 1;
nextTurnIds.set(socketId, current + 1);
return current;
}
function createTextTurn(socketId, text, gameState = {}, suggestions) {
const paragraphs = (0, turn_result_1.textToParagraphs)(text);
return {
turnId: nextTurnId(socketId),
paragraphs,
choices: [],
inputMode: 'text',
gameState,
suggestions,
};
}
function normalizeSaveSlot(slot) {
const value = Number(slot);
return Number.isInteger(value) && value > 0 ? value : 1;
}
async function startDemoGameForSocket(socket) {
nextTurnIds.set(socket.id, 1);
const gameRunner = new game_runner_1.GameRunner();
const worldFile = process.env.DEFAULT_WORLD_FILE || './data/worlds/example_world.yml';
const worldFile = (0, game_config_1.projectPath)(process.env.DEFAULT_WORLD_FILE || engineConfig.paths.mainGameFile);
await gameRunner.initialize(worldFile);
gameSessions.set(socket.id, gameRunner);
const gameState = gameRunner.getGameState();
socket.emit('gameIntroduction', {
introduction: gameState.world.introduction,
initialRoomDescription: gameRunner.getCurrentRoomDescription(),
currentRoomId: gameState.currentRoomId
const paragraphs = [
...(0, turn_result_1.textToParagraphs)(gameState.world.introduction),
...(0, turn_result_1.textToParagraphs)(gameRunner.getCurrentRoomDescription()),
];
socket.emit('narrativeResponse', {
turnId: nextTurnId(socket.id),
paragraphs,
choices: [],
inputMode: 'text',
gameState: {
currentRoomId: gameState.currentRoomId,
},
});
return gameRunner;
}
@@ -140,6 +172,7 @@ async function handleGameApi(socket, method, args = []) {
// Handle socket connections
io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);
socket.emit('gameConfig', (0, game_config_1.clientGameConfig)(engineConfig));
socket.data.saveGames = new Map();
socket.on('gameApi', async (request, respond) => {
try {
@@ -176,13 +209,9 @@ io.on('connection', (socket) => {
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: command,
gameState: {
currentRoomId: gameRunner.getGameState().currentRoomId
},
suggestions: gameRunner.getSuggestions()
});
socket.emit('narrativeResponse', createTextTurn(socket.id, command, {
currentRoomId: gameRunner.getGameState().currentRoomId
}, gameRunner.getSuggestions()));
}
catch (error) {
console.error('Error processing command:', error);
@@ -232,6 +261,7 @@ io.on('connection', (socket) => {
if (gameSessions.has(socket.id)) {
gameSessions.delete(socket.id);
}
nextTurnIds.delete(socket.id);
});
});
// Ensure required asset folders exist
@@ -250,6 +280,7 @@ function ensureDirectories() {
(0, fs_1.mkdirSync)(dir, { recursive: true });
}
}
(0, game_config_1.ensureConfiguredAssetDirectories)(engineConfig);
}
// Copy kokoro-js library from node_modules if not already present
function ensureKokoroJs() {
@@ -278,14 +309,16 @@ async function startServer(initialPort, range) {
}
// Try to start the server on the current port
await new Promise((resolve, reject) => {
server.listen(currentPort, () => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
server.once('listening', () => {
console.log(`AI Interactive Fiction web server running on http://localhost:${currentPort}`);
resolve();
});
server.on('error', (error) => {
server.once('error', (error) => {
// If port is in use, try next port
if (error.code === 'EADDRINUSE') {
console.log(`Port ${currentPort} is in use, trying next port...`);
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') {
console.log(`Port ${currentPort} is unavailable (${error.code}), trying next port...`);
server.close();
currentPort++;
reject();
@@ -296,6 +329,7 @@ async function startServer(initialPort, range) {
reject(error);
}
});
server.listen(currentPort);
});
// If we reach here, server started successfully
return;
+1 -1
View File
File diff suppressed because one or more lines are too long
+26 -10
View File
@@ -47,6 +47,7 @@ const http_1 = __importDefault(require("http"));
const socket_io_1 = require("socket.io");
const dotenv = __importStar(require("dotenv"));
const fs_1 = require("fs");
const turn_result_1 = require("./interfaces/turn-result");
// Load environment variables
dotenv.config();
// Create Express application
@@ -59,7 +60,7 @@ exports.io = io;
// Get port from environment variables or use default
const DEFAULT_PORT = 3001;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
const PORT_RANGE = 300; // Try enough ports to skip OS-excluded ranges.
// Serve static files from the public directory. Keep browser modules uncached
// during local development so fixes are visible without a hard cache clear.
app.use(express_1.default.static(path_1.default.join(__dirname, '../public'), {
@@ -82,14 +83,23 @@ io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);
let currentParagraphIndex = 0;
let gameRunning = false;
let nextTurnId = 1;
const saveGames = new Set();
const startDemoGame = () => {
gameRunning = true;
nextTurnId = 1;
currentParagraphIndex = 0;
socket.emit('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"
socket.emit('narrativeResponse', {
turnId: nextTurnId++,
paragraphs: [
...(0, turn_result_1.textToParagraphs)("#chapter[Interactive Fiction Test]\n\nWelcome to the Interactive Fiction Test. This is a simplified version that sends predefined paragraphs instead of using an LLM."),
...(0, turn_result_1.textToParagraphs)(TEST_PARAGRAPHS[0]),
],
choices: [],
inputMode: 'text',
gameState: {
currentRoomId: 'test-room',
},
});
};
const normalizeSaveSlot = (slot) => {
@@ -174,7 +184,10 @@ io.on('connection', (socket) => {
console.log(`Received command: ${data.command}`);
// Send narrative response to client
socket.emit('narrativeResponse', {
text: data.command,
turnId: nextTurnId++,
paragraphs: (0, turn_result_1.textToParagraphs)(String(data.command || '')),
choices: [],
inputMode: 'text',
gameState: {
currentRoomId: "test-room"
},
@@ -235,15 +248,17 @@ async function startServer(initialPort, range) {
}
// Try to start the server on the current port
await new Promise((resolve, reject) => {
server.listen(currentPort, () => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
server.once('listening', () => {
console.log(`AI Interactive Fiction TEST SERVER running on http://localhost:${currentPort}`);
console.log('This server is sending predefined test paragraphs instead of using an LLM');
resolve();
});
server.on('error', (error) => {
server.once('error', (error) => {
// If port is in use, try next port
if (error.code === 'EADDRINUSE') {
console.log(`Port ${currentPort} is in use, trying next port...`);
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') {
console.log(`Port ${currentPort} is unavailable (${error.code}), trying next port...`);
server.close();
currentPort++;
reject();
@@ -254,6 +269,7 @@ async function startServer(initialPort, range) {
reject(error);
}
});
server.listen(currentPort);
});
// If we reach here, server started successfully
return;
+1 -1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
import type { StoryTag } from '../interfaces/turn-result';
export declare function parseTag(raw: string): StoryTag | null;
export declare function parseTags(rawTags: unknown[] | undefined): StoryTag[];
export declare function getTagValue(tags: StoryTag[], key: string): string | undefined;
+45
View File
@@ -0,0 +1,45 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseTag = parseTag;
exports.parseTags = parseTags;
exports.getTagValue = getTagValue;
const LEGACY_TAG_ALIASES = {
audio: 'sfx',
audioloop: 'music',
separator: 'section',
};
function normalizeKey(key) {
const normalized = key.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
return LEGACY_TAG_ALIASES[normalized] || normalized;
}
function parseTag(raw) {
const text = String(raw || '').trim().replace(/^#\s*/, '');
if (!text)
return null;
const bracketMatch = text.match(/^([A-Za-z][\w-]*)(?:\[([^\]]*)\])?(?:\(([^)]*)\))?$/);
if (bracketMatch) {
const tag = { key: normalizeKey(bracketMatch[1]) };
if (typeof bracketMatch[2] !== 'undefined')
tag.value = bracketMatch[2].trim();
if (typeof bracketMatch[3] !== 'undefined')
tag.param = bracketMatch[3].trim();
return tag;
}
const bareMatch = text.match(/^[A-Za-z][\w-]*$/);
if (bareMatch) {
return { key: normalizeKey(text) };
}
return null;
}
function parseTags(rawTags) {
if (!Array.isArray(rawTags))
return [];
return rawTags
.map((raw) => parseTag(String(raw ?? '')))
.filter((tag) => Boolean(tag));
}
function getTagValue(tags, key) {
const normalizedKey = normalizeKey(key);
return tags.find((tag) => tag.key === normalizedKey)?.value;
}
//# sourceMappingURL=tag-parser.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"tag-parser.js","sourceRoot":"","sources":["../../src/utils/tag-parser.ts"],"names":[],"mappings":";;AAaA,4BAkBC;AAED,8BAMC;AAED,kCAGC;AA1CD,MAAM,kBAAkB,GAA2B;IACjD,KAAK,EAAE,KAAK;IACZ,SAAS,EAAE,OAAO;IAClB,SAAS,EAAE,SAAS;CACrB,CAAC;AAEF,SAAS,YAAY,CAAC,GAAW;IAC/B,MAAM,UAAU,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC,CAAC;IAC1E,OAAO,kBAAkB,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC;AACtD,CAAC;AAED,SAAgB,QAAQ,CAAC,GAAW;IAClC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IAC3D,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IAEvB,MAAM,YAAY,GAAG,IAAI,CAAC,KAAK,CAAC,qDAAqD,CAAC,CAAC;IACvF,IAAI,YAAY,EAAE,CAAC;QACjB,MAAM,GAAG,GAAa,EAAE,GAAG,EAAE,YAAY,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7D,IAAI,OAAO,YAAY,CAAC,CAAC,CAAC,KAAK,WAAW;YAAE,GAAG,CAAC,KAAK,GAAG,YAAY,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC/E,IAAI,OAAO,YAAY,CAAC,CAAC,CAAC,KAAK,WAAW;YAAE,GAAG,CAAC,KAAK,GAAG,YAAY,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC/E,OAAO,GAAG,CAAC;IACb,CAAC;IAED,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAC;IACjD,IAAI,SAAS,EAAE,CAAC;QACd,OAAO,EAAE,GAAG,EAAE,YAAY,CAAC,IAAI,CAAC,EAAE,CAAC;IACrC,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAgB,SAAS,CAAC,OAA8B;IACtD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,CAAC;IAEvC,OAAO,OAAO;SACX,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,CAAC;SACzC,MAAM,CAAC,CAAC,GAAG,EAAmB,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;AACpD,CAAC;AAED,SAAgB,WAAW,CAAC,IAAgB,EAAE,GAAW;IACvD,MAAM,aAAa,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC;IACxC,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,GAAG,KAAK,aAAa,CAAC,EAAE,KAAK,CAAC;AAC9D,CAAC"}
+50 -15
View File
@@ -56,6 +56,8 @@ This means:
All engines author tags using the same `key[value]` bracket convention. For Ink this maps directly to native `# key[value]` tags. For YAML and Z-Code the server synthesises equivalent tag objects.
Ink choice text uses square brackets for its own display/control syntax. For choice-local tags, the integration therefore also accepts the prototype form `# key: value` and normalises it to the same structured tag object. Prefer `# letter: o` and `# action: examine` on choices when inkjs treats bracketed tags as choice text.
| Tag | Scope | Meaning |
|---|---|---|
| `music[filename]` | paragraph or global | Start looping music track |
@@ -70,6 +72,7 @@ All engines author tags using the same `key[value]` bracket convention. For Ink
| `title[text]` | global (first turn) | Set document/story title |
| `author[text]` | global (first turn) | Set byline |
| `action[category]` | choice | Sort this choice into a named column |
| `letter[x]` | choice | Reserve keyboard letter `x` for this choice |
### 1.3 Parsed Tag Object Shape
@@ -109,7 +112,7 @@ These work identically on all three servers:
| Direction | Event | Payload |
|---|---|---|
| client → server | `gameApi` | `{ method, args }``respond(result)` |
| server → client | `gameIntroduction` | `{ title, author, inputMode }` |
| server → client | `narrativeResponse` | `TurnResult` |
| server → client | `gameSaved` | `{ slot }` |
| server → client | `gameLoaded` | `{ slot }` |
| server → client | `error` | `{ message }` |
@@ -128,6 +131,7 @@ This is the core change. Previously `narrativeResponse` carried `{ text, gameSta
```ts
interface TurnResult {
turnId: number; // Unique ascending id within the game session
paragraphs: ParagraphResult[]; // Ordered list of story paragraphs this turn
choices: ChoiceResult[]; // Available choices (empty in text-input mode)
inputMode: 'choice' | 'text' | 'end';
@@ -149,10 +153,11 @@ interface ChoiceResult {
text: string; // Display text (SmartyPants applied)
tags: StoryTag[]; // Per-choice tags, e.g. action[examine]
category?: string; // Derived from action[...] tag for column grouping
letter?: string; // Optional reserved keyboard letter, derived from letter[x]
}
```
The old `{ text }` field is removed. For backward compatibility during transition, the server may also emit a flattened `text` field containing all paragraph texts joined with newlines.
The old flattened `{ text }` field is removed. Servers must emit `TurnResult` only.
### 2.3 `playerCommand` vs `chooseChoice`
@@ -245,6 +250,34 @@ async function handleGameApi(socket, method, args): Promise<object> {
## 5. Client-Side Changes
### 5.0 Choice UI Architecture
The first Ink integration should ship with the simplest useful choice UI:
- Display all available choices in one ordered list.
- Ignore choice grouping tags visually for now.
- Assign every visible choice a keyboard letter.
- Allow at most 26 visible choices (`A` through `Z`), which is enough for the current interaction model.
- Choices with explicit `letter[x]` tags reserve that letter first.
- Remaining choices receive letters in ascending screen order, skipping letters already reserved by tags.
- The chosen letter is shown in the UI and pressing that letter selects the same choice as clicking it.
- Letter matching is case-insensitive.
The architecture must still be ready for later choice templates. The choice renderer should normalize choices into a presentation model:
```ts
interface ChoicePresentation {
index: number;
text: string;
tags: StoryTag[];
category?: string;
letter: string;
templateCell: string; // initially always "default"
}
```
The first implemented template has exactly one full-size cell named `default`. It receives every choice whose tags do not match a registered template cell. A later template can register cells such as `examine`, `ask`, `inventory`, or `reflect`, and route choices by `action[...]`, `category`, or a future `cell[...]` tag without changing the server protocol.
### 5.1 Tag Event Dispatch
**Modified: `game-loop-module.js`** (or `socket-client-module.js`)
@@ -288,12 +321,13 @@ Remove all tag-detection regex from the text stream. The module now only applies
**Effort: ~150 lines new**
Renders choice columns, exactly as in the prototype's `createChoiceContainer`. Registered in `module-registry.js`.
Renders available choices from the canonical `TurnResult.choices` array. Registered in `module-registry.js`.
- Listens for a `story:choices` event (dispatched by `game-loop-module.js` from the `TurnResult.choices` array).
- Groups choices by `category` field into named columns with localised headers.
- Registers keyboard shortcuts (A/B/C... or 1/2/3... depending on whether choices are categorised).
- Uses a template object with one initial full-size `default` cell.
- Assigns keyboard letters from `letter[x]` tags first, then fills remaining choices with `A` through `Z` in visible order.
- On click/keypress: calls `socketClient.sendChoice(index)``gameApi { method: 'chooseChoice', args: [index] }`.
- Future grouped or column layouts should be implemented by adding more template cells and routing rules, not by changing the server protocol.
### 5.6 `ui-input-handler-module.js`
@@ -361,13 +395,14 @@ On `narrativeResponse`:
## 8. Recommended Implementation Order
1. Extract `server-base.ts`; verify YAML engine still works.
2. Define `TurnResult` + `StoryTag` interfaces.
3. Write `tag-parser.ts`; unit test it.
4. Rewrite `GameRunner` to produce `TurnResult`; update `server.ts` to emit it.
5. Update `game-loop-module.js` to consume `TurnResult` and dispatch tag events.
6. Update `audio-manager-module.js` and `ui-display-handler-module.js` to listen for tag events.
7. Remove tag parsing from `markup-parser-module.js`.
8. Build `choice-display-module.js` and `ui-input-handler` mode switching.
9. Build `InkEngine` and `server-ink.ts`; smoke-test with a compiled `.ink.json`.
10. Add Z-Code server (see `zcode_inclusion.md`).
1. Define `TurnResult` + `StoryTag` interfaces.
2. Write `tag-parser.ts`; parse the single canonical tag shape `key[value](param)`.
3. Update the client socket pipeline to consume canonical `TurnResult` objects only.
4. Dispatch structured `story:tag`, `story:choices`, and `story:input-mode` events from the socket adapter.
5. Add `choice-display-module.js` with the single-cell default template and letter assignment described in §5.0.
6. Update `audio-manager-module.js` and `ui-display-handler-module.js` to listen for `story:tag` events by translating them into the current media/display paths.
7. Convert Zork server emission from flattened text to canonical `TurnResult`.
8. Convert YAML server introduction/responses to canonical `TurnResult`.
9. Extract `server-base.ts`; verify YAML and Zork still work.
10. Build `InkEngine` and `server-ink.ts`; smoke-test with a compiled `.ink.json`.
11. Once all engines emit structured tags, remove structural tag parsing from `markup-parser-module.js`.
+10
View File
@@ -15,6 +15,7 @@
"express": "^5.1.0",
"hyphenopoly": "^6.0.0",
"ifvms": "^1.1.6",
"inkjs": "^2.4.0",
"js-yaml": "^4.1.0",
"kokoro-js": "^1.2.0",
"openai": "^4.91.0",
@@ -4533,6 +4534,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/inkjs": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/inkjs/-/inkjs-2.4.0.tgz",
"integrity": "sha512-EoPCYESIbMtfI8SqEDZCJwn+A5is0QozMLw250iic1ReJCgZpRKIezWj0VqgRUzAx0f3MmEbsUjY/ILe2815JQ==",
"license": "MIT",
"bin": {
"inkjs-compiler": "bin/inkjs-compiler.js"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+19 -2
View File
@@ -13,18 +13,34 @@
"start:cli": "node dist/index.js --cli",
"predev": "npm run check:node",
"dev": "nodemon --watch src --ext ts,json --exec \"ts-node src/index.ts\"",
"predev:yaml": "npm run check:node",
"dev:yaml": "nodemon --watch src --watch data/worlds --watch config/engines/yaml.json --ext ts,json,yml --exec \"ts-node src/server.ts\"",
"dev:yaml:debug": "powershell -NoProfile -Command \"$env:YAML_DEBUG='1'; npm run dev:yaml\"",
"dev:yaml:inspect": "powershell -NoProfile -Command \"$env:YAML_DEBUG='1'; nodemon --watch src --watch data/worlds --watch config/engines/yaml.json --ext ts,json,yml --exec \\\"node --inspect=127.0.0.1:9230 -r ts-node/register src/server.ts\\\"\"",
"predev:web": "npm run check:node",
"dev:web": "nodemon --watch src --ext ts,json --exec \"ts-node src/index.ts\"",
"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": "nodemon --watch src --watch data/zork-prompts --watch config/engines/zork.json --ext ts,json,yml --exec \"ts-node src/server-zork.ts\"",
"dev:zork:debug": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; npm run dev:zork\"",
"dev:zork:inspect": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; nodemon --watch src --watch data/zork-prompts --ext ts,json,yml --exec \\\"node --inspect=127.0.0.1:9229 -r ts-node/register src/server-zork.ts\\\"\"",
"dev:zork:inspect": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; nodemon --watch src --watch data/zork-prompts --watch config/engines/zork.json --ext ts,json,yml --exec \\\"node --inspect=127.0.0.1:9229 -r ts-node/register src/server-zork.ts\\\"\"",
"predev:ink": "npm run check:node",
"dev:ink": "nodemon --watch src --watch data/ink-src --watch config/engines/ink.json --ext ts,json,ink --exec \"ts-node src/server-ink.ts\"",
"dev:ink:debug": "powershell -NoProfile -Command \"$env:INK_DEBUG='1'; npm run dev:ink\"",
"dev:ink:inspect": "powershell -NoProfile -Command \"$env:INK_DEBUG='1'; nodemon --watch src --watch data/ink-src --watch config/engines/ink.json --ext ts,json,ink --exec \\\"node --inspect=127.0.0.1:9231 -r ts-node/register src/server-ink.ts\\\"\"",
"prestart:yaml": "npm run check:node && npm run build",
"start:yaml": "node dist/server.js",
"start:yaml:debug": "powershell -NoProfile -Command \"$env:YAML_DEBUG='1'; npm run start:yaml\"",
"start:yaml:inspect": "powershell -NoProfile -Command \"$env:YAML_DEBUG='1'; node --inspect=127.0.0.1:9230 dist/server.js\"",
"prestart:zork": "npm run check:node && npm run build",
"start:zork": "node dist/server-zork.js",
"start:zork:debug": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; npm run start:zork\"",
"start:zork:inspect": "powershell -NoProfile -Command \"$env:ZORK_DEBUG='1'; node --inspect=127.0.0.1:9229 dist/server-zork.js\"",
"prestart:ink": "npm run check:node && npm run build",
"start:ink": "node dist/server-ink.js",
"start:ink:debug": "powershell -NoProfile -Command \"$env:INK_DEBUG='1'; npm run start:ink\"",
"start:ink:inspect": "powershell -NoProfile -Command \"$env:INK_DEBUG='1'; node --inspect=127.0.0.1:9231 dist/server-ink.js\"",
"pretest-server": "npm run check:node",
"test-server": "ts-node src/test-server.ts",
"build": "tsc",
@@ -60,6 +76,7 @@
"express": "^5.1.0",
"hyphenopoly": "^6.0.0",
"ifvms": "^1.1.6",
"inkjs": "^2.4.0",
"js-yaml": "^4.1.0",
"kokoro-js": "^1.2.0",
"openai": "^4.91.0",
+148 -4
View File
@@ -421,6 +421,54 @@ ol.choice {
overflow-anchor: none;
}
.story-image-block {
box-sizing: border-box;
margin: 0 auto;
padding: 0;
overflow: hidden;
background: transparent;
}
.story-image-block img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
mix-blend-mode: multiply;
filter: contrast(1.05);
}
.story-image-landscape,
.story-image-square {
clear: both;
margin-left: auto;
margin-right: auto;
}
.story-image-portrait {
float: left;
margin-left: 0;
margin-right: 0;
shape-outside: inset(0);
}
.story-image-portrait.story-image-float-right {
float: right;
margin-left: 0;
margin-right: 0;
}
.story-image-pending img {
opacity: 0;
clip-path: polygon(0 0, 0 0, 0 0);
}
.story-image-visible img {
opacity: 1;
clip-path: polygon(0 0, 220% 0, 0 220%);
transition: opacity 900ms ease, clip-path 900ms ease;
}
/* #story p span {
font-feature-settings: 'kern' on, 'liga' on, 'onum' on, 'clig' on, 'hlig' on;
} */
@@ -547,10 +595,22 @@ ol.choice {
position: absolute;
left: 0;
right: 0;
bottom: 0;
bottom: 0.65rem;
text-align: center;
margin: 0 auto;
background-color: transparent;
line-height: 1.1;
pointer-events: none;
}
#remark_hint {
font-size: 0.82rem;
}
#game_legal {
margin-top: 0.18rem;
font-size: 0.72rem;
color: rgba(0, 0, 0, 0.62);
}
#lighting {
@@ -596,6 +656,17 @@ body:not([data-game-running="true"]) #command_history {
#command_history .history-item {
margin-bottom: 0.25rem;
color: rgba(0, 0, 0, 0.82);
cursor: pointer;
transition: color 160ms ease, opacity 160ms ease;
}
#command_history .history-item:hover,
#command_history .history-item.active {
color: rgba(0, 0, 0, 1);
}
#command_history .history-item.active {
font-weight: 600;
}
#command_history::-webkit-scrollbar {
@@ -633,6 +704,76 @@ body:not([data-game-running="true"]) #command_history {
pointer-events: none; /* Prevent interaction while faded out */
}
.story-choices {
width: 100%;
margin: 0 0 1rem 0;
opacity: 0;
transition: opacity 0.45s ease;
pointer-events: none;
}
.story-choices[hidden] {
display: none;
}
html[data-process-state="waiting-generating"] .story-choices,
html[data-process-state="playing-generating"] .story-choices,
html[data-process-state="playing-ready"] .story-choices,
html[data-process-state="command-waiting"] .story-choices {
opacity: 0;
pointer-events: none;
}
html[data-process-state="ready"] .story-choices[data-choice-ready="true"] {
opacity: 1;
pointer-events: auto;
}
.choice-list {
list-style: none;
margin: 0;
padding: 0;
}
.choice-list-item {
margin: 0 0 0.45rem 0;
}
.choice-list .choice-button {
display: grid;
grid-template-columns: 1.8em 1fr;
align-items: baseline;
gap: 0.45rem;
width: 100%;
padding: 0.15rem 0;
border: 0;
background: transparent;
color: rgba(37, 31, 24, 0.72);
font-family: inherit;
font-size: 1rem;
line-height: 1.25;
text-align: left;
cursor: pointer;
transition: color 0.25s ease, opacity 0.25s ease;
}
.choice-list .choice-button:hover,
.choice-list .choice-button:focus-visible {
color: rgba(0, 0, 0, 0.96);
text-decoration: none;
outline: none;
}
.choice-list kbd {
display: inline-block;
min-width: 1.4em;
font-family: inherit;
font-size: 0.85em;
font-weight: 700;
text-align: center;
border-bottom: 0;
}
/* Input wrapper for positioning cursor */
.input-wrapper {
position: relative;
@@ -745,15 +886,18 @@ html[data-process-state="playing-ready"] * {
#story p.story-chapter-heading {
position: relative;
height: auto;
margin: 0 0 1.45em 0;
text-align: center;
font-size: 1.2rem;
font-style: italic;
line-height: 1.45;
}
#story p.story-textblock-start {
margin-top: 1.45em;
#story p.story-section-heading {
position: relative;
height: auto;
text-align: center;
font-style: normal;
line-height: 1.45;
}
/* Typography for word elements in rendered paragraphs */
+5 -4
View File
@@ -6,14 +6,15 @@ Story image paths resolve relative to this directory.
Image block markup:
```text
::image[widescreen](image-name.jpg)
::image[portrait](image-name.jpg)
#image[image-name.jpg](landscape)
#image[image-name.jpg](portrait)
```
Sizes:
- `widescreen`: exactly 100% of the page width and 50% of the page height.
- `portrait`: exactly 100% of the page width and 100% of the page height.
- `landscape`/`widescreen`: 16:9, centered, near full page width, height snapped to whole line heights.
- `portrait`: 16:9, half page width, height snapped to whole line heights, with following prose flowing beside it.
- `square`: 1:1, centered, near full page width, height snapped to whole line heights.
Image markup is parsed and queued by the story markup system, but final image rendering is still future work. Keep assets ready for that renderer by using browser-friendly formats such as `.jpg`, `.png`, `.webp`, or `.avif`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

+3 -3
View File
@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en-US">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Interactive Fiction</title>
<title></title>
<link rel="preload" href="/fonts/EBGaramond12-Regular.otf" as="font" type="font/otf" crossorigin>
<link rel="preload" href="/fonts/EBGaramond12-Italic.otf" as="font" type="font/otf" crossorigin>
<link rel="icon" href="/favicon.ico" type="image/x-icon">
@@ -297,6 +297,6 @@
originalLog.apply(console, args);
};
</script>
<script type="module" src="/js/loader.js?v=20260514-new-game-click"></script>
<script type="module" src="/js/loader.js?v=20260515-lead-kap-verified"></script>
</body>
</html>
+144 -2
View File
@@ -89,6 +89,14 @@ class AudioManagerModule extends BaseModule {
this.handleMediaBlock(event.detail || {});
});
this.addEventListener(document, 'story:tag', (event) => {
this.handleStoryTag(event.detail || {});
});
this.addEventListener(document, 'game:config', (event) => {
this.applyGameConfig(event.detail || {});
});
this.addEventListener(document, 'preference-updated', (event) => {
const { category, key, value } = event.detail || {};
if (category !== 'audio') {
@@ -132,6 +140,15 @@ class AudioManagerModule extends BaseModule {
document.addEventListener('keydown', unlock);
}
applyGameConfig(config) {
const assets = config?.assets || {};
this.assetRoots = {
images: assets.images || this.assetRoots.images,
music: assets.music || this.assetRoots.music,
sounds: assets.sounds || assets.sfx || this.assetRoots.sounds
};
}
/**
* Set up Web Audio API context if needed
*/
@@ -438,7 +455,7 @@ class AudioManagerModule extends BaseModule {
}
if (cue.type === 'sfx') {
this.playSfx(cue.filename);
this.playSfx(cue.filename, cue);
} else if (cue.type === 'music') {
this.playMusic(cue.filename, cue.mode || 'crossfade', { loop: cue.loop !== false });
}
@@ -452,18 +469,122 @@ class AudioManagerModule extends BaseModule {
this.playMusic(block.filename, block.mode || 'crossfade', { loop: block.loop !== false });
}
async playSfx(filename) {
handleStoryTag(tag) {
const key = String(tag?.key || '').toLowerCase();
const filename = String(tag?.value || tag?.filename || '').trim();
if (!key || !filename) {
return;
}
if (key === 'sfx' || key === 'sound' || key === 'audio') {
this.playSfx(filename, this.parseSfxTagOptions(tag.param || tag.options || ''));
return;
}
if (key === 'music') {
const options = this.parseMusicTagOptions(tag.param || tag.options || '');
this.playMusic(filename, options.mode, { loop: options.loop });
}
}
parseMusicTagOptions(optionText) {
const options = {
mode: 'crossfade',
loop: true
};
String(optionText || '')
.split(/[,\s]+/)
.map(token => token.trim().toLowerCase())
.filter(Boolean)
.forEach(token => {
const [key, value] = token.split('=');
if (['queue', 'crossfade', 'cut'].includes(token)) {
options.mode = token;
} else if (['loop', 'looped', 'repeat'].includes(token)) {
options.loop = true;
} else if (['once', 'single', 'no-loop', 'noloop'].includes(token)) {
options.loop = false;
} else if (key === 'loop') {
options.loop = !['false', '0', 'no', 'once'].includes(value);
} else if (key === 'mode' && ['queue', 'crossfade', 'cut'].includes(value)) {
options.mode = value;
}
});
return options;
}
parseSfxTagOptions(optionText) {
const options = {
maxDurationSeconds: 0,
endMode: 'stop',
fadeDurationSeconds: 2
};
String(optionText || '')
.split(/[,\s]+/)
.map(token => token.trim().toLowerCase())
.filter(Boolean)
.forEach(token => {
const [key, value] = token.split('=');
if (['fade', 'fadeout', 'fade-out'].includes(token)) {
options.endMode = 'fade';
} else if (['stop', 'cut', 'halt'].includes(token)) {
options.endMode = 'stop';
} else if (['max', 'duration', 'max-duration', 'limit', 'stop-after', 'fade-after'].includes(key)) {
const seconds = Number(value);
options.maxDurationSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
if (key === 'fade-after') options.endMode = 'fade';
} else if (/^\d+(\.\d+)?s?$/.test(token)) {
options.maxDurationSeconds = Number(token.replace(/s$/, ''));
} else if (key === 'mode' && ['fade', 'fadeout', 'fade-out', 'stop', 'cut'].includes(value)) {
options.endMode = value.startsWith('fade') ? 'fade' : 'stop';
} else if (['fade-duration', 'fade-time', 'fade'].includes(key)) {
const seconds = Number(value);
if (Number.isFinite(seconds)) {
options.fadeDurationSeconds = Math.max(0.1, seconds);
options.endMode = 'fade';
}
}
});
return options;
}
async playSfx(filename, options = {}) {
try {
const template = await this.preloadSfx(filename);
const audio = template.cloneNode(true);
audio.volume = this.getSfxVolume();
this.currentAudio = audio;
const maxDuration = Math.max(0, Number(options.maxDurationSeconds || options.maxDuration || 0)) * 1000;
const endMode = String(options.endMode || options.mode || 'stop').toLowerCase().startsWith('fade') ? 'fade' : 'stop';
const fadeDuration = Math.max(100, Number(options.fadeDurationSeconds || options.fadeDuration || 2) * 1000);
let maxTimer = null;
audio.addEventListener('ended', () => {
if (maxTimer) clearTimeout(maxTimer);
if (this.currentAudio === audio) {
this.currentAudio = null;
}
}, { once: true });
await audio.play();
if (maxDuration > 0) {
const timeoutDuration = endMode === 'fade'
? Math.max(0, maxDuration - fadeDuration)
: maxDuration;
maxTimer = setTimeout(() => {
if (audio.paused || audio.ended) return;
if (endMode === 'fade') {
console.log(`AudioManager: Fading sound effect ${filename} over ${fadeDuration}ms`);
this.fadeOutAudio(audio, fadeDuration);
} else {
audio.pause();
audio.currentTime = 0;
if (this.currentAudio === audio) this.currentAudio = null;
}
}, timeoutDuration);
}
console.log(`AudioManager: Playing sound effect ${filename}`);
return audio;
} catch (error) {
@@ -472,6 +593,27 @@ class AudioManagerModule extends BaseModule {
}
}
fadeOutAudio(audio, duration = 1000) {
if (!audio) return Promise.resolve(false);
const startVolume = audio.volume;
const startedAt = performance.now();
return new Promise(resolve => {
const step = () => {
const progress = Math.min(1, (performance.now() - startedAt) / duration);
audio.volume = startVolume * (1 - progress);
if (progress < 1 && !audio.paused && !audio.ended) {
requestAnimationFrame(step);
return;
}
audio.pause();
audio.currentTime = 0;
if (this.currentAudio === audio) this.currentAudio = null;
resolve(true);
};
requestAnimationFrame(step);
});
}
async playMusic(filename, mode = 'crossfade', options = {}) {
const url = this.getAssetUrl('music', filename);
const shouldLoop = options.loop !== false;
+7 -1
View File
@@ -310,7 +310,13 @@ export class BaseModule {
this._trackResource(url);
const script = document.createElement('script');
script.src = url;
const cacheBuster = window.MODULE_CACHE_BUSTER;
if (cacheBuster && /^\/(js|css)\//.test(url)) {
const separator = url.includes('?') ? '&' : '?';
script.src = `${url}${separator}v=${encodeURIComponent(cacheBuster)}`;
} else {
script.src = url;
}
if (isModule) {
script.type = 'module';
}
+261
View File
@@ -0,0 +1,261 @@
/**
* Choice Display Module
* Renders choice-mode interactions from TurnResult choices.
*/
import { BaseModule } from './base-module.js';
class ChoiceDisplayModule extends BaseModule {
constructor() {
super('choice-display', 'Choice Display');
this.dependencies = ['socket-client'];
this.socketClient = null;
this.container = null;
this.choices = [];
this.inputMode = 'text';
this.processState = document.documentElement.dataset.processState || 'ready';
this.template = {
cells: {
default: {
label: '',
match: () => true
}
},
fallbackCell: 'default'
};
this.bindMethods([
'initialize',
'setupContainer',
'handleChoices',
'handleInputMode',
'handleProcessState',
'handleKeyDown',
'render',
'clear',
'normalizeChoices',
'assignLetters',
'selectChoice',
'getTagValue',
'getTemplateCell'
]);
}
async initialize() {
this.socketClient = this.getModule('socket-client');
this.setupContainer();
this.addEventListener(document, 'story:choices', (event) => {
this.handleChoices(event.detail || []);
});
this.addEventListener(document, 'story:input-mode', (event) => {
this.handleInputMode(event.detail || 'text');
});
this.addEventListener(document, 'story:process-state', (event) => {
this.handleProcessState(event.detail?.state || 'ready');
});
this.addEventListener(document, 'keydown', this.handleKeyDown);
this.reportProgress(100, 'Choice display ready');
return true;
}
setupContainer() {
const choicesRoot = document.getElementById('choices');
if (!choicesRoot) {
return;
}
this.container = document.getElementById('story_choices');
if (!this.container) {
this.container = document.createElement('div');
this.container.id = 'story_choices';
this.container.className = 'story-choices';
}
const commandInput = document.getElementById('command_input');
if (this.container.parentElement !== choicesRoot) {
choicesRoot.insertBefore(this.container, commandInput || null);
} else if (commandInput && this.container.nextElementSibling !== commandInput) {
choicesRoot.insertBefore(this.container, commandInput);
} else if (!commandInput && this.container !== choicesRoot.lastElementChild) {
choicesRoot.appendChild(this.container);
}
}
handleChoices(choices) {
this.choices = this.normalizeChoices(Array.isArray(choices) ? choices : []);
this.render();
}
handleInputMode(inputMode) {
this.inputMode = ['text', 'choice', 'end'].includes(inputMode) ? inputMode : 'text';
this.render();
}
handleProcessState(state) {
this.processState = state || 'ready';
this.render();
}
handleKeyDown(event) {
if (this.inputMode !== 'choice' || !this.choices.length) {
return;
}
const optionsModal = document.getElementById('options-modal');
if (optionsModal && optionsModal.style.display !== 'none') {
return;
}
if (event.ctrlKey || event.metaKey || event.altKey || event.key.length !== 1) {
return;
}
const letter = event.key.toUpperCase();
const choice = this.choices.find((item) => item.letter === letter);
if (!choice) {
return;
}
event.preventDefault();
this.selectChoice(choice.index);
}
normalizeChoices(choices) {
return this.assignLetters(choices.slice(0, 26).map((choice, order) => {
const tags = Array.isArray(choice.tags) ? choice.tags : [];
const category = choice.category || this.getTagValue(tags, 'action');
return {
index: Number.isInteger(choice.index) ? choice.index : order,
text: String(choice.text || ''),
tags,
category,
letter: '',
templateCell: this.getTemplateCell({ ...choice, tags, category })
};
}));
}
assignLetters(choices) {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const reserved = new Set();
choices.forEach((choice) => {
const explicit = String(choice.letter || this.getTagValue(choice.tags, 'letter') || '')
.trim()
.charAt(0)
.toUpperCase();
if (alphabet.includes(explicit) && !reserved.has(explicit)) {
choice.letter = explicit;
reserved.add(explicit);
}
});
let nextLetterIndex = 0;
choices.forEach((choice) => {
if (choice.letter) return;
while (nextLetterIndex < alphabet.length && reserved.has(alphabet[nextLetterIndex])) {
nextLetterIndex += 1;
}
if (nextLetterIndex < alphabet.length) {
choice.letter = alphabet[nextLetterIndex];
reserved.add(choice.letter);
nextLetterIndex += 1;
}
});
return choices;
}
getTemplateCell(choice) {
const entries = Object.entries(this.template.cells);
const match = entries.find(([cellName, cell]) => {
if (cellName === this.template.fallbackCell) return false;
return typeof cell.match === 'function' && cell.match(choice);
});
return match ? match[0] : this.template.fallbackCell;
}
getTagValue(tags, key) {
const normalizedKey = String(key).toLowerCase();
const tag = tags.find((item) => String(item?.key || '').toLowerCase() === normalizedKey);
return tag?.value;
}
render() {
this.setupContainer();
if (!this.container) return;
this.container.innerHTML = '';
const readyForChoices = this.inputMode === 'choice' && this.choices.length > 0 && this.processState === 'ready';
this.container.hidden = !readyForChoices;
this.container.dataset.choiceReady = readyForChoices ? 'true' : 'false';
if (this.container.hidden) {
return;
}
const list = document.createElement('ol');
list.className = 'choice-list choice-template-default';
this.choices.forEach((choice) => {
const item = document.createElement('li');
item.className = 'choice-list-item';
item.dataset.choiceIndex = String(choice.index);
item.dataset.choiceLetter = choice.letter;
item.dataset.templateCell = choice.templateCell;
const button = document.createElement('button');
button.type = 'button';
button.className = 'choice-button';
button.innerHTML = `<kbd>${choice.letter}</kbd><span>${this.escapeHtml(choice.text)}</span>`;
button.addEventListener('click', () => this.selectChoice(choice.index));
item.appendChild(button);
list.appendChild(item);
});
this.container.appendChild(list);
}
async selectChoice(index) {
if (!this.socketClient) {
this.socketClient = this.getModule('socket-client');
}
if (!this.socketClient || typeof this.socketClient.chooseChoice !== 'function') {
console.error('ChoiceDisplay: Socket client cannot choose choices');
return;
}
this.clear();
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'command-waiting', reason: 'choice-selected', choiceIndex: index }
}));
await this.socketClient.chooseChoice(index);
}
clear() {
this.choices = [];
if (this.container) {
this.container.innerHTML = '';
this.container.hidden = true;
}
}
escapeHtml(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
}
const choiceDisplay = new ChoiceDisplayModule();
export { choiceDisplay as ChoiceDisplay };
if (window.moduleRegistry) {
window.moduleRegistry.register(choiceDisplay);
}
window.ChoiceDisplay = choiceDisplay;
+103
View File
@@ -0,0 +1,103 @@
/**
* Game Config Module
* Loads engine metadata, locale, and asset roots before the UI is created.
*/
import { BaseModule } from './base-module.js';
class GameConfigModule extends BaseModule {
constructor() {
super('game-config', 'Game Config');
this.dependencies = ['localization', 'persistence-manager'];
this.config = null;
this.bindMethods([
'getConfig',
'getMetadata',
'getLocale',
'loadConfig',
'applyDocumentMetadata'
]);
}
async initialize() {
try {
this.reportProgress(20, 'Loading game configuration');
this.config = await this.loadConfig();
const localization = this.getModule('localization');
if (localization && this.config?.locale) {
await localization.applyServerLocale(this.config.locale);
}
this.applyDocumentMetadata();
document.dispatchEvent(new CustomEvent('game:config', {
detail: this.config
}));
this.reportProgress(100, 'Game configuration ready');
return true;
} catch (error) {
console.error('GameConfig: Failed to load game configuration:', error);
this.config = {
engine: 'unknown',
locale: 'en_US',
metadata: {
title: 'AI Interactive Fiction',
author: '',
subtitle: '',
version: '',
copyright: ''
},
assets: {
music: '/music/',
sfx: '/sounds/',
sounds: '/sounds/',
images: '/images/'
}
};
this.applyDocumentMetadata();
document.dispatchEvent(new CustomEvent('game:config', { detail: this.config }));
this.reportProgress(100, 'Game configuration fallback ready');
return true;
}
}
async loadConfig() {
const response = await fetch('/api/game-config', { cache: 'no-store' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
applyDocumentMetadata() {
const metadata = this.getMetadata();
if (metadata?.title) {
document.title = metadata.subtitle
? `${metadata.title} - ${metadata.subtitle}`
: metadata.title;
}
}
getConfig() {
return this.config;
}
getMetadata() {
return this.config?.metadata || {};
}
getLocale() {
return this.config?.locale || 'en_US';
}
}
const GameConfig = new GameConfigModule();
export { GameConfig };
if (window.moduleRegistry) {
window.moduleRegistry.register(GameConfig);
}
window.GameConfig = GameConfig;
+1 -27
View File
@@ -33,8 +33,7 @@ class GameLoopModule extends BaseModule {
'requestStartGame',
'requestSaveGame',
'requestLoadGame',
'resetClientPlaybackAndDisplay',
'addText'
'resetClientPlaybackAndDisplay'
]);
}
@@ -109,15 +108,6 @@ class GameLoopModule extends BaseModule {
// Text processing is handled by socket-client -> text-buffer -> ui-controller pipeline
});
// Listen for game introduction
socketClient.on('gameIntroduction', (data) => {
console.log("GameLoop: Received gameIntroduction");
this.gameState.started = true;
this.gameState.canSave = true;
this.updateUIState();
// Text processing is handled by socket-client -> text-buffer -> ui-controller pipeline
});
socketClient.on('gameSaved', () => {
this.gameState.canLoad = true;
this.updateUIState();
@@ -292,22 +282,6 @@ class GameLoopModule extends BaseModule {
}
}
/**
* Manually add text to the buffer
* Useful for testing or adding local messages
* @param {string} text - Text to add
*/
addText(text) {
// Use parent's getModule method
const textBuffer = this.getModule('text-buffer');
if (!textBuffer) {
console.warn("Text buffer not available");
return;
}
textBuffer.addText(text);
}
}
// Create the singleton instance
+12 -1
View File
@@ -96,6 +96,15 @@ class LayoutRendererModule extends BaseModule {
}
const lineHeight = lineHeightPx || parseFloat(window.getComputedStyle(paragraph).lineHeight) || 24;
if (layoutData.role === 'chapter-heading') {
paragraph.style.marginTop = `${lineHeight * 2}px`;
paragraph.style.marginBottom = `${lineHeight}px`;
} else if (layoutData.role === 'section-heading') {
paragraph.style.marginTop = `${lineHeight}px`;
paragraph.style.marginBottom = `${lineHeight}px`;
} else if (layoutData.addTopSpace) {
paragraph.style.marginTop = `${lineHeight}px`;
}
const maxLineWidth = Array.isArray(measures) && measures.length > 0
? Math.max(...measures)
: storyElement.clientWidth;
@@ -139,7 +148,9 @@ class LayoutRendererModule extends BaseModule {
: lineWidth;
const lineOffset = isCentered
? Math.max(0, (maxLineWidth - naturalLineWidth) / 2)
: maxLineWidth - lineWidth;
: Array.isArray(layoutData.lineOffsets)
? (layoutData.lineOffsets[Math.min(lineIndex, layoutData.lineOffsets.length - 1)] || 0)
: maxLineWidth - lineWidth;
let currentLeft = 0;
lastChild = null;
+4 -1
View File
@@ -24,7 +24,8 @@ const ModuleState = {
ERROR: 'ERROR'
};
const MODULE_CACHE_BUSTER = '20260514-new-game-click';
const MODULE_CACHE_BUSTER = '20260515-lead-kap-verified';
window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER;
/**
* Module Loader - Manages the loading of all modules
@@ -102,6 +103,7 @@ const ModuleLoader = (function() {
// Core functionality modules
{ id: 'persistence-manager', script: '/js/persistence-manager-module.js', weight: 12 },
{ id: 'localization', script: '/js/localization-module.js', weight: 12 },
{ id: 'game-config', script: '/js/game-config-module.js', weight: 8 },
{ id: 'text-processor', script: '/js/text-processor-module.js', weight: 15 },
{ id: 'markup-parser', script: '/js/markup-parser-module.js', weight: 5 },
{ id: 'paragraph-layout', script: '/js/paragraph-layout-module.js', weight: 17 },
@@ -123,6 +125,7 @@ const ModuleLoader = (function() {
{ id: 'ui-effects', script: '/js/ui-effects-module.js', weight: 12 }, // Add UI Effects module
{ id: 'ui-input-handler', script: '/js/ui-input-handler-module.js', weight: 27 }, // Add UI Input Handler module
{ id: 'ui-display-handler', script: '/js/ui-display-handler-module.js', weight: 27 }, // Add UI Display Handler module
{ id: 'choice-display', script: '/js/choice-display-module.js', weight: 8 },
{ id: 'ui-controller', script: '/js/ui-controller-module.js', weight: 27 },
{ id: 'options-ui', script: '/js/options-ui-module.js', weight: 13 },
{ id: 'socket-client', script: '/js/socket-client-module.js', weight: 17 },
+52 -28
View File
@@ -13,20 +13,21 @@ class LocalizationModule extends BaseModule {
// Current locale
this.translations = {};
this.defaultLocale = 'en-us';
this.defaultLocale = 'en_US';
this.currentLocale = this.defaultLocale;
this.dependencies = ['persistence-manager'];
// Available translations
this.languageNames = {
'en-us': 'English (US)',
'en-gb': 'English (UK)',
'de-de': 'Deutsch (Deutschland)'
'en_US': 'English (US)',
'de_DE': 'Deutsch (Deutschland)'
};
// Bind methods
this.bindMethods([
'setLocale',
'applyServerLocale',
'normalizeLocale',
'getLocale',
'translate',
'getAvailableLocales',
@@ -44,7 +45,7 @@ class LocalizationModule extends BaseModule {
this.reportProgress(10, "Initializing localization");
// Load default English locale
await this.loadTranslations('en-us');
await this.loadTranslations(this.defaultLocale);
this.reportProgress(50, "Loaded default locale");
// Get stored locale from persistence manager if available
@@ -53,27 +54,26 @@ class LocalizationModule extends BaseModule {
if (persistenceManager) {
const storedLocale = persistenceManager.getPreference('app', 'locale');
if (storedLocale) {
console.log(`Localization: Found stored locale: ${storedLocale}`);
await this.loadTranslations(storedLocale);
this.currentLocale = storedLocale;
this.reportProgress(80, `Loaded stored locale: ${storedLocale}`);
const normalizedLocale = this.normalizeLocale(storedLocale);
console.log(`Localization: Found stored locale: ${normalizedLocale}`);
await this.loadTranslations(normalizedLocale);
this.currentLocale = normalizedLocale;
this.reportProgress(80, `Loaded stored locale: ${normalizedLocale}`);
} else {
// If no stored locale, ensure en-us is the default and persist it
console.log('Localization: No stored locale found, using default en-us');
persistenceManager.updatePreference('app', 'locale', 'en-us');
persistenceManager.updatePreference('tts', 'language', 'en-us');
this.currentLocale = 'en-us';
this.reportProgress(80, "Using default locale: en-us");
console.log(`Localization: No stored locale found, using default ${this.defaultLocale}`);
this.currentLocale = this.defaultLocale;
this.reportProgress(80, `Using default locale: ${this.defaultLocale}`);
}
} else {
console.log('Localization: Persistence manager not available, using default en-us locale');
this.reportProgress(80, "Using default locale: en-us");
console.log(`Localization: Persistence manager not available, using default ${this.defaultLocale} locale`);
this.reportProgress(80, `Using default locale: ${this.defaultLocale}`);
}
// Dispatch event to notify about loaded locale
document.dispatchEvent(new CustomEvent('localization:languageChanged', {
detail: { locale: this.currentLocale }
}));
document.documentElement.lang = this.currentLocale.replace('_', '-');
this.reportProgress(100, "Localization ready");
return true;
@@ -90,14 +90,12 @@ class LocalizationModule extends BaseModule {
* @returns {Promise<void>}
*/
async loadTranslations(locale) {
if (this.translations[locale]) {
const normalizedLocale = this.normalizeLocale(locale);
if (this.translations[normalizedLocale]) {
return; // Already loaded
}
try {
// Normalize locale
const normalizedLocale = locale.toLowerCase();
// Try to load the exact locale
const response = await fetch(`/locales/${normalizedLocale}.json`);
@@ -106,7 +104,7 @@ class LocalizationModule extends BaseModule {
this.translations[normalizedLocale] = translations;
} else {
// If exact locale not found, try to load just the language part
const langPart = normalizedLocale.split('-')[0];
const langPart = normalizedLocale.split('_')[0];
if (langPart !== normalizedLocale) {
const langResponse = await fetch(`/locales/${langPart}.json`);
if (langResponse.ok) {
@@ -128,12 +126,12 @@ class LocalizationModule extends BaseModule {
* @param {string} locale - Locale to set
* @returns {Promise<boolean>} - Success status
*/
async setLocale(locale) {
async setLocale(locale, options = {}) {
if (!locale) return false;
try {
// Normalize locale
const normalizedLocale = locale.toLowerCase();
const normalizedLocale = this.normalizeLocale(locale);
const userInitiated = options.userInitiated !== false;
// Load translations if not already loaded
if (!this.translations[normalizedLocale]) {
@@ -148,12 +146,23 @@ class LocalizationModule extends BaseModule {
if (persistenceManager) {
persistenceManager.updatePreference('app', 'locale', normalizedLocale);
persistenceManager.updatePreference('tts', 'language', normalizedLocale);
if (userInitiated) {
persistenceManager.updatePreference('app', 'localeUserOverride', true);
}
}
document.documentElement.lang = normalizedLocale.replace('_', '-');
// Dispatch locale change event
this.dispatchEvent('locale-changed', {
locale: normalizedLocale
});
document.dispatchEvent(new CustomEvent('locale:changed', {
detail: { locale: normalizedLocale }
}));
document.dispatchEvent(new CustomEvent('localization:languageChanged', {
detail: { locale: normalizedLocale }
}));
return true;
} catch (error) {
@@ -162,6 +171,21 @@ class LocalizationModule extends BaseModule {
}
}
async applyServerLocale(locale) {
const persistenceManager = this.getModule('persistence-manager');
const userOverride = persistenceManager?.getPreference('app', 'localeUserOverride', false);
if (userOverride) {
return false;
}
return this.setLocale(locale, { userInitiated: false });
}
normalizeLocale(locale) {
const normalized = String(locale || this.defaultLocale).trim().replace('-', '_').toLowerCase();
if (normalized.startsWith('de')) return 'de_DE';
return 'en_US';
}
/**
* Get the current locale
* @returns {string} - Current locale
@@ -175,7 +199,7 @@ class LocalizationModule extends BaseModule {
* @returns {string} - Language code
*/
getLanguage() {
return this.currentLocale.split('-')[0];
return this.currentLocale.split('_')[0];
}
/**
@@ -197,7 +221,7 @@ class LocalizationModule extends BaseModule {
if (!locale) return '';
// Normalize locale
const normalizedLocale = locale.toLowerCase();
const normalizedLocale = this.normalizeLocale(locale);
// Try exact match
if (this.languageNames[normalizedLocale]) {
@@ -205,7 +229,7 @@ class LocalizationModule extends BaseModule {
}
// Try language part only
const langPart = normalizedLocale.split('-')[0];
const langPart = normalizedLocale.split('_')[0];
if (this.languageNames[langPart]) {
return this.languageNames[langPart];
}
+73 -90
View File
@@ -18,6 +18,8 @@ class MarkupParserModule extends BaseModule {
'parse',
'parseParagraph',
'parseInline',
'parseImageOptions',
'parseSfxOptions',
'parseMusicOptions',
'markdownToHtml',
'markdownToPlainText',
@@ -38,7 +40,6 @@ class MarkupParserModule extends BaseModule {
const text = String(input || '').replace(/\r\n?/g, '\n');
const blocks = [];
let paragraphBuffer = [];
let nextParagraphRole = null;
const flushParagraph = () => {
if (paragraphBuffer.length === 0) return;
@@ -48,8 +49,7 @@ class MarkupParserModule extends BaseModule {
const paragraph = this.parseParagraph(raw);
if (!paragraph.text) return;
const role = nextParagraphRole || 'body';
nextParagraphRole = null;
const role = 'body';
blocks.push(this.buildParagraphBlock(paragraph, role));
};
@@ -61,65 +61,6 @@ class MarkupParserModule extends BaseModule {
return;
}
const chapter = trimmed.match(/^::chapter(?:\[(.*?)\]|\s+(.+))$/i);
if (chapter) {
flushParagraph();
const heading = (chapter[1] || chapter[2] || '').trim();
if (heading) {
const normalizedHeading = this.normalizeParagraph(heading);
blocks.push({
type: 'heading',
text: this.markdownToPlainText(normalizedHeading),
layoutText: this.markdownToHtml(normalizedHeading),
role: 'chapter-heading'
});
}
nextParagraphRole = 'chapter-first';
return;
}
const section = trimmed.match(/^::(?:section|textblock)(?:\[(.*?)\]|\s+(.+))?$/i);
if (section) {
flushParagraph();
const heading = (section[1] || section[2] || '').trim();
if (heading) {
const normalizedHeading = this.normalizeParagraph(heading);
blocks.push({
type: 'heading',
text: this.markdownToPlainText(normalizedHeading),
layoutText: this.markdownToHtml(normalizedHeading),
role: 'section-heading'
});
}
nextParagraphRole = 'textblock-first';
return;
}
const image = trimmed.match(/^::image\[(widescreen|portrait)\]\(([^)]+)\)$/i);
if (image) {
flushParagraph();
blocks.push({
type: 'image',
size: image[1].toLowerCase(),
filename: image[2].trim(),
url: this.resolveAssetUrl('images', image[2].trim())
});
return;
}
const music = trimmed.match(/^::music(?:\[([^\]]*)\])?\(([^)]+)\)$/i);
if (music) {
flushParagraph();
const options = this.parseMusicOptions(music[1] || 'crossfade');
blocks.push({
type: 'music',
...options,
filename: music[2].trim(),
url: this.resolveAssetUrl('music', music[2].trim())
});
return;
}
paragraphBuffer.push(line);
});
@@ -128,6 +69,35 @@ class MarkupParserModule extends BaseModule {
return blocks;
}
parseImageOptions(optionText) {
const options = {
size: 'landscape',
leadInSeconds: 0
};
String(optionText || 'landscape')
.split(/[,\s]+/)
.map(token => token.trim())
.filter(Boolean)
.forEach((token, index) => {
const lower = token.toLowerCase();
const [key, value] = lower.split('=');
if (['landscape', 'widescreen', 'portrait', 'square'].includes(lower)) {
options.size = lower === 'widescreen' ? 'landscape' : lower;
} else if (['lead', 'lead-in', 'leadins', 'lead-in-seconds', 'delay', 'intro', 'pause', 'wait', 'hold'].includes(key)) {
const seconds = Number(value);
options.leadInSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
} else if (/^\d+(\.\d+)?s?$/.test(lower)) {
options.leadInSeconds = Number(lower.replace(/s$/, ''));
} else if (index === 0) {
console.warn(`MarkupParser: Unknown image size "${token}", using landscape`);
}
});
return options;
}
parseMusicOptions(optionText) {
const options = {
mode: 'crossfade',
@@ -162,6 +132,45 @@ class MarkupParserModule extends BaseModule {
return options;
}
parseSfxOptions(optionText) {
const options = {
maxDurationSeconds: 0,
endMode: 'stop',
fadeDurationSeconds: 2
};
String(optionText || '')
.split(/[,\s]+/)
.map(token => token.trim())
.filter(Boolean)
.forEach(token => {
const lower = token.toLowerCase();
const [key, value] = lower.split('=');
if (['fade', 'fadeout', 'fade-out'].includes(lower)) {
options.endMode = 'fade';
} else if (['stop', 'cut', 'halt'].includes(lower)) {
options.endMode = 'stop';
} else if (['max', 'duration', 'max-duration', 'limit', 'stop-after', 'fade-after'].includes(key)) {
const seconds = Number(value);
options.maxDurationSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
if (key === 'fade-after') options.endMode = 'fade';
} else if (/^\d+(\.\d+)?s?$/.test(lower)) {
options.maxDurationSeconds = Number(lower.replace(/s$/, ''));
} else if (key === 'mode' && ['fade', 'fadeout', 'fade-out', 'stop', 'cut'].includes(value)) {
options.endMode = value.startsWith('fade') ? 'fade' : 'stop';
} else if (['fade-duration', 'fade-time', 'fade'].includes(key)) {
const seconds = Number(value);
if (Number.isFinite(seconds)) {
options.fadeDurationSeconds = Math.max(0.1, seconds);
options.endMode = 'fade';
}
}
});
return options;
}
parseParagraph(rawText) {
const inline = this.parseInline(this.normalizeParagraph(rawText));
return {
@@ -185,35 +194,9 @@ class MarkupParserModule extends BaseModule {
}
parseInline(text) {
const cueMarkers = [];
let output = '';
let cursor = 0;
const markerPattern = /\{\{\s*(sfx|music)\s*:\s*(?:(queue|crossfade|cut)\s*:\s*)?([^}]+?)\s*\}\}/gi;
for (const match of text.matchAll(markerPattern)) {
output += text.slice(cursor, match.index);
const charIndex = output.length;
const wordIndex = this.countWords(output);
const type = match[1].toLowerCase();
const mode = type === 'music' ? (match[2] || 'crossfade').toLowerCase() : null;
cueMarkers.push({
type,
mode,
filename: match[3].trim(),
url: this.resolveAssetUrl(type === 'sfx' ? 'sounds' : 'music', match[3].trim()),
charIndex,
wordIndex
});
cursor = match.index + match[0].length;
}
output += text.slice(cursor);
return {
text: output.replace(/\s{2,}/g, ' ').trim(),
cueMarkers
text: String(text || '').replace(/\s{2,}/g, ' ').trim(),
cueMarkers: []
};
}
+1 -1
View File
@@ -27,7 +27,7 @@ export class ModuleRegistry {
if (dependencies) {
this.moduleDependencies.set(module.id, dependencies);
// Also set them on the module itself for backwards compatibility
// Mirror explicit dependencies onto the module instance.
if (module.dependencies === undefined) {
module.dependencies = [...dependencies];
}
+41 -20
View File
@@ -46,10 +46,31 @@ class OptionsUIModule extends BaseModule {
'dispatchApiChangeEvent',
'getPreference',
'updatePreference',
'updateUIText',
'renderProviderStatuses'
]);
}
t(key, params = {}) {
const localization = this.getModule('localization');
return localization?.translate?.(key, params) || key;
}
updateUIText() {
if (!this.modal) return;
const wasOpen = this.modal.style.display === 'flex';
this.modal.remove();
this.modal = null;
this.elements = {};
this.createModal();
this.setupPreferenceBindings();
this.populateTtsSystems();
this.populateLanguages();
this.populateVoices();
this.renderProviderStatuses();
if (wasOpen) this.show();
}
/**
* Dispatches an API change event
* @param {string} eventType - Event type (e.g. 'api:key:change')
@@ -135,7 +156,7 @@ class OptionsUIModule extends BaseModule {
header.className = 'modal-header';
const title = document.createElement('h2');
title.textContent = 'Options';
title.textContent = this.t('options.title');
header.appendChild(title);
const closeButton = document.createElement('span');
@@ -156,7 +177,7 @@ class OptionsUIModule extends BaseModule {
appSettingsSection.className = 'options-section';
const appSettingsTitle = document.createElement('h3');
appSettingsTitle.textContent = 'Application Settings';
appSettingsTitle.textContent = this.t('options.applicationSettings');
appSettingsSection.appendChild(appSettingsTitle);
// Language
@@ -164,7 +185,7 @@ class OptionsUIModule extends BaseModule {
languageContainer.className = 'option-item';
const languageLabel = document.createElement('label');
languageLabel.textContent = 'Language:';
languageLabel.textContent = this.t('options.language') + ':';
languageContainer.appendChild(languageLabel);
this.elements.language = createUIElement('select', {
@@ -178,7 +199,7 @@ class OptionsUIModule extends BaseModule {
speedContainer.className = 'option-item';
const speedLabel = document.createElement('label');
speedLabel.textContent = 'Speed:';
speedLabel.textContent = this.t('options.speed') + ':';
speedContainer.appendChild(speedLabel);
const speedValue = document.createElement('span');
@@ -210,7 +231,7 @@ class OptionsUIModule extends BaseModule {
ttsSection.className = 'options-section';
const ttsTitle = document.createElement('h3');
ttsTitle.textContent = 'Text-to-Speech';
ttsTitle.textContent = this.t('options.speech');
ttsSection.appendChild(ttsTitle);
// TTS Enable
@@ -218,7 +239,7 @@ class OptionsUIModule extends BaseModule {
ttsEnableContainer.className = 'option-item';
const ttsEnableLabel = document.createElement('label');
ttsEnableLabel.textContent = 'Enable TTS:';
ttsEnableLabel.textContent = this.t('options.enableSpeech') + ':';
ttsEnableContainer.appendChild(ttsEnableLabel);
this.elements.ttsEnabled = createUIElement('input', {
@@ -233,7 +254,7 @@ class OptionsUIModule extends BaseModule {
ttsSystemContainer.className = 'option-item';
const ttsSystemLabel = document.createElement('label');
ttsSystemLabel.textContent = 'TTS System:';
ttsSystemLabel.textContent = this.t('options.provider') + ':';
ttsSystemContainer.appendChild(ttsSystemLabel);
this.elements.ttsSystem = createUIElement('select', {
@@ -252,7 +273,7 @@ class OptionsUIModule extends BaseModule {
ttsVoiceContainer.className = 'option-item';
const ttsVoiceLabel = document.createElement('label');
ttsVoiceLabel.textContent = 'Voice:';
ttsVoiceLabel.textContent = this.t('options.voice') + ':';
ttsVoiceContainer.appendChild(ttsVoiceLabel);
this.elements.ttsVoice = createUIElement('select', {
@@ -272,7 +293,7 @@ class OptionsUIModule extends BaseModule {
audioSection.className = 'options-section';
const audioTitle = document.createElement('h3');
audioTitle.textContent = 'Audio';
audioTitle.textContent = this.t('options.audio');
audioSection.appendChild(audioTitle);
// Master Volume
@@ -280,7 +301,7 @@ class OptionsUIModule extends BaseModule {
masterVolumeContainer.className = 'option-item';
const masterVolumeLabel = document.createElement('label');
masterVolumeLabel.textContent = 'Master Volume:';
masterVolumeLabel.textContent = this.t('options.masterVolume') + ':';
masterVolumeContainer.appendChild(masterVolumeLabel);
const masterVolumeValue = document.createElement('span');
@@ -310,7 +331,7 @@ class OptionsUIModule extends BaseModule {
ttsVolumeContainer.className = 'option-item';
const ttsVolumeLabel = document.createElement('label');
ttsVolumeLabel.textContent = 'Speech Volume:';
ttsVolumeLabel.textContent = this.t('options.speechVolume') + ':';
ttsVolumeContainer.appendChild(ttsVolumeLabel);
const ttsVolumeValue = document.createElement('span');
@@ -340,7 +361,7 @@ class OptionsUIModule extends BaseModule {
musicVolumeContainer.className = 'option-item';
const musicVolumeLabel = document.createElement('label');
musicVolumeLabel.textContent = 'Music Volume:';
musicVolumeLabel.textContent = this.t('options.musicVolume') + ':';
musicVolumeContainer.appendChild(musicVolumeLabel);
const musicVolumeValue = document.createElement('span');
@@ -370,7 +391,7 @@ class OptionsUIModule extends BaseModule {
sfxVolumeContainer.className = 'option-item';
const sfxVolumeLabel = document.createElement('label');
sfxVolumeLabel.textContent = 'Sound Effects Volume:';
sfxVolumeLabel.textContent = this.t('options.sfxVolume') + ':';
sfxVolumeContainer.appendChild(sfxVolumeLabel);
const sfxVolumeValue = document.createElement('span');
@@ -404,7 +425,7 @@ class OptionsUIModule extends BaseModule {
footer.className = 'modal-footer';
const closeModalButton = document.createElement('button');
closeModalButton.textContent = 'Close';
closeModalButton.textContent = this.t('options.close');
closeModalButton.onclick = () => this.hide();
footer.appendChild(closeModalButton);
@@ -432,7 +453,7 @@ class OptionsUIModule extends BaseModule {
elevenLabsSettings.style.display = 'none';
const elevenLabsTitle = document.createElement('h3');
elevenLabsTitle.textContent = 'ElevenLabs API Settings';
elevenLabsTitle.textContent = this.t('options.elevenLabsSettings');
elevenLabsSettings.appendChild(elevenLabsTitle);
// ElevenLabs API Key
@@ -440,7 +461,7 @@ class OptionsUIModule extends BaseModule {
elevenLabsApiKeyContainer.className = 'option-item';
const elevenLabsApiKeyLabel = document.createElement('label');
elevenLabsApiKeyLabel.textContent = 'API Key:';
elevenLabsApiKeyLabel.textContent = this.t('options.apiKey') + ':';
elevenLabsApiKeyContainer.appendChild(elevenLabsApiKeyLabel);
this.elements.elevenLabsApiKey = createUIElement('input', {
@@ -455,7 +476,7 @@ class OptionsUIModule extends BaseModule {
elevenLabsApiUrlContainer.className = 'option-item';
const elevenLabsApiUrlLabel = document.createElement('label');
elevenLabsApiUrlLabel.textContent = 'API URL:';
elevenLabsApiUrlLabel.textContent = this.t('options.apiUrl') + ':';
elevenLabsApiUrlContainer.appendChild(elevenLabsApiUrlLabel);
this.elements.elevenLabsApiUrl = createUIElement('input', {
@@ -471,7 +492,7 @@ class OptionsUIModule extends BaseModule {
openaiSettings.style.display = 'none';
const openaiTitle = document.createElement('h3');
openaiTitle.textContent = 'OpenAI API Settings';
openaiTitle.textContent = this.t('options.openAiSettings');
openaiSettings.appendChild(openaiTitle);
// OpenAI API Key
@@ -479,7 +500,7 @@ class OptionsUIModule extends BaseModule {
openaiApiKeyContainer.className = 'option-item';
const openaiApiKeyLabel = document.createElement('label');
openaiApiKeyLabel.textContent = 'API Key:';
openaiApiKeyLabel.textContent = this.t('options.apiKey') + ':';
openaiApiKeyContainer.appendChild(openaiApiKeyLabel);
this.elements.openaiApiKey = createUIElement('input', {
@@ -494,7 +515,7 @@ class OptionsUIModule extends BaseModule {
openaiApiUrlContainer.className = 'option-item';
const openaiApiUrlLabel = document.createElement('label');
openaiApiUrlLabel.textContent = 'API URL:';
openaiApiUrlLabel.textContent = this.t('options.apiUrl') + ':';
openaiApiUrlContainer.appendChild(openaiApiUrlLabel);
this.elements.openaiApiUrl = createUIElement('input', {
+4 -3
View File
@@ -33,7 +33,7 @@ class PersistenceManagerModule extends BaseModule {
enabled: false,
preferred_handler: 'none',
speed: 1.0,
language: 'en-us',
language: 'en_US',
voice: '',
'elevenlabs-tts_api_key': '',
'elevenlabs-tts_api_url': 'https://api.elevenlabs.io/v1',
@@ -47,8 +47,10 @@ class PersistenceManagerModule extends BaseModule {
sfxVolume: 1.0,
},
app: {
locale: 'en-us',
locale: null,
localeUserOverride: false,
speed: 1.0,
autoplay: true,
}
};
@@ -649,7 +651,6 @@ class PersistenceManagerModule extends BaseModule {
}
}
} else {
// Try to parse as JSON for backward compatibility
const customTransformer = JSON.parse(element.dataset.prefTransform);
if (customTransformer && typeof customTransformer === 'object') {
transformer = customTransformer;
+375 -40
View File
@@ -9,7 +9,7 @@ class SentenceQueueModule extends BaseModule {
super('sentence-queue', 'Sentence Queue');
// Dependencies
this.dependencies = ['text-buffer', 'tts-factory', 'paragraph-layout', 'audio-manager'];
this.dependencies = ['text-buffer', 'tts-factory', 'paragraph-layout', 'audio-manager', 'persistence-manager'];
// Queue state
this.sentenceQueue = [];
@@ -18,6 +18,9 @@ class SentenceQueueModule extends BaseModule {
// Cache for prefetched sentences
this.preparedCache = new Map();
this.prefetchingCache = new Map();
this.activeImageWrap = null;
this.autoplay = true;
// Bind methods
this.bindMethods([
@@ -26,8 +29,18 @@ class SentenceQueueModule extends BaseModule {
'processNextSentence',
'setOnSentenceReady',
'completeSentence',
'getCacheKey',
'getPreparedSentence',
'prefetchAhead',
'isSpeechItem',
'getMediaPauseSeconds',
'readFirstFiniteNumber',
'waitForSkippableMediaPause',
'shouldAutoplay',
'waitForManualContinue',
'prepareSentence',
'prepareLayout',
'prepareImageLayout',
'extractWords',
'getDropCapText',
'extractDropCapText',
@@ -56,6 +69,16 @@ class SentenceQueueModule extends BaseModule {
});
this.reportProgress(100, "Sentence queue ready");
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager && typeof persistenceManager.getPreference === 'function') {
this.autoplay = persistenceManager.getPreference('app', 'autoplay', true) !== false;
}
this.addEventListener(document, 'preference-updated', (event) => {
const { category, key, value } = event.detail || {};
if (category === 'app' && key === 'autoplay') {
this.autoplay = value !== false;
}
});
return true;
} catch (error) {
console.error("Error initializing Sentence Queue:", error);
@@ -106,44 +129,11 @@ class SentenceQueueModule extends BaseModule {
const item = this.sentenceQueue[0];
try {
// Check if sentence is already in cache
const cacheKey = `${item.id || ''}:${item.text}`;
let sentence = this.preparedCache.get(cacheKey);
const sentence = await this.getPreparedSentence(item);
if (!sentence) {
// Prepare complete sentence object (TTS + layout in parallel)
sentence = await this.prepareSentence(item);
} else {
console.log('SentenceQueue: Using cached sentence');
this.preparedCache.delete(cacheKey);
}
// Prefetch next sentence while current displays
if (this.sentenceQueue.length > 1) {
const nextItem = this.sentenceQueue[1];
const nextCacheKey = `${nextItem.id || ''}:${nextItem.text}`;
if (!this.preparedCache.has(nextCacheKey)) {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-generating', reason: 'prefetch-start', sentenceId: nextItem.id }
}));
console.log('Process state: playing-generating', { reason: 'prefetch-start', sentenceId: nextItem.id });
this.prepareSentence(nextItem)
.then(prepared => {
this.preparedCache.set(nextCacheKey, prepared);
console.log('SentenceQueue: Prefetched next sentence');
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id }
}));
console.log('Process state: playing-ready', { reason: 'prefetch-complete', sentenceId: nextItem.id });
})
.catch(err => console.warn('SentenceQueue: Prefetch failed:', err));
}
} else {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: 'no-prefetch-needed', sentenceId: item.id }
}));
console.log('Process state: playing-ready', { reason: 'no-prefetch-needed', sentenceId: item.id });
}
// Prefetch far enough ahead that media pauses do not block TTS
// generation for the next spoken paragraph.
this.prefetchAhead();
// Notify display handler with complete sentence
if (this.onSentenceReadyCallback) {
@@ -153,6 +143,15 @@ class SentenceQueueModule extends BaseModule {
});
}
const mediaPauseSeconds = this.getMediaPauseSeconds(sentence);
if (mediaPauseSeconds > 0) {
await this.waitForSkippableMediaPause(mediaPauseSeconds, sentence.kind, sentence.id);
}
if (sentence.kind === 'paragraph' && !this.shouldAutoplay()) {
await this.waitForManualContinue(sentence.id);
}
// Remove from queue and continue
this.sentenceQueue.shift();
if (item.callback) item.callback({ success: true });
@@ -275,12 +274,17 @@ class SentenceQueueModule extends BaseModule {
}
}
const imageLayout = metadata.type === 'image'
? await this.prepareImageLayout(metadata)
: null;
return {
id,
kind: metadata.type,
text: text || '',
turnId: metadata.turnId ?? null,
status: 'ready',
metadata,
metadata: imageLayout ? { ...metadata, imageLayout } : metadata,
tts: { duration: 0, provider: null, audioData: null, play: null, stop: null, enabled: false },
animation: { wordTimings: [], cueTimings: [], totalDuration: 0 },
element: null,
@@ -309,6 +313,7 @@ class SentenceQueueModule extends BaseModule {
id,
kind: metadata.type === 'heading' ? 'heading' : 'paragraph',
text,
turnId: metadata.turnId ?? null,
paragraphIndex: metadata.paragraphIndex ?? null,
isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter),
role: metadata.role || (metadata.type === 'heading' ? 'chapter-heading' : 'body'),
@@ -383,10 +388,27 @@ class SentenceQueueModule extends BaseModule {
const indentWidth = (isHeading || metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5;
const layoutText = metadata.layoutText || text;
const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText;
const wrap = this.consumeImageWrap();
// Measures are consumed in line order by the line breaker.
const wrappedWidth = wrap ? Math.max(120, containerWidth - wrap.width) : containerWidth;
const imageLeftOffset = wrap && wrap.side !== 'right' ? wrap.width : 0;
const imageRightOffset = wrap && wrap.side === 'right' ? wrap.width : 0;
const measures = isHeading
? [containerWidth]
: wrap && metadata.dropCap
? [
Math.max(120, wrappedWidth - dropCapWidth),
Math.max(120, wrappedWidth - dropCapWidth),
...Array(Math.max(0, wrap.lines - dropCapLines)).fill(wrappedWidth),
containerWidth
]
: wrap
? [
Math.max(120, wrappedWidth - indentWidth),
...Array(Math.max(0, wrap.lines - 1)).fill(wrappedWidth),
containerWidth
]
: metadata.dropCap
? [
Math.max(120, containerWidth - dropCapWidth),
@@ -398,8 +420,34 @@ class SentenceQueueModule extends BaseModule {
containerWidth,
containerWidth
];
const lineOffsets = isHeading
? [0]
: wrap && metadata.dropCap
? [
imageLeftOffset + dropCapWidth,
imageLeftOffset + dropCapWidth,
...Array(Math.max(0, wrap.lines - dropCapLines)).fill(imageLeftOffset),
0
]
: wrap
? [
imageLeftOffset + indentWidth,
...Array(Math.max(0, wrap.lines - 1)).fill(imageLeftOffset),
0
]
: metadata.dropCap
? [
dropCapWidth,
dropCapWidth,
0
]
: [
indentWidth,
0,
0
];
console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}]`);
console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, imageRightOffset: ${imageRightOffset.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}], offsets: [${lineOffsets.map(m => m.toFixed(1)).join(', ')}]`);
const layout = paragraphLayout.calculateLayout(layoutPlainText, {
measures,
@@ -413,13 +461,23 @@ class SentenceQueueModule extends BaseModule {
throw new Error('Paragraph layout calculation failed');
}
if (wrap) {
const usedLines = Math.max(0, (layout.breaks?.length || 1) - 1);
const remainingLines = Math.max(0, wrap.lines - usedLines);
this.activeImageWrap = remainingLines > 0
? { ...wrap, lines: remainingLines }
: null;
}
return {
breaks: layout.breaks,
nodes: layout.nodes,
processedText: layout.processedText || text,
sourceLayoutText: layoutText,
measures,
lineOffsets,
indentWidth,
imageWrap: wrap,
dropCap: Boolean(metadata.dropCap),
dropCapText: metadata.dropCap ? this.getDropCapText(layoutText) : '',
dropCapLines,
@@ -437,6 +495,282 @@ class SentenceQueueModule extends BaseModule {
}
}
shouldAutoplay() {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager && typeof persistenceManager.getPreference === 'function') {
return persistenceManager.getPreference('app', 'autoplay', this.autoplay) !== false;
}
return this.autoplay !== false;
}
waitForManualContinue(sentenceId) {
document.documentElement.dataset.skippablePause = 'true';
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'paused', reason: 'autoplay-disabled', sentenceId }
}));
return new Promise(resolve => {
let resolved = false;
const finish = () => {
if (resolved) return;
resolved = true;
delete document.documentElement.dataset.skippablePause;
document.removeEventListener('ui:command', onCommand);
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: 'manual-continue', sentenceId }
}));
resolve();
};
const onCommand = (event) => {
if (event.detail?.type === 'continue') {
finish();
}
};
document.addEventListener('ui:command', onCommand);
});
}
getCacheKey(item) {
return `${item?.id || ''}:${item?.text || ''}`;
}
async getPreparedSentence(item) {
const cacheKey = this.getCacheKey(item);
const cached = this.preparedCache.get(cacheKey);
if (cached) {
console.log('SentenceQueue: Using cached sentence');
this.preparedCache.delete(cacheKey);
return cached;
}
const pending = this.prefetchingCache.get(cacheKey);
if (pending) {
console.log('SentenceQueue: Awaiting active prefetch');
try {
const prepared = await pending;
return prepared || await this.prepareSentence(item);
} finally {
this.prefetchingCache.delete(cacheKey);
this.preparedCache.delete(cacheKey);
}
}
return this.prepareSentence(item);
}
prefetchAhead(maxLookahead = 4) {
if (this.sentenceQueue.length <= 1) {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: 'no-prefetch-needed', sentenceId: this.sentenceQueue[0]?.id }
}));
console.log('Process state: playing-ready', { reason: 'no-prefetch-needed', sentenceId: this.sentenceQueue[0]?.id });
return;
}
let started = 0;
let spokenPrepared = 0;
const limit = Math.min(this.sentenceQueue.length, maxLookahead + 1);
for (let index = 1; index < limit; index += 1) {
const nextItem = this.sentenceQueue[index];
const nextCacheKey = this.getCacheKey(nextItem);
if (this.preparedCache.has(nextCacheKey) || this.prefetchingCache.has(nextCacheKey)) {
if (this.isSpeechItem(nextItem)) spokenPrepared += 1;
continue;
}
const state = this.isSpeechItem(nextItem) ? 'playing-generating' : 'playing-ready';
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state, reason: 'prefetch-start', sentenceId: nextItem.id, queueIndex: index }
}));
console.log(`Process state: ${state}`, { reason: 'prefetch-start', sentenceId: nextItem.id, queueIndex: index });
const promise = this.prepareSentence(nextItem)
.then(prepared => {
this.preparedCache.set(nextCacheKey, prepared);
console.log('SentenceQueue: Prefetched queued item', { sentenceId: nextItem.id, queueIndex: index });
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index }
}));
console.log('Process state: playing-ready', { reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index });
return prepared;
})
.catch(err => {
console.warn('SentenceQueue: Prefetch failed:', err);
return null;
})
.finally(() => {
this.prefetchingCache.delete(nextCacheKey);
});
this.prefetchingCache.set(nextCacheKey, promise);
started += 1;
if (this.isSpeechItem(nextItem)) {
spokenPrepared += 1;
}
if (spokenPrepared >= 1 && started >= 2) {
break;
}
}
if (started === 0) {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: 'prefetch-already-ready', sentenceId: this.sentenceQueue[0]?.id }
}));
console.log('Process state: playing-ready', { reason: 'prefetch-already-ready', sentenceId: this.sentenceQueue[0]?.id });
}
}
isSpeechItem(item) {
const type = item?.type || 'paragraph';
return type === 'paragraph' || type === 'heading' || !['image', 'music'].includes(type);
}
getMediaPauseSeconds(sentence) {
if (!sentence || !['image', 'music'].includes(sentence.kind)) {
return 0;
}
const metadata = sentence.metadata || {};
const configuredPause = this.readFirstFiniteNumber(
metadata.leadInSeconds,
metadata.leadIn,
metadata.pause,
metadata.delay,
0
);
if (sentence.kind !== 'image') {
return configuredPause;
}
const revealSeconds = Number(metadata.imageRevealSeconds || metadata.revealSeconds || 0.9);
return Math.max(configuredPause, Number.isFinite(revealSeconds) ? revealSeconds : 0.9);
}
readFirstFiniteNumber(...values) {
for (const value of values) {
const number = Number(value);
if (Number.isFinite(number)) {
return Math.max(0, number);
}
}
return 0;
}
waitForSkippableMediaPause(seconds, kind = 'media', sentenceId = null) {
const duration = Math.max(0, Number(seconds) || 0) * 1000;
if (duration <= 0) return Promise.resolve(false);
const startedAt = performance.now();
console.log(`SentenceQueue: Waiting ${seconds}s for ${kind} lead`, { sentenceId });
document.documentElement.dataset.skippablePause = 'true';
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: `${kind}-pause-start`, duration, sentenceId }
}));
return new Promise(resolve => {
let finished = false;
let timeoutId = null;
const finish = (skipped, source = null) => {
if (finished) return;
finished = true;
clearTimeout(timeoutId);
document.removeEventListener('ui:command', onCommand);
delete document.documentElement.dataset.skippablePause;
const elapsedMs = Math.round(performance.now() - startedAt);
console.log(`SentenceQueue: ${kind} lead ${skipped ? 'skipped' : 'complete'}`, { sentenceId, elapsedMs, source });
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: `${kind}-pause-${skipped ? 'skipped' : 'complete'}`, duration, elapsedMs, sentenceId }
}));
resolve(skipped);
};
const onCommand = (event) => {
if (event.detail?.type === 'continue') {
finish(true, event.detail);
}
};
document.addEventListener('ui:command', onCommand);
timeoutId = setTimeout(() => finish(false), duration);
});
}
async prepareImageLayout(metadata = {}) {
const storyElement = document.getElementById('story');
if (!storyElement) {
throw new Error("Story container not found");
}
if (document.fonts && document.fonts.ready) {
await document.fonts.ready;
}
const probe = document.createElement('p');
probe.style.visibility = 'hidden';
probe.style.position = 'absolute';
probe.style.left = '-8000px';
probe.style.top = '-8000px';
storyElement.appendChild(probe);
const computedStyle = window.getComputedStyle(probe);
const lineHeight = parseFloat(computedStyle.lineHeight) || 24;
probe.remove();
const pageWidth = storyElement.clientWidth;
const size = String(metadata.size || 'landscape').toLowerCase();
const aspect = size === 'portrait' ? (9 / 16) : size === 'square' ? 1 : (16 / 9);
const imageGap = lineHeight * 0.9;
const maxWidth = size === 'portrait' ? pageWidth * 0.5 : pageWidth;
const naturalHeight = maxWidth / aspect;
const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight));
const height = imageLineCount * lineHeight;
const width = Math.min(maxWidth, height * aspect);
const verticalMargin = lineHeight / 2;
const lineCount = imageLineCount + 1;
if (size === 'portrait') {
this.activeImageWrap = {
lines: lineCount,
width: width + imageGap,
imageWidth: width,
gap: imageGap,
height,
lineHeight,
side: metadata.floatSide || 'left'
};
}
return {
size,
aspect,
width,
height,
gap: imageGap,
lineCount,
imageLineCount,
lineHeight,
verticalMargin,
floatSide: metadata.floatSide || 'left',
pageWidth
};
}
consumeImageWrap() {
if (!this.activeImageWrap || this.activeImageWrap.lines <= 0) {
this.activeImageWrap = null;
return null;
}
const wrap = { ...this.activeImageWrap };
this.activeImageWrap = null;
return wrap;
}
/**
* Extract words from layout nodes
* @param {Array} nodes - Layout nodes from Knuth-Plass algorithm
@@ -546,6 +880,7 @@ class SentenceQueueModule extends BaseModule {
this.sentenceQueue = [];
this.isProcessing = false;
this.preparedCache.clear();
this.activeImageWrap = null;
document.dispatchEvent(new CustomEvent('tts:queue-empty', {
detail: { reason: 'sentence-queue-cleared' }
}));
+290 -34
View File
@@ -9,7 +9,7 @@ class SocketClientModule extends BaseModule {
super('socket-client', 'Socket Client');
// Dependencies
this.dependencies = ['text-buffer'];
this.dependencies = ['text-buffer', 'markup-parser'];
this.socket = null;
this.textBuffer = null;
@@ -31,6 +31,7 @@ class SocketClientModule extends BaseModule {
'newGame',
'loadGame',
'saveGame',
'chooseChoice',
'hasSaveGame',
'getSaveGames',
'isGameRunning',
@@ -41,7 +42,21 @@ class SocketClientModule extends BaseModule {
'off',
'emitEvent',
'setupGameEventHandlers',
'processTextFragment',
'processTurnResult',
'processParagraphResult',
'dispatchTurnTags',
'isTimedCueTag',
'cueMarkersFromTags',
'dispatchChoices',
'dispatchInputMode',
'isStructuralTag',
'blocksFromTags',
'enqueueStructuredBlock',
'parseImageTagOptions',
'parseSfxTagOptions',
'parseMusicTagOptions',
'resolveAssetUrl',
'looksLikeAssetPath',
'attemptReconnect',
'getConnectionStatus',
'loadSocketIO'
@@ -166,48 +181,285 @@ class SocketClientModule extends BaseModule {
// Special handling for narrative text
this.socket.on('narrativeResponse', (data) => {
if (data && data.text && this.textBuffer) {
this.processTextFragment(data.text);
}
this.processTurnResult(data);
});
// Special handling for introduction text
this.socket.on('gameIntroduction', (data) => {
if (data && data.introduction && this.textBuffer) {
this.processTextFragment(data.introduction);
}
if (data && data.initialRoomDescription && this.textBuffer) {
this.processTextFragment(data.initialRoomDescription);
}
this.socket.on('gameConfig', (data) => {
document.dispatchEvent(new CustomEvent('game:config', {
detail: data
}));
});
}
/**
* Process a text fragment by adding it to the TextBuffer
* @param {string} text - Text fragment to process
*/
processTextFragment(text) {
if (!text) return;
processTurnResult(data) {
if (!data) return;
// Add text to the buffer if available
if (this.textBuffer) {
console.log(`Socket Client: Processing text fragment: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
const turnId = Number(data.turnId);
if (!Number.isInteger(turnId) || turnId < 1 || !Array.isArray(data.paragraphs)) {
console.error('Socket Client: Invalid TurnResult received', data);
return;
}
if (Array.isArray(data.globalTags) && data.globalTags.length > 0) {
document.dispatchEvent(new CustomEvent('story:global-tags', {
detail: data.globalTags
}));
}
document.dispatchEvent(new CustomEvent('story:turn-start', {
detail: { turnId, turn: data }
}));
let pendingParagraph = {
role: null,
cueTags: []
};
data.paragraphs.forEach((paragraph) => {
pendingParagraph = this.processParagraphResult(paragraph, turnId, pendingParagraph);
});
this.dispatchChoices(Array.isArray(data.choices) ? data.choices : []);
this.dispatchInputMode(data.inputMode || (Array.isArray(data.choices) && data.choices.length > 0 ? 'choice' : 'text'));
}
dispatchTurnTags(tags, paragraph = null) {
if (!Array.isArray(tags)) return;
tags.forEach((tag) => {
if (!tag || !tag.key) return;
document.dispatchEvent(new CustomEvent('story:tag', {
detail: {
...tag,
paragraph
}
}));
});
}
dispatchChoices(choices) {
document.dispatchEvent(new CustomEvent('story:choices', {
detail: choices
}));
}
dispatchInputMode(inputMode) {
const mode = ['text', 'choice', 'end'].includes(inputMode) ? inputMode : 'text';
document.dispatchEvent(new CustomEvent('story:input-mode', {
detail: mode
}));
}
processParagraphResult(paragraph, turnId, pendingParagraph = null) {
const pending = pendingParagraph && typeof pendingParagraph === 'object'
? pendingParagraph
: { role: pendingParagraph || null, cueTags: [] };
const tags = Array.isArray(paragraph?.tags) ? paragraph.tags : [];
const { blocks, paragraphRole } = this.blocksFromTags(tags, turnId);
const text = String(paragraph?.text || '').trim();
const cueTags = tags.filter(tag => this.isTimedCueTag(tag));
const immediateTags = tags.filter(tag => !this.isStructuralTag(tag) && !this.isTimedCueTag(tag));
this.dispatchTurnTags(immediateTags, paragraph);
blocks.forEach(block => this.enqueueStructuredBlock(block));
if (!text) {
return {
role: paragraphRole || pending.role || null,
cueTags: [
...(Array.isArray(pending.cueTags) ? pending.cueTags : []),
...cueTags
]
};
}
const role = pending.role || paragraphRole || 'body';
const cueMarkers = [
...(Array.isArray(paragraph.cueMarkers) ? paragraph.cueMarkers : []),
...this.cueMarkersFromTags([
...(Array.isArray(pending.cueTags) ? pending.cueTags : []),
...cueTags
])
];
this.enqueueStructuredBlock({
type: 'paragraph',
text,
layoutText: paragraph.layoutText || text,
cueMarkers,
role,
isFirstParagraphInChapter: role === 'chapter-first' || role === 'textblock-first',
dropCap: role === 'chapter-first',
addTopSpace: role === 'textblock-first',
turnId
});
return { role: null, cueTags: [] };
}
isStructuralTag(tag) {
const key = String(tag?.key || '').toLowerCase();
return ['chapter', 'heading', 'section', 'textblock', 'image', 'music'].includes(key);
}
isTimedCueTag(tag) {
const key = String(tag?.key || '').toLowerCase();
return ['sfx', 'sound', 'audio'].includes(key);
}
cueMarkersFromTags(tags) {
if (!Array.isArray(tags)) return [];
return tags
.filter(tag => this.isTimedCueTag(tag))
.map(tag => {
const filename = String(tag?.value || tag?.filename || '').trim();
if (!filename) return null;
const options = this.parseSfxTagOptions(tag?.param || tag?.options || '');
return {
type: 'sfx',
...options,
filename,
url: this.resolveAssetUrl('sounds', filename),
wordIndex: 0,
charIndex: 0
};
})
.filter(Boolean);
}
blocksFromTags(tags, turnId = null) {
const result = {
blocks: [],
paragraphRole: null
};
if (!Array.isArray(tags)) return result;
tags.forEach((tag) => {
const key = String(tag?.key || '').toLowerCase();
const value = String(tag?.value || '').trim();
const param = String(tag?.param || tag?.options || '').trim();
if ((key === 'chapter' || key === 'heading') && value) {
result.blocks.push({
type: 'heading',
text: value,
layoutText: value,
role: 'chapter-heading',
turnId
});
result.paragraphRole = 'chapter-first';
} else if (key === 'section' || key === 'textblock') {
result.blocks.push({
type: 'heading',
text: value || '* * *',
layoutText: value || '* * *',
role: 'section-heading',
turnId
});
result.paragraphRole = 'textblock-first';
} else if (key === 'image') {
let filename = value;
let optionText = param;
if (this.looksLikeAssetPath(param) && value && !this.looksLikeAssetPath(value)) {
filename = param;
optionText = value;
}
if (!filename) return;
const options = this.parseImageTagOptions(optionText);
const chapterOpening = result.paragraphRole === 'chapter-first';
result.blocks.push({
type: 'image',
...options,
floatSide: chapterOpening && String(options.size || '').toLowerCase() === 'portrait' ? 'right' : 'left',
chapterOpening,
filename,
url: this.resolveAssetUrl('images', filename),
turnId
});
} else if (key === 'music') {
let filename = value;
let optionText = param;
if (this.looksLikeAssetPath(param) && value && !this.looksLikeAssetPath(value)) {
filename = param;
optionText = value;
}
if (!filename) return;
const options = this.parseMusicTagOptions(optionText);
const leadInSeconds = Number(options.leadInSeconds);
result.blocks.push({
type: 'music',
...options,
leadInSeconds: Number.isFinite(leadInSeconds) ? leadInSeconds : 0,
leadIn: Number.isFinite(leadInSeconds) ? leadInSeconds : 0,
pause: Number.isFinite(leadInSeconds) ? leadInSeconds : 0,
filename,
url: this.resolveAssetUrl('music', filename),
turnId
});
}
});
return result;
}
enqueueStructuredBlock(block) {
if (!block) return;
if (!this.textBuffer) {
this.textBuffer = this.getModule('text-buffer');
}
if (this.textBuffer && typeof this.textBuffer.addBlock === 'function') {
console.log(`Socket Client: Queueing ${block.type} block`);
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'waiting-generating', reason: 'server-response-received' }
}));
this.textBuffer.addText(text);
} else {
console.error('Socket Client: Text buffer not available');
// Attempt to get text buffer again using parent's getModule method
this.textBuffer = this.getModule('text-buffer');
if (this.textBuffer) {
this.textBuffer.addText(text);
} else {
// Emit a text event as fallback if no text buffer
this.emitEvent('text', text);
}
this.textBuffer.addBlock(block);
return;
}
console.error('Socket Client: Text buffer not available for structured block', block);
}
parseImageTagOptions(optionText) {
const parser = this.getModule('markup-parser');
if (parser && typeof parser.parseImageOptions === 'function') {
return parser.parseImageOptions(optionText);
}
return { size: 'landscape', leadInSeconds: 0 };
}
parseSfxTagOptions(optionText) {
const parser = this.getModule('markup-parser');
if (parser && typeof parser.parseSfxOptions === 'function') {
return parser.parseSfxOptions(optionText);
}
return { maxDurationSeconds: 0, endMode: 'stop', fadeDurationSeconds: 2 };
}
parseMusicTagOptions(optionText) {
const parser = this.getModule('markup-parser');
if (parser && typeof parser.parseMusicOptions === 'function') {
return parser.parseMusicOptions(optionText);
}
return { mode: 'crossfade', loop: true, leadInSeconds: 0 };
}
resolveAssetUrl(kind, filename) {
const parser = this.getModule('markup-parser');
if (parser && typeof parser.resolveAssetUrl === 'function') {
return parser.resolveAssetUrl(kind, filename);
}
const root = kind === 'images' ? '/images/' : kind === 'music' ? '/music/' : '/sounds/';
const safeName = String(filename || '').replace(/\\/g, '/').replace(/^\/+/, '');
if (!safeName || safeName.includes('..') || /^[a-z]+:/i.test(safeName)) {
return '';
}
return root + safeName.split('/').map(encodeURIComponent).join('/');
}
looksLikeAssetPath(value) {
return /[./\\]/.test(String(value || '')) || /\.(png|jpe?g|gif|webp|svg|ogg|mp3|wav|m4a|flac)$/i.test(String(value || ''));
}
/**
@@ -329,6 +581,10 @@ class SocketClientModule extends BaseModule {
return this.callGameApi('saveGame', [slot]);
}
chooseChoice(choiceIndex) {
return this.callGameApi('chooseChoice', [choiceIndex]);
}
hasSaveGame(slot = 1) {
return this.callGameApi('hasSaveGame', [slot]);
}
+38
View File
@@ -21,6 +21,7 @@ class TextBufferModule extends BaseModule {
// Bind methods using parent's bindMethods utility
this.bindMethods([
'addText',
'addBlock',
'splitIntoParagraphs',
'processNextFromQueue',
'processSentences',
@@ -135,6 +136,43 @@ class TextBufferModule extends BaseModule {
}
}
/**
* Add an already parsed render block to the processing queue.
* Engine protocols should prefer this over re-serializing tags into text markup.
* @param {Object} block - Parsed paragraph/media/heading block
*/
addBlock(block) {
if (!block || !block.type) return;
if (block.type === 'paragraph') {
const paragraphId = block.id || `paragraph-${this.paragraphCounter + 1}`;
this.processingQueue.push({
...block,
id: paragraphId,
paragraphIndex: this.paragraphCounter,
textBlockId: this.currentTextBlockId,
text: String(block.text || '').trim(),
layoutText: block.layoutText || block.text || ''
});
this.paragraphCounter += 1;
} else {
this.processingQueue.push({
...block,
id: block.id || `${block.type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
});
if (block.type === 'image') {
this.currentTextBlockId += 1;
}
}
if (!this.isProcessingActive && this.onSentenceReadyCallback) {
this.processNextFromQueue();
} else {
console.log(`TextBuffer: ${block.type} block queued for processing`);
}
}
/**
* Split an incoming narrative fragment into book paragraphs.
* Single newlines inside a paragraph are normalized to spaces; blank lines
+1 -1
View File
@@ -239,7 +239,7 @@ class TextProcessorModule extends BaseModule {
}
normalizeHyphenationLocale(locale) {
const normalized = String(locale || 'en-us').toLowerCase();
const normalized = String(locale || 'en-us').trim().toLowerCase().replace('_', '-');
if (normalized === 'en') return 'en-us';
if (normalized === 'de-de') return 'de';
return normalized;
+2 -5
View File
@@ -182,12 +182,11 @@ class TTSFactoryModule extends BaseModule {
this.reportProgress(100, 'TTS Factory initialized');
console.log(`TTS Factory: Initialization complete, TTS available: ${this.ttsAvailable}`);
// To maintain backward compatibility, we always return true
// since TTS is now optional and the system should function without it
// TTS is optional; the client must continue to function without it.
return true;
} catch (error) {
console.error('TTS Factory: Initialization error:', error);
return true; // Still return true for backward compatibility
return true;
}
}
@@ -655,7 +654,6 @@ class TTSFactoryModule extends BaseModule {
detail: { handler: 'none', available: false }
}));
// Also dispatch tts:engine:change for compatibility with Options UI
document.dispatchEvent(new CustomEvent('tts:engine:change', {
detail: { engine: 'none', handler: 'none', available: false }
}));
@@ -719,7 +717,6 @@ class TTSFactoryModule extends BaseModule {
});
document.dispatchEvent(event);
// Also dispatch tts:engine:change for compatibility with Options UI
document.dispatchEvent(new CustomEvent('tts:engine:change', {
detail: { engine: id, handler: id, available: isReady }
}));
+78 -2
View File
@@ -7,7 +7,7 @@ class UIControllerModule extends BaseModule {
// Remove 'tts' from direct dependencies to break circular dependency
// UI Controller will access TTS through the Game Loop instead
this.dependencies = ['animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects', 'text-buffer', 'socket-client', 'sentence-queue', 'playback-coordinator'];
this.dependencies = ['animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects', 'text-buffer', 'socket-client', 'sentence-queue', 'playback-coordinator', 'persistence-manager'];
// References to sub-modules
this.displayHandler = null;
@@ -47,6 +47,8 @@ class UIControllerModule extends BaseModule {
'syncTopControls',
'getStoredTtsPreference',
'setStoredTtsPreference',
'getStoredAppPreference',
'setStoredAppPreference',
'sliderValueFromSpeed',
'speedFromSliderValue',
'initializeTextBuffer',
@@ -267,7 +269,8 @@ class UIControllerModule extends BaseModule {
}
const playbackCoordinator = this.getModule('playback-coordinator');
if (playbackCoordinator && playbackCoordinator.isPlaying) {
const hasSkippablePause = document.documentElement.dataset.skippablePause === 'true';
if ((playbackCoordinator && playbackCoordinator.isPlaying) || hasSkippablePause) {
this.handleCommand({ type: 'continue', source: 'book-click' });
}
@@ -350,6 +353,9 @@ class UIControllerModule extends BaseModule {
document.addEventListener('preference-updated', (event) => {
const { category, key, value } = event.detail || {};
if (category !== 'tts') {
if (category === 'app' && key === 'autoplay') {
this.syncTopControls();
}
return;
}
@@ -391,6 +397,7 @@ class UIControllerModule extends BaseModule {
bindTopControls() {
const speechToggle = document.getElementById('speech');
const autoplayToggle = document.getElementById('autoplay');
const speedSlider = document.getElementById('speed');
const speedReset = document.getElementById('speed_reset');
@@ -428,6 +435,22 @@ class UIControllerModule extends BaseModule {
});
}
if (autoplayToggle && autoplayToggle.dataset.uiControllerBound !== 'true') {
autoplayToggle.dataset.uiControllerBound = 'true';
autoplayToggle.removeAttribute('disabled');
autoplayToggle.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const nextAutoplay = !this.getStoredAppPreference('autoplay', true);
this.setStoredAppPreference('autoplay', nextAutoplay);
console.log(`UIController: Autoplay set to ${nextAutoplay ? 'enabled' : 'disabled'}`);
this.syncTopControls();
document.dispatchEvent(new CustomEvent('app:autoplay:change', {
detail: { enabled: nextAutoplay, source: 'topbar' }
}));
});
}
if (speedSlider && speedSlider.dataset.uiControllerBound !== 'true') {
speedSlider.dataset.uiControllerBound = 'true';
speedSlider.min = speedSlider.min || '50';
@@ -519,6 +542,48 @@ class UIControllerModule extends BaseModule {
}
}
getStoredAppPreference(key, defaultValue) {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager && typeof persistenceManager.getPreference === 'function') {
const value = persistenceManager.getPreference('app', key, undefined);
if (typeof value !== 'undefined' && value !== null) {
return value;
}
}
try {
const raw = localStorage.getItem('ai-interactive-fiction-preferences');
if (raw) {
const prefs = JSON.parse(raw);
if (prefs && prefs.app && Object.prototype.hasOwnProperty.call(prefs.app, key)) {
return prefs.app[key];
}
}
} catch (error) {
console.warn('UIController: Failed to read app preference fallback:', error);
}
return defaultValue;
}
setStoredAppPreference(key, value) {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager && typeof persistenceManager.updatePreference === 'function') {
persistenceManager.updatePreference('app', key, value);
}
try {
const storageKey = 'ai-interactive-fiction-preferences';
const raw = localStorage.getItem(storageKey);
const prefs = raw ? JSON.parse(raw) : {};
prefs.app = prefs.app || {};
prefs.app[key] = value;
localStorage.setItem(storageKey, JSON.stringify(prefs));
} catch (error) {
console.warn('UIController: Failed to write app preference fallback:', error);
}
}
async setupMainUI() {
// Ensure all UI components exist
if (!this.bookElement || !this.leftPage || !this.rightPage || !this.storyElement) {
@@ -583,6 +648,9 @@ class UIControllerModule extends BaseModule {
break;
case 'continue':
{
document.dispatchEvent(new CustomEvent('ui:command', {
detail: { moduleId: this.id, type: 'continue', source: command.source || 'ui-controller-forward' }
}));
const playbackCoordinator = this.getModule('playback-coordinator');
if (playbackCoordinator && playbackCoordinator.isPlaying) {
playbackCoordinator.fastForward();
@@ -633,6 +701,7 @@ class UIControllerModule extends BaseModule {
const loadButton = document.getElementById('reload');
const restartButton = document.getElementById('rewind');
const speechToggle = document.getElementById('speech');
const autoplayToggle = document.getElementById('autoplay');
// Update save button state
if (saveButton && typeof canSave === 'boolean') {
@@ -679,6 +748,13 @@ class UIControllerModule extends BaseModule {
speechToggle.title = 'Enable speech';
}
}
if (autoplayToggle) {
const autoplay = this.getStoredAppPreference('autoplay', true) !== false;
autoplayToggle.removeAttribute('disabled');
autoplayToggle.style.fontWeight = autoplay ? 'bold' : 'normal';
autoplayToggle.style.color = autoplay ? '#000' : '#999';
}
}
// Public API methods
+360 -30
View File
@@ -9,7 +9,7 @@ class UIDisplayHandlerModule extends BaseModule {
super('ui-display-handler', 'UI Display Handler');
// Module dependencies
this.dependencies = ['layout-renderer', 'playback-coordinator'];
this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization'];
// DOM elements
this.container = null;
@@ -31,10 +31,19 @@ class UIDisplayHandlerModule extends BaseModule {
// Bind methods using parent's bindMethods utility
this.bindMethods([
'initializeContainers',
'applyGameConfig',
'applyTranslations',
'displayText',
'renderSentence',
'handleDeferredMediaBlock',
'renderImageBlock',
'calculateImageMetrics',
'readFirstFiniteNumber',
'waitForSkippablePause',
'scrollStoryToEnd',
'animatePageScroll',
'scrollToTurn',
'handleStoryScroll',
'rerenderStory',
'clear',
'scheduleRerender',
@@ -47,6 +56,10 @@ class UIDisplayHandlerModule extends BaseModule {
console.log('UIDisplayHandler: Constructor initialized');
}
t(key, params = {}) {
return this.localization?.translate?.(key, params) || key;
}
async initialize() {
try {
this.reportProgress(10, "Initializing UI Display Handler");
@@ -61,6 +74,8 @@ class UIDisplayHandlerModule extends BaseModule {
// Get references to required modules using parent's getModule method
this.layoutRenderer = this.getModule('layout-renderer');
this.playbackCoordinator = this.getModule('playback-coordinator');
this.gameConfig = this.getModule('game-config');
this.localization = this.getModule('localization');
this.reportProgress(50, "Initializing display containers");
@@ -73,6 +88,24 @@ class UIDisplayHandlerModule extends BaseModule {
this.addEventListener(document, 'book:resized', () => {
this.scheduleRerender();
});
this.addEventListener(document, 'game:config', (event) => {
this.applyGameConfig(event.detail);
});
this.addEventListener(document, 'localization:languageChanged', () => {
this.applyTranslations();
});
this.addEventListener(document, 'story:scroll-to-turn', (event) => {
this.scrollToTurn(event.detail?.turnId);
});
this.addEventListener(document, 'story:process-state', (event) => {
const state = event.detail?.state || 'ready';
const remark = document.getElementById('remark_text');
if (remark) {
remark.textContent = state === 'paused'
? this.t('title.continueHint')
: this.t('title.fastForwardHint');
}
});
if (window.ResizeObserver && this.paragraphContainer) {
this.storyResizeObserver = new ResizeObserver((entries) => {
@@ -196,9 +229,9 @@ class UIDisplayHandlerModule extends BaseModule {
const header = document.createElement('div');
header.className = 'header';
header.innerHTML = `
<h2 class="byline l10n-by">powered by Generative AI</h2>
<h1 class="title">AI Interactive Fiction</h1>
<h3 class="subtitle">An open-world text adventure</h3>
<h2 class="byline" id="game_author"></h2>
<h1 class="title" id="game_title"></h1>
<h3 class="subtitle" id="game_subtitle"></h3>
<div class="separator"><double></double></div>
`;
this.pageLeft.appendChild(header);
@@ -208,12 +241,13 @@ class UIDisplayHandlerModule extends BaseModule {
controls.id = 'controls';
controls.className = 'buttons';
controls.innerHTML = `
<a class="l10n-speech" id="speech" title="Toggle text to speech">speech</a>
<span><a id="speed_reset"><span class="l10n-speed">speed<sup>*</sup></span></a><input type="range" min="50" max="150" value="100" id="speed" name="speed" /></span>
<a class="l10n-restart" id="rewind" title="Start a new game">new game</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>
<a class="l10n-options" id="options" title="Options">options</a>
<a id="speech"></a>
<a id="autoplay"></a>
<span><a id="speed_reset"><span id="speed_label"></span><sup>*</sup></a><input type="range" min="50" max="150" value="100" id="speed" name="speed" /></span>
<a id="rewind"></a>
<a id="save"></a>
<a id="reload" disabled="disabled"></a>
<a id="options"></a>
`;
this.pageLeft.appendChild(controls);
@@ -232,7 +266,7 @@ class UIDisplayHandlerModule extends BaseModule {
commandInput.id = 'command_input';
commandInput.innerHTML = `
<div class="input-wrapper">
<textarea id="player_input" placeholder="Enter your command..." rows="1" autofocus></textarea>
<textarea id="player_input" rows="1" autofocus autocomplete="off" autocorrect="off" autocapitalize="sentences" spellcheck="true" aria-autocomplete="none" data-form-type="other" data-1p-ignore="true" data-lpignore="true" data-bwignore="true"></textarea>
<span id="cursor"></span>
</div>
`;
@@ -243,8 +277,10 @@ class UIDisplayHandlerModule extends BaseModule {
// Create remark
const remark = document.createElement('div');
remark.id = 'remark';
remark.className = 'l10n-remark';
remark.innerHTML = '<i><sup>*</sup>click on page or press spacebar to fast forward text animation</i>';
remark.innerHTML = `
<div id="remark_hint"><i><sup>*</sup><span id="remark_text"></span></i></div>
<div id="game_legal"></div>
`;
this.pageLeft.appendChild(remark);
bookContainer.appendChild(this.pageLeft);
@@ -272,7 +308,6 @@ class UIDisplayHandlerModule extends BaseModule {
if (!document.getElementById('start_prompt')) {
const startPrompt = document.createElement('div');
startPrompt.id = 'start_prompt';
startPrompt.textContent = 'Klick on new game or load to start the game';
this.pageRight.appendChild(startPrompt);
}
@@ -302,6 +337,66 @@ class UIDisplayHandlerModule extends BaseModule {
}
console.log('UIDisplayHandler: All containers initialized');
this.applyGameConfig(this.gameConfig?.getConfig?.());
this.applyTranslations();
if (this.pageRight && !this.pageRight.dataset.turnScrollBound) {
this.pageRight.dataset.turnScrollBound = 'true';
this.pageRight.addEventListener('scroll', this.handleStoryScroll, { passive: true });
}
}
applyGameConfig(config) {
const metadata = config?.metadata || this.gameConfig?.getMetadata?.() || {};
const titleElement = document.getElementById('game_title');
const authorElement = document.getElementById('game_author');
const subtitleElement = document.getElementById('game_subtitle');
const legalElement = document.getElementById('game_legal');
document.getElementById('game_version')?.remove();
document.getElementById('game_copyright')?.remove();
if (titleElement) titleElement.textContent = metadata.title || '';
if (authorElement) authorElement.textContent = metadata.author ? this.t('title.byAuthor', { author: metadata.author }) : '';
if (subtitleElement) subtitleElement.textContent = metadata.subtitle || '';
if (legalElement) {
const items = [
metadata.version ? this.t('title.version', { version: metadata.version }) : '',
metadata.copyright || ''
].filter(Boolean);
legalElement.textContent = items.join(' · ');
}
}
applyTranslations() {
this.localization = this.getModule('localization') || this.localization;
const setText = (id, key) => {
const element = document.getElementById(id);
if (element) element.textContent = this.t(key);
};
const setTitle = (id, key) => {
const element = document.getElementById(id);
if (element) element.setAttribute('title', this.t(key));
};
setText('speech', 'topbar.speech');
setText('autoplay', 'topbar.autoplay');
setText('speed_label', 'topbar.speed');
setText('rewind', 'topbar.newGame');
setText('save', 'topbar.save');
setText('reload', 'topbar.load');
setText('options', 'topbar.options');
setText('remark_text', 'title.fastForwardHint');
setText('start_prompt', 'title.startPrompt');
setTitle('speech', 'topbar.speechTitle');
setTitle('autoplay', 'topbar.autoplayTitle');
setTitle('rewind', 'topbar.newGameTitle');
setTitle('save', 'topbar.saveTitle');
setTitle('reload', 'topbar.loadTitle');
setTitle('options', 'topbar.optionsTitle');
const input = document.getElementById('player_input');
if (input) input.setAttribute('placeholder', this.t('input.placeholder'));
this.applyGameConfig(this.gameConfig?.getConfig?.());
}
/**
@@ -327,17 +422,13 @@ class UIDisplayHandlerModule extends BaseModule {
/**
* Display text in the UI (backward compatibility)
* Note: Text should flow through SentenceQueue instead
* @param {string} text - Text to display
* @param {Object} options - Display options
* @returns {Promise<HTMLElement>} - Promise resolving to the displayed paragraph element
* Display a local UI message outside the server turn protocol.
* Story output must flow through structured TurnResult objects instead.
*/
displayText(text, options = {}) {
if (!text) return Promise.resolve(null);
// For backward compatibility, delegate to sentence queue
console.warn('UIDisplayHandler.displayText called directly, text should flow through SentenceQueue');
console.warn('UIDisplayHandler.displayText called directly; story text should come from TurnResult');
const sentenceQueue = this.getModule('sentence-queue');
if (sentenceQueue) {
@@ -369,6 +460,10 @@ class UIDisplayHandlerModule extends BaseModule {
sentence.layout,
{ id: sentence.id }
);
if (sentence.turnId != null) {
paragraphElement.dataset.turnId = String(sentence.turnId);
paragraphElement.classList.add('story-turn-block');
}
// Append to container
if (this.paragraphContainer) {
@@ -386,6 +481,7 @@ class UIDisplayHandlerModule extends BaseModule {
this.renderedItems.push({
type: sentence.kind === 'heading' ? 'heading' : 'paragraph',
id: sentence.id,
turnId: sentence.turnId ?? null,
text: sentence.text,
metadata: {
layoutText: sentence.layout?.sourceLayoutText || sentence.text,
@@ -427,9 +523,25 @@ class UIDisplayHandlerModule extends BaseModule {
this.paragraphContainer.innerHTML = '';
for (const item of this.renderedItems) {
if (item.type === 'image') {
const sentenceQueue = this.getModule('sentence-queue');
const imageLayout = sentenceQueue && typeof sentenceQueue.prepareImageLayout === 'function'
? await sentenceQueue.prepareImageLayout(item.metadata || {})
: null;
this.renderImageBlock({
...(item.metadata || {}),
imageLayout: imageLayout || item.metadata?.imageLayout
}, false);
continue;
}
if (item.type === 'heading') {
const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {});
const heading = this.layoutRenderer.renderParagraph(layout, { id: item.id });
if (item.turnId != null) {
heading.dataset.turnId = String(item.turnId);
heading.classList.add('story-turn-block');
}
heading.querySelectorAll('.word').forEach(word => {
word.style.transition = 'none';
word.style.animation = 'none';
@@ -446,6 +558,10 @@ class UIDisplayHandlerModule extends BaseModule {
const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {});
const paragraph = this.layoutRenderer.renderParagraph(layout, { id: item.id });
if (item.turnId != null) {
paragraph.dataset.turnId = String(item.turnId);
paragraph.classList.add('story-turn-block');
}
paragraph.querySelectorAll('.word').forEach(word => {
word.style.transition = 'none';
word.style.animation = 'none';
@@ -468,13 +584,74 @@ class UIDisplayHandlerModule extends BaseModule {
}
window.requestAnimationFrame(() => {
this.pageRight.scrollTo({
top: Math.max(0, this.pageRight.scrollHeight - this.pageRight.clientHeight),
behavior: smooth ? 'smooth' : 'auto'
});
this.animatePageScroll(
Math.max(0, this.pageRight.scrollHeight - this.pageRight.clientHeight),
smooth ? 720 : 0
);
});
}
animatePageScroll(targetTop, duration = 720) {
if (!this.pageRight) return;
if (!duration) {
this.pageRight.scrollTop = targetTop;
return;
}
const startTop = this.pageRight.scrollTop;
const delta = targetTop - startTop;
if (Math.abs(delta) < 1) return;
const startedAt = performance.now();
const ease = (t) => 1 - Math.pow(1 - t, 3);
const step = (now) => {
const progress = Math.min(1, (now - startedAt) / duration);
this.pageRight.scrollTop = startTop + (delta * ease(progress));
if (progress < 1) {
requestAnimationFrame(step);
}
};
requestAnimationFrame(step);
}
scrollToTurn(turnId) {
if (!this.pageRight || turnId == null) return;
const escapedTurnId = CSS.escape(String(turnId));
const target = this.paragraphContainer?.querySelector(`[data-turn-id="${escapedTurnId}"]`);
if (!target) return;
this.pageRight.scrollTo({
top: Math.max(0, target.offsetTop - 12),
behavior: 'smooth'
});
}
handleStoryScroll() {
if (!this.pageRight || !this.paragraphContainer) return;
const blocks = Array.from(this.paragraphContainer.querySelectorAll('[data-turn-id]'));
if (blocks.length === 0) return;
const viewportMiddle = this.pageRight.scrollTop + (this.pageRight.clientHeight / 2);
let best = null;
let bestDistance = Infinity;
blocks.forEach((block) => {
const blockMiddle = block.offsetTop + (block.offsetHeight / 2);
const distance = Math.abs(blockMiddle - viewportMiddle);
if (distance < bestDistance) {
bestDistance = distance;
best = block;
}
});
if (best?.dataset?.turnId && this.activeTurnId !== best.dataset.turnId) {
this.activeTurnId = best.dataset.turnId;
document.dispatchEvent(new CustomEvent('story:visible-turn', {
detail: { turnId: Number(best.dataset.turnId) }
}));
}
}
async handleDeferredMediaBlock(sentence) {
document.dispatchEvent(new CustomEvent('story:media-block', {
detail: {
@@ -484,12 +661,27 @@ class UIDisplayHandlerModule extends BaseModule {
}
}));
if (sentence.kind === 'music') {
const leadInSeconds = Number(sentence.metadata?.leadInSeconds || sentence.metadata?.leadIn || 0);
if (leadInSeconds > 0) {
console.log(`UIDisplayHandler: Waiting ${leadInSeconds}s before continuing after music block`);
await new Promise(resolve => setTimeout(resolve, leadInSeconds * 1000));
if (sentence.kind === 'image') {
const element = this.renderImageBlock(sentence.metadata || {}, true);
this.renderedItems.push({
type: 'image',
id: sentence.id,
turnId: sentence.turnId ?? null,
text: '',
metadata: sentence.metadata || {}
});
this.scrollStoryToEnd(true);
if (sentence.onComplete) {
sentence.onComplete();
}
return element;
}
if (sentence.kind === 'music') {
console.log('UIDisplayHandler: Music block started', sentence.metadata || {});
}
if (sentence.onComplete) {
@@ -499,7 +691,145 @@ class UIDisplayHandlerModule extends BaseModule {
return null;
}
readFirstFiniteNumber(...values) {
for (const value of values) {
const number = Number(value);
if (Number.isFinite(number)) {
return Math.max(0, number);
}
}
return 0;
}
waitForSkippablePause(seconds, kind = 'media') {
const duration = Math.max(0, Number(seconds) || 0) * 1000;
if (duration <= 0) return Promise.resolve(false);
document.documentElement.dataset.skippablePause = 'true';
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: `${kind}-pause-start`, duration }
}));
return new Promise(resolve => {
let finished = false;
let timeoutId = null;
const finish = (skipped) => {
if (finished) return;
finished = true;
clearTimeout(timeoutId);
document.removeEventListener('ui:command', onCommand);
delete document.documentElement.dataset.skippablePause;
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: `${kind}-pause-${skipped ? 'skipped' : 'complete'}` }
}));
resolve(skipped);
};
const onCommand = (event) => {
if (event.detail?.type === 'continue') {
finish(true);
}
};
document.addEventListener('ui:command', onCommand);
timeoutId = setTimeout(() => finish(false), duration);
});
}
renderImageBlock(metadata = {}, animate = true) {
if (!this.paragraphContainer) return null;
const metrics = metadata.imageLayout || this.calculateImageMetrics(metadata.size);
const figure = document.createElement('figure');
figure.className = [
'story-image-block',
`story-image-${metrics.size || 'landscape'}`,
metrics.floatSide === 'right' ? 'story-image-float-right' : '',
metrics.floatSide === 'left' ? 'story-image-float-left' : '',
animate ? 'story-image-pending' : 'story-image-visible'
].filter(Boolean).join(' ');
figure.style.width = `${metrics.width}px`;
figure.style.height = `${metrics.height}px`;
figure.style.marginTop = `${metrics.verticalMargin || 0}px`;
figure.style.marginBottom = `${metrics.verticalMargin || 0}px`;
figure.dataset.animationMs = '900';
if (metadata.turnId != null) {
figure.dataset.turnId = String(metadata.turnId);
figure.classList.add('story-turn-block');
}
const img = document.createElement('img');
img.src = metadata.url || metadata.filename || '';
img.alt = metadata.alt || '';
img.decoding = 'async';
img.loading = 'eager';
figure.appendChild(img);
this.paragraphContainer.appendChild(figure);
if (animate) {
window.requestAnimationFrame(() => {
figure.classList.remove('story-image-pending');
figure.classList.add('story-image-visible');
});
} else {
figure.classList.remove('story-image-pending');
figure.classList.add('story-image-visible');
}
return figure;
}
calculateImageMetrics(size = 'landscape') {
const storyElement = document.getElementById('story');
const pageWidth = storyElement?.clientWidth || 600;
const probe = document.createElement('p');
probe.style.visibility = 'hidden';
probe.style.position = 'absolute';
probe.style.left = '-8000px';
probe.style.top = '-8000px';
(storyElement || document.body).appendChild(probe);
const lineHeight = parseFloat(window.getComputedStyle(probe).lineHeight) || 24;
probe.remove();
const normalizedSize = String(size || 'landscape').toLowerCase() === 'widescreen'
? 'landscape'
: String(size || 'landscape').toLowerCase();
const aspect = normalizedSize === 'portrait' ? (9 / 16) : normalizedSize === 'square' ? 1 : (16 / 9);
const imageGap = lineHeight * 0.9;
const maxWidth = normalizedSize === 'portrait' ? pageWidth * 0.5 : pageWidth;
const naturalHeight = maxWidth / aspect;
const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight));
const height = imageLineCount * lineHeight;
const width = Math.min(maxWidth, height * aspect);
const verticalMargin = lineHeight / 2;
const lineCount = imageLineCount + 1;
return {
size: normalizedSize,
aspect,
width,
height,
gap: imageGap,
lineCount,
imageLineCount,
lineHeight,
verticalMargin,
floatSide: 'left',
pageWidth
};
}
clear() {
if (document.documentElement.dataset.skippablePause === 'true') {
document.dispatchEvent(new CustomEvent('ui:command', {
detail: { moduleId: this.id, type: 'continue', source: 'display-clear' }
}));
delete document.documentElement.dataset.skippablePause;
}
if (this.container) {
this.container.innerHTML = '';
this.paragraphContainer = document.createElement('div');
+77 -5
View File
@@ -18,6 +18,7 @@ class UIInputHandlerModule extends BaseModule {
this.historyIndex = -1;
this.commandHistory = [];
this.inputBuffer = '';
this.inputMode = 'text';
// Bind methods using the parent class bindMethods utility
this.bindMethods([
@@ -28,11 +29,14 @@ class UIInputHandlerModule extends BaseModule {
'handleKeyboardInput',
'submitCommand',
'addToHistory',
'bindHistoryToTurn',
'highlightHistoryTurn',
'formatCommandHistory',
'resetCursorPosition',
'focusInput',
'setProcessState',
'setInputAvailability',
'setMode',
'clearHistory'
]);
@@ -61,6 +65,15 @@ class UIInputHandlerModule extends BaseModule {
this.addEventListener(document, 'story:process-state', (event) => {
this.setProcessState(event.detail?.state || 'ready', event.detail || {});
});
this.addEventListener(document, 'story:input-mode', (event) => {
this.setMode(event.detail || 'text');
});
this.addEventListener(document, 'story:turn-start', (event) => {
this.bindHistoryToTurn(event.detail?.turnId);
});
this.addEventListener(document, 'story:visible-turn', (event) => {
this.highlightHistoryTurn(event.detail?.turnId);
});
this.reportProgress(100, 'UI Input Handler ready');
return true;
@@ -87,7 +100,7 @@ class UIInputHandlerModule extends BaseModule {
return;
}
if (event.key === ' ' && this.isPlaybackActive()) {
if (event.key === ' ' && (this.isPlaybackActive() || this.isSkippablePauseActive())) {
document.dispatchEvent(new CustomEvent('ui:command', {
detail: { type: 'continue', source: 'spacebar' }
}));
@@ -95,7 +108,7 @@ class UIInputHandlerModule extends BaseModule {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
if (document.body.dataset.gameRunning !== 'true') {
if (document.body.dataset.gameRunning !== 'true' || this.inputMode !== 'text') {
return;
}
this.submitCommand();
@@ -110,6 +123,9 @@ class UIInputHandlerModule extends BaseModule {
if (document.body.dataset.gameRunning !== 'true') {
return;
}
if (this.inputMode !== 'text') {
return;
}
event.preventDefault();
this.focusInput();
const start = this.playerInput.selectionStart ?? this.playerInput.value.length;
@@ -176,8 +192,6 @@ class UIInputHandlerModule extends BaseModule {
playerInput.id = 'player_input';
playerInput.rows = 1;
playerInput.placeholder = 'What will you do?';
playerInput.setAttribute('autocomplete', 'off');
playerInput.setAttribute('spellcheck', 'true');
// Fix horizontal scrolling by ensuring the textbox wraps text
playerInput.style.overflowX = 'hidden';
@@ -187,6 +201,7 @@ class UIInputHandlerModule extends BaseModule {
inputWrapper.appendChild(playerInput);
}
this.playerInput = playerInput;
this.applyTextInputAttributes(playerInput);
// Create the cursor if needed
let cursor = document.getElementById('cursor');
@@ -260,7 +275,7 @@ class UIInputHandlerModule extends BaseModule {
}
setInputAvailability(enabled) {
this.inputEnabled = Boolean(enabled);
this.inputEnabled = Boolean(enabled) && this.inputMode === 'text';
const commandInput = document.getElementById('command_input');
if (commandInput) {
commandInput.classList.toggle('fading', !this.inputEnabled);
@@ -276,6 +291,31 @@ class UIInputHandlerModule extends BaseModule {
}
}
applyTextInputAttributes(playerInput) {
if (!playerInput) return;
const attributes = {
autocomplete: 'off',
autocorrect: 'off',
autocapitalize: 'sentences',
spellcheck: 'true',
'aria-autocomplete': 'none',
'data-form-type': 'other',
'data-1p-ignore': 'true',
'data-lpignore': 'true',
'data-bwignore': 'true'
};
Object.entries(attributes).forEach(([name, value]) => {
playerInput.setAttribute(name, value);
});
}
setMode(mode) {
this.inputMode = ['text', 'choice', 'end'].includes(mode) ? mode : 'text';
this.setInputAvailability(this.inputMode === 'text');
}
applyMouseCursor(state) {
const root = document.documentElement;
if (!root) {
@@ -365,6 +405,7 @@ class UIInputHandlerModule extends BaseModule {
submitCommand() {
if (!this.playerInput || !this.playerInput.value.trim()) return;
if (document.body.dataset.gameRunning !== 'true' || !this.inputEnabled) return;
if (this.inputMode !== 'text') return;
const command = this.playerInput.value.trim();
console.log(`UIInputHandler: Submitting command: "${command}"`);
@@ -420,7 +461,15 @@ class UIInputHandlerModule extends BaseModule {
if (this.commandHistoryElement && this.commandHistoryElement.appendChild) {
const historyItem = document.createElement('div');
historyItem.className = 'history-item';
historyItem.dataset.turnId = 'pending';
historyItem.innerHTML = `&gt; ${this.formatCommandHistory(command)}`;
historyItem.addEventListener('click', () => {
const turnId = historyItem.dataset.turnId;
if (!turnId || turnId === 'pending') return;
document.dispatchEvent(new CustomEvent('story:scroll-to-turn', {
detail: { turnId: Number(turnId) }
}));
});
this.commandHistoryElement.appendChild(historyItem);
// Limit visible history items
@@ -433,6 +482,25 @@ class UIInputHandlerModule extends BaseModule {
}
}
bindHistoryToTurn(turnId) {
if (!Number.isInteger(Number(turnId))) return;
if (!this.commandHistoryElement) {
this.commandHistoryElement = document.getElementById('command_history');
}
const pending = this.commandHistoryElement?.querySelector('.history-item[data-turn-id="pending"]');
if (!pending) return;
pending.dataset.turnId = String(turnId);
pending.classList.remove('history-pending');
}
highlightHistoryTurn(turnId) {
if (!this.commandHistoryElement || turnId == null) return;
const id = String(turnId);
this.commandHistoryElement.querySelectorAll('.history-item').forEach((item) => {
item.classList.toggle('active', item.dataset.turnId === id);
});
}
formatCommandHistory(command) {
const parser = this.getModule('markup-parser') || window.MarkupParser;
if (parser && typeof parser.markdownToHtml === 'function') {
@@ -450,6 +518,10 @@ class UIInputHandlerModule extends BaseModule {
return Boolean(playbackCoordinator && playbackCoordinator.isPlaying);
}
isSkippablePauseActive() {
return document.documentElement.dataset.skippablePause === 'true';
}
/**
* Resets the cursor position to the start.
*/
-1
View File
@@ -1 +0,0 @@
{}
+40
View File
@@ -0,0 +1,40 @@
{
"title.byAuthor": "von {{author}}",
"title.version": "Version {{version}}",
"title.fastForwardHint": "auf die Seite klicken oder Leertaste druecken, um die Textanimation vorzuspulen",
"title.continueHint": "auf die Seite klicken oder Leertaste druecken, um fortzufahren",
"title.startPrompt": "Klicke auf Neues Spiel oder Laden, um das Spiel zu starten",
"topbar.speech": "Sprache",
"topbar.autoplay": "Auto",
"topbar.speed": "Tempo",
"topbar.newGame": "Neues Spiel",
"topbar.save": "Speichern",
"topbar.load": "Laden",
"topbar.options": "Optionen",
"topbar.speechTitle": "Sprachausgabe ein- oder ausschalten",
"topbar.autoplayTitle": "Automatisches Abspielen absatzweise ein- oder ausschalten",
"topbar.newGameTitle": "Neues Spiel starten",
"topbar.saveTitle": "Fortschritt speichern",
"topbar.loadTitle": "Gespeicherten Fortschritt laden",
"topbar.optionsTitle": "Optionen",
"input.placeholder": "Befehl eingeben...",
"options.title": "Optionen",
"options.close": "Schliessen",
"options.applicationSettings": "Anwendung",
"options.language": "Sprache",
"options.speech": "Sprachausgabe",
"options.enableSpeech": "Sprachausgabe aktivieren",
"options.provider": "Anbieter",
"options.voice": "Stimme",
"options.speed": "Tempo",
"options.audio": "Audio",
"options.volume": "Lautstaerke",
"options.masterVolume": "Gesamtlautstaerke",
"options.speechVolume": "Sprachlautstaerke",
"options.musicVolume": "Musiklautstaerke",
"options.sfxVolume": "Effektlautstaerke",
"options.elevenLabsSettings": "ElevenLabs API-Einstellungen",
"options.openAiSettings": "OpenAI API-Einstellungen",
"options.apiKey": "API-Schluessel",
"options.apiUrl": "API-URL"
}
-1
View File
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
+40
View File
@@ -0,0 +1,40 @@
{
"title.byAuthor": "by {{author}}",
"title.version": "Version {{version}}",
"title.fastForwardHint": "click on page or press spacebar to fast forward text animation",
"title.continueHint": "click on page or press spacebar to continue",
"title.startPrompt": "Click on new game or load to start the game",
"topbar.speech": "speech",
"topbar.autoplay": "autoplay",
"topbar.speed": "speed",
"topbar.newGame": "new game",
"topbar.save": "save",
"topbar.load": "load",
"topbar.options": "options",
"topbar.speechTitle": "Toggle text to speech",
"topbar.autoplayTitle": "Toggle paragraph autoplay",
"topbar.newGameTitle": "Start a new game",
"topbar.saveTitle": "Save progress",
"topbar.loadTitle": "Load saved progress",
"topbar.optionsTitle": "Options",
"input.placeholder": "Enter your command...",
"options.title": "Options",
"options.close": "Close",
"options.applicationSettings": "Application Settings",
"options.language": "Language",
"options.speech": "Speech",
"options.enableSpeech": "Enable text to speech",
"options.provider": "Provider",
"options.voice": "Voice",
"options.speed": "Speed",
"options.audio": "Audio",
"options.volume": "Volume",
"options.masterVolume": "Master Volume",
"options.speechVolume": "Speech Volume",
"options.musicVolume": "Music Volume",
"options.sfxVolume": "Sound Effects Volume",
"options.elevenLabsSettings": "ElevenLabs API Settings",
"options.openAiSettings": "OpenAI API Settings",
"options.apiKey": "API Key",
"options.apiUrl": "API URL"
}
+1 -7
View File
@@ -6,13 +6,7 @@ Story music paths resolve relative to this directory.
Block music markup:
```text
::music[crossfade, loop, lead=4](track-name.ogg)
```
Inline music cue:
```text
The candles gutter. {{music:cut:danger.ogg}} Something moves upstairs.
#music[track-name.ogg](crossfade, loop, lead=4)
```
Supported modes:
+8 -4
View File
@@ -3,13 +3,14 @@ Sound Effects
Story sound effect paths resolve relative to this directory.
Use inline sound effect markup inside narrative text:
Use a sound effect story tag:
```text
The old door opens {{sfx:squeaky-door.ogg}} into the dark.
#sfx[squeaky-door.ogg]
The old door opens into the dark.
```
The marker is removed from displayed text and from TTS input. It is kept as a timed media cue, preloaded by the client, and played when the text animation reaches that cue position.
The server parses the tag into a structured `StoryTag`. The tag is never sent to the client as display text or TTS input.
Supported browser-friendly formats are recommended: `.ogg`, `.mp3`, and `.wav`. Keep files small enough for responsive preload.
@@ -17,6 +18,9 @@ Sound effect loudness is controlled by the master volume and sound effects volum
Document third-party source and license information here or next to the file.
Current test asset:
Current assets:
- `squeaky-door.ogg`: Wikimedia Commons, "Squeaky door.ogg", sourced from PDSounds and marked public domain.
- `steam-whistle.ogg`: Wikimedia Commons, "WWS SteamWhistle.ogg" by Work With Sounds / Konrad Gutkowski and Julian Blaschke, Creative Commons Attribution 4.0 International.
- `horse-neigh.ogg`: Wikimedia Commons, "Wiehern.ogg" by Hue, released into the public domain by the author.
- `church-bells.ogg`: Wikimedia Commons, "Churchbells.ogg" by Natalie, sourced from PDSounds and released into the public domain.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+120
View File
@@ -0,0 +1,120 @@
import path from 'path';
import { existsSync, mkdirSync, readFileSync } from 'fs';
export type EngineName = 'yaml' | 'ink' | 'zork' | string;
export interface GameMetadata {
title: string;
author?: string;
subtitle?: string;
version?: string;
copyright?: string;
}
export interface GamePaths {
mainGameFile: string;
inkSource?: string;
inkCompiled?: string;
promptDir?: string;
music?: string;
sfx?: string;
images?: string;
[key: string]: string | undefined;
}
export interface GameEngineConfig {
engine: EngineName;
locale: 'en_US' | 'de_DE' | string;
paths: GamePaths;
metadata: GameMetadata;
}
const PROJECT_ROOT = path.resolve(__dirname, '../..');
function fallbackConfig(engine: EngineName): GameEngineConfig {
return {
engine,
locale: 'en_US',
paths: {
mainGameFile:
engine === 'ink'
? 'data/ink/story.ink.json'
: engine === 'zork'
? 'data/z-code/zork1.bin'
: 'data/worlds/example_world.yml',
music: 'public/music',
sfx: 'public/sounds',
images: 'public/images',
},
metadata: {
title: 'AI Interactive Fiction',
author: 'Generative AI',
subtitle: 'An open-world text adventure',
version: '1.0.0',
copyright: '',
},
};
}
export function projectPath(relativeOrAbsolutePath: string): string {
return path.isAbsolute(relativeOrAbsolutePath)
? relativeOrAbsolutePath
: path.resolve(PROJECT_ROOT, relativeOrAbsolutePath);
}
export function loadGameConfig(configPath: string, engine: EngineName): GameEngineConfig {
const absolutePath = projectPath(configPath);
if (!existsSync(absolutePath)) {
console.warn(`[config] Missing ${absolutePath}; using ${engine} defaults.`);
return fallbackConfig(engine);
}
const parsed = JSON.parse(readFileSync(absolutePath, 'utf8')) as Partial<GameEngineConfig>;
const fallback = fallbackConfig(engine);
return {
engine: parsed.engine ?? fallback.engine,
locale: parsed.locale ?? fallback.locale,
paths: {
...fallback.paths,
...(parsed.paths ?? {}),
},
metadata: {
...fallback.metadata,
...(parsed.metadata ?? {}),
},
};
}
export function ensureConfiguredAssetDirectories(config: GameEngineConfig): void {
const directories = [
config.paths.music,
config.paths.sfx,
config.paths.images,
config.paths.inkSource ? path.dirname(config.paths.inkSource) : undefined,
config.paths.inkCompiled ? path.dirname(config.paths.inkCompiled) : undefined,
config.paths.mainGameFile ? path.dirname(config.paths.mainGameFile) : undefined,
config.paths.promptDir,
];
for (const directory of directories) {
if (!directory) continue;
const absolutePath = projectPath(directory);
if (!existsSync(absolutePath)) {
mkdirSync(absolutePath, { recursive: true });
}
}
}
export function clientGameConfig(config: GameEngineConfig) {
return {
engine: config.engine,
locale: config.locale,
metadata: config.metadata,
assets: {
music: '/music/',
sfx: '/sounds/',
sounds: '/sounds/',
images: '/images/',
},
};
}
+176
View File
@@ -0,0 +1,176 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import path from 'path';
import { Story } from 'inkjs';
import {
ChoiceResult,
StoryTag,
TurnResult,
} from '../interfaces/turn-result';
import { getTagValue, parseTags } from '../utils/tag-parser';
const { Compiler } = require('inkjs/full') as { Compiler: new (
inkSource: string,
options?: {
sourceFilename?: string;
errorHandler?: (message: string, type: number) => void;
fileHandler?: {
ResolveInkFilename: (filename: string) => string;
LoadInkFileContents: (filename: string) => string;
};
},
) => { Compile: () => { ToJson: () => string } | null } };
export interface InkCompileResult {
sourcePath: string;
outputPath: string;
warningCount: number;
}
export function compileInkSource(sourcePath: string, outputPath: string): InkCompileResult {
const resolvedSource = path.resolve(sourcePath);
const resolvedOutput = path.resolve(outputPath);
if (!existsSync(resolvedSource)) {
throw new Error(`Ink source file not found: ${resolvedSource}`);
}
const warnings: string[] = [];
const errors: string[] = [];
const source = readFileSync(resolvedSource, 'utf8').replace(/^\uFEFF/, '');
const sourceDir = path.dirname(resolvedSource);
const fileHandler = {
ResolveInkFilename: (filename: string) =>
path.isAbsolute(filename) ? filename : path.resolve(sourceDir, filename),
LoadInkFileContents: (filename: string) =>
readFileSync(path.isAbsolute(filename) ? filename : path.resolve(sourceDir, filename), 'utf8')
.replace(/^\uFEFF/, ''),
};
const compiler = new Compiler(source, {
sourceFilename: resolvedSource,
fileHandler,
errorHandler: (message: string, type: number) => {
if (type === 1) {
warnings.push(message);
} else {
errors.push(message);
}
},
});
const story = compiler.Compile();
if (!story || errors.length > 0) {
throw new Error(`Ink compilation failed:\n${errors.join('\n')}`);
}
if (warnings.length > 0) {
warnings.forEach((warning) => console.warn(`[ink] ${warning}`));
}
mkdirSync(path.dirname(resolvedOutput), { recursive: true });
writeFileSync(resolvedOutput, story.ToJson(), 'utf8');
return {
sourcePath: resolvedSource,
outputPath: resolvedOutput,
warningCount: warnings.length,
};
}
export class InkEngine {
private story: Story | null = null;
private nextTurnId = 1;
constructor(private readonly storyPath: string) {}
isRunning(): boolean {
if (!this.story) return false;
return this.story.canContinue || this.story.currentChoices.length > 0;
}
newGame(): TurnResult {
this.story = this.loadStory();
this.nextTurnId = 1;
return this.continueStory();
}
chooseChoice(choiceIndex: number): TurnResult {
if (!this.story) {
throw new Error('No active Ink story');
}
const choice = this.story.currentChoices.find((item) => item.index === choiceIndex);
if (!choice) {
throw new Error(`Ink choice ${choiceIndex} is not available`);
}
this.story.ChooseChoiceIndex(choice.index);
return this.continueStory();
}
saveGame(): string {
if (!this.story) {
throw new Error('No active Ink story to save');
}
return this.story.state.toJson();
}
loadGame(savedState: string): TurnResult {
this.story = this.loadStory();
this.story.state.LoadJson(savedState);
return this.continueStory();
}
private loadStory(): Story {
const resolvedPath = path.resolve(this.storyPath);
if (!existsSync(resolvedPath)) {
throw new Error(`Ink story file not found: ${resolvedPath}`);
}
const storyJson = JSON.parse(readFileSync(resolvedPath, 'utf8'));
return new Story(storyJson);
}
private continueStory(): TurnResult {
if (!this.story) {
throw new Error('No active Ink story');
}
const paragraphs: TurnResult['paragraphs'] = [];
const globalTags: StoryTag[] = [];
while (this.story.canContinue) {
const rawText = this.story.Continue();
const text = String(rawText || '').trim();
const tags = parseTags(this.story.currentTags || []);
tags
.filter((tag) => tag.key === 'title' || tag.key === 'author')
.forEach((tag) => globalTags.push(tag));
if (text) {
paragraphs.push({ text, tags });
} else {
tags.forEach((tag) => globalTags.push(tag));
}
}
const choices = this.story.currentChoices.map((choice): ChoiceResult => {
const tags = parseTags(choice.tags || []);
const category = getTagValue(tags, 'action');
const letter = getTagValue(tags, 'letter');
return {
index: choice.index,
text: String(choice.text || '').trim(),
tags,
category,
letter,
};
});
return {
turnId: this.nextTurnId++,
paragraphs,
choices,
inputMode: choices.length > 0 ? 'choice' : 'end',
globalTags: globalTags.length > 0 ? globalTags : undefined,
};
}
}
+13 -11
View File
@@ -20,6 +20,10 @@ import * as os from 'os';
import * as yaml from 'js-yaml';
import axios, { AxiosError, AxiosInstance } from 'axios';
import * as dotenv from 'dotenv';
import {
textToParagraphs,
TurnResult,
} from '../interfaces/turn-result';
dotenv.config();
@@ -91,13 +95,7 @@ export interface ZorkSession {
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 type ZorkTurnResult = TurnResult;
interface PromptConfig {
system: string;
@@ -418,6 +416,7 @@ export class ZorkLlmEngine {
private llmCallCounter = 0;
private maxRetries: number;
private historySize: number;
private nextTurnId = 1;
private storyPath: string;
private static readonly DEPRECATED_MODEL_REPLACEMENTS: Record<string, string> = {
@@ -425,7 +424,7 @@ export class ZorkLlmEngine {
'openai/gpt-5.4-mini': 'openai/gpt-5.5',
};
constructor() {
constructor(options: { storyPath?: string; promptDir?: string } = {}) {
const apiKey = process.env.OPENROUTER_API_KEY;
const model = process.env.OPENROUTER_MODEL;
if (!apiKey || !model) {
@@ -450,10 +449,10 @@ export class ZorkLlmEngine {
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',
options.storyPath ?? process.env.ZORK_STORY_FILE ?? './data/z-code/zork1.bin',
);
const promptDir = path.resolve('./data/zork-prompts');
const promptDir = path.resolve(options.promptDir ?? './data/zork-prompts');
this.prompts = loadPrompts(promptDir);
this.llm = axios.create({
@@ -582,6 +581,7 @@ export class ZorkLlmEngine {
async newGame(): Promise<ZorkTurnResult> {
// Kill any existing game
if (this.zork.isAlive()) this.zork.kill();
this.nextTurnId = 1;
if (!fs.existsSync(this.storyPath)) {
throw new Error(
@@ -1148,8 +1148,10 @@ export class ZorkLlmEngine {
private buildTurnResult(text: string): ZorkTurnResult {
const alive = this.zork.isAlive();
if (!alive && this.session) this.session.running = false;
const paragraphs = textToParagraphs(text);
return {
paragraphs: [{ text, tags: [] }],
turnId: this.nextTurnId++,
paragraphs,
choices: [],
inputMode: alive ? 'text' : 'end',
gameState: { statusLine: this.session?.currentRoom },
+8 -3
View File
@@ -7,6 +7,7 @@ import * as dotenv from 'dotenv';
import { GameRunner } from './cli/game-runner';
// Import the server module and the startServer function for the web interface
import { startServer } from './server';
import { loadGameConfig, projectPath } from './config/game-config';
// Load environment variables
console.log('Loading environment variables...');
@@ -27,8 +28,12 @@ async function main(): Promise<void> {
console.log('A modern take on classic text adventures with LLM-powered interactions');
console.log('');
// Get the world file path from environment variables or use default
const worldFile = process.env.DEFAULT_WORLD_FILE || './data/worlds/example_world.yml';
// Get the world file path from the YAML engine config, with environment override.
const engineConfig = loadGameConfig(
process.env.YAML_CONFIG_FILE || './config/engines/yaml.json',
'yaml',
);
const worldFile = projectPath(process.env.DEFAULT_WORLD_FILE || engineConfig.paths.mainGameFile);
console.log(`Using world file: ${worldFile}`);
console.log(`OpenRouter API Key: ${process.env.OPENROUTER_API_KEY ? '✓ Found' : '✗ Missing'}`);
console.log(`OpenRouter Model: ${process.env.OPENROUTER_MODEL || '✗ Not specified'}`);
@@ -58,7 +63,7 @@ async function main(): Promise<void> {
// Get port configuration
const DEFAULT_PORT = 3000;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10;
const PORT_RANGE = 300;
// Start the web server with port fallback
console.log('Starting web server...');
+71
View File
@@ -0,0 +1,71 @@
/**
* Shared engine-to-client turn protocol.
*/
import { parseTag } from '../utils/tag-parser';
export type InputMode = 'text' | 'choice' | 'end';
export interface StoryTag {
key: string;
value?: string;
param?: string;
}
export interface ParagraphResult {
text: string;
tags: StoryTag[];
}
export interface ChoiceResult {
index: number;
text: string;
tags: StoryTag[];
category?: string;
letter?: string;
}
export interface TurnResult {
turnId: number;
paragraphs: ParagraphResult[];
choices: ChoiceResult[];
inputMode: InputMode;
globalTags?: StoryTag[];
gameState?: {
currentRoomId?: string;
score?: number;
moves?: number;
statusLine?: string;
};
suggestions?: string[];
}
export function textToParagraphs(text: string, tags: StoryTag[] = []): ParagraphResult[] {
return String(text || '')
.replace(/\r\n?/g, '\n')
.split(/\n{2,}/)
.map((paragraph) => paragraph.trim())
.filter(Boolean)
.map((paragraph) => {
const lines = paragraph.split('\n');
const paragraphTags: StoryTag[] = [...tags];
const textLines: string[] = [];
let tagPrefixOpen = true;
for (const line of lines) {
const trimmed = line.trim();
const maybeTag = tagPrefixOpen && trimmed.startsWith('#') ? parseTag(trimmed) : null;
if (maybeTag) {
paragraphTags.push(maybeTag);
} else {
tagPrefixOpen = false;
textLines.push(line);
}
}
return {
text: textLines.join('\n').trim(),
tags: paragraphTags,
};
});
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Ink Engine Server
*
* Serves the shared client UI and runs a compiled Ink JSON story through the
* unified TurnResult socket protocol.
*/
import path from 'path';
import http from 'http';
import express from 'express';
import { Server as SocketIOServer } from 'socket.io';
import * as dotenv from 'dotenv';
import { existsSync, mkdirSync, copyFileSync } from 'fs';
import { compileInkSource, InkEngine } from './engine/ink-engine';
import {
clientGameConfig,
ensureConfiguredAssetDirectories,
loadGameConfig,
projectPath,
} from './config/game-config';
dotenv.config();
const app = express();
const server = http.createServer(app);
const io = new SocketIOServer(server);
const DEFAULT_PORT = 3003;
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
const PORT_RANGE = 300;
const engineConfig = loadGameConfig(
process.env.INK_CONFIG_FILE || './config/engines/ink.json',
'ink',
);
app.use(
express.static(path.join(__dirname, '../public'), {
etag: false,
lastModified: false,
setHeaders: (res) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
},
}),
);
app.get('/api/game-config', (_req, res) => {
res.json(clientGameConfig(engineConfig));
});
const sessions = new Map<string, InkEngine>();
const saveSlots = new Map<string, Map<number, string>>();
function normalizeSaveSlot(slot: unknown): number {
const n = Number(slot);
return Number.isInteger(n) && n > 0 ? n : 1;
}
function getStoryPath(): string {
return projectPath(
process.env.INK_STORY_FILE ||
engineConfig.paths.inkCompiled ||
engineConfig.paths.mainGameFile,
);
}
function getSourcePath(): string {
return projectPath(process.env.INK_SOURCE_FILE || engineConfig.paths.inkSource || '');
}
function compileConfiguredStory(): void {
const sourcePath = getSourcePath();
const outputPath = getStoryPath();
const result = compileInkSource(sourcePath, outputPath);
console.log(
`[ink] Compiled ${result.sourcePath} -> ${result.outputPath}` +
(result.warningCount > 0 ? ` (${result.warningCount} warnings)` : ''),
);
}
function getSlots(socketId: string): Map<number, string> {
let slots = saveSlots.get(socketId);
if (!slots) {
slots = new Map();
saveSlots.set(socketId, slots);
}
return slots;
}
function getOrCreateEngine(socketId: string): InkEngine {
let engine = sessions.get(socketId);
if (!engine) {
engine = new InkEngine(getStoryPath());
sessions.set(socketId, engine);
}
return engine;
}
async function handleGameApi(
socket: ReturnType<SocketIOServer['sockets']['sockets']['get']> & { id: string },
method: string,
args: unknown[],
): Promise<object> {
const slots = getSlots(socket.id);
switch (method) {
case 'newGame':
case 'newGame()': {
const engine = new InkEngine(getStoryPath());
sessions.set(socket.id, engine);
socket.emit('narrativeResponse', engine.newGame());
return { success: true, result: true, running: true, canLoad: slots.size > 0 };
}
case 'chooseChoice':
case 'chooseChoice()': {
const engine = sessions.get(socket.id);
if (!engine?.isRunning()) {
return { success: false, error: 'game_not_running', result: false };
}
const choiceIndex = Number(args[0]);
if (!Number.isInteger(choiceIndex)) {
return { success: false, error: 'invalid_choice', result: false };
}
socket.emit('narrativeResponse', engine.chooseChoice(choiceIndex));
return { success: true, result: true };
}
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);
socket.emit('narrativeResponse', engine.loadGame(slots.get(slot)!));
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]);
slots.set(slot, engine.saveGame());
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}` };
}
}
io.on('connection', (socket) => {
console.log(`[ink] Client connected: ${socket.id}`);
socket.emit('gameConfig', clientGameConfig(engineConfig));
socket.on(
'gameApi',
async (
request: { method?: string; args?: unknown[] },
respond: (result: object) => void,
) => {
try {
const result = await handleGameApi(
socket as Parameters<typeof handleGameApi>[0],
String(request?.method ?? ''),
Array.isArray(request?.args) ? request.args : [],
);
if (typeof respond === 'function') respond(result);
} catch (error) {
console.error('[ink] gameApi error:', error);
if (typeof respond === 'function') {
respond({
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
},
);
socket.on('disconnect', () => {
console.log(`[ink] Client disconnected: ${socket.id}`);
sessions.delete(socket.id);
saveSlots.delete(socket.id);
});
});
function ensureDirectories(): void {
const dirs = [
path.join(__dirname, '../public'),
path.join(__dirname, '../public/js'),
path.join(__dirname, '../public/css'),
path.join(__dirname, '../public/images'),
path.join(__dirname, '../public/music'),
path.join(__dirname, '../public/sounds'),
path.join(__dirname, '../public/fonts'),
];
for (const dir of dirs) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
ensureConfiguredAssetDirectories(engineConfig);
}
function ensureKokoroJs(): void {
const source = path.join(__dirname, '../node_modules/kokoro-js/dist/index.js');
const destination = path.join(__dirname, '../public/js/kokoro-js.js');
if (existsSync(source) && !existsSync(destination)) {
copyFileSync(source, destination);
}
}
export async function startServer(initialPort: number, range: number): Promise<void> {
ensureDirectories();
try { ensureKokoroJs(); } catch { /* optional */ }
compileConfiguredStory();
if (!existsSync(getStoryPath())) {
console.error(`[ink] Story file missing: ${getStoryPath()}`);
console.error('[ink] Set INK_SOURCE_FILE or configure paths.inkSource in config/engines/ink.json.');
}
let port = initialPort;
while (port < initialPort + range) {
try {
await new Promise<void>((resolve, reject) => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
server.once('listening', () => {
console.log(`[ink] Ink server running on http://localhost:${port}`);
resolve();
});
server.once('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') {
console.log(`Port ${port} unavailable (${error.code}), trying ${port + 1}...`);
server.close();
port++;
reject();
} else {
reject(error);
}
});
server.listen(port);
});
return;
} catch {
if (port >= initialPort + range - 1) {
throw new Error(`Failed to start server on ports ${initialPort} to ${initialPort + range - 1}`);
}
}
}
}
if (require.main === module) {
startServer(PORT, PORT_RANGE).catch((error) => {
console.error('[ink] Failed to start:', error);
process.exit(1);
});
}
export { app, server, io };
+36 -23
View File
@@ -21,6 +21,12 @@ import { Server as SocketIOServer } from 'socket.io';
import * as dotenv from 'dotenv';
import { existsSync, mkdirSync, copyFileSync } from 'fs';
import { ZorkLlmEngine, ZorkTurnResult } from './engine/zork-llm-engine';
import {
clientGameConfig,
ensureConfiguredAssetDirectories,
loadGameConfig,
projectPath,
} from './config/game-config';
dotenv.config();
@@ -30,8 +36,12 @@ const io = new SocketIOServer(server);
const DEFAULT_PORT = 3002;
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
const PORT_RANGE = 10;
const PORT_RANGE = 300;
const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.ZORK_DEBUG ?? '');
const engineConfig = loadGameConfig(
process.env.ZORK_CONFIG_FILE || './config/engines/zork.json',
'zork',
);
function debugLog(message: string, details?: unknown): void {
if (!DEBUG_ENABLED) return;
@@ -58,23 +68,20 @@ app.use(
}),
);
app.get('/api/game-config', (_req, res) => {
res.json(clientGameConfig(engineConfig));
});
// One engine instance per connected socket
const sessions = new Map<string, ZorkLlmEngine>();
// Save-game slot maps: socketId → Map<slotNumber, serialisedJson>
const saveSlots = new Map<string, Map<number, string>>();
function toLegacyNarrative(turn: ZorkTurnResult): {
text: string;
gameState: { currentRoomId?: string; statusLine?: string };
} {
const text = (turn.paragraphs ?? [])
.map((p) => String(p?.text ?? '').trim())
.filter(Boolean)
.join('\n\n');
function toClientTurn(turn: ZorkTurnResult): ZorkTurnResult {
return {
text,
...turn,
gameState: {
...turn.gameState,
currentRoomId: turn.gameState?.statusLine,
statusLine: turn.gameState?.statusLine,
},
@@ -89,7 +96,10 @@ function normalizeSaveSlot(slot: unknown): number {
function getOrCreateEngine(socketId: string): ZorkLlmEngine {
let engine = sessions.get(socketId);
if (!engine) {
engine = new ZorkLlmEngine();
engine = new ZorkLlmEngine({
storyPath: projectPath(process.env.ZORK_STORY_FILE || engineConfig.paths.mainGameFile),
promptDir: projectPath(engineConfig.paths.promptDir || 'data/zork-prompts'),
});
sessions.set(socketId, engine);
}
return engine;
@@ -119,7 +129,7 @@ async function handleGameApi(
case 'newGame()': {
const engine = getOrCreateEngine(socket.id);
const turn = await engine.newGame();
socket.emit('narrativeResponse', toLegacyNarrative(turn));
socket.emit('narrativeResponse', toClientTurn(turn));
return {
success: true,
result: true,
@@ -136,7 +146,7 @@ async function handleGameApi(
}
const engine = getOrCreateEngine(socket.id);
const turn = await engine.loadGame(slots.get(slot)!);
socket.emit('narrativeResponse', toLegacyNarrative(turn));
socket.emit('narrativeResponse', toClientTurn(turn));
socket.emit('gameLoaded', { slot });
return { success: true, result: true, running: true, slot };
}
@@ -180,10 +190,8 @@ async function handleGameApi(
}
function checkRuntimeConfiguration(): void {
const storyPath = path.resolve(
process.env.ZORK_STORY_FILE ?? './data/z-code/zork1.bin',
);
const promptDir = path.resolve('./data/zork-prompts');
const storyPath = projectPath(process.env.ZORK_STORY_FILE ?? engineConfig.paths.mainGameFile);
const promptDir = projectPath(engineConfig.paths.promptDir || 'data/zork-prompts');
const promptFiles = [
'character-generation.yml',
'text-rewriter.yml',
@@ -223,6 +231,7 @@ function checkRuntimeConfiguration(): void {
io.on('connection', (socket) => {
console.log(`[zork] Client connected: ${socket.id}`);
socket.emit('gameConfig', clientGameConfig(engineConfig));
socket.on(
'gameApi',
@@ -272,7 +281,7 @@ io.on('connection', (socket) => {
paragraphs: turn.paragraphs.length,
statusLine: turn.gameState?.statusLine,
});
socket.emit('narrativeResponse', toLegacyNarrative(turn));
socket.emit('narrativeResponse', toClientTurn(turn));
} catch (error) {
console.error('[zork] playerCommand error:', error);
socket.emit('error', {
@@ -309,6 +318,7 @@ function ensureDirectories(): void {
for (const dir of dirs) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
ensureConfiguredAssetDirectories(engineConfig);
}
function ensureKokoroJs(): void {
@@ -326,15 +336,17 @@ async function startServer(initialPort: number, range: number): Promise<void> {
while (port < initialPort + range) {
try {
await new Promise<void>((resolve, reject) => {
server.listen(port, () => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
server.once('listening', () => {
console.log(
`[zork] Zork Narrator server running on http://localhost:${port}`,
);
resolve();
});
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.log(`Port ${port} in use, trying ${port + 1}`);
server.once('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
console.log(`Port ${port} unavailable (${err.code}), trying ${port + 1}...`);
server.close();
port++;
reject();
@@ -342,6 +354,7 @@ async function startServer(initialPort: number, range: number): Promise<void> {
reject(err);
}
});
server.listen(port);
});
return;
} catch {
+70 -18
View File
@@ -10,6 +10,16 @@ import { Server as SocketIOServer } from 'socket.io';
import * as dotenv from 'dotenv';
import { GameRunner } from './cli/game-runner';
import { existsSync, mkdirSync, copyFileSync } from 'fs';
import {
textToParagraphs,
TurnResult,
} from './interfaces/turn-result';
import {
clientGameConfig,
ensureConfiguredAssetDirectories,
loadGameConfig,
projectPath,
} from './config/game-config';
// Load environment variables
dotenv.config();
@@ -22,7 +32,11 @@ const io = new SocketIOServer(server);
// Get port from environment variables or use default
const DEFAULT_PORT = 3001;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
const PORT_RANGE = 300; // Try enough ports to skip OS-excluded ranges.
const engineConfig = loadGameConfig(
process.env.YAML_CONFIG_FILE || './config/engines/yaml.json',
'yaml',
);
// Serve static files from the public directory. During local development the
// browser must not keep stale ES modules, otherwise UI fixes appear to do
@@ -37,8 +51,36 @@ app.use(express.static(path.join(__dirname, '../public'), {
}
}));
app.get('/api/game-config', (_req, res) => {
res.json(clientGameConfig(engineConfig));
});
// Set up game sessions
const gameSessions = new Map<string, GameRunner>();
const nextTurnIds = new Map<string, number>();
function nextTurnId(socketId: string): number {
const current = nextTurnIds.get(socketId) || 1;
nextTurnIds.set(socketId, current + 1);
return current;
}
function createTextTurn(
socketId: string,
text: string,
gameState: TurnResult['gameState'] = {},
suggestions?: string[],
): TurnResult {
const paragraphs = textToParagraphs(text);
return {
turnId: nextTurnId(socketId),
paragraphs,
choices: [],
inputMode: 'text',
gameState,
suggestions,
};
}
function normalizeSaveSlot(slot: unknown): number {
const value = Number(slot);
@@ -46,17 +88,26 @@ function normalizeSaveSlot(slot: unknown): number {
}
async function startDemoGameForSocket(socket: any): Promise<GameRunner> {
nextTurnIds.set(socket.id, 1);
const gameRunner = new GameRunner();
const worldFile = process.env.DEFAULT_WORLD_FILE || './data/worlds/example_world.yml';
const worldFile = projectPath(process.env.DEFAULT_WORLD_FILE || engineConfig.paths.mainGameFile);
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
const paragraphs = [
...textToParagraphs(gameState.world.introduction),
...textToParagraphs(gameRunner.getCurrentRoomDescription()),
];
socket.emit('narrativeResponse', {
turnId: nextTurnId(socket.id),
paragraphs,
choices: [],
inputMode: 'text',
gameState: {
currentRoomId: gameState.currentRoomId,
},
});
return gameRunner;
@@ -117,6 +168,7 @@ async function handleGameApi(socket: any, method: string, args: unknown[] = [])
// Handle socket connections
io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);
socket.emit('gameConfig', clientGameConfig(engineConfig));
socket.data.saveGames = new Map<number, any>();
@@ -159,13 +211,9 @@ io.on('connection', (socket) => {
// 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: command,
gameState: {
currentRoomId: gameRunner.getGameState().currentRoomId
},
suggestions: gameRunner.getSuggestions()
});
socket.emit('narrativeResponse', createTextTurn(socket.id, command, {
currentRoomId: gameRunner.getGameState().currentRoomId
}, gameRunner.getSuggestions()));
} catch (error) {
console.error('Error processing command:', error);
@@ -225,6 +273,7 @@ io.on('connection', (socket) => {
if (gameSessions.has(socket.id)) {
gameSessions.delete(socket.id);
}
nextTurnIds.delete(socket.id);
});
});
@@ -245,6 +294,7 @@ function ensureDirectories() {
mkdirSync(dir, { recursive: true });
}
}
ensureConfiguredAssetDirectories(engineConfig);
}
// Copy kokoro-js library from node_modules if not already present
@@ -278,15 +328,16 @@ export async function startServer(initialPort: number, range: number): Promise<v
// Try to start the server on the current port
await new Promise<void>((resolve, reject) => {
server.listen(currentPort, () => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
server.once('listening', () => {
console.log(`AI Interactive Fiction web server running on http://localhost:${currentPort}`);
resolve();
});
server.on('error', (error: NodeJS.ErrnoException) => {
server.once('error', (error: NodeJS.ErrnoException) => {
// If port is in use, try next port
if (error.code === 'EADDRINUSE') {
console.log(`Port ${currentPort} is in use, trying next port...`);
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') {
console.log(`Port ${currentPort} is unavailable (${error.code}), trying next port...`);
server.close();
currentPort++;
reject();
@@ -296,6 +347,7 @@ export async function startServer(initialPort: number, range: number): Promise<v
reject(error);
}
});
server.listen(currentPort);
});
// If we reach here, server started successfully
+26 -11
View File
@@ -9,6 +9,7 @@ import http from 'http';
import { Server as SocketIOServer } from 'socket.io';
import * as dotenv from 'dotenv';
import { existsSync, mkdirSync, copyFileSync } from 'fs';
import { textToParagraphs } from './interfaces/turn-result';
// Load environment variables
dotenv.config();
@@ -21,7 +22,7 @@ const io = new SocketIOServer(server);
// Get port from environment variables or use default
const DEFAULT_PORT = 3001;
const PORT = process.env.PORT ? parseInt(process.env.PORT) : DEFAULT_PORT;
const PORT_RANGE = 10; // Try up to 10 ports starting from the default
const PORT_RANGE = 300; // Try enough ports to skip OS-excluded ranges.
// Serve static files from the public directory. Keep browser modules uncached
// during local development so fixes are visible without a hard cache clear.
@@ -47,15 +48,24 @@ io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);
let currentParagraphIndex = 0;
let gameRunning = false;
let nextTurnId = 1;
const saveGames = new Set<number>();
const startDemoGame = () => {
gameRunning = true;
nextTurnId = 1;
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"
socket.emit('narrativeResponse', {
turnId: nextTurnId++,
paragraphs: [
...textToParagraphs("#chapter[Interactive Fiction Test]\n\nWelcome to the Interactive Fiction Test. This is a simplified version that sends predefined paragraphs instead of using an LLM."),
...textToParagraphs(TEST_PARAGRAPHS[0]),
],
choices: [],
inputMode: 'text',
gameState: {
currentRoomId: 'test-room',
},
});
};
@@ -145,7 +155,10 @@ io.on('connection', (socket) => {
// Send narrative response to client
socket.emit('narrativeResponse', {
text: data.command,
turnId: nextTurnId++,
paragraphs: textToParagraphs(String(data.command || '')),
choices: [],
inputMode: 'text',
gameState: {
currentRoomId: "test-room"
},
@@ -214,16 +227,17 @@ async function startServer(initialPort: number, range: number): Promise<void> {
// Try to start the server on the current port
await new Promise<void>((resolve, reject) => {
server.listen(currentPort, () => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
server.once('listening', () => {
console.log(`AI Interactive Fiction TEST SERVER running on http://localhost:${currentPort}`);
console.log('This server is sending predefined test paragraphs instead of using an LLM');
resolve();
});
server.on('error', (error: NodeJS.ErrnoException) => {
server.once('error', (error: NodeJS.ErrnoException) => {
// If port is in use, try next port
if (error.code === 'EADDRINUSE') {
console.log(`Port ${currentPort} is in use, trying next port...`);
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') {
console.log(`Port ${currentPort} is unavailable (${error.code}), trying next port...`);
server.close();
currentPort++;
reject();
@@ -233,6 +247,7 @@ async function startServer(initialPort: number, range: number): Promise<void> {
reject(error);
}
});
server.listen(currentPort);
});
// If we reach here, server started successfully
+45
View File
@@ -0,0 +1,45 @@
import type { StoryTag } from '../interfaces/turn-result';
const LEGACY_TAG_ALIASES: Record<string, string> = {
audio: 'sfx',
audioloop: 'music',
separator: 'section',
};
function normalizeKey(key: string): string {
const normalized = key.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
return LEGACY_TAG_ALIASES[normalized] || normalized;
}
export function parseTag(raw: string): StoryTag | null {
const text = String(raw || '').trim().replace(/^#\s*/, '');
if (!text) return null;
const bracketMatch = text.match(/^([A-Za-z][\w-]*)(?:\[([^\]]*)\])?(?:\(([^)]*)\))?$/);
if (bracketMatch) {
const tag: StoryTag = { key: normalizeKey(bracketMatch[1]) };
if (typeof bracketMatch[2] !== 'undefined') tag.value = bracketMatch[2].trim();
if (typeof bracketMatch[3] !== 'undefined') tag.param = bracketMatch[3].trim();
return tag;
}
const bareMatch = text.match(/^[A-Za-z][\w-]*$/);
if (bareMatch) {
return { key: normalizeKey(text) };
}
return null;
}
export function parseTags(rawTags: unknown[] | undefined): StoryTag[] {
if (!Array.isArray(rawTags)) return [];
return rawTags
.map((raw) => parseTag(String(raw ?? '')))
.filter((tag): tag is StoryTag => Boolean(tag));
}
export function getTagValue(tags: StoryTag[], key: string): string | undefined {
const normalizedKey = normalizeKey(key);
return tags.find((tag) => tag.key === normalizedKey)?.value;
}