Files
alchemist-accessibility/accessibility/docs/03-implementation-plan.md
T
Accessibility ModandClaude Opus 5 6380998376 docs: final handoff pass
Adds accessibility/README.md, the player-facing setup and keymap reference --
the thing to read before playtesting.

Brings the developer docs in line with what was actually built:

- docs/README.md rewritten as an index with current status and a one-paragraph
  architecture summary.
- docs/scratch.md gains a HANDOFF section: state of play, the commands to run
  after any change, the five traps that each cost real debugging time, what is
  NOT verified and why, deliberate omissions, and how to rebase onto a future
  game version.
- docs/03-implementation-plan.md gains a status banner listing where the
  implementation deliberately diverged from the plan (menu narration pulled
  forward as a blocker, levelCursorPopup not patched, tryInteract insufficient
  as a tracker filter, testbed added, no settings UI built).
- docs/04-open-questions.md gains a resolution banner: Q6 and Q8 remain open.

Also records that the testbed check count varies between runs (23-26) because
some blocks are conditional, so what matters is zero failures rather than the
total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:25:09 +02:00

199 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Implementation plan
> ## ⚠ 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.