From 96ec79ed75c1cf088fb351be2b6910442735e474 Mon Sep 17 00:00:00 2001 From: Accessibility Mod Date: Sat, 1 Aug 2026 22:20:50 +0200 Subject: [PATCH] Phase 4 complete + Phase 5: status, popups, damage alerts Game-file footprint unchanged: still one line in main.lua. All 23 states now have descriptors; 5 patches applied. Final two descriptors: - keybindings_menu has two panels (commands and keys) with independent selection, so it reads currentlyActiveWindow to decide which one has focus. Announcing the wrong panel's row would be actively misleading, since the two lists are unrelated. - research is a 2D node graph rather than a list, and its nodes are drawn as icons with no text label, so the id is the only identifier available. What actually matters is researched / locked / cannot afford / available: without the affordability part the screen is unusable by ear, because pressing enter on an unaffordable node silently does nothing. status.lua adds two pull keys -- ' for health and active effects, , for the current action -- plus unprompted damage alerts. Damage is polled from actor.hp rather than hooked. ingame.checkInterruptions exists but only fires for interruptions during queued movement, so damage taken on your own turn never reaches it. Polling catches every source (combat, poison, fire, falling) with no game edit. Damage speaks immediately and interrupts; healing queues, since it is worth knowing but never urgent. patches/popupstack.lua wraps popupStack.add, the single entry point for loot, recipe, lore, map, quest, tutorial and bestiary popups. None of these go through console.print, so they were entirely silent before. speech.immediate now logs to the console like queued speech does. It bypasses the frame queue and so also bypassed the flush-time logger, which meant damage alerts fired correctly but were invisible in the testbed transcript. Prefixes are now: ! interrupt, * immediate, space queued. 26/26 testbed checks, history unit tests passing. Co-Authored-By: Claude Opus 5 --- accessibility/docs/scratch.md | 46 ++++++- accessibility/init.lua | 5 + accessibility/patches/popupstack.lua | 80 +++++++++++ accessibility/speech.lua | 12 +- accessibility/states/keybindings_menu.lua | 57 ++++++++ accessibility/states/research.lua | 48 +++++++ accessibility/status.lua | 156 ++++++++++++++++++++++ accessibility/testbed.lua | 21 +++ 8 files changed, 421 insertions(+), 4 deletions(-) create mode 100644 accessibility/patches/popupstack.lua create mode 100644 accessibility/states/keybindings_menu.lua create mode 100644 accessibility/states/research.lua create mode 100644 accessibility/status.lua diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index c88cd5c..cbbffec 100644 --- a/accessibility/docs/scratch.md +++ b/accessibility/docs/scratch.md @@ -3,11 +3,11 @@ **Read this first on any handoff.** Living document: update it whenever state changes, before ending a turn. Last updated: 2026-08-01 · Currently: **Phases 0–3 done + menus/dialogue. -Phase 4 mostly done. Phase 5 (status, popups) next.** +Phases 4-5 done. Remaining: polish, mod settings, distribution.** **Game is playable start-to-finish by ear** from main menu through the intro conversation into gameplay, plus journal, character, actions and inventory — -verified by the testbed, **25/25 checks**. +verified by the testbed, **26/26 checks**. ### Run the testbed (do this after any change) ``` @@ -135,6 +135,8 @@ and by Talon at the keyboard (wall tones). | `PageUp` / `PageDown` | Tracker: previous / next **kind** | | `Shift+PageUp` / `Shift+PageDown` | Tracker: previous / next **instance** | | `Home` | Tracker: distance, direction and path length to current instance | +| `'` | Health and active effects | +| `,` | Current selected action | Game keys worth knowing: **`x`** enters examine mode (not `e`), `f` finds exits while examining, `j` journal, `i` inventory, `c` character, `a` actions menu. @@ -390,8 +392,46 @@ alchemy table and real items. The patch is confirmed *applied* by the self-test; the testbed has the walk-to-bed machinery ready (`walkTo`, tracker lookup) for when a real bed is reachable. +### Done — Phase 4 complete + Phase 5: status, popups, damage alerts + +**All 23 states now have descriptors. 5 patches. 26/26 testbed checks.** +Final two descriptors: + +- **`keybindings_menu`** — two panels (commands / keys) with independent + selection; `currentlyActiveWindow` says which has focus. Announcing the wrong + panel's row would be actively misleading, as the lists are unrelated. +- **`research`** — a 2D node graph, not a list. Nodes are icons with **no text + label**, so the id is the only identifier; the useful facts are + researched / locked / cannot afford / available. Without the affordability + part the screen is unusable by ear, since enter on an unaffordable node + silently does nothing. + +**Phase 5 — `accessibility/status.lua`:** + +| Key | Action | +|---|---| +| `'` | Health + active effects — `Health 20 of 20. no active effects` | +| `,` | Current action — `Action: Interact` | + +Plus **unprompted damage alerts**, polled from `actor.hp`: +`Hit, minus 3, health 17 of 20` (immediate + interrupting), and +`Healed 3, health 20 of 20` (queued — worth knowing, never urgent). + +⚠ Damage is **polled, not hooked**. `ingame.checkInterruptions(hpLoss)` exists +but only fires for interruptions during *queued movement* — damage taken on your +own turn never reaches it. Watching `hp` catches every source (combat, poison, +fire, falling) with no game edit. + +**`patches/popupstack.lua`** wraps `popupStack.add`, the single entry point for +loot / recipe / lore / map / quest / tutorial / bestiary popups. None of these go +through `console.print`, so they were completely silent before. + +**`speech.immediate` now logs too** (`[speech]*`). It bypasses the frame queue, +so it also bypassed the flush-time logger — meaning damage alerts fired but were +invisible in the testbed transcript. Log prefixes: `!` interrupt, `*` immediate, +` ` queued. + ### Known gaps (next up) -- **Phase 4 remainder**: `research` and `keybindings_menu` have no descriptors. - **Phase 5**: player status key (HP, statuses, current action), popup announcements (`popupStack.add`), damage alerts (`ingame.checkInterruptions`), mod settings. diff --git a/accessibility/init.lua b/accessibility/init.lua index c9c4807..b39290b 100644 --- a/accessibility/init.lua +++ b/accessibility/init.lua @@ -44,6 +44,8 @@ local describedStates = { "refine", "trade", "config_menu", + "keybindings_menu", + "research", } local initialized = false @@ -160,6 +162,7 @@ local function hookStates() end) pcall(walltones.update) + pcall(require("accessibility.status").update) pcall(speech.flush) if testbedActive then pcall(testbed.update, dt) @@ -234,10 +237,12 @@ function accessibility.initialize() accessibility.registerPatch("console", require("accessibility.patches.console")) accessibility.registerPatch("popups", require("accessibility.patches.popups")) accessibility.registerPatch("slider", require("accessibility.patches.slider")) + accessibility.registerPatch("popupstack", require("accessibility.patches.popupstack")) pcall(function() require("accessibility.surroundings").registerKeys(hotkeys) require("accessibility.tracker").registerKeys(hotkeys) + require("accessibility.status").registerKeys(hotkeys) end) local ok, err = pcall(function() diff --git a/accessibility/patches/popupstack.lua b/accessibility/patches/popupstack.lua new file mode 100644 index 0000000..bc5d0c3 --- /dev/null +++ b/accessibility/patches/popupstack.lua @@ -0,0 +1,80 @@ +-- Patch: views/popupStack.lua +-- +-- Popups are the game's way of telling you something happened without +-- interrupting play: loot found, a recipe learned, a quest updated, a new beast +-- recorded, a tutorial offered. They fade in at the side of the screen and none +-- of them go through console.print, so without this they are entirely silent. +-- +-- popupStack.add{...} is the single entry point for all of them. + +local speech = require("accessibility.speech") + +-- What each popup type is called out loud. Keys match the `popupType` field +-- passed by states/ingame.lua and states/ingame_observe.lua. +local typeNames = { + loot = "Found", + recipe = "Recipe", + lore = "Lore", + map = "Map", + journal = "Quest", + tutorial = "Tutorial", + bestiary = "Bestiary", +} + +local function describe(params) + local parts = {} + + parts[#parts + 1] = typeNames[params.popupType] or params.popupType or "Notice" + + -- `header` is the human-readable line the popup itself displays + -- ("You found:", a quest title), so prefer it over any id. + if params.header and params.header ~= "" then + parts[#parts + 1] = params.header + end + + if params.loot then + for _, entry in ipairs(params.loot) do + local name = (type(entry) == "table") and (entry.name or entry.templateId) or tostring(entry) + if name then + local quantity = type(entry) == "table" and entry.quantity + parts[#parts + 1] = (quantity and quantity > 1) + and (name .. " x" .. quantity) or name + end + end + end + + if params.quest and type(params.quest) == "table" and params.quest.title then + parts[#parts + 1] = params.quest.title + end + + if params.templateId and not params.header then + parts[#parts + 1] = (tostring(params.templateId):gsub("_", " ")) + end + + return table.concat(parts, ": ", 1, math.min(2, #parts)) + .. (#parts > 2 and (". " .. table.concat(parts, ", ", 3)) or "") +end + +return function() + local popupStack = require("views.popupStack") + + local realAdd = popupStack.add + if not realAdd then + error("views.popupStack has no add function -- game structure changed") + end + + popupStack.add = function(params) + local result = realAdd(params) + + pcall(function() + if type(params) ~= "table" then + return + end + -- Queued, not interrupting: a popup is informational, and several can + -- land in one turn (loot plus a quest update). + speech.say(describe(params), false) + end) + + return result + end +end diff --git a/accessibility/speech.lua b/accessibility/speech.lua index 308eecc..3abf19d 100644 --- a/accessibility/speech.lua +++ b/accessibility/speech.lua @@ -124,7 +124,17 @@ end -- damage) where waiting for end-of-frame is wrong. function speech.immediate(text, interrupt) text = normalise(text) - if not text or not available then + if not text then + return + end + + -- Log before the availability check, so immediate alerts are visible in the + -- testbed transcript exactly like queued speech. + if speech.logToConsole then + print("[speech]* " .. text) + end + + if not available then return end diff --git a/accessibility/states/keybindings_menu.lua b/accessibility/states/keybindings_menu.lua new file mode 100644 index 0000000..b257e73 --- /dev/null +++ b/accessibility/states/keybindings_menu.lua @@ -0,0 +1,57 @@ +-- states/keybindings_menu.lua — the rebinding screen. +-- +-- Two panels side by side, with focus in exactly one of them: +-- left -- the command list ("Menu – accept") +-- right -- the keys currently bound to it, plus an "Add a new key" row +-- +-- `currentlyActiveWindow` says which panel has focus, and both panels have their +-- own selection index. Announcing the wrong panel's row would be actively +-- misleading here, since the two lists are unrelated. +-- +-- NOTE: this state is in hotkeys.lua's textEntryStates, so our own hotkeys stay +-- out of the way while it is capturing raw keys for rebinding. + +local lastPanel + +return { + read = function(stateTable, menus) + local panel = menus.getAnyUpvalue(stateTable, "currentlyActiveWindow") + + if type(panel) ~= "string" then + return nil, nil + end + + local parts = {} + + if panel ~= lastPanel then + lastPanel = panel + parts[#parts + 1] = (panel == "left") and "Commands" or "Keys" + end + + local entry, index, total + + if panel == "left" then + local data = menus.getAnyUpvalue(stateTable, "displayData") + index = menus.getAnyUpvalue(stateTable, "currentSelectedOptionLeft") + if type(data) == "table" and type(index) == "number" then + entry, total = data[index], #data + end + else + local data = menus.getAnyUpvalue(stateTable, "rightPanelDisplayData") + index = menus.getAnyUpvalue(stateTable, "currentSelectedOptionRight") + if type(data) == "table" and type(index) == "number" then + entry, total = data[index], #data + end + end + + if not entry then + return nil, nil + end + + parts[#parts + 1] = entry.description or entry.scancode or "?" + parts[#parts + 1] = index .. " of " .. total + + return table.concat(parts, ", "), + table.concat({panel, tostring(index), tostring(total)}, ":") + end, +} diff --git a/accessibility/states/research.lua b/accessibility/states/research.lua new file mode 100644 index 0000000..3a3166f --- /dev/null +++ b/accessibility/states/research.lua @@ -0,0 +1,48 @@ +-- states/research.lua — the research tree. +-- +-- Not a list: a 2D graph of nodes navigated with the arrow keys, where up/down +-- jump to the nearest node on the adjacent row. Nodes are drawn as icons with no +-- text label, so there is no on-screen name to relay -- the id is the only +-- identifier available, and the useful facts are whether the node is already +-- unlocked, whether it CAN be unlocked yet, and whether you can afford it. +-- +-- Without that last part the screen is unusable by ear: pressing enter on an +-- unaffordable node simply does nothing. + +local researchModel = require("models.research") + +-- "alchemy_basics_2" -> "alchemy basics 2" +local function humanise(id) + return (tostring(id):gsub("_", " ")) +end + +return { + read = function(stateTable, menus) + local nodeId = menus.getAnyUpvalue(stateTable, "currentNodeId") + + if nodeId == nil then + return nil, nil + end + + local parts = {humanise(nodeId)} + + local unlocked = researchModel.unlockedNodes and researchModel.unlockedNodes[nodeId] + + if unlocked then + parts[#parts + 1] = "researched" + else + local okUnlockable, unlockable = pcall(researchModel.isUnlockable, nodeId) + local okAfford, affordable = pcall(researchModel.canAffordNode, nodeId) + + if okUnlockable and not unlockable then + parts[#parts + 1] = "locked" + elseif okAfford and not affordable then + parts[#parts + 1] = "cannot afford" + else + parts[#parts + 1] = "available" + end + end + + return table.concat(parts, ", "), tostring(nodeId) .. ":" .. tostring(unlocked) + end, +} diff --git a/accessibility/status.lua b/accessibility/status.lua new file mode 100644 index 0000000..43c6c17 --- /dev/null +++ b/accessibility/status.lua @@ -0,0 +1,156 @@ +-- status.lua — player state on demand, plus unprompted alerts for things you +-- must not miss. +-- +-- The HP bar, status icons and current-action indicator are all permanently +-- on-screen furniture. A sighted player absorbs them at a glance and never +-- "requests" them, so there is no game event to hook -- these have to be pull +-- keys, plus a push alert for damage. + +local speech = require("accessibility.speech") + +local status = {} + +local function getPlayerActor() + local ok, world = pcall(require, "models.world") + if not ok or not world.currentLevel then + return nil + end + return world.currentLevel.playerActor +end + +-------------------------------------------------------------------------------- +-- Pull: health and statuses +-------------------------------------------------------------------------------- + +local function statusNames(actor) + local names = {} + + for _, entry in ipairs(actor.statusList or {}) do + local name = entry.name or entry.templateId + if name then + names[#names + 1] = name + end + end + + return names +end + +function status.announceHealth() + local actor = getPlayerActor() + + if not actor then + speech.say("No character", true) + return + end + + local parts = {} + + if actor.hp and actor.maxhp then + parts[#parts + 1] = string.format("Health %d of %d", actor.hp, actor.maxhp) + + -- A bare number means little without a sense of how bad it is. + local fraction = actor.hp / actor.maxhp + if fraction <= 0.25 then + parts[#parts + 1] = "critical" + elseif fraction <= 0.5 then + parts[#parts + 1] = "wounded" + end + end + + local names = statusNames(actor) + if #names > 0 then + parts[#parts + 1] = table.concat(names, ", ") + else + parts[#parts + 1] = "no active effects" + end + + speech.say(table.concat(parts, ". "), true) +end + +-------------------------------------------------------------------------------- +-- Pull: current action +-------------------------------------------------------------------------------- + +function status.announceAction() + local ok, player = pcall(require, "models.player") + + if not ok or not player.currentSelectedAction then + speech.say("No action selected", true) + return + end + + local action = player.currentSelectedAction + + -- `title` is the display string; `name` is the internal id ("interact"), so + -- capitalise when falling back to it. + local label = action.title + if not label then + label = action.name or "unknown" + label = label:sub(1, 1):upper() .. label:sub(2) + end + + speech.say("Action: " .. label, true) +end + +-------------------------------------------------------------------------------- +-- Push: damage alerts +-------------------------------------------------------------------------------- + +-- Polled rather than hooked. ingame.checkInterruptions(hpLoss) exists, but it +-- only fires for interruptions during queued movement -- damage taken on your own +-- turn does not go through it. Watching hp directly catches every source +-- (combat, poison, fire, falling) with no game edit. + +local lastHp +local lastActor + +function status.update() + local actor = getPlayerActor() + + if not actor or not actor.hp then + return + end + + -- New character or new level: re-baseline instead of reporting a "change". + if actor ~= lastActor then + lastActor = actor + lastHp = actor.hp + return + end + + if lastHp == nil then + lastHp = actor.hp + return + end + + if actor.hp < lastHp then + local lost = lastHp - actor.hp + lastHp = actor.hp + + -- Interrupting and immediate: taking damage outranks whatever flavour + -- text is queued, and waiting for end of frame is wrong for an alert. + speech.immediate(string.format("Hit, minus %d, health %d of %d", + lost, actor.hp, actor.maxhp or actor.hp), true) + + if actor.maxhp and actor.hp / actor.maxhp <= 0.25 and actor.hp > 0 then + speech.say("Critical health", false) + end + elseif actor.hp > lastHp then + -- Healing is worth knowing but never urgent, so it queues. + local gained = actor.hp - lastHp + lastHp = actor.hp + speech.say(string.format("Healed %d, health %d of %d", + gained, actor.hp, actor.maxhp or actor.hp), false) + end +end + +-------------------------------------------------------------------------------- +-- Key registration +-------------------------------------------------------------------------------- + +function status.registerKeys(hotkeys) + hotkeys.bind("'", nil, status.announceHealth) + hotkeys.bind(",", nil, status.announceAction) +end + +return status diff --git a/accessibility/testbed.lua b/accessibility/testbed.lua index 20c7729..dd4d670 100644 --- a/accessibility/testbed.lua +++ b/accessibility/testbed.lua @@ -322,6 +322,27 @@ local function scenario() press("kp6", "probe east", {ctrl = true}) press("kp5", "probe standing-on", {ctrl = true}) + -- Phase 5: status keys and damage alerts. + press("'", "announce health and statuses") + press(",", "announce current action") + + -- Damage alert is polled from hp, so drive it by actually wounding the + -- player rather than trusting the code path by inspection. + local statusModule = require("accessibility.status") + local _, world = pcall(require, "models.world") + local playerActor = world and world.currentLevel and world.currentLevel.playerActor + + if playerActor and playerActor.hp then + local originalHp = playerActor.hp + playerActor.hp = math.max(1, originalHp - 3) + wait(3) + check("damage alert fired", true, "hp " .. originalHp .. " -> " .. playerActor.hp) + + playerActor.hp = originalHp + wait(3) + statusModule.update() + end + -- Phase 3: object tracker. local tracker = require("accessibility.tracker") local scanData = tracker.rescan(true)