From 55b19dd5370fdb7cf9cfa26b3a546e01dcf07e75 Mon Sep 17 00:00:00 2001 From: Accessibility Mod Date: Mon, 3 Aug 2026 02:19:51 +0200 Subject: [PATCH] hotbar: say what the key you pressed just did Pressing a hotbar key ran hotkey.action(state), which for an equipment binding calls an itemActions entry's onActivation directly -- Wield, Drink, Switch on, Pack away. None of those reach console.print or a popup; most only play a sound. So the whole hotbar was silent: no item name, no verb, no confirmation the key had registered at all. patches/hotbar.lua wraps the hotbar VIEW factory, not models/hotkey: models/hotbar.lua captures newHotkey as a file-local at require time, and models/world.lua captures newHotbar the same way, both long before our entry point. views/hotbar.lua is required only by the three states, which load lazily -- the same ordering argument as patches/slider.lua. The verb is computed BEFORE the action runs. The itemAction conditions key off item.isEquipped, which the action itself flips, so asked afterwards they return the opposite verb and a press that wielded would announce "Unwield". Also narrates the empty slot, the silent no-applicable-action branch, binding and unbinding (sound-only in game), and action-type slots. Says nothing when the item is missing from the inventory -- the game prints that itself and patches/console.lua speaks it. 78 testbed checks, 0 failures. The prologue gives the player no items, so the live path is exercised by planting a torch, binding it to slot 1, pressing 1 twice and putting the world back. Co-Authored-By: Claude Opus 5 --- accessibility/docs/scratch.md | 56 ++++++- accessibility/init.lua | 1 + accessibility/patches/hotbar.lua | 250 +++++++++++++++++++++++++++++++ accessibility/testbed.lua | 104 +++++++++++++ 4 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 accessibility/patches/hotbar.lua diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index 2cee2e2..d6ed781 100644 --- a/accessibility/docs/scratch.md +++ b/accessibility/docs/scratch.md @@ -7,7 +7,7 @@ fixing what Talon hits. Remaining work is polish, mod settings and 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, **71/71 checks**. +verified by the testbed, **78/78 checks**. **Not set up yet, or unsure how the two trees relate?** → [00-dev-setup.md](00-dev-setup.md). @@ -740,6 +740,56 @@ tile back** — otherwise only the "nothing here" branch would ever run. Plus th pure phrasing checks, the FoV invariant over the real grid, the `levelWindow` probe, and an end-to-end `s` press. +### Done — hotbar keys 1–0 (bug fix, reported by Talon) + +**Symptom:** bind an item to the hotbar, press its key, and you hear the sound +the action plays — and nothing else. No item name, no verb, no confirmation that +the key registered at all. + +**Cause.** `views/hotbar.lua:checkKeypress` resolves the slot and calls +`hotkey.action(state)` (`models/hotkey.lua`), which for an equipment binding +looks up the applicable entry in `modules/item/itemActions.lua` and calls its +`onActivation` **directly** — Wield, Unwield, Drink, Switch on, Pack away… None +of those go through `console.print` or a popup; most only play a sound. So the +whole hotbar was silent by construction. + +**`accessibility/patches/hotbar.lua`** (7th patch) wraps the hotbar **view** +factory. Live text: `Torch, Wield` → `Torch, Unwield`. + +Decisions worth not relitigating: + +- **The view, not the model.** `models/hotbar.lua` captures `newHotkey` as a + file-local at require time, and `models/world.lua` captures `newHotbar` the + same way — both long before we run, so replacing `package.loaded` for either + would change nothing. `views/hotbar.lua` is required only by + `states/{ingame,inventory,actions_menu}`, which load lazily; same ordering + argument as `patches/slider.lua`. +- **The verb is computed BEFORE the action runs.** The itemAction conditions key + off `item.isEquipped`, which the action itself flips — asked afterwards they + return the opposite verb, so a press that wielded would announce "Unwield". +- **`hotkey.pack()` is the only public view of a hotkey** (`{type, command, + index, itemTemplateId}`); everything else in it is a closure local. + `hotbarModel` is tracked by wrapping `refreshModel` rather than probed, since + it is swapped when the player changes hotbar page. +- **`actionIdsFor` mirrors a file-local table** (`itemActionsByEquipmentCommand` + in `models/hotkey.lua`) that has no getter. It is exported and canaried by the + testbed: every id must still exist and still carry a header. +- **Item not in inventory: we say nothing.** The game already prints + `" not found in your inventory."` and `patches/console.lua` speaks it. +- **Empty slot** is announced (`Hotbar 1, empty`) — silence is indistinguishable + from a dropped key. So is the silent "no applicable action" branch, except + where `autoSwitchToThrow` fires, which reports the new action instead. +- **Binding and unbinding are narrated too** (`self.bind`), since those are + sound-only as well: `Torch bound to hotbar 1` / `Hotbar 1 cleared`. +- Action-type hotkeys (hit/interact/throw/push) report the resulting action with + `status.lua`'s wording: `Action: Throw`. + +**78 testbed checks, 0 failures** (was 71). The prologue gives the player no +items, so the live path is exercised by **planting a torch, binding it to slot 1, +pressing `1` twice, and putting the world back** (a lit torch would otherwise +change FoV for the viewwatch and loot checks that follow). Plus the header-table +canary and the `Wield`/`Unwield` flip. + ### Known gaps (next up) - **Phase 5**: player status key (HP, statuses, current action), popup announcements (`popupStack.add`), damage alerts @@ -804,7 +854,7 @@ utterance — speech is verifiable from the log, not only by ear. `!` after ## HANDOFF — read this if you are resuming ### State of play -Phases 0–5 complete. **23 state descriptors, 6 patches, 71/71 testbed checks**, +Phases 0–5 complete. **23 state descriptors, 7 patches, 78/78 testbed checks**, history unit tests passing, **one line changed in the game** (`main.lua`). Everything in the original brief is implemented: console speech with @@ -814,7 +864,7 @@ exploration, wall tones, and keyboard+speech access to every screen. ### First thing to do after any change ``` lua accessibility/tests/test_history.lua # pure Lua, instant -bash accessibility/tests/run-testbed.sh # ~4 min, 71 checks +bash accessibility/tests/run-testbed.sh # ~4 min, 78 checks git diff --stat vanilla-0.4.2 -- . ':!accessibility' ':!.gitignore' ':!CLAUDE.md' # must stay: main.lua | 1 + ``` diff --git a/accessibility/init.lua b/accessibility/init.lua index 5bf624c..a0593d8 100644 --- a/accessibility/init.lua +++ b/accessibility/init.lua @@ -251,6 +251,7 @@ function accessibility.initialize() accessibility.registerPatch("slider", require("accessibility.patches.slider")) accessibility.registerPatch("popupstack", require("accessibility.patches.popupstack")) accessibility.registerPatch("itemmenu", require("accessibility.patches.itemmenu")) + accessibility.registerPatch("hotbar", require("accessibility.patches.hotbar").apply) pcall(function() require("accessibility.surroundings").registerKeys(hotkeys) diff --git a/accessibility/patches/hotbar.lua b/accessibility/patches/hotbar.lua new file mode 100644 index 0000000..23bd764 --- /dev/null +++ b/accessibility/patches/hotbar.lua @@ -0,0 +1,250 @@ +-- Patch: views/hotbar.lua -- the hotbar keys 1-0. +-- +-- Pressing a hotbar key runs `hotkey.action(state)` (models/hotkey.lua). For an +-- equipment binding that resolves to one of the entries in +-- modules/item/itemActions.lua and calls its onActivation directly -- Wield, +-- Drink, Switch on, Pack away and so on. None of those print to the console; +-- most only play a sound. So by ear a hotbar press was a click and nothing else: +-- you could not tell what item it was, what it did to it, or whether the key had +-- even registered. +-- +-- We wrap the hotbar VIEW factory rather than models/hotkey, because +-- models/hotbar.lua captures `newHotkey` as a file-local at require time -- long +-- before we run -- so replacing package.loaded["models.hotkey"] would change +-- nothing. views/hotbar is only ever required by states/{ingame,inventory, +-- actions_menu}, which load lazily. +-- +-- Three things are narrated: +-- +-- * activating a slot -- "Torch, Switch on" / "Action: Throw" +-- * an empty slot -- silent in game; indistinguishable from a dropped key +-- * binding / unbinding -- self.bind plays a sound and says nothing +-- +-- ORDERING: like patches/slider.lua this replaces package.loaded, so it only +-- affects requires that happen afterwards. Safe for the same reason: states are +-- loaded lazily by states.lua, and applyPatches() runs before the self-test. + +local speech = require("accessibility.speech") + +-------------------------------------------------------------------------------- +-- Which itemAction a hotbar equipment command resolves to. +-- +-- This mirrors `itemActionsByEquipmentCommand` + `findItemAction` in +-- models/hotkey.lua, which are file-locals with no getter. We need it BEFORE the +-- action runs: afterwards the item's `isEquipped` has flipped and the conditions +-- would pick the opposite verb ("Unwield" for a press that wielded). +-- +-- hotbar.actionIdsFor is exported so the testbed can assert every id still +-- exists and still has a header -- the version-bump canary for this table. +-------------------------------------------------------------------------------- + +local hotbar = {} + +hotbar.actionIdsFor = { + equipUnequip = {7, 8, 9, 10, 13, 14}, + prepareThrow = {2, 3}, + drink = {4}, + apply = {5}, + eat = {6}, + read = {11}, + open = {12}, + activate = {1}, +} + +function hotbar.headerFor(item, equipmentCommand) + local ids = hotbar.actionIdsFor[equipmentCommand] + if not ids then + return nil + end + + local itemActions = require("modules.item.itemActions") + local player = require("models.player") + + for _, id in ipairs(ids) do + local action = itemActions[id] + if action and (not action.condition or action.condition(item, player)) then + return action.header + end + end + + return nil +end + +-- The current action, phrased as accessibility/status.lua phrases it. +local function currentActionLabel() + local player = require("models.player") + local action = player.currentSelectedAction + if not action then + return nil + end + + -- `title` is the display string; `name` is the internal id ("interact"), and + -- setCurrentAction does not copy `title`, so the fallback is the usual path. + local label = action.title + if not label then + label = action.name or "unknown" + label = label:sub(1, 1):upper() .. label:sub(2) + end + return label +end + +-------------------------------------------------------------------------------- +-- What to say for one slot, decided BEFORE the action runs. +-- Returns a string, or nil to stay quiet, plus a flag asking for the current +-- action to be read afterwards instead (the binding changed it). +-------------------------------------------------------------------------------- + +local function describeActivation(packed) + if packed.type == "action" then + return nil, true + end + + if packed.type ~= "equipment" then + return nil, false + end + + local player = require("models.player") + local item = player.inventory and player.inventory.findItemByTemplateId(packed.itemTemplateId) + + if not item then + -- The game itself prints " not found in your inventory." here, and + -- patches/console.lua speaks it. Saying anything more would double up. + return nil, false + end + + local name = item.name + if not name then + local templates = require("modules.item.templates") + local template = templates[packed.itemTemplateId] + name = template and template.name or "Item" + end + + local header = hotbar.headerFor(item, packed.command) + if not header then + -- getAction bails out silently in this case. The one exception is + -- prepareThrow with autoSwitchToThrow, which still switches the action. + if packed.command == "prepareThrow" then + local ok, config = pcall(require, "models.config") + if ok and config.get("autoSwitchToThrow") then + return nil, true + end + end + return name .. ", no action available", false + end + + return name .. ", " .. header, false +end + +-------------------------------------------------------------------------------- + +function hotbar.apply() + local newHotbarView = require("views.hotbar") + + if type(newHotbarView) ~= "function" then + error("views.hotbar is not a factory function -- game structure changed") + end + + package.loaded["views.hotbar"] = function(params) + local view = newHotbarView(params) + if type(view) ~= "table" then + return view + end + + -- refreshModel swaps the model when the player switches hotbar page, so + -- track it rather than holding params.hotbarModel forever. + local model = params and params.hotbarModel + local realRefresh = view.refreshModel + if type(realRefresh) == "function" then + view.refreshModel = function(newModel, ...) + model = newModel + return realRefresh(newModel, ...) + end + end + + local realCheckKeypress = view.checkKeypress + if type(realCheckKeypress) == "function" then + view.checkKeypress = function(state, scancode, bindCallback) + local text, readAction + + -- Only the activate hotbars act on a press; on the bind hotbars + -- (inventory / actions menu) the same key opens a bind menu, + -- which those states' descriptors already narrate. + if params and params.actionOnClick == "activate" then + pcall(function() + local keybindings = require("models.keybindings") + local command = keybindings.getCommand(scancode, "hotbar") + local index = command and tonumber(command) + if not index then + return + end + + local hotkey = model and model.hotkeys and model.hotkeys[index] + if not hotkey then + text = "Hotbar " .. index .. ", empty" + return + end + + -- pack() is the only public view of what a hotkey holds: + -- {type, command, index, itemTemplateId}. + local packed = hotkey.pack() + text, readAction = describeActivation(packed) + end) + end + + local result = realCheckKeypress(state, scancode, bindCallback) + + pcall(function() + if readAction then + local label = currentActionLabel() + text = label and ("Action: " .. label) or text + end + if text then + -- Interrupting: this is the answer to the key just pressed. + speech.say(text, true) + end + end) + + return result + end + end + + -- Binding and unbinding are sound-only in game (sound.bind / sound.unbind). + local realBind = view.bind + if type(realBind) == "function" then + view.bind = function(bindParams, index) + -- self.bind mutates bindParams (it sets .index) and reset()s the + -- selection, so read what we need first. + local slot = index or (view.isReadyForBinding and view.isReadyForBinding()) + local existing = slot and model and model.hotkeys and model.hotkeys[slot] + + local result = realBind(bindParams, index) + + pcall(function() + if not slot then + return + end + local nowBound = model and model.hotkeys and model.hotkeys[slot] + if nowBound then + local what + if bindParams.type == "equipment" then + local templates = require("modules.item.templates") + local template = templates[bindParams.itemTemplateId] + what = template and template.name + else + what = bindParams.command + end + speech.say((what or "Bound") .. " bound to hotbar " .. slot, true) + elseif existing then + speech.say("Hotbar " .. slot .. " cleared", true) + end + end) + + return result + end + end + + return view + end +end + +return hotbar diff --git a/accessibility/testbed.lua b/accessibility/testbed.lua index b397d01..093719d 100644 --- a/accessibility/testbed.lua +++ b/accessibility/testbed.lua @@ -464,6 +464,110 @@ local function scenario() end end + -- The HOTBAR keys. patches/hotbar.lua has to work out which itemAction a + -- hotbar equipment command will resolve to *before* the action runs (the + -- item's isEquipped flips, and afterwards the conditions pick the opposite + -- verb). That mirrors a file-local table in models/hotkey.lua, so this is + -- its version-bump canary: every id must still exist and still have a + -- header, and a real item must still resolve to the expected verb. + do + local hotbarPatch = require("accessibility.patches.hotbar") + local itemActions = require("modules.item.itemActions") + local newItem = require("modules.item") + + local missing = {} + for command, ids in pairs(hotbarPatch.actionIdsFor) do + for _, id in ipairs(ids) do + local action = itemActions[id] + if not (action and action.header) then + missing[#missing + 1] = command .. "/" .. id + end + end + end + check("every hotbar item action id still has a header", + #missing == 0, "missing: " .. table.concat(missing, ", ")) + + -- A torch is onWield/onUnequip, so equipUnequip must flip between the + -- two verbs with isEquipped -- exactly what the announcement says. + local torch = newItem{templateId = "torch", quantity = 1} + check("hotbar names the verb for an unequipped item", + hotbarPatch.headerFor(torch, "equipUnequip") == "Wield", + tostring(hotbarPatch.headerFor(torch, "equipUnequip"))) + + torch.isEquipped = true + check("hotbar names the opposite verb once equipped", + hotbarPatch.headerFor(torch, "equipUnequip") == "Unwield", + tostring(hotbarPatch.headerFor(torch, "equipUnequip"))) + torch.isEquipped = false + + local cannotDrink = hotbarPatch.headerFor(torch, "drink") + check("hotbar stays quiet for a command the item cannot do", + cannotDrink == nil, tostring(cannotDrink)) + end + + -- End to end: bind a real item to slot 1 and press it. The prologue gives + -- the player nothing, so plant a torch, bind it, press "1" twice (wield, + -- then unwield) and put everything back. This is the only check that + -- proves the whole chain -- patch applied, keybinding resolved, model + -- looked up, pack() read, verb chosen -- rather than one link of it. + do + local speech = require("accessibility.speech") + local player = require("models.player") + local world = require("models.world") + + local spoken = {} + local realSay = speech.say + speech.say = function(text, ...) + spoken[#spoken + 1] = tostring(text) + return realSay(text, ...) + end + + local hotbarModel = world.hotbars and world.hotbars[1] + local hadHotkey = hotbarModel and hotbarModel.hotkeys[1] + + -- An empty slot is silent in game -- press before binding anything. + if hotbarModel and not hadHotkey then + spoken = {} + press("1", nil, "hotbar: empty slot") + check("hotbar says when a slot is empty", + table.concat(spoken, " | "):find("empty", 1, true) ~= nil, + table.concat(spoken, " | ")) + end + + if hotbarModel then + player.addItem{templateId = "torch", quantity = 1} + hotbarModel.addHotkey{ + type = "equipment", + command = "equipUnequip", + itemTemplateId = "torch", + index = 1, + } + + spoken = {} + press("1", nil, "hotbar: wield the torch") + local said = table.concat(spoken, " | ") + check("hotbar names the item and what it did to it", + said:find("Torch", 1, true) ~= nil and said:find("Wield", 1, true) ~= nil, + said) + + spoken = {} + press("1", nil, "hotbar: put the torch away") + local saidAgain = table.concat(spoken, " | ") + check("a second press announces the opposite verb", + saidAgain:find("Unwield", 1, true) ~= nil, saidAgain) + + -- Put the world back: a lit torch changes FoV and light, which the + -- viewwatch and loot-overlay checks below would then inherit. + hotbarModel.hotkeys[1] = hadHotkey + local planted = player.inventory.findItemByTemplateId("torch") + if planted then + planted.remove(1) + end + end + + speech.say = realSay + end + -- Item DETAILS (the `=` key and the settle timer). Same problem as the -- action menu above: the prologue inventory is empty, so there is no real -- item to focus. The fragile parts are (a) item.getPopupMessage still