Files
alchemist-accessibility/accessibility/docs/03-implementation-plan.md
T

199 lines
13 KiB
Markdown
Raw Normal View History

# Implementation plan
2026-08-01 22:25:09 +02:00
> ## ⚠ Status: Phases 0–5 are COMPLETE.
>
> This document is the *original* plan, kept for its reasoning. Where the
> implementation diverged, the reason is recorded in
> [scratch.md](scratch.md) and [05-decisions.md](05-decisions.md).
>
> Notable divergences from what is written below:
>
> * **Phase 4 was pulled forward, partly into Phase 1's slot.** Menu narration
> turned out to be a hard blocker — the screen name was spoken but not the
> selected option, so a blind player could not start a new game at all.
> * **`levelCursorPopup` is NOT patched** (contrary to Phase 2a below). It fires
> from an animation-completion callback and lands a keypress late; the
> `ingame_observe` descriptor polls the cursor position instead.
> * **`tile.tryInteract` is not a sufficient tracker filter** (contrary to
> Phase 3 below). `wall` carries a flavour-only `tryInteractFunction`.
> See the D4 correction in `05-decisions.md`.
> * **A code-driven testbed was added** (`accessibility/testbed.lua`), not in the
> original plan. It found five real bugs that would otherwise have surfaced
> only during play.
> * **Phase 5's mod-settings screen was not built.** Settings exist as fields in
> the relevant modules and are not yet exposed at runtime.
Phases are ordered so each one is independently playable-and-testable. Nothing later is required for something earlier to be useful.
---
## Phase 0 — Baseline & bootstrap
1. `git init` in the game root; commit the pristine tree, tag `vanilla-0.4.2`. (`.gitignore`: `dist/`, `*.json` save data.)
2. Vendor `d:\code\love2talk` into `accessibility/vendor/love2talk/` (drop `objc/` and `main.lua`).
3. Resolve the Tolk DLL placement question (see `04-open-questions.md` Q1) and document the launch procedure.
4. `accessibility/speech.lua`:
- `pcall(require, "accessibility.vendor.love2talk")`; on failure, all functions become no-ops and we log once.
- `speech.say(text, interrupt)` — normalises `nil`/empty, collapses whitespace, strips the game's inline markup if any.
- `speech.interrupt(text)` — convenience for keypress-driven output.
- A small **queue** so consecutive `say` calls in one frame don't clobber each other: buffer within a frame, flush one joined utterance at end of `update`. This matters a lot — a single game turn can emit several `console.print` lines.
5. `accessibility/init.lua` with `preInit`/`postInit`, everything inside `pcall` so a mod bug never bricks the game.
6. Add the bootstrap line(s) to `main.lua`. Add mod globals to `.luacheckrc`.
**Test:** game launches, speaks "Main menu" on startup.
---
## Phase 1 — Console speech + line-by-line review
This is the highest-value, lowest-risk piece.
**`accessibility/history.lua`**
- Wrap `console.print(message, colorTemplate)`.
- Append `{text, colorTemplate, turn = world.turn, time = os.clock()}` to an unbounded (or N=1000 ring) list.
- Mirror the game's coalescing: if the new message equals the last, increment `times` rather than appending, so review output matches what a sighted player sees (`"You feel cold (x3)"`).
- Speak new lines automatically, non-interrupting, queued.
**Review keys** (working proposal, all rebindable):
| Key | Action |
|---|---|
| `Ctrl+[` / `Ctrl+]` | Previous / next history line (moves review cursor, speaks it) |
| `Ctrl+Home` / `Ctrl+End` | Jump to oldest / newest line |
| `Ctrl+\` | Speak current line again; press twice quickly to spell it |
| `Ctrl+Shift+C` | Copy current line to clipboard (`love.system.setClipboardText`) |
Entering review mode does **not** pause the game (it's turn-based; nothing advances without input anyway). Review cursor resets to newest whenever a new line arrives *unless* the user is actively reviewing.
**Open design point:** should routine flavour messages (`colorTemplate == "flavor"`, greyed out in-game) be spoken automatically or only on review? Proposal: a config toggle, default *on* but spoken last in the frame's queue. See Q4.
**Test:** walk around, take a hit, harvest something — all log text is spoken; review keys walk the full session history.
---
## Phase 2 — Tile inspection & surroundings
The game already has a full examine cursor (`states/ingame_observe.lua`), driven by `moveCursor(dx,dy)` and producing text via `levelWindow.getCursorPopupStats()` → `level.getCursorMessage()`.
**2a. Narrate the existing examine mode (nearly free).**
Patch `views/levelCursorPopup.set(lines, …)` to speak `lines`. That instantly makes the game's own examine mode (default key: the `examination` command) fully usable — arrow keys move the cursor, each tile is spoken.
Augment the spoken string beyond what's drawn:
- Prepend distance + direction from the player (`"3 north-east"`).
- Append passability (`"blocked"` / `"passable"` / `"water"`), whether the tile is currently visible vs. remembered (`"remembered"`), and whether it holds lootable items (`tile.loot and not tile.unlootable`).
- Announce cursor-hit-edge-of-map rather than silently not moving.
**2b. Surroundings explorer (new).**
A quick "what's around me" that doesn't require entering examine mode.
| Key | Action |
|---|---|
| Numpad 1–9 (with a modifier, e.g. `Alt`) | Speak the single tile in that direction, without moving |
| `Alt+5` | Speak the tile the player is standing on |
| A "scan" key | Speak all 8 neighbours in clockwise order, terse |
**2c. Wall tones.**
`accessibility/assets/walltones/walltone_{n,s,e,w}.wav` are present. Play the corresponding tone when the player *bumps* a wall (movement blocked) and optionally as a continuous ambient cue when a wall is directly adjacent. Use `TEsound.play(..., {"sfx","interface"})` so the game's own volume sliders apply. Needs a decision on when they fire — see Q5.
**Test:** blind-navigate a corridor in the starting area using only examine + surroundings + wall tones.
---
## Phase 3 — Object tracker
The headline feature. Key scheme as specified:
| Key | Action |
|---|---|
| `Ctrl+Page Up` / `Ctrl+Page Down` | Previous / next **category** |
| `Page Up` / `Page Down` | Previous / next **item within the current category** |
| `Shift+Page Up` / `Shift+Page Down` | Previous / next **instance of that specific object** |
| `Home` | Announce distance + direction of the current item |
Semantics I'm assuming (please confirm — Q2): a *category* is e.g. "Creatures"; an *item within a category* is a distinct kind, e.g. "rat"; an *instance* is a specific rat at (12,7). So Page Down cycles rat → beetle → guard, and Shift+Page Down cycles rat #1 → rat #2 → rat #3.
**`accessibility/categories.lua`** — the tracker's own category table, since the game's `templatesByTag` only defines `exit`. Proposed categories:
1. **Creatures** — from `level.actors`, excluding the player, filtered to visible-or-remembered. Sub-grouped by `templateId`, labelled with `actor.nameObserved`.
2. **Items** — lootable tiles / items on the ground (`getItemOnCoordinates`, and tiles with `loot`). Sub-grouped by item category from `modules/item/categories.lua`.
3. **Exits** — reuse `level.getListByTag("exit")` verbatim (doors, ladders, ways out, horses).
4. **Interactables** — tiles with `tryInteract` that aren't exits (containers, levers, alchemy tables, beds, fires).
5. **Terrain features** — water, fire, hazards (spore clouds, slime), stairs.
6. **Alchemy** — harvestable ingredient nodes specifically; this is an alchemy game and these matter enough to deserve their own category.
Category membership is determined by a predicate over `templateId` + tile flags. Building this requires a pass over `modules/tile/templates.lua` (6745 lines) to classify template IDs — the single biggest chunk of manual work in the project. **Mitigation:** classify by *flags first* (`tryInteract`, `loot`, `blocksMovement`, `blocksVision`) and fall back to an explicit ID list only for the cases flags get wrong. That should keep the hand-maintained list to dozens rather than hundreds of entries, and it degrades gracefully on new content in future versions.
**Scanning.** One function `tracker.rescan()` walking `x=1..xMax, y=1..yMax`, honouring `FoV` / `rememberedFoV` exactly as `getListByTag` does (so the tracker never reveals unseen information — important, this must not become a cheat). Results cached and invalidated on turn advance / level change / FoV recalculation. Sort each bucket by distance to player (`sortByDistanceToPlayer` logic already exists in `modules/level.lua`).
**Announcements.**
- Category change: `"Creatures, 4 kinds"`.
- Item change: `"rat, 3"` (3 instances).
- Instance change: `"rat 2 of 3, 5 tiles north-west"`.
- `Home`: `"5 tiles north-west, 7 steps by path"` — using `calculatePathFromPlayer` for the true walking distance, which is far more useful than Euclidean in a walled map.
- Empty category: `"Exits, none"`.
**Extras worth adding** (cheap, given the above): a key to move the examine cursor to the tracked object, and a key to auto-path to it (`bufferInput("move", calculatePathFromPlayer(x,y), ingame)` — the game already does exactly this in `ingame_observe.keypressed` under `confirmTarget`).
**Test:** in a populated level, enumerate every creature, exit and item; verify counts match what examine mode finds; verify nothing outside FoV is ever reported.
---
## Phase 4 — Menus and screens
Now the ~16 states that keep focus in file-locals.
**`accessibility/states/<statename>.lua`** — one descriptor per state:
```lua
return {
probe = "keypressed", -- which state function to read upvalues from
focus = "selectedOption", -- upvalue holding the index
list = "optionsList", -- upvalue holding the entries
title = "Main menu",
render = function(entry, index, total, state)
return ("%s, %d of %d%s"):format(entry.title, index, total,
entry.isEnabled and "" or ", unavailable")
end,
}
```
The generic driver (`patches/states.lua`) wraps `states.keypressed`: run the real handler, then re-probe the descriptor; if focus changed, speak. It also wraps `states.switch` to announce the screen title and the initially-focused entry.
States needing descriptors: `main_menu`, `settings_menu`, `config_menu`, `keybindings_menu`, `load_game`, `new_game`, `journal`, `character`, `research`, `worldmap`, `actions_menu`, `alchemy_table`, `ingame_dialogue`.
Special cases:
- **`ingame_dialogue`** — must speak the NPC's line *and* the numbered response options; the game already supports number keys 1–0 to pick a response directly, which is ideal. Needs a "repeat the NPC line" key.
- **`journal`, `character`, `research`** — tabbed and paged; need tab-change announcements (`activeTabIndex`, `selectedPage`, `selectedPageId`) plus a "read the page body" key that hands the long text to the history reviewer from Phase 1, so it can be read line by line.
- **`menu_text`, `narration`, `ingame_message`, `menu_message`** — full-screen prose. Route the whole text into the review buffer and speak it; these have no meaningful focus state, so they need no upvalue probing.
- **`craft`, `refine`, `trade`, `inventory`** — already covered by the `inventoryCursorPopup` patch from Phase 2a. But they also have a **confirmation sub-window with a slider** (`modules/slider.lua`, quantity selection). Left/Right adjusts it (`adjustByFive` modifier for ×5) — needs the current value spoken on change.
- **`ingame_targeting_melee` / `ingame_targeting_throw`** — targeting cursor; reuse the Phase-2 cursor narration plus valid/invalid-target announcement (`canThrowOnCell`, `level.recalculateInteractableArea`).
**Test:** complete a full loop — new game → dialogue → inventory → craft a potion → journal — with the monitor off.
---
## Phase 5 — Player status, polish, packaging
- **Status key(s):** HP (`player.actor.hp`/`maxhp`), active statuses (`views/statusGrid.lua`, `models/status`), current action (`player.currentSelectedAction`), hotbar contents, level name + in-game time (`level.getLevelDescriptionMessage()`), player coordinates.
- **Popup announcements:** wrap `popupStack.add` and the `ingame.*Popup` methods so loot/recipe/lore/bestiary/tutorial popups are spoken and reviewable.
- **Interruptions:** `ingame.checkInterruptions(hpLoss)` fires on damage — speak an urgent, interrupting alert.
- **Mod settings screen:** speech rate/verbosity, toggles for flavour text, wall tones, auto-speak. Either a new state or (cleaner, more decoupled) a plain `accessibility/config.json` plus hotkeys to toggle at runtime with spoken confirmation.
- **Self-test:** startup check that walks every state descriptor and every patch target, logging what resolved. This is the version-bump canary.
- **Packaging:** decide `.love`/exe distribution vs. source drop, given the DLL constraint (Q1). Update `build.lua`'s `ignore` if needed.
- **Docs:** a player-facing `accessibility/README.md` with the full keymap.
---
## Effort estimate
| Phase | Relative size | Risk |
|---|---|---|
| 0 Bootstrap | S | Low (DLL placement is the only unknown) |
| 1 Console | S | Low |
| 2 Tiles | M | Low — builds on existing examine mode |
| 3 Tracker | L | Medium — template classification is the grind |
| 4 Menus | L | Medium — 13 descriptors, upvalue fragility |
| 5 Polish | M | Low |
The pleasant surprise is that Phases 1, 2a and much of 4 lean on text the game **already generates** (`console.print`, `getCursorMessage`, `getCursorPopupStats`). We are mostly re-routing existing strings to speech, not writing a description engine.