# pncdsl — reference manual

A point-and-click adventure game framework for Go, built on top of
[Ebitengine](https://ebitengine.org/). Games are composed by registering
plain struct literals into per-entity `*Manager` registries hanging off a
single `*Game` root. The library handles input, rendering, dialog trees,
cutscenes, HUD, themes and lazy asset loading; the domain only declares
content.

This document is the full library reference. For a 5-minute tour of the
shipped demo, see [`DEMO.md`](DEMO.md).

---

## Table of contents

1. [Introduction](#1-introduction)
2. [Quick start](#2-quick-start)
3. [The Manager pattern](#3-the-manager-pattern)
4. [The Game aggregate](#4-the-game-aggregate)
5. [Geometry primitives](#5-geometry-primitives)
6. [Entity reference](#6-entity-reference)
   - 6.1 [Asset](#61-asset)
   - 6.2 [Scene](#62-scene)
   - 6.3 [Hotspot](#63-hotspot)
   - 6.4 [Trigger](#64-trigger)
   - 6.5 [Item](#65-item)
   - 6.6 [Inventory](#66-inventory)
   - 6.7 [Character](#67-character)
   - 6.8 [Dialogue](#68-dialogue)
   - 6.9 [Script](#69-script)
   - 6.10 [Verb](#610-verb)
7. [The action system](#7-the-action-system)
   - 7.1 [Action and Runner](#71-action-and-runner)
   - 7.2 [Status and Ctx](#72-status-and-ctx)
   - 7.3 [Built-in actions](#73-built-in-actions)
   - 7.4 [Custom actions](#74-custom-actions)
8. [Conditions](#8-conditions)
9. [World state](#9-world-state)
10. [The widget system](#10-the-widget-system)
    - 10.1 [Widget interface](#101-widget-interface)
    - 10.2 [Z-order and input consumption](#102-z-order-and-input-consumption)
    - 10.3 [Built-in widgets](#103-built-in-widgets)
    - 10.4 [The clickBlocker contract](#104-the-clickblocker-contract)
    - 10.5 [Registration helpers](#105-registration-helpers)
    - 10.6 [Custom widgets](#106-custom-widgets)
11. [Themes](#11-themes)
12. [Asset pipeline](#12-asset-pipeline)
13. [Audio](#13-audio)
14. [Input](#14-input)
15. [The game loop](#15-the-game-loop)
16. [Scene transitions](#16-scene-transitions)
17. [Validation](#17-validation)
18. [Save and load](#18-save-and-load)
19. [Errors](#19-errors)
20. [Testing](#20-testing)
21. [Project layout](#21-project-layout)
22. [License](#22-license)

---

## 1. Introduction

`pncdsl` is **not** a generic engine — it is a thin Go-level DSL layer on
top of Ebitengine that implements the fixed skeleton of the point-and-click
adventure genre as it existed in the LucasArts SCUMM era (Maniac Mansion,
Monkey Island, Day of the Tentacle):

- scenes with hotspots and click zones,
- a verb-based interaction grammar (Look, Use, Talk, Take, …),
- inventory, item-on-item and item-on-hotspot combinations,
- dialog trees with conditional choices,
- a scripting / cutscene system built from composable `Action` values,
- a widget-tree HUD that you can extend with anything Ebitengine can draw,
- swappable color themes,
- placeholder graphics so the game runs without any art on disk.

The design collapses to **one organising pattern**: every named entity is
registered via `Manager.Register(Entity{Name: "..."})`. No builders, no
fluent chains, no scattered registries. The same mental model applies to
items, scenes, characters, dialogues, scripts, assets, verbs, widgets and
themes alike.

A complete example game ("Morning Coffee") lives in `domain/` and is
described in [`DEMO.md`](DEMO.md).

---

## 2. Quick start

**Prerequisites.** Go 1.24+ (for generic type aliases) and a working OpenGL
context. Ebitengine handles windowing and the render loop.

**Run the bundled demo:**

```bash
git clone <repo>
cd pncdsl
go run .
```

A window opens at 1280×800 (the library uses a 320×200 internal resolution
and Ebitengine scales it 4×). No asset files are required — placeholders
are generated for anything missing under `assets/`. Press **F1** at any
time to toggle the hotspot debug overlay.

**Minimum game:**

```go
package main

import (
    "log"
    "pncdsl/pncdsl"
)

func main() {
    g := pncdsl.NewGame("Sample", 320, 200)

    g.AssetManager.Register(pncdsl.Asset{
        Name: "bg/room", Path: "assets/bg/room.png", Kind: pncdsl.AssetImage,
    })
    g.CharacterManager.Register(pncdsl.Character{Name: "player", W: 28, H: 62})
    g.SceneManager.Register(pncdsl.Scene{
        Name:       "room",
        Background: "bg/room",
        Actors:     []pncdsl.SceneActor{{CharacterName: "player", At: pncdsl.Point{X: 160, Y: 140}}},
        Hotspots: []pncdsl.Hotspot{{
            Name:   "door",
            Area:   pncdsl.Rect(260, 50, 40, 90),
            Label:  "door",
            OnLook: pncdsl.Say("player", "A wooden door."),
        }},
    })

    g.StartAt("room")
    pncdsl.RegisterDefaultUI(g) // optional; auto-called by Run if no widgets registered
    if err := pncdsl.Run(g); err != nil {
        log.Fatal(err)
    }
}
```

---

## 3. The Manager pattern

Every name-addressable entity goes through one shared generic type:

```go
// pncdsl/core.manager.go

type Named interface {
    GetName() string
}

type Manager[T Named] struct { /* ... */ }
```

Each entity kind has its own alias (generic type alias, Go 1.24+):

```go
type ItemManager       = Manager[Item]
type SceneManager      = Manager[Scene]
type CharacterManager  = Manager[Character]
type DialogueManager   = Manager[Dialogue]
type ScriptManager     = Manager[Script]
type AssetManager      = Manager[Asset]
type VerbManager       = Manager[Verb]
type UIManager         = Manager[Widget]
type ThemeManager      = Manager[Theme]
```

### 3.1 Public API

| Method                 | Behaviour                                                  |
|------------------------|------------------------------------------------------------|
| `Register(v T)`        | Adds `v` to the registry. Panics on empty or duplicate `Name`. |
| `Get(name) (T, bool)`  | Looks up by name. The boolean is `false` if absent.        |
| `MustGet(name) T`      | Same as `Get`, but panics on missing names.                |
| `Has(name) bool`       | True if the name is registered.                            |
| `Len() int`            | Number of registered entries.                              |
| `Names() []string`     | Returns names in **insertion order** (used for widget Z-order). |
| `SortedNames() []string` | Returns names alphabetically.                            |
| `Each(fn func(T))`     | Iterates in insertion order.                               |
| `Remove(name string)`  | Drops a registration; silent no-op if unknown.             |

### 3.2 The `Named` contract

Every registerable struct implements `GetName() string`. Most also
implement an optional `TypeLabel() string` that the manager uses in panic
messages for clearer errors:

```go
func (i Item) GetName() string   { return i.Name }
func (i Item) TypeLabel() string { return "item" }
```

### 3.3 Why panic on duplicates?

A duplicate registration is a construction-time bug — code that ran once
on startup. Crashing loudly during `Build()` surfaces the problem in
development; quietly accepting the second registration would silently mask
shadowed entities at runtime.

---

## 4. The Game aggregate

```go
// pncdsl/core.game.go

type Game struct {
    Title         string
    Width, Height int

    // entity managers
    ItemManager      *ItemManager
    SceneManager     *SceneManager
    CharacterManager *CharacterManager
    DialogueManager  *DialogueManager
    ScriptManager    *ScriptManager
    AssetManager     *AssetManager
    VerbManager      *VerbManager
    UIManager        *UIManager
    ThemeManager     *ThemeManager

    // runtime services
    State     *State
    Inventory *Inventory
    Audio     *AudioPlayer
    Camera    *Camera
    Input     *Input

    // log buffer
    MaxLogLines int
    /* unexported runtime fields */
}
```

### 4.1 Construction

```go
func NewGame(title string, w, h int) *Game
```

Initialises every manager (empty), registers the default SCUMM verb set
(`look`, `use`, `talk`, `take`), installs all four preset themes, and
selects `classic-scumm` as the active theme. Widgets are **not**
auto-registered — call `RegisterDefaultUI(g)` (or one of its siblings)
explicitly, or let `Run` install the default set if `UIManager` is empty.

### 4.2 Lifecycle hooks

```go
func (g *Game) StartAt(name string) *Game     // entry scene
func (g *Game) OnStart(a Action) *Game        // action run after the scene's OnEnter
func (g *Game) Validate() error               // cross-check name references
func (g *Game) Run() error                    // same as pncdsl.Run(g)
```

`StartAt` and `OnStart` return `*Game` so they chain at the end of `Build`.

### 4.3 Theme accessors

```go
func (g *Game) Theme() Theme                  // active theme value
func (g *Game) UseTheme(name string)          // switch theme (runtime ok)
```

The active theme is addressed by name, not by pointer — switching is just
`g.UseTheme("paper-notebook")` and the next frame uses the new colors.

### 4.4 UI runtime helpers

The current `selectedVerb`, `hoverLabel`, speech and flash message live on
`Game`. They are written by actions / the engine, read by widgets:

```go
func (g *Game) SelectedVerb() string
func (g *Game) SetSelectedVerb(s string)

func (g *Game) HoverLabel() string            // raw target label
func (g *Game) SetHoverLabel(s string)
func (g *Game) HoverHint() string             // composed "verb + target"

func (g *Game) SetSpeech(speaker, text string)
func (g *Game) ClearSpeech()

func (g *Game) FlashLine(text string)         // ~2s status banner
```

### 4.5 Chat-log API

The engine and the `Say` action push entries here; the `ChatLog` widget
reads them. Set `Game.MaxLogLines` to bound the ring buffer (0 = unbounded).

```go
type LogKind int
const (
    LogAction   // "> look at door" lines (player commands)
    LogResponse // in-world replies / Say output
    LogSystem   // game/system meta
)

type LogMessage struct {
    Speaker string
    Text    string
    Kind    LogKind
}

func (g *Game) LogAction(text string)
func (g *Game) LogResponse(speaker, text string)
func (g *Game) LogSystem(text string)
func (g *Game) Messages() []LogMessage
```

### 4.6 Scene helpers

```go
func (g *Game) CharacterInScene(name string) bool
```

Used by `CharacterPanel` to auto-hide when its character isn't an actor in
the current scene.

### 4.7 Text drawing hook

```go
func (g *Game) DrawText(dst *ebiten.Image, s string, x, y int, c color.Color)
```

Single indirection so widgets can hand a `color.Color` today and the
library can swap to `text/v2` later without changing call sites.

---

## 5. Geometry primitives

```go
// pncdsl/util.geometry.go

type Point struct{ X, Y float64 }

func (p Point) Add(q Point) Point
func (p Point) Sub(q Point) Point
func (p Point) Dist(q Point) float64

type Shape interface {
    Contains(p Point) bool
    Bounds() Rectangle
}

type Rectangle struct{ X, Y, W, H float64 }

func Rect(x, y, w, h float64) Rectangle
func (r Rectangle) Contains(p Point) bool
func (r Rectangle) Bounds() Rectangle
func (r Rectangle) Center() Point

type Polygon struct{ Points []Point }

func Poly(pts ...Point) Polygon
func (g Polygon) Contains(pt Point) bool      // ray-casting
func (g Polygon) Bounds() Rectangle           // axis-aligned bbox
```

Both `Rectangle` and `Polygon` satisfy `Shape` and can be used as a
`Hotspot.Area`. Internally, hotspots are hit-tested against `Shape.Contains`
each frame.

---

## 6. Entity reference

### 6.1 Asset

```go
// pncdsl/asset.def.go

type AssetKind int
const (
    AssetImage AssetKind = iota
    AssetAudio
    AssetFont
)

type Asset struct {
    Name string  // logical id used everywhere ("bg/kitchen")
    Path string  // disk path ("assets/bg/kitchen.png")
    Kind AssetKind
}
```

Assets are **lazy**: `Register` only stores the spec. The file is opened on
the first `loadedAssets.image(...)` request. Missing files fall back to a
deterministic colored placeholder so the game runs without any art —
useful for prototyping or CI.

```go
g.AssetManager.Register(pncdsl.Asset{
    Name: "bg/kitchen",
    Path: "assets/bg/kitchen.png",
    Kind: pncdsl.AssetImage,
})
```

### 6.2 Scene

```go
// pncdsl/scene.def.go

type Scene struct {
    Name       string
    Title      string         // optional human-readable name (TopBar widget reads this)
    Background string         // Asset.Name
    Music      string         // Asset.Name (optional)
    Hotspots   []Hotspot
    Walkboxes  []Polygon
    Triggers   []Trigger
    Actors     []SceneActor
    OnEnter    Action
    OnLeave    Action
}

type SceneActor struct {
    CharacterName string
    At            Point
}
```

`OnEnter` is queued the moment the scene becomes current (after a fade-in);
`OnLeave` runs on the transition out. `Actors` lists which registered
characters are placed in the scene and where their feet start.

`Walkboxes` is reserved for pathfinding (currently a stub — characters
walk in a straight line). `Triggers` is also a stub at this milestone.

### 6.3 Hotspot

```go
// pncdsl/scene.hotspot.go

type Hotspot struct {
    Name   string
    Area   Shape           // Rectangle or Polygon
    Label  string          // hover hint
    Cursor CursorKind

    // built-in verb handlers
    OnLook Action
    OnUse  Action
    OnTalk Action
    OnTake Action
    OnGive Action

    // when the player has an item selected and clicks this hotspot,
    // the engine looks up the item's Name here first.
    OnUseWith map[string]Action

    // custom verbs registered through g.VerbManager.
    OnVerb map[string]Action
}

type CursorKind int
const (
    CursorDefault CursorKind = iota
    CursorLook
    CursorUse
    CursorTalk
    CursorTake
    CursorExit
)
```

Hotspots are children of `Scene` — not their own manager. The engine
resolves a click via `hotspot.handler(verbName)` which maps the four
built-in verbs to their `On*` fields and falls back to `OnVerb[verbName]`
for custom verbs.

### 6.4 Trigger

```go
// pncdsl/scene.trigger.go

type Trigger struct {
    Name string
    When Condition
    Do   Action
    Once bool
}
```

Reserved for "fire when condition becomes true after scene enter" — the
struct exists so domain code can declare them, but the engine doesn't
sweep triggers yet.

### 6.5 Item

```go
// pncdsl/item.def.go

type Item struct {
    Name        string
    Sprite      string                  // Asset.Name
    Description string

    OnUseSelf Action
    // targetName → action. Target can be a hotspot Name or another item Name.
    OnUseWith map[string]Action
}
```

Items live in the `ItemManager`. When the player picks one up
(`Give("key")`) the item is appended to the `Inventory`. Clicking a
hotspot with an item selected resolves the action via, in order:
1. `hotspot.OnUseWith[item.Name]`
2. `item.OnUseWith[hotspot.Name]`
3. Otherwise the engine flashes "Nem ehhez." and deselects.

### 6.6 Inventory

```go
// pncdsl/item.inventory.go

type Inventory struct{ /* unexported */ }

func NewInventory() *Inventory
func (i *Inventory) Add(name string)
func (i *Inventory) Remove(name string)
func (i *Inventory) Has(name string) bool
func (i *Inventory) Select(name string)        // "" clears
func (i *Inventory) Selected() string
func (i *Inventory) Items() []string           // copy
```

Not a manager — pure runtime state owned by `Game`. Mutated by actions
(`Give`, `TakeAway`) and by the `InventoryBar` widget on click.

### 6.7 Character

```go
// pncdsl/actor.def.go

type Character struct {
    Name        string
    Sprite      string                       // Asset.Name (placeholder if missing)
    Animations  map[string]AnimationClip
    Speed       float64
    SpeechColor color.Color
    Start       Point
    W, H        float64                      // size hint for placeholder
}

type AnimationClip struct {
    Frames    []Rectangle
    FrameTime float64
    Loop      bool
}
```

When no real sprite is on disk, `drawCharacter` falls back to a stylised
placeholder: humanoid if `W < H`, quadruped otherwise. `SpeechColor` (if
an `RGBA`) colours both the speech-bubble text and the placeholder body.

Movement is driven by the `Walk` action and `Game.tickCharacters`
(straight-line stepping at `Speed` pixels/sec, default 60).

### 6.8 Dialogue

```go
// pncdsl/dialog.def.go

type Dialogue struct {
    Name  string
    Start string                        // initial node Name; "" = first in slice
    Nodes []DialogueNode
}

type DialogueNode struct {
    Name    string
    Lines   []DialogueLine              // shown one-at-a-time on click
    Choices []DialogueChoice
}

type DialogueLine struct {
    Speaker string                      // Character.Name
    Text    string
}

type DialogueChoice struct {
    Text    string
    Show    Condition                   // nil = always visible
    Once    bool                        // hide after first pick
    Actions []Action                    // run when chosen
}

func (d Dialogue) Node(name string) (DialogueNode, bool)
```

The dialog flow:
1. `RunDialogue("name")` action starts the dialogue; the runner keeps
   returning `StatusRunning` until the dialog closes.
2. The `DialogBox` widget renders lines one at a time; click advances.
3. After the last line, visible `Choices` are shown.
4. Picking a choice runs `Seq(choice.Actions...)`. `Once` choices remember
   that they fired via `State.NoteTalked`.
5. `EndDialogue()` closes the conversation; `GotoNode("other")` jumps to
   another node in the same dialogue.

### 6.9 Script

```go
// pncdsl/action.script.go

type Script struct {
    Name    string
    Actions Action                      // typically Seq(...)
}
```

A `Script` is just a named composite action — useful when you want to
reuse a cutscene (intro, victory, transition) from multiple call sites.
Fire one with `RunScript("name")`.

### 6.10 Verb

```go
// pncdsl/ui.verb.go

type Verb struct {
    Name    string                      // canonical id, e.g. "look"
    Label   string                      // display label, e.g. "Nézd"
    Default Action                      // run when a hotspot has no handler
}
```

`NewGame` pre-registers the four SCUMM verbs (`look`, `use`, `talk`,
`take`). Add your own:

```go
g.VerbManager.Register(pncdsl.Verb{
    Name: "push", Label: "Lökd",
    Default: pncdsl.Say("player", "Nem mozdul."),
})
```

The verb-bar and verb-wheel widgets read `VerbManager.Names()` every
frame, so adding a verb at runtime is enough to make it appear.

---

## 7. The action system

### 7.1 Action and Runner

```go
// pncdsl/action.def.go

type Action interface {
    Start() Runner
}

type Runner interface {
    Tick(ctx *Ctx) Status
}
```

An `Action` is an **immutable spec** of work — safe to store in a
`Hotspot.OnLook` field and use across many clicks. A `Runner` is one
in-flight execution, created by `Start()`, carrying state (elapsed time,
sub-runners, started-flag). The engine never ticks an `Action` directly;
it always asks for a fresh `Runner` per invocation.

Only one runner is active at a time (the "script slot"). New action
queues issued while one is running are dropped (logged at debug level).
A multi-actor scene can still run parallel work via `Par(...)` inside a
single composite action.

### 7.2 Status and Ctx

```go
type Status int
const (
    StatusRunning Status = iota
    StatusDone
    StatusFailed
)

type Ctx struct {
    Game    *Game
    DT      float64                     // seconds since last tick
    Scene   *Scene                      // current scene (may be nil)
    Hotspot *Hotspot                    // populated only for action contexts where applicable
    Item    *Item
}
```

`Status` controls how the parent runner reacts: `StatusDone` advances a
`Seq` to the next step, `StatusFailed` short-circuits the whole branch
(`RequireItem` uses this to abort a sequence when the item is absent).

### 7.3 Built-in actions

| Constructor                              | Behaviour                                                                 |
|------------------------------------------|---------------------------------------------------------------------------|
| `Seq(a ...Action) Action`                | Run children in order. Nested `Seq` are flattened. `nil` children are ignored. |
| `Par(a ...Action) Action`                | Run children simultaneously; completes when every child reports `Done`.   |
| `If(c Condition, then Action, els ...Action) Action` | Run `then` if `c` evaluates true; otherwise `Seq(els...)` (if any).|
| `Wait(seconds float64) Action`           | Block the runner for `seconds`.                                            |
| `Say(speaker, text string) Action`       | Show the line above the speaker (`SpeechBubble`), append to chat-log. Click-to-skip. Duration scales with text length, 1.2s floor. |
| `GoTo(scene string) Action`              | Switch the current scene via a fade transition.                            |
| `Walk(character string, to Point) Action`| Move a character to `to` at `Character.Speed`. Returns when arrived.       |
| `Give(item string) Action`               | Add `item` to inventory.                                                   |
| `TakeAway(item string) Action`           | Remove `item` from inventory.                                              |
| `RequireItem(item string) Action`        | If the item is not in inventory, flash "Ehhez kell egy …" and fail.        |
| `SetFlag(name string) Action`            | `State.SetFlag(name)`.                                                     |
| `ClearFlag(name string) Action`          | `State.ClearFlag(name)`.                                                   |
| `SetVar(name string, v any) Action`      | `State.SetVar(name, v)`.                                                   |
| `PlayMusic(asset string) Action`         | Hand off to `AudioPlayer.PlayMusic`. (Currently logged only.)              |
| `StopMusic() Action`                     | `AudioPlayer.StopMusic`.                                                   |
| `PlaySound(asset string) Action`         | `AudioPlayer.PlaySound`.                                                   |
| `RunDialogue(name string) Action`        | Start a registered `Dialogue`; runner blocks until the dialog closes.       |
| `EndDialogue() Action`                   | Close the active dialog.                                                   |
| `GotoNode(node string) Action`           | Jump to a node within the active dialog.                                   |
| `RunScript(name string) Action`          | Run a registered `Script`'s `Actions`.                                     |
| `ShowEnd(text string) Action`            | Display the end-card overlay; runner never finishes.                       |
| `Custom(fn func(*Ctx) Status) Action`    | Escape hatch — wrap an arbitrary callback as an action.                    |

### 7.4 Custom actions

Implement `Action` and `Runner` directly when you need long-running state
(an animation, a tween, an HTTP poll):

```go
type fadeAction struct{ target string; duration float64 }

func (a *fadeAction) Start() Runner { return &fadeRunner{spec: a} }

type fadeRunner struct {
    spec    *fadeAction
    elapsed float64
}

func (r *fadeRunner) Tick(ctx *Ctx) Status {
    r.elapsed += ctx.DT
    if r.elapsed >= r.spec.duration {
        return StatusDone
    }
    // mutate state, draw a fade, etc.
    return StatusRunning
}
```

For one-shot mutations, `Custom` is shorter:

```go
bumpScore := pncdsl.Custom(func(ctx *pncdsl.Ctx) pncdsl.Status {
    cur := ctx.Game.State.Var("score").(int)
    ctx.Game.State.SetVar("score", cur+10)
    return pncdsl.StatusDone
})
```

---

## 8. Conditions

```go
// pncdsl/action.condition.go

type Condition interface {
    Eval(ctx *Ctx) bool
}
```

| Constructor                            | Meaning                                          |
|----------------------------------------|--------------------------------------------------|
| `Not(c Condition) Condition`           | `!c`                                             |
| `And(cs ...Condition) Condition`       | All must evaluate true.                          |
| `Or(cs ...Condition) Condition`        | At least one true.                               |
| `Flag(name string) Condition`          | `State.Flag(name)`                               |
| `HasItem(name string) Condition`       | `Inventory.Has(name)`                            |
| `SelectedItem(name string) Condition`  | `Inventory.Selected() == name`                   |
| `InScene(name string) Condition`       | The current `Ctx.Scene` matches.                 |
| `VarEq(name string, v any) Condition`  | `State.Var(name) == v` (Go equality).            |

Conditions are stateless — they are evaluated lazily, including inside
`If` and `DialogueChoice.Show`.

---

## 9. World state

```go
// pncdsl/state.def.go

type State struct{ /* unexported */ }

func NewState() *State

func (s *State) Flag(name string) bool
func (s *State) SetFlag(name string)
func (s *State) ClearFlag(name string)

func (s *State) Var(name string) any
func (s *State) SetVar(name string, v any)

func (s *State) Visited(name string) int
func (s *State) NoteVisit(name string)             // engine bumps on scene enter

func (s *State) Talked(node string) int
func (s *State) NoteTalked(node string)            // DialogBox bumps on Once-choice pick
```

`Flag`/`SetFlag` are the workhorse for puzzle state ("cupboard_open").
`SetVar` accepts any value — strings for text, numbers for scores, struct
references for richer dashboards. The `TopBar` widget reads vars by name
to render score / time.

`Visited` and `Talked` underpin the "Once" semantics in dialogues and
could be used by triggers once those land.

---

## 10. The widget system

The HUD is a tree of `Widget`s, each registered into `g.UIManager`. The
library ships built-in widgets for every classic adventure UI piece;
domain code can register arbitrary new ones — chat panels, minimaps,
hotbars — without touching the library.

### 10.1 Widget interface

```go
// pncdsl/ui.widget.go

type Widget interface {
    Named
    Tick(ctx *UICtx)
    Draw(dst *ebiten.Image, ctx *UICtx)
}

type UICtx struct {
    Game *Game
    DT   float64
}

type MouseButton int
const (
    MouseButtonLeft MouseButton = iota
    MouseButtonRight
)

type Size struct{ W, H int }

type Align int
const (
    AlignLeft Align = iota
    AlignCenter
    AlignRight
)
```

A widget is responsible for its own input handling, hit-testing, state
and rendering. The library does not impose a layout layer — widgets carry
their own `Bounds` (or compute them dynamically, like `RadialVerbs`).

### 10.2 Z-order and input consumption

- **`Tick` runs in reverse registration order.** Widgets registered later
  (drawn on top) get the click first. Each widget calls
  `ctx.Game.Input.ConsumeLeft()` / `ConsumeRight()` to claim the event;
  later widgets see `LeftClicked() == false`.
- **`Draw` runs in registration order** — registered last → painted on top.
- The `Cursor` widget is registered last by convention so it always wins
  on visual layer (and effectively never claims clicks).

After all widgets ticked, the engine offers the (possibly consumed) click
to `handleSceneInput`, which is where hotspot interactions live. If a
widget already consumed the click, `handleSceneInput` becomes a no-op for
that frame.

### 10.3 Built-in widgets

Each widget lives in its own `pncdsl/ui.<name>.go` file. Fields with a
zero value fall back to a sensible default.

#### `Panel` (`ui.panel.go`)

A generic colored rectangle, optionally with a stroke border. Useful as a
backdrop for grouping other widgets.

```go
type Panel struct {
    Name        string
    Bounds      Rectangle
    BG          color.Color    // nil → Theme.PanelBG
    Border      int            // 0 = no border
    BorderColor color.Color    // nil → Theme.DialogBorder
}
```

#### `StatusLine` (`ui.status.go`)

A single line at `Y`. Shows the active flash message (if any) — set by
`Game.FlashLine` — otherwise the composed hover hint (`verb + target` or
`verb + item + target`).

```go
type StatusLine struct {
    Name        string
    Y           int
    Align       Align          // default AlignCenter
    ScreenWidth int            // 0 → Game.Width
}
```

#### `VerbBar` (`ui.verb_bar.go`)

The SCUMM permanent verb panel. Reads `VerbManager.Names()` every frame
and lays the labels into a `Cols × ceil(N/Cols)` grid.

```go
type VerbBar struct {
    Name    string
    Origin  Point
    Cols    int               // default 2
    Button  Size
    Gap     Point
    PanelBG bool              // draw a Theme.PanelBG backdrop behind buttons
}
```

Click on a button → `Game.SetSelectedVerb(name)`. Implements
`BlocksClickAt` so `RadialVerbs` does not pop up over it.

#### `RadialVerbs` (`ui.verb_radial.go`)

Verb-coin or permanent verb-wheel.

```go
type RadialVerbs struct {
    Name    string
    Trigger MouseButton                  // default MouseButtonRight
    Radius  float64                       // default 40

    // AlwaysVisible turns the widget into a permanent wheel at Center.
    // Trigger is ignored in this mode; left-click picks a slice.
    AlwaysVisible bool
    Center        Point

    // Override the rendered label per verb name (e.g. shorten "Használd"
    // → "Tedd" so it fits inside the disk).
    Labels map[string]string
}
```

In **trigger mode** (default), the wheel is invisible until the trigger
mouse button is pressed somewhere not covered by a `clickBlocker`. The
center latches to the click point. Left-click on a slice picks the verb;
trigger-click again closes without picking.

In **always-visible mode**, the wheel is drawn permanently at `Center`
and left-click on a slice picks the verb. Labels are positioned at
`0.62 × Radius` from the center so short overrides stay inside the rim.

#### `InventoryBar` (`ui.inventory.go`)

Item slots in a grid. Hover sets `Game.SetHoverLabel(item.Description)`;
click toggles selection (or, under the `look` verb, runs a `Say` with
the item description).

```go
type InventoryBar struct {
    Name     string
    Origin   Point
    Slots    int             // total slot count
    Cols     int             // 0 → Slots (single row)
    SlotSize int
    Gap      int
    PanelBG  bool
}
```

Implements `BlocksClickAt` covering the slot grid.

#### `SpeechBubble` (`ui.speech.go`)

Renders whatever `Game.SetSpeech(...)` last set, positioned above the
speaker's head (`character.pos.Y − character.def.H − OffsetY`). Falls back
to a screen-top position if the speaker is unknown.

```go
type SpeechBubble struct {
    Name      string
    MaxWidth  int                // wrap width, default 200
    Padding   int                // bubble padding, default 3
    OffsetY   int                // vertical lift above head
    FallbackY int                // anchor Y when speaker unknown
}
```

Text color follows `Character.SpeechColor` when set, otherwise
`Theme.SpeechDefaultText`.

#### `DialogBox` (`ui.dialog_box.go`)

Dormant unless `Game.dialogueActive()`. While active, **consumes every
click** — the rest of the HUD and the scene are inert. The widget renders
either the current line of `runtimeDialog.Node.Lines` (click → next line)
or the visible `Choices` once the lines are exhausted.

```go
type DialogBox struct {
    Name       string
    Bounds     Rectangle      // default Rect(0, 140, Width, 60)
    LineHeight int            // default 14
    Padding    int            // default 6
}
```

Hovered choices use `Theme.DialogChoiceHover`. Once-only choices are
omitted automatically after their first pick (tracked by
`State.NoteTalked`).

#### `EndCard` (`ui.end_card.go`)

A fullscreen overlay shown when `Game.showEndCard(text)` is called (which
the `ShowEnd` action does). Consumes every click so nothing underneath
reacts.

```go
type EndCard struct{ Name string }
```

#### `Cursor` (`ui.cursor.go`)

The crosshair cursor (color from `Theme.CursorColor`). When the inventory
has a selected item with a non-placeholder sprite, that sprite is drawn
at the cursor position instead.

```go
type Cursor struct{ Name string }
```

#### `HotspotDebug` (`ui.hotspot_debug.go`)

Stroke-outlines every hotspot in the current scene. F1 toggles it at
runtime (override via `ToggleKey`).

```go
type HotspotDebug struct {
    Name      string
    Enabled   bool
    ToggleKey ebiten.Key       // 0 → ebiten.KeyF1
}
```

#### `TopBar` (`ui.top_bar.go`)

A horizontal strip across the top with three sections:

- **Left:** `LeftText` override or `Scene.Title` (falling back to `Name`).
- **Center:** score string built from `State.Var(ScoreVar)`. With
  `ScoreMax > 0`, formatted as `"Score: X/MAX"`, else `"Score: X"`.
- **Right:** time string from `State.Var(TimeVar)`.

```go
type TopBar struct {
    Name     string
    Height   int                  // default 12
    LeftText string                // overrides scene title
    ScoreVar string                // State.Var key; "" = no score
    ScoreMax int
    TimeVar  string                // State.Var key; "" = no time
}
```

Empty fields are skipped, so the widget gracefully degrades to two- or
one-section layouts.

#### `CharacterPanel` (`ui.character_panel.go`)

A floating info card showing one character's portrait, role badge and
custom stat rows. **Auto-hides** when the character isn't an actor in the
current scene — same registration works across all scenes.

```go
type CharStat struct {
    Label  string
    VarKey string                  // State.Var(VarKey) is fmt-printed
}

type CharacterPanel struct {
    Name      string
    Bounds    Rectangle
    Character string                // Character.Name
    Title     string                // role badge ("PLAYER", "NPC", "GUIDE", …)
    Stats     []CharStat
}
```

The portrait reuses the placeholder rendering when no sprite is on disk
(humanoid vs. quadruped picked from `W` vs. `H`).

#### `ChatLog` (`ui.chat_log.go`)

Renders the `Game.Messages()` ring buffer with per-kind colors —
`LogAction` lines use `Theme.ChatLogPrompt`, `LogResponse` uses
`Theme.ChatLogResponse`, `LogSystem` uses `Theme.ChatLogSystem`. Newest
message at the bottom; older messages scroll out of view as the buffer
fills.

```go
type ChatLog struct {
    Name       string
    Bounds     Rectangle
    LineHeight int                 // default 10
    Padding    int                 // default 3
    ShowBorder bool                // draws a 1px border in CharacterPanelBorder
}
```

Set `Game.MaxLogLines` to bound the buffer (recommended ~64 for a small
ChatLog).

### 10.4 The `clickBlocker` contract

Some widgets occupy a fixed area; clicking inside should not pop a
`RadialVerbs` menu over them. They implement the optional library-internal
interface:

```go
type clickBlocker interface {
    BlocksClickAt(p Point) bool
}
```

Built-ins that implement it: `VerbBar`, `InventoryBar`, `DialogBox`,
`TopBar`, `CharacterPanel`, `ChatLog`, `RadialVerbs` (itself, when
visible). Implement it on your own widgets to mark them as solid input
zones for the radial menu.

### 10.5 Registration helpers

```go
// pncdsl/ui.defaults.go

func RegisterDefaultUI(g *Game)                              // SCUMM-style verb bar + inventory
func RegisterRadialVerbUI(g *Game)                            // verb-coin (right-click) + wider inventory
func RegisterRichUI(g *Game, playerName, npcName string)      // top bar + character panels + chat log + verb wheel
```

Pass `""` for any character name to skip its `CharacterPanel`. All three
helpers register in conventional Z-order (debug overlay back, cursor
front).

### 10.6 Custom widgets

Anything implementing `Widget` plugs in. The library doesn't care about
widget identity beyond the name; it just calls `Tick` and `Draw`. Read
state from `ctx.Game`, consume input via `ctx.Game.Input`, draw with
Ebitengine primitives, and you're done.

```go
type Minimap struct {
    Name   string
    Bounds pncdsl.Rectangle
}

func (m *Minimap) GetName() string                                { return m.Name }
func (m *Minimap) Tick(ctx *pncdsl.UICtx)                         {}
func (m *Minimap) Draw(dst *ebiten.Image, ctx *pncdsl.UICtx) {
    // ... render scene thumbnail, mark NPCs, etc.
}

g.UIManager.Register(&Minimap{
    Name:   "minimap",
    Bounds: pncdsl.Rect(220, 4, 96, 56),
})
```

---

## 11. Themes

```go
// pncdsl/ui.theme.go

type Theme struct {
    Name string

    // panel + status
    PanelBG    color.Color
    StatusText color.Color
    FlashText  color.Color

    // verbs
    VerbButtonBG         color.Color
    VerbButtonSelectedBG color.Color
    VerbButtonText       color.Color

    // inventory
    InventorySlotBG         color.Color
    InventorySlotSelectedBG color.Color

    // speech
    SpeechBubbleBG    color.Color
    SpeechDefaultText color.Color

    // dialog
    DialogBG          color.Color
    DialogBorder      color.Color
    DialogChoiceBG    color.Color
    DialogChoiceHover color.Color
    DialogSpeaker     color.Color
    DialogText        color.Color

    // misc overlays
    EndCardBG      color.Color
    EndCardText    color.Color
    CursorColor    color.Color
    HotspotOutline color.Color
    SceneBackdrop  color.Color

    // rich-UI widgets
    TopBarBG       color.Color
    TopBarText     color.Color
    TopBarAccent   color.Color

    ChatLogBG       color.Color
    ChatLogPrompt   color.Color
    ChatLogResponse color.Color
    ChatLogSystem   color.Color

    CharacterPanelBG     color.Color
    CharacterPanelBorder color.Color
    CharacterPanelTitle  color.Color
}
```

### 11.1 Preset themes

`NewGame` automatically calls `RegisterPresetThemes(g)` and
`UseTheme("classic-scumm")`. The four shipped presets:

| Name              | Mood                                                        |
|-------------------|-------------------------------------------------------------|
| `classic-scumm`   | Default SCUMM-era dark blue + warm amber accents.          |
| `sierra-coin`     | Darker purple + orange; designed for the verb-coin layout. |
| `paper-notebook`  | Cream paper + ink + a touch of sepia for the highlights.   |
| `terminal-green`  | Retro phosphor green on black.                              |

### 11.2 Custom themes

Register your own:

```go
g.ThemeManager.Register(pncdsl.Theme{
    Name:      "midnight-noir",
    PanelBG:   color.RGBA{6, 6, 12, 255},
    StatusText: color.White,
    // ... ~30 color fields ...
})
g.UseTheme("midnight-noir")
```

Switching at runtime is just `g.UseTheme(name)` — widgets read the active
theme every frame, so the change shows up on the next draw.

---

## 12. Asset pipeline

`AssetManager.Register` only stores the spec (`Name`, `Path`, `Kind`). The
first request for an image goes through `loadedAssets.image(name)`:

1. Look up the file from `Asset.Path`.
2. If the file is missing or unreadable, generate a deterministic
   placeholder color from `fnv32a(name)`. Mark the entry as a placeholder.
3. Cache the decoded `*ebiten.Image` for subsequent calls.

```go
// internal:
type loadedAssets struct {
    images       map[string]*ebiten.Image
    placeholders map[string]bool
}

func (la *loadedAssets) image(am *AssetManager, name string) *ebiten.Image
func (la *loadedAssets) isPlaceholder(name string) bool
```

`isPlaceholder` is used by widgets to decide whether to render a stylised
character placeholder (humanoid / quadruped) or the real sprite.

Supported image formats: PNG and JPEG (registered via blank imports in
`asset.manager.go`). Fonts and audio share the same registry but are not
loaded by the library at this milestone.

---

## 13. Audio

```go
// pncdsl/asset.audio.go

type AudioPlayer struct{ /* unexported */ }

func NewAudioPlayer() *AudioPlayer
func (a *AudioPlayer) PlayMusic(name string)
func (a *AudioPlayer) StopMusic()
func (a *AudioPlayer) PlaySound(name string)
```

Currently a **stub**: requests are logged via `logf` and the
"current music" string is tracked so duplicate `PlayMusic(same)` calls
no-op. The `PlayMusic` / `PlaySound` / `StopMusic` actions wire through
unchanged, so when the real Ebiten audio backend lands the domain code
keeps working.

---

## 14. Input

```go
// pncdsl/input.def.go

type Input struct{ /* unexported */ }

func (i *Input) Pos() (int, int)
func (i *Input) Point() Point
func (i *Input) LeftClicked() bool       // just-pressed AND not consumed
func (i *Input) RightClicked() bool
func (i *Input) ConsumeLeft()
func (i *Input) ConsumeRight()
```

The engine `poll`s once per frame, snapshotting cursor position and the
just-pressed state for both buttons. Widgets and the engine use the
**consume-on-use** pattern: one click is authoritative — whoever consumes
first wins, later checks see `LeftClicked() == false`.

`Game.Input` is exposed so widgets and custom actions can read it
directly.

---

## 15. The game loop

```go
// pncdsl/core.dsl.go

func Run(g *Game) error                          // toplevel — same as g.Run()
```

`Run` does, in order:

1. `g.Validate()` — cross-check name references between managers.
2. If `UIManager` is empty, call `RegisterDefaultUI(g)`.
3. Place the start scene directly (no transition), bump
   `State.NoteVisit`, position registered actors, kick off music.
4. Compose `Seq(scene.OnEnter, game.OnStart)` and queue it as the initial
   action — the first script tick runs both in order.
5. `ebiten.SetWindowSize(Width*4, Height*4)`,
   `ebiten.SetWindowTitle(g.Title)`, then `ebiten.RunGame(&engine{g})`.

### 15.1 `engine.Update`

```
poll input
update transition
if transition fading out → return

if scriptRunner != nil:
    tick the runner
    tick characters
    return

clear hoverLabel
for w in reversed(UIManager):
    w.Tick(uictx)        // widgets consume input top-down

handleSceneInput()       // hotspot resolution + right-click reset
tick characters
```

### 15.2 `engine.Draw`

```
fill Theme.SceneBackdrop (if any)
draw scene background image
for c in characters sorted by Y:
    drawCharacter(c)
for w in UIManager (registration order):
    w.Draw(screen, uictx)
draw transition overlay
```

### 15.3 `handleSceneInput`

Runs only after every widget had a chance. The flow:

1. Set hover label from the hotspot under the cursor (if any).
2. On right-click: deselect the held item if any, otherwise reset
   `selectedVerb` to `"look"`.
3. On left-click:
   - If an item is selected, try `hotspot.OnUseWith[item]`, then
     `item.OnUseWith[hotspot]`. Fail with a "Nem ehhez." flash.
   - Otherwise look up `hotspot.handler(selectedVerb)`. Fall back to the
     verb's `Default` action. Fail with "Semmi említésre méltó."
4. On every successful click, push a `LogAction` line so the `ChatLog`
   widget can display it.

### 15.4 Character movement

`Game.tickCharacters` runs every frame, stepping moving characters towards
their `target` at `def.Speed` pixels per second. The `Walk(name, to)`
runner returns `StatusRunning` until `characterMoving(name)` is false.

---

## 16. Scene transitions

```go
// pncdsl/scene.transition.go (unexported)

type transition struct{ /* unexported */ }
```

A scene change calls `Game.changeScene(name)`, which starts a fade-to-
black overlay (`duration = 0.25s`). At the midpoint:

1. `prevScene.OnLeave` (if any) is queued.
2. `currentScene` is set to the new name.
3. `State.NoteVisit(name)` bumps the visit counter.
4. Actors are placed at their `SceneActor.At` positions.
5. The new scene's `Music` plays.
6. `newScene.OnEnter` is queued.

The fade-from-black plays after the midpoint. During the fade-out half,
`engine.Update` returns early — input is frozen briefly.

The initial scene (from `StartAt`) is set **without** a transition so the
intro script runs immediately.

---

## 17. Validation

```go
func (g *Game) Validate() error
```

Run by `Run` before entering the Ebiten loop, and again from your tests.
It cross-checks:

- `StartAt` points to a registered scene.
- Every scene has a non-empty `Background` referencing a registered
  `Asset`.
- Optional `Scene.Music` (if non-empty) references a registered `Asset`.
- Every `SceneActor.CharacterName` is a registered character.
- An active theme is selected and registered.

Returns the first error wrapping one of the `Err...` sentinels (so callers
can `errors.Is` against them). Duplicate-name registration is caught
eagerly at `Register` time via panic.

---

## 18. Save and load

```go
// pncdsl/state.save.go

func (g *Game) Save(slot int) error
func (g *Game) Load(slot int) error
```

**Stub** at this milestone — both return `nil` without doing anything.
The intended scope when filled in:

- Persist `currentScene`, character positions, the entire `State`
  (flags, vars, visited, talked), inventory contents, and the active
  script PC.
- The managers themselves (Items, Scenes, …) are **not** persisted; they
  are reconstructed by `domain.Build()` on every launch.

---

## 19. Errors

```go
// pncdsl/core.errors.go

var (
    ErrUnknownAsset           = errors.New("pncdsl: unknown asset")
    ErrUnknownScene           = errors.New("pncdsl: unknown scene")
    ErrUnknownItem            = errors.New("pncdsl: unknown item")
    ErrUnknownCharacter       = errors.New("pncdsl: unknown character")
    ErrUnknownDialogue        = errors.New("pncdsl: unknown dialogue")
    ErrUnknownDialogueNode    = errors.New("pncdsl: unknown dialogue node")
    ErrUnknownScript          = errors.New("pncdsl: unknown script")
    ErrUnknownVerb            = errors.New("pncdsl: unknown verb")
    ErrDuplicateName          = errors.New("pncdsl: duplicate name")
    ErrNoStartScene           = errors.New("pncdsl: StartAt not set or unknown scene")
    ErrSceneMissingBackground = errors.New("pncdsl: scene has no background")
)
```

All wrap-able with `fmt.Errorf("%w: …", err, ctx)` and checkable with
`errors.Is`. `Manager.Register` and `MustGet` use `panic` instead of
returning these errors because they signal construction-time bugs that
should crash loudly in development.

---

## 20. Testing

```bash
go test ./...
```

`domain/build_test.go` is the canonical headless smoke test:

```go
func TestBuildValidates(t *testing.T) {
    g := domain.Build()
    if err := g.Validate(); err != nil {
        t.Fatalf("validate: %v", err)
    }
    // ... assert specific managers contain expected entries ...
}
```

It calls `Build()` (which registers every entity) and `Validate()` (which
cross-checks every name reference). No Ebiten window is opened — perfect
for CI.

Library-internal unit tests are not shipped yet; the manager + action
primitives are intentionally small enough to verify through smoke runs
of the demo for the time being.

### 20.1 Useful runtime debug switches

```go
pncdsl.DebugLog = true                 // logf output to stderr (script queue, audio, scene change)
&pncdsl.HotspotDebug{Enabled: true}    // start with the F1 overlay on
```

---

## 21. Project layout

```
pncdsl/
├── main.go                        # entry point: pncdsl.Run(domain.Build())
├── pncdsl/                        # the library — theme-prefixed file names
│   ├── core.doc.go                # package docs
│   ├── core.manager.go            # Manager[T Named], Named, TypeLabel
│   ├── core.game.go               # Game aggregate, runtime state, helpers
│   ├── core.engine.go             # ebiten.Game adapter (Update/Draw/Layout)
│   ├── core.dsl.go                # Run()
│   ├── core.errors.go             # sentinel errors
│   │
│   ├── util.geometry.go           # Point, Rectangle, Polygon, Shape
│   ├── util.timer.go              # Timer helper
│   ├── util.log.go                # DebugLog + logf
│   │
│   ├── asset.def.go               # Asset, AssetKind
│   ├── asset.manager.go           # AssetManager alias + lazy loader
│   ├── asset.audio.go             # AudioPlayer (stub)
│   ├── asset.text.go              # drawText / wrapText helpers
│   │
│   ├── scene.def.go               # Scene, SceneActor
│   ├── scene.manager.go           # SceneManager alias
│   ├── scene.hotspot.go           # Hotspot, CursorKind
│   ├── scene.trigger.go           # Trigger (stub)
│   ├── scene.transition.go        # fade-to-black overlay (internal)
│   ├── scene.camera.go            # Camera (stub identity transform)
│   │
│   ├── item.def.go                # Item
│   ├── item.manager.go            # ItemManager alias
│   ├── item.inventory.go          # Inventory
│   │
│   ├── actor.def.go               # Character
│   ├── actor.manager.go           # CharacterManager alias
│   ├── actor.animation.go         # AnimationClip (stub)
│   │
│   ├── dialog.def.go              # Dialogue, DialogueNode, DialogueChoice
│   ├── dialog.manager.go          # DialogueManager alias
│   │
│   ├── action.def.go              # Action, Runner, Ctx, Status + every built-in action
│   ├── action.condition.go        # Condition interface + combinators
│   ├── action.script.go           # Script entity
│   ├── action.manager.go          # ScriptManager alias
│   │
│   ├── state.def.go               # State (flags, vars, visited, talked)
│   ├── state.save.go              # Save/Load (stub)
│   │
│   ├── input.def.go               # Input (consume-on-use)
│   │
│   ├── ui.widget.go               # Widget interface, UICtx, Size, Align
│   ├── ui.manager.go              # UIManager alias + reversed/ordered iterators
│   ├── ui.theme.go                # Theme + ThemeManager
│   ├── ui.theme_presets.go        # 4 preset themes
│   ├── ui.defaults.go             # RegisterDefaultUI/RadialVerbUI/RichUI
│   ├── ui.verb.go                 # Verb + VerbManager
│   ├── ui.verb_bar.go             # VerbBar widget
│   ├── ui.verb_radial.go          # RadialVerbs widget
│   ├── ui.inventory.go            # InventoryBar widget
│   ├── ui.status.go               # StatusLine widget
│   ├── ui.speech.go               # SpeechBubble widget
│   ├── ui.dialog_box.go           # DialogBox widget
│   ├── ui.end_card.go             # EndCard widget
│   ├── ui.cursor.go               # Cursor widget
│   ├── ui.hotspot_debug.go        # HotspotDebug widget
│   ├── ui.panel.go                # Panel widget
│   ├── ui.top_bar.go              # TopBar widget
│   ├── ui.character_panel.go      # CharacterPanel widget + CharStat
│   └── ui.chat_log.go             # ChatLog widget
│
├── domain/                        # the concrete game — see DEMO.md
├── assets/                        # optional image / audio files
├── PLAN.md                        # original design document
├── UIPLAN.md                      # widget-system design
├── DEMO.md                        # walkthrough of the bundled game
├── GFX.md                         # asset prompts for image AIs
├── LICENSE.md                     # MIT
└── README.md                      # this file
```

Files under `domain/` follow `theme.identifier.go` (e.g.
`scene.kitchen.go`, `item.key.go`) — `ls domain/scene.*` instantly lists
every location.

---

## 22. License

MIT — see [`LICENSE.md`](LICENSE.md). Copyright © 2026 Teletype Games.
