diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index f61382e..c88cd5c 100644 --- a/accessibility/docs/scratch.md +++ b/accessibility/docs/scratch.md @@ -7,7 +7,7 @@ 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, plus journal, character, actions and inventory — -verified by the testbed, **21/21 checks**. +verified by the testbed, **25/25 checks**. ### Run the testbed (do this after any change) ``` @@ -358,12 +358,40 @@ 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. +### Done — Phase 4 remainder: crafting screens, slider, config + +**21 states have descriptors; 4 patches applied.** 25/25 testbed checks. + +**The slider is genuinely generic** — `modules/slider.lua` is a *factory* +(`newSlider(params)`), and one wrapper on it narrates **all four** users: +`craft`, `refine`, `trade` and `ingame_wait` (the wait-N-turns screen). Same +leverage as the `console.print` and `inventoryCursorPopup` patches. + +⚠ **But the options screens do NOT use it.** `config_menu` changes values through +`config.increase` / `config.decrease`, so it needed its own descriptor. It +reports tab + option name + value: +`Allow hitting yourself, No, 3 of 11` → right → `…, Yes, …`. The focus key +includes the value, so left/right re-announce even though focus hasn't moved. + +`accessibility/states/_itemgrid.lua` is now shared by `inventory`, `craft`, +`refine` and `trade` — all four build a `views/inventoryWindow`. It stays silent +while `confirmationWindowEnabled` is set, so the slider owns speech during +quantity selection rather than the two fighting. + +**Ordering note:** `patches/slider.lua` replaces `package.loaded["modules.slider"]`, +so it only affects requires that happen *after* it. Safe because states load +lazily and `applyPatches()` runs before the self-test (the one thing that loads +state files early). + +⚠ **Slider not exercised live.** `ingame_wait` is unreachable in the prologue — +`tutorial_bed = { parent = "bed", tryInteractFunction = function() end }`, i.e. +the tutorial bed does nothing *by design*. `craft`/`refine`/`trade` need an +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. + ### Known gaps (next up) -- **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 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 26b06ff..c9c4807 100644 --- a/accessibility/init.lua +++ b/accessibility/init.lua @@ -40,6 +40,10 @@ local describedStates = { "ingame_message", "menu_message", "inventory", + "craft", + "refine", + "trade", + "config_menu", } local initialized = false @@ -229,6 +233,7 @@ function accessibility.initialize() accessibility.registerPatch("console", require("accessibility.patches.console")) accessibility.registerPatch("popups", require("accessibility.patches.popups")) + accessibility.registerPatch("slider", require("accessibility.patches.slider")) pcall(function() require("accessibility.surroundings").registerKeys(hotkeys) diff --git a/accessibility/patches/slider.lua b/accessibility/patches/slider.lua new file mode 100644 index 0000000..8902969 --- /dev/null +++ b/accessibility/patches/slider.lua @@ -0,0 +1,67 @@ +-- Patch: modules/slider.lua +-- +-- The slider is genuinely generic: `modules/slider.lua` is a factory +-- (`newSlider(params)`) and every quantity slider in the game comes from it. +-- Four states use it: +-- +-- craft -- how many items to process +-- refine -- same +-- trade -- how many to buy/sell +-- ingame_wait -- how many turns to wait +-- +-- So one wrapper on the factory narrates all four, with no per-state work. This +-- is the same leverage as the console.print and inventoryCursorPopup patches. +-- +-- NOTE: the options screens (config_menu / settings_menu) do NOT use this. They +-- adjust values through config.increase / config.decrease instead, and are +-- handled by accessibility/states/config_menu.lua. + +-- ORDERING: this replaces package.loaded, so it only affects requires that happen +-- AFTER it runs. That is safe here because states are loaded lazily by states.lua +-- (each `require("states/"..name)` happens on first switch), and these four are +-- the only requirers. applyPatches() also runs before the self-test, which is the +-- one thing that loads state files early. + +local speech = require("accessibility.speech") + +return function() + local newSlider = require("modules.slider") + + if type(newSlider) ~= "function" then + error("modules.slider is not a factory function -- game structure changed") + end + + package.loaded["modules.slider"] = function(params) + local slider = newSlider(params) + + local realSetValue = slider.setValue + if not realSetValue then + return slider + end + + -- Announce only on an actual change. setValue is called during + -- construction, on every frame of a mouse drag, and on each repeat of a + -- held key -- speaking unconditionally would stutter badly. + local lastSpoken = slider.currentValue + + slider.setValue = function(newValue, noFlip) + local result = realSetValue(newValue, noFlip) + + pcall(function() + local current = slider.currentValue + if current == lastSpoken then + return + end + lastSpoken = current + + -- Interrupt: the value is the answer to the key just pressed, and + -- a backlog of intermediate numbers is worse than useless. + speech.say(tostring(current), true) + end) + + return result + end + + return slider + end +end diff --git a/accessibility/states/_itemgrid.lua b/accessibility/states/_itemgrid.lua new file mode 100644 index 0000000..3acb796 --- /dev/null +++ b/accessibility/states/_itemgrid.lua @@ -0,0 +1,78 @@ +-- Shared descriptor for the item-grid screens: inventory, craft, refine, trade. +-- +-- All four build a `views/inventoryWindow` and move a cursor over a grid of +-- cells. The inventoryCursorPopup patch speaks item details, but ONLY when a cell +-- actually holds an item -- so empty cells and empty tabs were silent and you +-- could not tell the cursor had moved. This owns narration for these screens +-- instead (patches/popups.lua stands down wherever a descriptor exists). +-- +-- Returns a descriptor; call with the state's window upvalue name. + +local itemCategories = require("modules.item.categories") + +return function(options) + options = options or {} + + local windowName = options.window or "inventoryWindow" + local lastTab + + return { + read = function(stateTable, menus) + -- While the quantity confirmation window is open, the slider owns + -- speech (see patches/slider.lua). Announcing the grid cell over the + -- top of it would fight the number the player is adjusting. + local confirming = menus.getAnyUpvalue(stateTable, "confirmationWindowEnabled") + if confirming then + return nil, nil + end + + local window = menus.getAnyUpvalue(stateTable, windowName) + + 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 + if item.isUnavailable then + parts[#parts + 1] = "unavailable" + end + else + parts[#parts + 1] = "empty" + end + + parts[#parts + 1] = "slot " .. position + + return table.concat(parts, ", "), + table.concat({ + tostring(tabIndex), tostring(position), + tostring(ok and item and (item.name or "?")), + }, ":") + end, + } +end diff --git a/accessibility/states/config_menu.lua b/accessibility/states/config_menu.lua new file mode 100644 index 0000000..e6663e4 --- /dev/null +++ b/accessibility/states/config_menu.lua @@ -0,0 +1,64 @@ +-- states/config_menu.lua — the options screens. +-- +-- These do NOT use modules/slider.lua. Values are changed through +-- config.increase / config.decrease on the focused option, so the slider patch +-- never sees them and this descriptor has to report the value itself. +-- +-- Three things move independently here: the tab (tabLeft/tabRight), the option +-- (up/down), and the option's VALUE (left/right). All three must be announced, +-- and the value especially -- pressing right with no feedback is indistinguishable +-- from pressing a dead key. + +local config = require("models.config") + +local lastTab + +return { + read = function(stateTable, menus) + local activeTab = menus.getAnyUpvalue(stateTable, "activeTab") + local selected = menus.getAnyUpvalue(stateTable, "currentSelectedOption") + + if type(activeTab) ~= "table" or type(selected) ~= "number" then + return nil, nil + end + + local options = activeTab.options + if type(options) ~= "table" then + return nil, nil + end + + local option = options[selected] + if not option then + return nil, nil + end + + local parts = {} + + if activeTab.header and activeTab.header ~= lastTab then + lastTab = activeTab.header + parts[#parts + 1] = activeTab.header + end + + -- config.output returns (name, "< value >"). Strip the angle brackets: + -- they are on-screen affordances for the left/right keys, not speech. + local ok, name, value = pcall(config.output, option) + + if ok and name then + parts[#parts + 1] = (name:gsub(":%s*$", "")) + if value then + parts[#parts + 1] = (value:gsub("^<%s*", ""):gsub("%s*>$", "")) + end + else + parts[#parts + 1] = option.name or "?" + end + + parts[#parts + 1] = selected .. " of " .. #options + + -- Key includes the value, so adjusting with left/right re-announces even + -- though the focused option has not changed. + local valueKey = ok and tostring(value) or "?" + + return table.concat(parts, ", "), + table.concat({tostring(activeTab.header), tostring(selected), valueKey}, ":") + end, +} diff --git a/accessibility/states/craft.lua b/accessibility/states/craft.lua new file mode 100644 index 0000000..2d6be63 --- /dev/null +++ b/accessibility/states/craft.lua @@ -0,0 +1,2 @@ +-- states/craft.lua -- shared item-grid descriptor, see _itemgrid.lua +return require("accessibility.states._itemgrid")() diff --git a/accessibility/states/inventory.lua b/accessibility/states/inventory.lua index 622adc5..189e6cf 100644 --- a/accessibility/states/inventory.lua +++ b/accessibility/states/inventory.lua @@ -1,61 +1,2 @@ --- 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, -} +-- states/inventory.lua -- shared item-grid descriptor, see _itemgrid.lua +return require("accessibility.states._itemgrid")() diff --git a/accessibility/states/refine.lua b/accessibility/states/refine.lua new file mode 100644 index 0000000..182f56d --- /dev/null +++ b/accessibility/states/refine.lua @@ -0,0 +1,2 @@ +-- states/refine.lua -- shared item-grid descriptor, see _itemgrid.lua +return require("accessibility.states._itemgrid")() diff --git a/accessibility/states/trade.lua b/accessibility/states/trade.lua new file mode 100644 index 0000000..4e9a2e0 --- /dev/null +++ b/accessibility/states/trade.lua @@ -0,0 +1,2 @@ +-- states/trade.lua -- shared item-grid descriptor, see _itemgrid.lua +return require("accessibility.states._itemgrid")() diff --git a/accessibility/testbed.lua b/accessibility/testbed.lua index a924145..20c7729 100644 --- a/accessibility/testbed.lua +++ b/accessibility/testbed.lua @@ -97,6 +97,46 @@ local function check(label, condition, detail) condition and "PASS" or "FAIL", label, detail and (" -- " .. detail) or "")) end +-- Walk to a tile using the game's own pathfinder, pressing one direction key per +-- step. Hardcoded direction sequences drift as soon as anything moves the player, +-- so navigate by path instead. +local directionKeys = { + ["0,-1"] = "kp8", ["1,-1"] = "kp9", ["1,0"] = "kp6", ["1,1"] = "kp3", + ["0,1"] = "kp2", ["-1,1"] = "kp1", ["-1,0"] = "kp4", ["-1,-1"] = "kp7", +} + +local function walkTo(x, y, note, stopWhenStateChanges) + local ok, world = pcall(require, "models.world") + if not ok or not world.currentLevel then + return false + end + + local level = world.currentLevel + local startState = currentState() + + for _ = 1, 30 do + if stopWhenStateChanges and currentState() ~= startState then + return true + end + + local pathOk, path = pcall(level.calculatePathFromPlayer, x, y) + if not pathOk or not path or #path == 0 then + return currentState() ~= startState + end + + local step = path[1] + local key = directionKeys[step.dx .. "," .. step.dy] + if not key then + return false + end + + press(key, note or ("walking to " .. x .. "," .. y)) + wait(8) + end + + return currentState() ~= startState +end + local function waitForState(stateName, maxFrames) local waited = 0 while currentState() ~= stateName and waited < (maxFrames or 300) do @@ -139,6 +179,64 @@ local function scenario() press("up", "back up") + -- Options -> config_menu. Verified read-only where it matters: changing + -- settings here has real consequences (resolution above all), so we only + -- adjust a value when the focused option is demonstrably safe, and we put it + -- straight back. + if selectByText("Options") then + press("return", "open settings") + wait(15) + + if currentState() == "settings_menu" then + -- Pick "Gameplay" specifically. The first entry is "Keybindings", + -- which opens keybindings_menu rather than config_menu, and "System" + -- holds resolution -- not something to poke at in an automated run. + selectByText("Gameplay") + press("return", "open Gameplay settings") + wait(15) + + if currentState() == "config_menu" then + check("config_menu describes option", focusText() ~= nil, + "focus=" .. tostring(focusText())) + + press("down", "config: next option") + press("down", "config: next option") + + local text = focusText() or "" + local risky = text:lower():find("resolution") + or text:lower():find("fullscreen") + + if risky then + print("[testbed] skipping value change, focused option is risky: " .. text) + else + local before = focusText() + press("right", "config: increase value") + local after = focusText() + check("config value change is announced", before ~= after, + string.format("before=%s after=%s", tostring(before), tostring(after))) + press("left", "config: restore original value") + check("config value restored", focusText() == before, + "now=" .. tostring(focusText())) + end + + press("escape", "leave config_menu") + wait(15) + end + end + + -- Unwind whatever depth we ended at, rather than assuming a fixed number + -- of escapes -- keybindings_menu and config_menu nest differently. + for _ = 1, 4 do + if currentState() == "main_menu" then + break + end + press("escape", "unwind to main menu (" .. currentState() .. ")") + wait(15) + end + end + + check("back at main menu", currentState() == "main_menu", "got " .. currentState()) + check("found New game", selectByText("New game"), "menu navigation by text") press("return", "confirm New game") @@ -314,6 +412,103 @@ local function scenario() end end + -- The quantity slider (patches/slider.lua) is shared by craft, refine, + -- trade and ingame_wait. Only the last is reachable this early: the + -- tutorial house has a bed, and interacting with one opens the + -- "wait N turns" slider. Walking into it triggers interact. + local sliderTested = false + + -- Locate the bed through our own tracker, then path to it. Walking into + -- an interactable triggers interact, which opens the wait slider. + local bedX, bedY + local freshScan = tracker.rescan(true) + for _, kind in ipairs(freshScan and freshScan.interactables and freshScan.interactables.kinds or {}) do + if kind.key and kind.key:find("bed") then + bedX, bedY = kind.instances[1].x, kind.instances[1].y + break + end + end + + if bedX then + print(string.format("[testbed] bed located at %d,%d via tracker", bedX, bedY)) + + -- A bed blocks movement, so calculatePathFromPlayer to its own tile + -- returns nothing. Path to a walkable neighbour first, then step INTO + -- the bed to trigger interact. + local _, world = pcall(require, "models.world") + local level = world and world.currentLevel + + for _ = 1, 12 do + if currentState() ~= "ingame" then + break + end + + local player = level.playerActor + local dx, dy = bedX - player.x, bedY - player.y + + if math.max(math.abs(dx), math.abs(dy)) <= 1 then + local key = directionKeys[ + ((dx > 0 and 1) or (dx < 0 and -1) or 0) .. "," .. + ((dy > 0 and 1) or (dy < 0 and -1) or 0)] + if key then + press(key, "stepping into the bed") + wait(12) + end + break + end + + -- Walk toward whichever neighbour of the bed we can reach. + local moved = false + for _, offset in ipairs({{0,-1},{1,0},{0,1},{-1,0},{1,-1},{1,1},{-1,1},{-1,-1}}) do + local tx, ty = bedX + offset[1], bedY + offset[2] + local pathOk, path = pcall(level.calculatePathFromPlayer, tx, ty) + if pathOk and path and #path > 0 then + local key = directionKeys[path[1].dx .. "," .. path[1].dy] + if key then + press(key, "walking toward the bed") + wait(8) + moved = true + break + end + end + end + + if not moved then + break + end + end + else + print("[testbed] no bed found in tracker scan") + end + + if currentState() == "ingame_wait" then + sliderTested = true + check("bed opened the wait slider", true, "state=ingame_wait") + press("right", "slider: increase") + press("right", "slider: increase") + press("left", "slider: decrease") + press("escape", "leave wait screen") + wait(15) + else + -- Deliberately NOT a failing check. `tutorial_bed` overrides bed's + -- tryInteractFunction with an empty function: + -- + -- tutorial_bed = { parent = "bed", tryInteractFunction = function() end } + -- + -- so the prologue bed does nothing by design and ingame_wait is + -- unreachable here. The other slider users (craft, refine, trade) need + -- an alchemy table and actual items. The patch is confirmed applied by + -- the startup self-test, but stays unexercised until the scenario can + -- reach a real bed or a crafting station. + print("[testbed] NOTE: ingame_wait unreachable in the prologue " .. + "(tutorial_bed has an empty tryInteractFunction by design); " .. + "slider patch applied but not exercised") + end + + if sliderTested then + check("slider announces value changes", true, "via ingame_wait") + end + -- The game's own examine mode. press("x", "enter examine mode") wait(10)