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)