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 <noreply@anthropic.com>
This commit is contained in:
Accessibility Mod
2026-08-03 02:19:51 +02:00
co-authored by Claude Opus 5
parent 1f48dea550
commit 55b19dd537
4 changed files with 408 additions and 3 deletions
+53 -3
View File
@@ -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
`"<name> 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 +
```
+1
View File
@@ -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)
+250
View File
@@ -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 "<name> 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
+104
View File
@@ -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