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 <noreply@anthropic.com>
This commit is contained in:
Accessibility Mod
2026-08-01 22:20:50 +02:00
co-authored by Claude Opus 5
parent 50b226620e
commit 96ec79ed75
8 changed files with 421 additions and 4 deletions
+43 -3
View File
@@ -3,11 +3,11 @@
**Read this first on any handoff.** Living document: update it whenever state changes, before ending a turn. **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. 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 **Game is playable start-to-finish by ear** from main menu through the intro
conversation into gameplay, plus journal, character, actions and inventory — 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) ### 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** | | `PageUp` / `PageDown` | Tracker: previous / next **kind** |
| `Shift+PageUp` / `Shift+PageDown` | Tracker: previous / next **instance** | | `Shift+PageUp` / `Shift+PageDown` | Tracker: previous / next **instance** |
| `Home` | Tracker: distance, direction and path length to current 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 Game keys worth knowing: **`x`** enters examine mode (not `e`), `f` finds exits
while examining, `j` journal, `i` inventory, `c` character, `a` actions menu. 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 the testbed has the walk-to-bed machinery ready (`walkTo`, tracker lookup) for
when a real bed is reachable. 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) ### Known gaps (next up)
- **Phase 4 remainder**: `research` and `keybindings_menu` have no descriptors.
- **Phase 5**: player status key (HP, statuses, current action), popup - **Phase 5**: player status key (HP, statuses, current action), popup
announcements (`popupStack.add`), damage alerts announcements (`popupStack.add`), damage alerts
(`ingame.checkInterruptions`), mod settings. (`ingame.checkInterruptions`), mod settings.
+5
View File
@@ -44,6 +44,8 @@ local describedStates = {
"refine", "refine",
"trade", "trade",
"config_menu", "config_menu",
"keybindings_menu",
"research",
} }
local initialized = false local initialized = false
@@ -160,6 +162,7 @@ local function hookStates()
end) end)
pcall(walltones.update) pcall(walltones.update)
pcall(require("accessibility.status").update)
pcall(speech.flush) pcall(speech.flush)
if testbedActive then if testbedActive then
pcall(testbed.update, dt) pcall(testbed.update, dt)
@@ -234,10 +237,12 @@ function accessibility.initialize()
accessibility.registerPatch("console", require("accessibility.patches.console")) accessibility.registerPatch("console", require("accessibility.patches.console"))
accessibility.registerPatch("popups", require("accessibility.patches.popups")) accessibility.registerPatch("popups", require("accessibility.patches.popups"))
accessibility.registerPatch("slider", require("accessibility.patches.slider")) accessibility.registerPatch("slider", require("accessibility.patches.slider"))
accessibility.registerPatch("popupstack", require("accessibility.patches.popupstack"))
pcall(function() pcall(function()
require("accessibility.surroundings").registerKeys(hotkeys) require("accessibility.surroundings").registerKeys(hotkeys)
require("accessibility.tracker").registerKeys(hotkeys) require("accessibility.tracker").registerKeys(hotkeys)
require("accessibility.status").registerKeys(hotkeys)
end) end)
local ok, err = pcall(function() local ok, err = pcall(function()
+80
View File
@@ -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
+11 -1
View File
@@ -124,7 +124,17 @@ end
-- damage) where waiting for end-of-frame is wrong. -- damage) where waiting for end-of-frame is wrong.
function speech.immediate(text, interrupt) function speech.immediate(text, interrupt)
text = normalise(text) 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 return
end end
+57
View File
@@ -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,
}
+48
View File
@@ -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,
}
+156
View File
@@ -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
+21
View File
@@ -322,6 +322,27 @@ local function scenario()
press("kp6", "probe east", {ctrl = true}) press("kp6", "probe east", {ctrl = true})
press("kp5", "probe standing-on", {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. -- Phase 3: object tracker.
local tracker = require("accessibility.tracker") local tracker = require("accessibility.tracker")
local scanData = tracker.rescan(true) local scanData = tracker.rescan(true)