Phase 4 remainder: crafting screens, slider, config options

Game-file footprint unchanged: still one line in main.lua.

The slider turned out to be genuinely generic: modules/slider.lua is a factory,
so 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.

The options screens do NOT share it, though: config_menu changes values through
config.increase/config.decrease, so it needed its own descriptor. It reports tab,
option name and value, and its focus key includes the value so left/right
re-announce even though the focused option has not moved.

states/_itemgrid.lua is now shared by inventory, craft, refine and trade, all of
which build a views/inventoryWindow. It stays silent while
confirmationWindowEnabled is set so the slider owns speech during quantity
selection instead of the two fighting.

testbed gains walkTo(), which navigates using the game's own pathfinder rather
than hardcoded direction sequences -- those drift as soon as anything moves the
player. The config test only adjusts a value when the focused option is
demonstrably safe, and puts it straight back.

The slider patch is applied and confirmed by the self-test but not exercised
live: ingame_wait is unreachable in the prologue because tutorial_bed overrides
bed's tryInteractFunction with an empty function by design, and the crafting
screens need an alchemy table and real items. Recorded as a NOTE rather than a
failing check, since it is a property of the game and not of the mod.

25/25 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:12:56 +02:00
co-authored by Claude Opus 5
parent d8bec6a3a8
commit 50b226620e
10 changed files with 451 additions and 67 deletions
+34 -6
View File
@@ -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 **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, **21/21 checks**. verified by the testbed, **25/25 checks**.
### Run the testbed (do this after any change) ### 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 The `inventory` descriptor reports item-or-empty plus slot and tab; letting both
speak would double up on every item. 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) ### Known gaps (next up)
- **Phase 4 remainder**: `research`, `config_menu`, `keybindings_menu` have no - **Phase 4 remainder**: `research` and `keybindings_menu` have no descriptors.
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 - **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
@@ -40,6 +40,10 @@ local describedStates = {
"ingame_message", "ingame_message",
"menu_message", "menu_message",
"inventory", "inventory",
"craft",
"refine",
"trade",
"config_menu",
} }
local initialized = false local initialized = false
@@ -229,6 +233,7 @@ 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"))
pcall(function() pcall(function()
require("accessibility.surroundings").registerKeys(hotkeys) require("accessibility.surroundings").registerKeys(hotkeys)
+67
View File
@@ -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
+78
View File
@@ -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
+64
View File
@@ -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,
}
+2
View File
@@ -0,0 +1,2 @@
-- states/craft.lua -- shared item-grid descriptor, see _itemgrid.lua
return require("accessibility.states._itemgrid")()
+2 -61
View File
@@ -1,61 +1,2 @@
-- states/inventory.lua — the item grid. -- states/inventory.lua -- shared item-grid descriptor, see _itemgrid.lua
-- return require("accessibility.states._itemgrid")()
-- 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,
}
+2
View File
@@ -0,0 +1,2 @@
-- states/refine.lua -- shared item-grid descriptor, see _itemgrid.lua
return require("accessibility.states._itemgrid")()
+2
View File
@@ -0,0 +1,2 @@
-- states/trade.lua -- shared item-grid descriptor, see _itemgrid.lua
return require("accessibility.states._itemgrid")()
+195
View File
@@ -97,6 +97,46 @@ local function check(label, condition, detail)
condition and "PASS" or "FAIL", label, detail and (" -- " .. detail) or "")) condition and "PASS" or "FAIL", label, detail and (" -- " .. detail) or ""))
end 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 function waitForState(stateName, maxFrames)
local waited = 0 local waited = 0
while currentState() ~= stateName and waited < (maxFrames or 300) do while currentState() ~= stateName and waited < (maxFrames or 300) do
@@ -139,6 +179,64 @@ local function scenario()
press("up", "back up") 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") check("found New game", selectByText("New game"), "menu navigation by text")
press("return", "confirm New game") press("return", "confirm New game")
@@ -314,6 +412,103 @@ local function scenario()
end end
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. -- The game's own examine mode.
press("x", "enter examine mode") press("x", "enter examine mode")
wait(10) wait(10)