Menu narration + code-driven testbed

Pulls menu focus narration forward from Phase 4: it was a blocker, since the
screen name was spoken but not the selected option, so the game could not be
started at all.

Game-file footprint unchanged: still one line in main.lua.

- menus.lua: descriptor driver supporting both D3 backends (upvalue probe and
  patched-announce), sharing one render per screen. Probe self-test included.
- Descriptors for main_menu, settings_menu, load_game, new_game, narration.
- testbed.lua: drives the game by code via love.event.push, so input takes the
  same path as a human keypress. Coroutine-based, and navigates by READING focus
  rather than counting keypresses, which drifts when options are disabled.

Three bugs found by the testbed and fixed:

1. love.keypressed was still bound to the pre-wrapper states.keypressed.
   states.setup() captures love[callback] by value and runs before us, and
   main.lua does not define love.keypressed itself, so wrapping the states table
   field alone had no effect. Now reassigns love.keypressed/textinput.

2. Focus can move with no keypress: main_menu.mousemoved assigns selectedOption
   from cursor position, silently overriding selection including at startup.
   Focus is now polled in the update hook instead of announced only on keypress.

3. Probes sometimes need chaining. narration.keypressed only calls
   continueNarration(), so the text locals are upvalues of that inner function.

Verified: main menu -> new game -> name entry -> intro narration -> gameplay,
8/8 checks passing, including console.print capture and its (x2) fold.

Known gap: ingame_dialogue announces 'Game' and reads no dialogue text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Accessibility Mod
2026-08-01 20:53:29 +02:00
co-authored by Claude Opus 5
parent fe872be683
commit 9945703fb7
9 changed files with 796 additions and 4 deletions
+55
View File
@@ -150,6 +150,61 @@ the more urgent fact). Flip the flag in `walltones.lua` if it reads backwards.
Respects `FoV`/`rememberedFoV` — never leaks unseen map geometry.
### Done — Menu narration (Phase 4, pulled forward) + testbed
Pulled forward because menus were a **blocker**: Talon could hear the screen name
but not the selected option, so the game could not be started at all.
- `accessibility/menus.lua` — descriptor driver. Supports both D3 backends
(`probe` via `debug.getupvalue`, `patched` via injected announce calls); the
per-screen `render` is shared. Includes the probe self-test.
- `accessibility/states/{main_menu,settings_menu,load_game,new_game,narration}.lua`
- `accessibility/testbed.lua` — drives the game by code. `lovec.exe . --a11y-test`
**Verified end to end: main menu → new game → name entry → intro narration →
gameplay, 8/8 checks passing.** Console patch smoke-tested including the `(x2)`
fold and review keys.
#### Three real bugs the testbed caught
1. **`love.keypressed` was bound to the pre-wrapper function.** `states.setup()`
does `love[callback] = love[callback] or states[callback]`, capturing **by
value**, and it runs before us. `main.lua` defines `love.load/update/
mousepressed/mousereleased/quit` but NOT `keypressed`/`textinput` — so those
were frozen to the original. Replacing the `states` table field alone did
nothing. **Fix: reassign `love.keypressed`/`love.textinput` after wrapping.**
`states.switch`/`states.update` were fine — looked up on the table at call time.
⚠ Any future callback we wrap needs the same treatment.
2. **Focus moves with no keypress.** `main_menu.mousemoved` assigns
`selectedOption` straight from cursor position, so wherever the mouse rests
silently overrides selection — including at startup. **Fix: poll focus in the
update hook** rather than announcing only on keypress. `announceScreen` now
speaks only the screen name and lets the poll add focus a frame later
(`justSwitched` makes that first one non-interrupting so it appends).
3. **Probes may need chaining.** `narration.keypressed` doesn't touch
`displayData`/`currentPage`/`currentLoadedLine` — it only calls
`continueNarration()`, so they are upvalues of *that* function. The descriptor
reaches `continueNarration` as an upvalue of `keypressed`, then reads through
it. Expect this pattern in other states.
#### Also learned
- `states.lua` loads states via `require("states/"..name)` — a **SLASH**.
`require()` caches on the exact string, so `require("states.main_menu")` would
load a *second independent copy* with its own upvalues. Always match the slash.
- **Plain movement is silent** — `actions/move.lua` only prints when a move fails
(bound, immobilized). Walking proves nothing about the console patch; drive
`console.print` directly to test it.
### Known gaps (next up)
- **`ingame_dialogue` announces "Game", not "Conversation", and reads no dialogue
text.** It appears to be entered via a path that announces `ingame` first.
Needs a descriptor + investigation of how it's switched to. **This is the next
blocker for actually playing.**
- `ingame` itself has no descriptor — that's Phase 2 (tile examination).
- Wall tones still unverified by ear.
### Next — Phase 2: tile examination
Patch `views/levelCursorPopup.set` to narrate the game's existing examine mode,
augmented with distance/direction/passability. Then the surroundings explorer