394 lines
22 KiB
Markdown
394 lines
22 KiB
Markdown
# Markup Guidelines
|
|
|
|
This file documents author-facing Ink tag conventions. The active parser normalizes tags into structured `StoryTag` objects before they reach the UI.
|
|
|
|
## Eibenreith Ink Architecture
|
|
|
|
`WritingWithInk.md` is the official Ink language reference and must not be edited for project conventions. This file is the author-facing project guide.
|
|
|
|
All Ink source and UI localization files must be UTF-8 and use real German characters directly: `ä`, `ö`, `ü`, `Ä`, `Ö`, `Ü`, `ß`, `„…“`. Do not replace them with `ae`, `oe`, `ue`, `ss`, or ASCII quotation marks as an encoding workaround.
|
|
|
|
Mandatory editing rules for AI/Codex work: no authored text changes through regex, bulk replacement, or scripted rewrites. Text edits must use `apply_patch`. PowerShell commands must set UTF-8 output before reading or displaying text. Before large text work, create a git check-in. Edit and inspect entries one by one, sequentially, without generated shortcuts.
|
|
|
|
Eibenreith uses a bucket architecture. This is mandatory for authored content, not a suggestion. The active choice surface is collected in this priority order:
|
|
|
|
1. Moment bucket
|
|
2. Room entry or room look
|
|
3. Room exits
|
|
4. Episode bucket
|
|
5. Game bucket
|
|
|
|
Every room is declared through `enter_room(location, entry, look, exits, bucket)`. The room entry knot contains the one-time first description. The look knot contains the repeat look action. The exits knot contains traversal choices. The bucket contains complete conditioned weaves for local interactions.
|
|
|
|
```ink
|
|
=== train_washroom ===
|
|
-> enter_room(loc_train_washroom, -> train_washroom_entry, -> train_washroom_look, -> train_washroom_exits, -> train_washroom_bucket) -> TURN
|
|
```
|
|
|
|
Bucket content must be written as complete conditioned weaves directly inside the bucket. Do not split ordinary bucket choices into one-line `_choices` knots or pass-through knots. A separate knot is allowed only when the content is genuinely reused or when the exception is explicitly justified, such as the identity papers tunnel in `character_generator.ink`.
|
|
|
|
The canonical atom header format is:
|
|
|
|
```ink
|
|
* {condition_one}
|
|
{condition_two}
|
|
[__Verb Charakter__: "..."]
|
|
Text and nested weave content.
|
|
-> TURN
|
|
```
|
|
|
|
Each precondition gets its own header line directly after the choice marker. The choice text gets its own header line after the preconditions. Do not put naked condition lines before the choice; they compile, but they do not gate the choice. Do not put visibility conditions inside the branch body.
|
|
|
|
Valid examples:
|
|
|
|
```ink
|
|
=== train_washroom_bucket ===
|
|
* {lacks(face_def)}
|
|
[AUTO: Spiegelbild im Waschraum] #auto
|
|
-> washroom_mirror_character_generator
|
|
|
|
* {lacks(washroom_toilet_used)}
|
|
[__Benutze__: Die Toilette. #key:t]
|
|
~ mark(washroom_toilet_used)
|
|
Du schiebst den Riegel vor und nimmst dir die Zeit, die eine Reise dem Körper selten höflich anbietet.
|
|
-> TURN
|
|
-> DONE
|
|
```
|
|
|
|
Use `-> TURN` at the end of a chosen atomic weave when play should continue at the current choice surface. Use `-> DONE` only to close a bucket/provider knot that merely offers choices. Do not call `-> provide_choices` in authored chapter files; it is internal helper implementation.
|
|
|
|
Choice text is written from Valerie's perspective before the action. It may describe intention, attention, posture, or immediate action. It must not reveal what the branch will discover after the choice.
|
|
|
|
Dialogue choices use a stricter form. If the choice represents a spoken line, the visible choice text must contain that line. If the addressee matters and is not obvious from the room or weave, put the addressee into the bold action phrase before the colon:
|
|
|
|
```ink
|
|
* [__Prüfe Viktor__: „Welche Zeile betrifft Sie, Herr Nowak?“]
|
|
```
|
|
|
|
Do not write dialogue choices as post-hoc summaries such as:
|
|
|
|
```ink
|
|
* [__Frage__: Viktor, weshalb er wirklich mitreist.]
|
|
```
|
|
|
|
Neutral UI verbs such as `Frage`, `Sage`, `Antworte`, and `Sprich` are too weak for ordinary dialogue. Prefer verbs that name Valerie's social move, such as `Prüfe`, `Wahre Form`, `Entwaffne`, `Benenne`, `Schone`, `Trotze`, `Reize`, or `Zügle dich`. Non-dialogue choices may omit a quoted line when they represent physical action, attention, traversal, or examination rather than speech.
|
|
|
|
Good:
|
|
|
|
```ink
|
|
* [__Versuche__: Die andere Übergangstür. #key:w]
|
|
```
|
|
|
|
Bad:
|
|
|
|
```ink
|
|
* [__Prüfe__: Die Tür zu den Wagen zweiter und dritter Klasse. #key:w]
|
|
```
|
|
|
|
Use Ink's built-in visit tracking for simple “this knot/choice/gather has been seen or chosen” facts. Do not create parallel flags such as `seen_train_compartment`. Use `LIST` state only for semantic progress, encounter chains, timetable state, locations, character-generation facts, tutorials, and other authored state that has meaning beyond a knot visit.
|
|
|
|
## Helper Conventions
|
|
|
|
Author-facing helper functions live in `data/ink-src/eibenreith/helpers.ink` and are documented in comments there. Important families:
|
|
|
|
- `route_*`: Valerie route counters such as `route_composure` and `route_sapphic`.
|
|
- `route_repeated(route_id, amount)`, `route_is_highest(route_id)`, `route_is_clear(route_id, margin)`, `route_beats(route_id, other_route_id, margin)`: route-pattern heuristics. Use `composure`, `detective`, `lover`, `sapphic`, `careless`, or `eccentric` as `RouteId` values.
|
|
- `time_*`, `day_*`, `slot_*`, `episode_*`: timetable and episode control.
|
|
- `meal_*`: arrival-day meal plan.
|
|
- `loc_*`, `enter_room`, `present`, `companion_*`: traversal, room setup, and companion presence.
|
|
- `first_meeting(character)`, `reunion(character)`, `parting(character)`: contact-manager transitions for the one choice surface after a character first appears, reappears, or stops being present.
|
|
- `alone`, `alone_with(character)`: privacy checks for dialogue that should only surface without witnesses or with exactly one companion.
|
|
- `state_*`: ordered high-watermark encounter/progress state.
|
|
- `mark`, `has`, `lacks`: exact checklist facts.
|
|
- `tutorial`: returns true once and marks the tutorial as shown.
|
|
- `claim_choice_gate_if(gate, available)`: transient choice-surface arbitration. Use only to allow at most one valid choice from a prioritized family, especially `#auto` groups.
|
|
- `timer_start(timer_id, turns)`, `timer_due(timer_id)`, `timer_due_if(timer_id, available)`, `timer_claim(timer_id)`: named turn timers for delayed events.
|
|
- `rel_*`: relationship counters and two-value relationship-axis queries.
|
|
|
|
Relationship counters use only the standard value pairs declared in `characters.ink`:
|
|
|
|
- `agreeable` / `adversarial`
|
|
- `open` / `closed`
|
|
- `bold` / `passive`
|
|
- `reliable` / `unreliable`
|
|
- `insightful` / `dull`
|
|
|
|
Do not add per-character custom relationship dimensions. If a concept does not fit the shared matrix, express it in prose or in a semantic encounter `LIST`.
|
|
|
|
Relationship values are impressions held by the named character about Valerie. `viktor_insightful` means Viktor experiences Valerie as insightful. It does not mean Viktor behaves insightfully.
|
|
|
|
## Tracking Domains
|
|
|
|
Keep the four tracking domains strictly separated:
|
|
|
|
- Route heuristics are for Valerie's general reputation, repeated social pattern, and ending-state coloration. Use route counters and `route_*` query helpers for these.
|
|
- The character matrix is for what a named character thinks about Valerie and how that character reacts to her presence and actions. `viktor_insightful` means Viktor reads Valerie as insightful.
|
|
- Callbacks are for one concrete choice taken or one concrete piece of content shown. Use Ink's named option/gather/knot tracking with labels such as `(asked_viktor_role)` and conditions such as `{train_compartment_bucket.asked_viktor_role}`. Do not create `cb_*` facts for ordinary callbacks.
|
|
- Encounter state trackers are for off-screen plotlines, knowledge chains, reached goals, NPC actions, relationship progress along a plotline, and other one-way progress. They must never track that one piece of content has merely played.
|
|
|
|
Use a named Ink callback when later content remembers one concrete earlier choice:
|
|
|
|
```ink
|
|
* (send_carriage_ahead_from_village) [__Verfüge__: Die Kutsche mit Gepäck und Nachricht vorausschicken.]
|
|
...
|
|
|
|
* {village_detour_exits.send_carriage_ahead_from_village}
|
|
[__Erinnere__: An die vorausgeschickte Kutsche.]
|
|
```
|
|
|
|
Use a separate semantic `LIST` with the `state_*` helpers whenever a tracker expresses a linear process. This also applies to small two-state processes such as "begun" and "completed"; if completion implies beginning, it is a progress tracker, not a pair of loose facts.
|
|
|
|
Use `state_reach(first_state)` when authoring the moment that begins the chain. Use `state_reach_if_started(later_state)` when an action can advance or complete a chain only if the player has already begun that line. The helper checks the chain automatically, so authors do not need to write noisy paired conditions such as `{state_reached(start)} ~ state_reach(done)`.
|
|
|
|
Use `state_started(state)` and `state_unstarted(state)` only when content must explicitly test the null/non-null state of a progress chain. Do not add a fake `none` or `unknown` item to every list just to represent the empty state.
|
|
|
|
Prefer multiple small parallel progress trackers over one large state chain when that better matches the encounter. Inkle-style knowledge bases work by advancing separate knowledge/progress lines independently, then querying their combination in content.
|
|
|
|
Use exact facts through `mark`, `has`, and `lacks` only for coherent groups of independent facts that can be true separately and do not imply order, such as tutorial display or mutually exclusive outcome facts.
|
|
|
|
Never put mutually exclusive alternatives into one `state_reach` list. For example, `public_mask_established` and `public_mask_strained` are exact facts, not ordered states; reaching one must not imply the other.
|
|
|
|
Use route and relationship helpers only as heuristics. They should color tone, summaries, repeated social readings, and available flavor, not replace callbacks to exact choices.
|
|
|
|
## Choice-Surface Gates
|
|
|
|
`claim_choice_gate_if(gate, available)` returns true only for the first valid condition that claims the given gate while the current choice surface is being built. It is reset automatically at the start of `provide_choices`.
|
|
|
|
Use it when several choices can be valid at the same time but the surface must offer only one of them. The main use case is prioritized `#auto` families:
|
|
|
|
```ink
|
|
+ {claim_choice_gate_if(return_auto, reunion(viktor) && state_reached(freshen_up_done))}
|
|
[AUTO: Viktors Rückkehr nach Frischmachen] #auto
|
|
...
|
|
-> TURN
|
|
|
|
+ {claim_choice_gate_if(return_auto, reunion(viktor) && state_reached(explore_train_done))}
|
|
[AUTO: Viktors Rückkehr nach Erkundung] #auto
|
|
...
|
|
-> TURN
|
|
```
|
|
|
|
The source order of the atoms is the priority order. Auto choices are not randomized by the UI; the first ready auto choice is selected like a normal Ink choice. The gate prevents lower-priority choices from surfacing on the same choice build. Use `claim_choice_gate_if(gate, available)` when availability is conditional; pass the whole availability expression as the second parameter so false candidates cannot consume the gate.
|
|
|
|
Do not use gates as story memory. World state belongs in Ink callbacks, `state_*` progress chains, or `mark/has/lacks` facts.
|
|
|
|
## Character Contact Manager
|
|
|
|
Character contact is managed centrally in `helpers.ink`. Authors should not add room-specific flags such as `viktor_back_in_compartment`, `met_viktor_here`, or `seen_viktor_leave`. `loc_move_to(...)` updates the player location, moves active companions, and refreshes contact state.
|
|
|
|
Use:
|
|
|
|
```ink
|
|
{present(viktor)}
|
|
{first_meeting(viktor)}
|
|
{reunion(viktor)}
|
|
{parting(viktor)}
|
|
{alone()}
|
|
{alone_with(viktor)}
|
|
```
|
|
|
|
`first_meeting`, `reunion`, and `parting` are transition checks. They are true only for the first choice surface after the transition happened. The next turn clears them centrally in `provide_choices`. Authors must not call `contact_clear_transitions()` or any other cleanup helper from content. This makes the transitions suitable for immediate one-shot auto reactions:
|
|
|
|
```ink
|
|
+ {claim_choice_gate_if(return_auto, reunion(viktor) && state_reached(freshen_up_done))}
|
|
[AUTO: Viktors Rückkehr nach Frischmachen] #auto
|
|
...
|
|
-> TURN
|
|
```
|
|
|
|
Companions are characters in the `companions` list. When Valerie traverses with `loc_move_to(...)`, companions automatically move to the new location before contact is updated. `companion_join(character)` and `companion_leave(character)` only change whether a character follows Valerie; they are not story-memory flags. Character starting positions and initial companion state belong in episode setup. If several characters are placed manually before play resumes, call `contact_sync()` once after setup to establish contact without firing a meeting or reunion reaction.
|
|
|
|
Episode setup may install a companion transition bucket through `enter_episode(value, slot, start_bucket, end_bucket, episode_bucket, companion_transition_bucket)`. `enter_room(...)` plays this bucket centrally after movement and before room content is installed. Use it for prose that describes how current companions traverse with Valerie, so individual exits do not need companion boilerplate. Such buckets should usually contain only conditioned prose:
|
|
|
|
```ink
|
|
=== train_companion_transition_bucket ===
|
|
{
|
|
- accompanied_by(viktor):
|
|
{
|
|
- traversal_between(loc_train_home_corridor, loc_train_clergy_corridor):
|
|
Viktor folgt dir über die schwankende Verbindung.
|
|
}
|
|
}
|
|
->->
|
|
```
|
|
|
|
Use `traversal_from(location)`, `traversal_to(location)`, or `traversal_between(origin, destination)` only inside companion transition buckets. Normal room, episode, and game buckets should gate on `loc(...)`, `present(...)`, `reunion(...)`, and semantic story state instead.
|
|
|
|
## Room Look Lifecycle
|
|
|
|
Room look content belongs in the room's look bucket passed to `enter_room(...)`. The Ink room engine exposes that bucket only after Valerie has left a room and re-entered it. The look choice then disappears after it is used and becomes available again on the next re-entry.
|
|
|
|
Authors do not add flags for this and do not call a cleanup helper. The shared room engine compares the current room-entry turn with Ink's visit tracking for the active look bucket, so any look bucket selected during the current visit is automatically hidden until the room is re-entered. Room look choices must keep using the `#key:l` convention:
|
|
|
|
```ink
|
|
=== train_compartment_look ===
|
|
+ [__Schaue__: Im Abteil umher. #key:l]
|
|
...
|
|
-> TURN
|
|
-> DONE
|
|
```
|
|
|
|
## Turn Timers
|
|
|
|
Use named timers for delayed events that should advance when any ordinary choice is taken, including unrelated dynamic bucket content. Timer IDs are values in the global `Timer` LIST.
|
|
|
|
```ink
|
|
~ timer_start(train_lunch_order, 3)
|
|
|
|
+ {timer_due_if(train_lunch_order, state_between(lunch_ordered, lunch_served))}
|
|
[AUTO: Der Kellner bringt die Bestellung] #auto
|
|
~ state_reach(lunch_served)
|
|
-> TURN
|
|
```
|
|
|
|
`timer_start(timer_id, turns)` first removes that timer name from all countdown, ready, and claimed buckets, then starts it again. Durations `1..10` count later player choices; the choice that starts the timer does not consume one of those turns.
|
|
|
|
When a timer expires, it stays in the ready bucket until claimed. `timer_due(timer_id)` returns true if the timer is ready and moves it to claimed; further checks in the same turn still return true. Claimed timers are cleared automatically on the next `TURN`. Use `timer_due_if(timer_id, available)` when additional story conditions must be true before the timer is claimed.
|
|
|
|
If chosen content makes the delayed event happen early, use `timer_claim(timer_id)` as part of that event, not as cleanup:
|
|
|
|
```ink
|
|
+ {state_between(lunch_ordered, lunch_served)}
|
|
[__Warte__: Auf die Bestellung.]
|
|
~ timer_claim(train_lunch_order)
|
|
~ state_reach(lunch_served)
|
|
-> TURN
|
|
```
|
|
|
|
## Implemented Tag Forms
|
|
|
|
Use bracket tags for titles, filenames, and longer text:
|
|
|
|
```ink
|
|
#chapter[Eibenreith]
|
|
#image[statue.png](square)
|
|
#music[Kaiserpunk Waltz.mp3](crossfade, loop, lead=8)
|
|
#sfx[church-bells.ogg](max=8, fade)
|
|
#score[You reached an ending.]
|
|
#achievement[First Steps]
|
|
#alert[Try examining the room.]
|
|
```
|
|
|
|
Use colon tags for short identifiers, categories, and choice keys:
|
|
|
|
```ink
|
|
#action:movement
|
|
#key:l
|
|
#sort:last
|
|
#gated:noble
|
|
```
|
|
|
|
Bare flags are accepted as tags with no value:
|
|
|
|
```ink
|
|
#optional
|
|
#auto
|
|
```
|
|
|
|
## Right-Page Glossary Notes
|
|
|
|
Glossary notes are story tags scoped to the paragraph/block they belong to. They affect only the right-page story rendering, never choice text or command history.
|
|
|
|
```ink
|
|
The conductor points toward Eibenreith.
|
|
#gloss[Eibenreith](A fictional alpine town in the Kaiserpunk setting.)
|
|
```
|
|
|
|
The bracket value is the visible term to find. The parenthesized value is the note shown on hover/focus. The renderer marks every matching instance of the term in the same right-page block. The tag is not displayed and is not sent to TTS. Avoid raw Ink control characters in the explanation; `|`, `{`, and `}` must be escaped in Ink as `\|`, `\{`, and `\}` if they are needed literally.
|
|
|
|
## TTS Reading Instructions
|
|
|
|
TTS instruction tags are story tags scoped to the paragraph/block they belong to. They are not rendered, and they are only sent to TTS providers that support per-request reading instructions. Currently this means OpenAI with `gpt-4o-mini-tts`.
|
|
|
|
```ink
|
|
„Ich habe nichts gesehen“, sagt Viktor.
|
|
#tts[Read softly, with controlled unease.]
|
|
```
|
|
|
|
The default form omits a provider and is the preferred authoring style. Providers that support instructions may consume it; providers that do not support instructions silently ignore it. Provider-specific instructions are only needed when two providers should receive different direction, or when an instruction must be hidden from all but one provider. They use the tag parameter position:
|
|
|
|
```ink
|
|
„Ich habe nichts gesehen“, sagt Viktor.
|
|
#tts[openai](Read softly, with controlled unease.)
|
|
```
|
|
|
|
The shorthand `#tts-openai[...]` is also accepted. `#tts(...)` is equivalent to providerless `#tts[...]` if parentheses read better in a local context. `tts-1` and `tts-1-hd` ignore these instructions because the OpenAI speech endpoint only supports the `instructions` request parameter for `gpt-4o-mini-tts`.
|
|
|
|
Keep instructions short and describe performance rather than content. OpenAI's TTS guide recommends using `gpt-4o-mini-tts` when you need controllable delivery; useful instruction targets include tone, emotional range, intonation, speaking speed, accent, impressions, and whispering. Good examples:
|
|
|
|
```ink
|
|
#tts[Speak with restrained concern and a slower pace.]
|
|
#tts[Whisper the line with controlled urgency.]
|
|
#tts-openai[Use a dry, formal tone; avoid melodrama.]
|
|
```
|
|
|
|
Avoid repeating the full dialogue in the instruction. Put the words to be spoken in the story text, and use `#tts` only to describe how the provider should read that block.
|
|
|
|
## Choice Metadata
|
|
|
|
Choice tags are placed on the Ink choice they belong to:
|
|
|
|
```ink
|
|
* [__Schaue__: Aus dem Fenster.]
|
|
#action:orientation
|
|
#key:l
|
|
```
|
|
|
|
Implemented choice metadata:
|
|
|
|
- `#key:x`: reserves keyboard key `X` for the choice.
|
|
- `#letter[x]`: older equivalent for reserving keyboard key `X`.
|
|
- `#action:group` or `#action[group]`: assigns the choice to an invisible action group.
|
|
- `#auto`: hides the choice from the visible list and lets the UI choose it automatically when it is ready.
|
|
|
|
The current UI renders all non-auto choices in one visible list. Choices are first grouped by `#action` in the order each new action group appears in the authored choice list. Choices inside each group are randomized. Choices without `#action` form one final unlabelled group shown after all tagged groups. Explicit keys are assigned before automatic keys; choices without explicit keys receive `1` through `0`, then `A` through `Z` in final visible order while skipping explicit keys. `#optional` choices are displayed italic. Grouping columns, `#gated[...]`, and `#sort[...]` are documented authoring conventions or future metadata, not fully implemented UI behavior yet.
|
|
|
|
Auto choices are ordinary Ink choices with a developer-facing choice text in `[...]`. The UI does not show that text in normal play, but Inky needs it for local testing and the text makes the source readable. Ink owns availability and once-only behavior; the UI owns automatic selection and timing. Supported forms:
|
|
|
|
```ink
|
|
* {condition}
|
|
[AUTO: Ereignisname] #auto
|
|
-> event
|
|
|
|
* {condition}
|
|
[AUTO: Ereignisname] #auto(2)
|
|
-> event
|
|
|
|
* {condition}
|
|
[AUTO: Ereignisname] #auto:tunnel(2)
|
|
-> event
|
|
```
|
|
|
|
`#auto` fires as soon as it is the first ready auto choice. `#auto(2)` waits at least two UI choice turns since the previous global auto trigger. `#auto:keyword(2)` waits only against the same keyword, so unrelated auto groups do not throttle each other. Use the global form when two different authored events must not fire immediately after each other. Use the colon form for keyed auto tags on choice lines.
|
|
|
|
## Popup And End-State Tags
|
|
|
|
These tags may appear as Ink global tags, paragraph tags, or empty tag-only lines. They are dispatched through the same tag channel as media tags.
|
|
|
|
```ink
|
|
#score[You reached the quiet ending.]
|
|
#error[The story ended unexpectedly.]
|
|
#achievement[First Steps]
|
|
#alert[Try examining objects before using them.]
|
|
```
|
|
|
|
- `#score[...]`: intended ending. When the turn reaches `inputMode: end`, the UI shows a localized ending popup with the tag value as the optional message.
|
|
- `#error[...]`: unrecoverable ending. The UI shows a localized error popup with the tag value as the optional message. The Ink engine emits this automatically if Ink runs out of content without an explicit `#score[...]` or `#error[...]`.
|
|
- `#achievement[...]`: queued localized achievement popup while the game continues.
|
|
- `#alert[...]`: queued localized player hint/tutorial popup while the game continues.
|
|
|
|
## Existing Media And Structure Tags
|
|
|
|
```ink
|
|
#chapter[Title]
|
|
#section
|
|
#textblock
|
|
#image[filename.png](landscape)
|
|
#image[filename.png](portrait pause=2)
|
|
#image[filename.png](square lead=1.5)
|
|
#music[track.mp3](crossfade, loop, lead=4)
|
|
#sfx[file.ogg](max=8 fade fade-duration=2)
|
|
```
|
|
|
|
Asset filenames resolve relative to the configured image, music, and sound folders.
|