diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index 9fc2e82..34c6175 100644 --- a/accessibility/docs/scratch.md +++ b/accessibility/docs/scratch.md @@ -3,7 +3,7 @@ **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 (remaining menus) next.** +Phase 4 mostly done. Phase 5 (status, popups) next.** **Game is playable start-to-finish by ear** from main menu through the intro conversation into gameplay — verified by the testbed, **13/13 checks**. @@ -324,17 +324,52 @@ they survive. 13 kinds/32 instances → 12 kinds/19 instances. `chest_tutorial` literally prints "find nothing of use for now" — it has no loot yet. Verify against a later area before trusting those two categories.)* +### Done — Phase 4 (most of it): menu descriptors + +**16 states now have descriptors** — all registered in `describedStates` in +`init.lua`, all resolving in the startup self-test: + +`main_menu` · `settings_menu` · `load_game` · `new_game` · `narration` · +`ingame_dialogue` · `ingame_observe` · `actions_menu` · `worldmap` · +`alchemy_table` · `journal` · `character` · `inventory` · +`menu_text` / `ingame_message` / `menu_message` (shared `_textscreen.lua`) + +Verified live: `Journal. Quests, A simple commission, 1 of 1` · +`Character, 1 of 3` · `Actions. Interact, 1 of 1` · +`Inventory. All items, empty, slot 1`. **21/21 testbed checks.** + +#### Two driver improvements (both prompted by real failures) + +- **`menus.getAnyUpvalue`** — finds a file-local across *all* of a state's + functions plus one level into inner closures. Safe because a Lua file-local is + a single shared upvalue, so any closure referencing it sees the same variable. + Needed because `actions_menu` and `worldmap` build their lists in `open()` and + never mention them in `keypressed`. The generic probe path now falls back to it. +- **`menus.hasUpvalue`** — checks a local *exists* rather than *holds a value*. + `getUpvalue` can't tell "no such local" from "declared but nil", which made the + self-test cry PROBE BROKEN on `worldmap` (whose `local options` is nil until + the screen opens). Self-test now reports `ok (unset until opened)`. + +#### Ownership rule: descriptors beat the popup patch +`patches/popups.lua` now **stands down for any state that has a descriptor.** +`inventoryCursorPopup.set` only fires when there *is* an item, so on an empty +slot or empty tab it said nothing — you couldn't tell whether the cursor moved. +The `inventory` descriptor reports item-or-empty plus slot and tab; letting both +speak would double up on every item. + ### Known gaps (next up) -- **Phase 4**: descriptors for `worldmap`, `journal`, `character`, `research`, - `inventory`, `craft`, `refine`, `trade`, `actions_menu`, `alchemy_table`, - `keybindings_menu`, `config_menu`, `menu_text`, `ingame_message`, - `menu_message`. (`inventory`/`craft`/`refine`/`trade` are partly covered - already by the `inventoryCursorPopup` patch.) +- **Phase 4 remainder**: `research`, `config_menu`, `keybindings_menu` have no + descriptors. `craft` / `refine` / `trade` are covered only by the + `inventoryCursorPopup` patch — so, like inventory was, they will be silent on + empty slots and say nothing about the quantity **slider** in the confirmation + window (`modules/slider.lua`, Left/Right adjusts, Shift for ×5). - **Phase 5**: player status key (HP, statuses, current action), popup - announcements, damage alerts, mod settings. + announcements (`popupStack.add`), damage alerts + (`ingame.checkInterruptions`), mod settings. - `ingame` itself has no descriptor. -- Tracker categories Items/Alchemy/Exits/Creatures/Terrain unverified against a - populated area — the tutorial house has none of them. +- **Unverified against real content:** tracker categories Items / Alchemy / + Exits / Creatures / Terrain (tutorial house has none), and inventory item + names (inventory is empty in the prologue). - `worldmap`, `journal`, `character`, `research`, `inventory`, `craft`, `refine`, `trade`, `actions_menu`, `alchemy_table`, `keybindings_menu`, `config_menu`, `menu_text`, `ingame_message`, `menu_message` still have no descriptors. diff --git a/accessibility/init.lua b/accessibility/init.lua index 5e33d59..26b06ff 100644 --- a/accessibility/init.lua +++ b/accessibility/init.lua @@ -31,6 +31,15 @@ local describedStates = { "narration", "ingame_dialogue", "ingame_observe", + "actions_menu", + "worldmap", + "alchemy_table", + "journal", + "character", + "menu_text", + "ingame_message", + "menu_message", + "inventory", } local initialized = false diff --git a/accessibility/menus.lua b/accessibility/menus.lua index 3786670..057070b 100644 --- a/accessibility/menus.lua +++ b/accessibility/menus.lua @@ -47,6 +47,52 @@ end menus.getUpvalue = getUpvalue +-- Find a file-local by name across ALL of a state's exported functions, and one +-- level into any inner functions they close over. +-- +-- Safe because a Lua file-local is a single shared upvalue: every closure in that +-- file that references it sees the same variable, so whichever function we reach +-- it through yields the same value. This saves hand-picking a probe function per +-- descriptor, and survives the common case where the local we want is touched by +-- draw() or an inner helper rather than by keypressed(). +function menus.getAnyUpvalue(stateTable, name) + if type(stateTable) ~= "table" then + return nil + end + + local nested = {} + + for _, fn in pairs(stateTable) do + if type(fn) == "function" then + local value = getUpvalue(fn, name) + if value ~= nil then + return value + end + + local i = 1 + while true do + local upName, upValue = debug.getupvalue(fn, i) + if not upName then + break + end + if type(upValue) == "function" then + nested[#nested + 1] = upValue + end + i = i + 1 + end + end + end + + for _, fn in ipairs(nested) do + local value = getUpvalue(fn, name) + if value ~= nil then + return value + end + end + + return nil +end + -- IMPORTANT: states.lua loads states with require("states/"..name) -- a SLASH. -- require() caches on the exact string, so require("states.main_menu") would load -- a SECOND, independent copy of the file with its own upvalues, and we would be @@ -62,6 +108,63 @@ end menus.getStateTable = getStateTable +-- Does a file-local of this name EXIST, regardless of its current value? +-- +-- getUpvalue returns the value, so it cannot tell "no such local" from "local +-- exists but is currently nil". That distinction matters for the self-test: +-- several states declare `local options` at the top and only assign it in +-- open(), so before the screen is first visited the value is legitimately nil. +-- Treating that as a broken probe produced a false alarm on worldmap. +function menus.hasUpvalue(stateTable, name) + if type(stateTable) ~= "table" then + return false + end + + local function scan(fn) + local i = 1 + while true do + local upName = debug.getupvalue(fn, i) + if not upName then + return false + end + if upName == name then + return true + end + i = i + 1 + end + end + + local nested = {} + + for _, fn in pairs(stateTable) do + if type(fn) == "function" then + if scan(fn) then + return true + end + + local i = 1 + while true do + local upName, upValue = debug.getupvalue(fn, i) + if not upName then + break + end + if type(upValue) == "function" then + nested[#nested + 1] = upValue + end + i = i + 1 + end + end + end + + for _, fn in ipairs(nested) do + if scan(fn) then + return true + end + end + + return false +end + -------------------------------------------------------------------------------- -- Descriptors -------------------------------------------------------------------------------- @@ -111,9 +214,21 @@ local function readFocus(stateName) return nil, nil end + -- Try the named probe function first, then fall back to searching every + -- function in the state. Several states (actions_menu, worldmap) build their + -- list in open() and never mention it in keypressed, so the named function + -- alone finds nothing. local probeFn = stateTable[probe.fn or "keypressed"] + local index = getUpvalue(probeFn, probe.focus) + if index == nil then + index = menus.getAnyUpvalue(stateTable, probe.focus) + end + local list = getUpvalue(probeFn, probe.list) + if list == nil then + list = menus.getAnyUpvalue(stateTable, probe.list) + end if type(index) ~= "number" or type(list) ~= "table" then return nil, nil @@ -216,18 +331,19 @@ function menus.selfTest(stateList) if not stateTable then results[stateName] = "STATE NOT LOADABLE" else - local probeFn = stateTable[descriptor.probe.fn or "keypressed"] - local index = getUpvalue(probeFn, descriptor.probe.focus) - local list = getUpvalue(probeFn, descriptor.probe.list) + -- Check that the NAMES exist, not that they currently hold values. + -- Most of these are assigned in open(), so before the screen has + -- been visited they are legitimately nil. + local hasFocus = menus.hasUpvalue(stateTable, descriptor.probe.focus) + local hasList = menus.hasUpvalue(stateTable, descriptor.probe.list) - -- index is often nil until the screen is opened; the list existing - -- is the real signal that the probe still points at something. - if list == nil then + if not hasList then results[stateName] = "PROBE BROKEN: no upvalue '" .. tostring(descriptor.probe.list) .. "'" - elseif index == nil and not descriptor.probe.focusMayBeNil then - results[stateName] = "probe partial: '" .. tostring(descriptor.probe.focus) .. "' nil (may be normal before open)" + elseif not hasFocus then + results[stateName] = "PROBE BROKEN: no upvalue '" .. tostring(descriptor.probe.focus) .. "'" else - results[stateName] = "ok" + local list = menus.getAnyUpvalue(stateTable, descriptor.probe.list) + results[stateName] = list == nil and "ok (unset until opened)" or "ok" end end end diff --git a/accessibility/patches/popups.lua b/accessibility/patches/popups.lua index e7a1c88..74c5490 100644 --- a/accessibility/patches/popups.lua +++ b/accessibility/patches/popups.lua @@ -55,6 +55,16 @@ return function() local result = realInventorySet(lines, ...) pcall(function() + -- Stand down for any state that has its own descriptor. A descriptor + -- knows about empty slots and tab changes, which this popup never sees + -- (it is only called when there IS an item), so letting both speak + -- would double up on every item. + local states = require("states") + local menus = require("accessibility.menus") + if menus.loadDescriptor(states.getCurrent()) then + return + end + local text = linesToText(lines) if not text then diff --git a/accessibility/states/_textscreen.lua b/accessibility/states/_textscreen.lua new file mode 100644 index 0000000..72327ed --- /dev/null +++ b/accessibility/states/_textscreen.lua @@ -0,0 +1,64 @@ +-- Shared descriptor for the full-screen prose states: menu_text (manual, +-- credits), ingame_message and menu_message. +-- +-- These have no focus cursor -- the whole screen is one block of text. Long ones +-- (the manual runs to thousands of words) are unusable as a single utterance, so +-- the text is ALSO pushed into the console history. That makes the existing +-- review keys ([ and ]) work on it: you can walk the manual paragraph by +-- paragraph, jump to the start with Shift+[, and re-read with \. + +local history = require("accessibility.history") + +-- Split on blank lines first, falling back to single newlines. Paragraph-sized +-- chunks read far better than either one huge utterance or line-by-line +-- fragments broken mid-sentence by the game's own wrapping. +local function paragraphs(text) + local chunks = {} + + for chunk in (text .. "\n\n"):gmatch("(.-)\n%s*\n") do + chunk = chunk:gsub("^%s+", ""):gsub("%s+$", "") + if chunk ~= "" then + chunks[#chunks + 1] = chunk + end + end + + if #chunks == 0 then + for line in text:gmatch("[^\n]+") do + line = line:gsub("^%s+", ""):gsub("%s+$", "") + if line ~= "" then + chunks[#chunks + 1] = line + end + end + end + + return chunks +end + +local lastText + +return { + read = function(stateTable, menus) + local text = menus.getAnyUpvalue(stateTable, "messageText") + + if type(text) ~= "string" or text == "" then + return nil, nil + end + + if text == lastText then + return nil, nil + end + lastText = text + + -- Feed the review buffer so [ and ] can walk it. + local chunks = paragraphs(text) + for _, chunk in ipairs(chunks) do + pcall(history.append, chunk, "text") + end + + local summary = #chunks > 1 + and string.format("%s. %d paragraphs, use review keys to read.", chunks[1], #chunks) + or chunks[1] + + return summary, text + end, +} diff --git a/accessibility/states/actions_menu.lua b/accessibility/states/actions_menu.lua new file mode 100644 index 0000000..350cf11 --- /dev/null +++ b/accessibility/states/actions_menu.lua @@ -0,0 +1,22 @@ +-- states/actions_menu.lua — the player's available actions. +-- +-- open() preselects whichever entry matches player.currentSelectedAction, so the +-- announcement on entry tells you what is already armed. + +return { + probe = { + fn = "keypressed", + focus = "selectedOption", + list = "optionsList", + }, + + render = function(entry, index, total) + local text = entry.title or entry.name or "?" + + if entry.enabled == false then + text = text .. ", unavailable" + end + + return text .. ", " .. index .. " of " .. total + end, +} diff --git a/accessibility/states/alchemy_table.lua b/accessibility/states/alchemy_table.lua new file mode 100644 index 0000000..59ae32a --- /dev/null +++ b/accessibility/states/alchemy_table.lua @@ -0,0 +1,37 @@ +-- states/alchemy_table.lua — sort / grind / distill. +-- +-- The options carry no title, only a `description` and a `refinementType`. The +-- refinement type is the actual name of the operation, so lead with that and let +-- the description follow. +-- +-- Distillation is gated behind a story flag, and open() recomputes `enabled` +-- every time -- announcing unavailability matters, since otherwise selecting it +-- appears to do nothing. + +return { + probe = { + fn = "keypressed", + focus = "selectedOption", + list = "craftingOptions", + }, + + render = function(entry, index, total) + local name = entry.refinementType or "?" + name = name:sub(1, 1):upper() .. name:sub(2) + + local parts = {name} + + if entry.enabled == false then + parts[#parts + 1] = "unavailable" + if entry.disabledDescription then + parts[#parts + 1] = entry.disabledDescription + end + elseif entry.description then + parts[#parts + 1] = entry.description + end + + parts[#parts + 1] = index .. " of " .. total + + return table.concat(parts, ", ") + end, +} diff --git a/accessibility/states/character.lua b/accessibility/states/character.lua new file mode 100644 index 0000000..2de2bd8 --- /dev/null +++ b/accessibility/states/character.lua @@ -0,0 +1,23 @@ +-- states/character.lua — Character / Status / Weather tabs. +-- +-- Purely tabbed: there is no per-entry cursor, the tab body is just drawn. So we +-- announce the tab, and the body is left to the dedicated status key (Phase 5). + +return { + read = function(stateTable, menus) + local tabIndex = menus.getAnyUpvalue(stateTable, "activeTabIndex") + local tabsList = menus.getAnyUpvalue(stateTable, "tabsList") + + if type(tabIndex) ~= "number" or type(tabsList) ~= "table" then + return nil, nil + end + + local tab = tabsList[tabIndex] + if not tab then + return nil, nil + end + + return string.format("%s, %d of %d", tab.header or "?", tabIndex, #tabsList), + tostring(tabIndex) + end, +} diff --git a/accessibility/states/ingame_message.lua b/accessibility/states/ingame_message.lua new file mode 100644 index 0000000..d819642 --- /dev/null +++ b/accessibility/states/ingame_message.lua @@ -0,0 +1,2 @@ +-- states/ingame_message.lua -- see _textscreen.lua +return require("accessibility.states._textscreen") diff --git a/accessibility/states/inventory.lua b/accessibility/states/inventory.lua new file mode 100644 index 0000000..622adc5 --- /dev/null +++ b/accessibility/states/inventory.lua @@ -0,0 +1,61 @@ +-- states/inventory.lua — the item grid. +-- +-- The inventoryCursorPopup patch already speaks item details, but only when an +-- item is actually under the cursor. Moving onto an empty slot, or switching to +-- an empty tab, produced silence -- so you could not tell whether the cursor had +-- moved, which tab you were on, or that the tab was simply empty. +-- +-- This descriptor owns narration for this screen instead: it reports the item if +-- there is one, "empty" if not, and the category whenever the tab changes. +-- patches/popups.lua stands down for any state that has a descriptor, so the two +-- never double-speak. + +local itemCategories = require("modules.item.categories") + +local lastTab + +return { + read = function(stateTable, menus) + local window = menus.getAnyUpvalue(stateTable, "inventoryWindow") + + if type(window) ~= "table" or not window.getItemOnCursor then + return nil, nil + end + + local position = window.cursorPosition + if type(position) ~= "number" then + return nil, nil + end + + -- activeTabIndex lives inside the window closure, not the state file, so + -- reach it through one of the window's own methods. + local tabIndex + if window.adjustTab then + tabIndex = menus.getUpvalue(window.adjustTab, "activeTabIndex") + end + + local parts = {} + + if tabIndex and tabIndex ~= lastTab then + lastTab = tabIndex + local category = itemCategories[tabIndex] + parts[#parts + 1] = (category and category.header) or ("tab " .. tabIndex) + end + + local ok, item = pcall(window.getItemOnCursor) + + if ok and item then + parts[#parts + 1] = item.name or item.templateId or "item" + if item.quantity and item.quantity > 1 then + parts[#parts + 1] = "x" .. item.quantity + end + else + parts[#parts + 1] = "empty" + end + + parts[#parts + 1] = "slot " .. position + + return table.concat(parts, ", "), + tostring(tabIndex) .. ":" .. tostring(position) .. ":" .. tostring(ok and item and (item.name or "?")) + end, +} diff --git a/accessibility/states/journal.lua b/accessibility/states/journal.lua new file mode 100644 index 0000000..d2130f2 --- /dev/null +++ b/accessibility/states/journal.lua @@ -0,0 +1,50 @@ +-- states/journal.lua — quests, lore, bestiary, knowledge, research, tutorials. +-- +-- Two axes move independently: the tab (left/right) and the entry within it +-- (up/down). Both must be announced, and the tab header must lead when it +-- changes, since "Quests" and "Bestiary" list very different things. +-- +-- Uses getAnyUpvalue because activeTabIndex, options and selectedOption are +-- touched by different functions here (gotoTab, refreshOptions, keypressed). + +local lastTab + +return { + read = function(stateTable, menus) + local tabIndex = menus.getAnyUpvalue(stateTable, "activeTabIndex") + local tabsList = menus.getAnyUpvalue(stateTable, "tabsList") + local options = menus.getAnyUpvalue(stateTable, "options") + local selected = menus.getAnyUpvalue(stateTable, "selectedOption") + + if type(tabIndex) ~= "number" or type(tabsList) ~= "table" then + return nil, nil + end + + local tab = tabsList[tabIndex] + local tabName = tab and tab.header or ("tab " .. tabIndex) + + local parts = {} + + -- Lead with the tab only when it just changed, so stepping through a long + -- quest list doesn't repeat "Quests" on every line. + if tabIndex ~= lastTab then + lastTab = tabIndex + parts[#parts + 1] = tabName + end + + if type(options) == "table" and type(selected) == "number" and options[selected] then + local entry = options[selected] + parts[#parts + 1] = entry.title or entry.header or entry.name or "?" + parts[#parts + 1] = selected .. " of " .. #options + elseif type(options) == "table" and #options == 0 then + parts[#parts + 1] = "empty" + end + + if #parts == 0 then + return nil, nil + end + + return table.concat(parts, ", "), + tostring(tabIndex) .. ":" .. tostring(selected) .. ":" .. tostring(options and #options) + end, +} diff --git a/accessibility/states/menu_message.lua b/accessibility/states/menu_message.lua new file mode 100644 index 0000000..1a92b51 --- /dev/null +++ b/accessibility/states/menu_message.lua @@ -0,0 +1,2 @@ +-- states/menu_message.lua -- see _textscreen.lua +return require("accessibility.states._textscreen") diff --git a/accessibility/states/menu_text.lua b/accessibility/states/menu_text.lua new file mode 100644 index 0000000..371c383 --- /dev/null +++ b/accessibility/states/menu_text.lua @@ -0,0 +1,2 @@ +-- states/menu_text.lua -- see _textscreen.lua +return require("accessibility.states._textscreen") diff --git a/accessibility/states/worldmap.lua b/accessibility/states/worldmap.lua new file mode 100644 index 0000000..f138931 --- /dev/null +++ b/accessibility/states/worldmap.lua @@ -0,0 +1,32 @@ +-- states/worldmap.lua — travel destinations. +-- +-- Entries are areas, sorted by the screen's own comparator. `visitedMessage` +-- ("Visited 3 days ago") is the game's own string and worth speaking: on a map of +-- similar-sounding place names it is the main way to tell where you have been. + +return { + probe = { + fn = "keypressed", + focus = "selectedOption", + list = "options", + focusMayBeNil = true, + }, + + render = function(entry, index, total) + local parts = {entry.name or "Unknown area"} + + if entry.isCompleted then + parts[#parts + 1] = "completed" + end + if entry.isFavourite then + parts[#parts + 1] = "favourite" + end + if entry.visitedMessage and entry.visitedMessage ~= "" then + parts[#parts + 1] = entry.visitedMessage + end + + parts[#parts + 1] = index .. " of " .. total + + return table.concat(parts, ", ") + end, +} diff --git a/accessibility/testbed.lua b/accessibility/testbed.lua index 1907959..a924145 100644 --- a/accessibility/testbed.lua +++ b/accessibility/testbed.lua @@ -285,6 +285,35 @@ local function scenario() press("pagedown", "tracker: next instance", {shift = true}) press("home", "tracker: announce location") + -- Phase 4: the menu screens reachable from gameplay. + -- + -- Each is opened, navigated once, and closed. `back` is bound to x/escape/ + -- backspace, but x also opens examine mode, so use escape. + local screens = { + {key = "j", state = "journal", nav = {"down", "right"}}, + {key = "c", state = "character", nav = {"right", "right"}}, + {key = "a", state = "actions_menu", nav = {"down"}}, + {key = "i", state = "inventory", nav = {"right", "down"}}, + } + + for _, screen in ipairs(screens) do + press(screen.key, "open " .. screen.state) + wait(10) + + local reached = currentState() == screen.state + check("opened " .. screen.state, reached, "got " .. currentState()) + + if reached then + for _, key in ipairs(screen.nav) do + press(key, screen.state .. ": " .. key) + end + check(screen.state .. " describes focus", focusText() ~= nil, + "focus=" .. tostring(focusText())) + press("escape", "close " .. screen.state) + wait(15) + end + end + -- The game's own examine mode. press("x", "enter examine mode") wait(10)