loot overlay: speak the contents of visible containers

The tutorial teaches "press s to see the contents of containers", and it was
silent. For a sighted player s sets levelWindow.itemsHighlightEnabled, which
draws the top item's icon on every loot-bearing tile in FoV.

We watch that flag rather than binding s ourselves -- it is game-claimed, and
keybindings.getCommand() ignores modifiers -- and speak on its rising edge, so
the player's own binding, hold vs toggle mode and the toolbar icon all work.
The flag is checked both at end of frame and inside the keypressed wrapper,
because in hold mode a quick tap goes up and down within one frame.

Reads the whole tile.loot list, not just the icon's top entry, nearest first,
with each container pushed to the console history for [ ] review.

71 testbed checks, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Accessibility Mod
2026-08-03 01:45:18 +02:00
co-authored by Claude Opus 5
parent dddd8ed9de
commit 93ef26349a
5 changed files with 529 additions and 3 deletions
+24
View File
@@ -87,6 +87,30 @@ yourself is spoken first, and any key that acts in the game clears them.
Nothing outside your current field of view is ever reported. Nothing outside your current field of view is ever reported.
#### Seeing inside containers (the game's own `s`)
The game's tutorial tells you to press **`s`** to see the contents of containers.
For a sighted player that draws the icon of whatever is inside every chest, sack
and pile you can see, straight onto the map. Pressing it now **speaks the same
thing**, nearest first:
```
A wooden chest, 2 tiles east, A bunch of ivy leaves, 3, A torch.
A burlap sack, 5 tiles north-west, Coarse salt
```
or `No loot in view` when there is nothing to find, so you can tell the key
worked rather than wondering whether it was dropped.
The icons can only show one item per container; you get the **whole list**. Each
container is also pushed into the message history, so `[`, `]` and `\` walk back
through a long list after it has been read out.
This is your own key — whatever you have `Highlight loot` bound to in the game's
controls, and however you have set *"highlight overlays require holding the
key"*. Clicking the toolbar icon works too. It also works in examine mode and
while aiming. Only containers you can currently see are reported.
The game's own examine mode (**`x`**) is also fully spoken: the arrow keys move a The game's own examine mode (**`x`**) is also fully spoken: the arrow keys move a
cursor around the map and each tile is described with its distance and direction cursor around the map and each tile is described with its distance and direction
from you. Press `f` in examine mode to jump between exits. from you. Press `f` in examine mode to jump between exits.
+62 -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 **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, **59/59 checks**. verified by the testbed, **71/71 checks**.
**Not set up yet, or unsure how the two trees relate?** → **Not set up yet, or unsure how the two trees relate?** →
[00-dev-setup.md](00-dev-setup.md). [00-dev-setup.md](00-dev-setup.md).
@@ -684,6 +684,62 @@ indistinguishable from a dropped key).
prologue inventory is empty and craft/refine/trade need an alchemy table. The prologue inventory is empty and craft/refine/trade need an alchemy table. The
live path is Talon's to confirm. live path is Talon's to confirm.
### Done — the loot overlay key `s` (Talon request)
**Symptom:** the tutorial says *"Press 's' to see the contents of containers"*
and nothing was spoken. That is the one mechanic the game explicitly teaches for
finding reagents, and alchemy starts with finding reagents.
**What `s` actually does.** It is the `lootOverlay` command, and it sets a single
flag — `levelWindow.itemsHighlightEnabled`. `views/levelWindow.lua:818` then
draws, over every tile in **current FoV**, the icon of
`levelModel.getItemOnCoordinates(x,y)` — i.e. the **topmost item of the topmost
loot-bearing tile**. So the sighted affordance is "there is something in that
chest", one icon per tile, not a contents list.
**`accessibility/lootoverlay.lua`** speaks the same set of tiles, nearest first,
and reads out the **whole `tile.loot` list** rather than just the top entry —
the icon can only show one, we are not so limited. Blocks are pushed into the
console history so `[` / `]` / `\` walk a long list back. `No loot in view` when
empty: silence would be indistinguishable from a dropped key.
Live text: `Tiled man-made floor, here, A bunch of ivy leaves, 3`.
Decisions worth not relitigating:
- **No key of ours.** `s` is game-claimed and `keybindings.getCommand()` ignores
modifiers, so binding it in our layer would collide. We watch the *flag*
instead, on its **rising edge**. That covers the key in both hold and toggle
mode (`holdForItemHighlight`, which defaults to **hold**), the toolbar icon,
the player's own rebinding, and all four ingame states — they share one
`levelWindow` instance, created once in `ingame.open` and passed on by params.
- **The flag is checked in TWO places.** The end-of-frame poll is not enough on
its own: in hold mode a quick tap pushes keypressed and keyreleased inside one
frame, so the poll only ever sees it back down. `init.lua`'s `states.keypressed`
wrapper therefore also calls `lootoverlay.update()` *between* the game's
keypressed and its keyreleased. The poll still earns its place — it is what
catches the mouse and the key being released.
- **`levelWindow` is reached by `menus.getAnyUpvalue` on `states/ingame`** and
cached (it is created once, not per level). Covered by the startup self-test
(`[accessibility] loot overlay: ok`) as the version-bump canary.
- **Loot entries are item *params*, not items** — `{templateId, quantity}`;
`modules/lootPopup.lua` only builds real items when the popup opens. Names come
from `modules/item/templates`, so nothing is allocated to say a name.
- **Membership matches levelWindow's test exactly**: current `FoV` only, not
hidden, `visibilityAlpha ~= 0`, non-empty loot. `conditionalLoot` is *not*
read — the overlay does not draw it either. Invariant 4 holds: nothing is said
that is not being drawn on screen at that moment.
- `unlootable` containers are still reported (the overlay draws them) but tagged
`cannot be taken`. Scan is bounded to a 20-tile box and runs on the edge only,
never per frame — invariant 5.
**71 testbed checks, 0 failures** (was 59). The prologue has no loot at all
(`chest_tutorial` is empty by design), so the live path is exercised by
**planting `ivy_leaves` on the tile under the player, scanning, and putting the
tile back** — otherwise only the "nothing here" branch would ever run. Plus the
pure phrasing checks, the FoV invariant over the real grid, the `levelWindow`
probe, and an end-to-end `s` press.
### Known gaps (next up) ### Known gaps (next up)
- **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
@@ -748,7 +804,7 @@ utterance — speech is verifiable from the log, not only by ear. `!` after
## HANDOFF — read this if you are resuming ## HANDOFF — read this if you are resuming
### State of play ### State of play
Phases 0–5 complete. **23 state descriptors, 6 patches, 59/59 testbed checks**, Phases 0–5 complete. **23 state descriptors, 6 patches, 71/71 testbed checks**,
history unit tests passing, **one line changed in the game** (`main.lua`). history unit tests passing, **one line changed in the game** (`main.lua`).
Everything in the original brief is implemented: console speech with Everything in the original brief is implemented: console speech with
@@ -758,7 +814,7 @@ exploration, wall tones, and keyboard+speech access to every screen.
### First thing to do after any change ### First thing to do after any change
``` ```
lua accessibility/tests/test_history.lua # pure Lua, instant lua accessibility/tests/test_history.lua # pure Lua, instant
bash accessibility/tests/run-testbed.sh # ~4 min, 59 checks bash accessibility/tests/run-testbed.sh # ~4 min, 71 checks
git diff --stat vanilla-0.4.2 -- . ':!accessibility' ':!.gitignore' ':!CLAUDE.md' # must stay: main.lua | 1 + git diff --stat vanilla-0.4.2 -- . ':!accessibility' ':!.gitignore' ':!CLAUDE.md' # must stay: main.lua | 1 +
``` ```
@@ -803,6 +859,9 @@ These are blocked on content the prologue does not contain, **not** known broken
`tutorial_bed` has an empty `tryInteractFunction` by design, and `tutorial_bed` has an empty `tryInteractFunction` by design, and
craft/refine/trade need an alchemy table and real items. The testbed already craft/refine/trade need an alchemy table and real items. The testbed already
has `walkTo()` and tracker-based bed lookup ready for when one is reachable. has `walkTo()` and tracker-based bed lookup ready for when one is reachable.
- **The loot overlay (`s`) against real loot.** The prologue contains none, so
live it has only ever said `No loot in view`. The contents path is proven by
the testbed's planted-loot check, not by a real chest.
- **Popup announcements** (`patches/popupstack.lua`) — loot/quest/recipe popups - **Popup announcements** (`patches/popupstack.lua`) — loot/quest/recipe popups
did not fire during the prologue run. did not fire during the prologue run.
+10
View File
@@ -99,6 +99,9 @@ function accessibility.selfTest()
end end
end end
local lootOk, lootStatus = pcall(require("accessibility.lootoverlay").probeStatus)
print("[accessibility] loot overlay: " .. (lootOk and tostring(lootStatus) or ("FAILED: " .. tostring(lootStatus))))
print("[accessibility] --- end self test ---") print("[accessibility] --- end self test ---")
end end
@@ -165,6 +168,7 @@ local function hookStates()
pcall(require("accessibility.viewwatch").update, dt) pcall(require("accessibility.viewwatch").update, dt)
pcall(require("accessibility.status").update) pcall(require("accessibility.status").update)
pcall(require("accessibility.details").update, dt) pcall(require("accessibility.details").update, dt)
pcall(require("accessibility.lootoverlay").update)
pcall(speech.flush) pcall(speech.flush)
if testbedActive then if testbedActive then
pcall(testbed.update, dt) pcall(testbed.update, dt)
@@ -190,6 +194,12 @@ local function hookStates()
local keyResult = realKeypressed(key, scancode) local keyResult = realKeypressed(key, scancode)
-- The loot overlay is edge-triggered off a flag the game sets, and in
-- hold-to-highlight mode a quick tap can set it and clear it again inside
-- one frame -- the end-of-frame poll would then never see it up. Checking
-- here, between the game's keypressed and its keyreleased, catches the tap.
pcall(require("accessibility.lootoverlay").update)
-- After the game handler, so we read the focus it just moved to. -- After the game handler, so we read the focus it just moved to.
pcall(function() pcall(function()
menus.announceFocusIfChanged(states.getCurrent()) menus.announceFocusIfChanged(states.getCurrent())
+326
View File
@@ -0,0 +1,326 @@
-- lootoverlay.lua — speak what the game's loot highlight shows.
--
-- The tutorial says: "Press 's' to see the contents of containers." For a
-- sighted player that turns on `levelWindow.itemsHighlightEnabled`, which draws
-- the icon of the topmost item of every visible container or pile straight onto
-- the map. By ear it did nothing at all — so the one mechanic the game
-- explicitly teaches for finding reagents was unusable, and alchemy starts with
-- finding reagents.
--
-- This announces the same thing in words, and goes one better: the overlay can
-- only draw ONE icon per tile, so it tells a sighted player "there is something
-- in that chest" but not what. We read the whole `tile.loot` list.
--
--------------------------------------------------------------------------------
-- Why this is polled rather than key-bound
--------------------------------------------------------------------------------
--
-- `s` is a game-claimed scancode, and keybindings.getCommand() dispatches on
-- scancode ignoring modifiers, so binding anything on it in our layer would
-- either eat the game's own command or fire alongside a modifier we don't want.
-- Instead we watch `levelWindow.itemsHighlightEnabled` and speak on its rising
-- edge. That is the single flag both `lootOverlayClick` and its mouse-button
-- twin set, so this covers:
--
-- * the key, in hold mode (config `holdForItemHighlight`) and in toggle mode,
-- * clicking the toolbar icon,
-- * `ingame`, `ingame_observe` and both targeting states, which all share the
-- one levelWindow instance (it is created once in ingame.open and passed on
-- through params).
--
-- No game file is touched, and the player's own key stays whatever they have
-- rebound it to.
--
--------------------------------------------------------------------------------
-- FoV
--------------------------------------------------------------------------------
--
-- Membership matches levelWindow's own test exactly: current `FoV` only (not
-- rememberedFoV), tile not hidden, `visibilityAlpha ~= 0`, loot list non-empty.
-- Nothing is reported that is not being drawn on screen at that moment.
local speech = require("accessibility.speech")
local describe = require("accessibility.describe")
local history = require("accessibility.history")
local menus = require("accessibility.menus")
local lootoverlay = {}
local config = {
-- Half-width of the scanned box around the player, in tiles. Sight range is
-- well inside this; the overlay itself only draws within the camera view.
radius = 20,
-- Longest utterance worth producing in one go. A warehouse floor covered in
-- piles would otherwise read for a minute with no way to stop partway.
maxTiles = 12,
}
lootoverlay.config = config
local wasEnabled = false
--------------------------------------------------------------------------------
-- Reading the level
--------------------------------------------------------------------------------
local function getLevel()
local ok, world = pcall(require, "models.world")
if not ok or not world.currentLevel then
return nil
end
return world.currentLevel
end
local function itemTemplates()
local ok, templates = pcall(require, "modules.item.templates")
return ok and templates or nil
end
-- Loot entries are item *parameters* ({templateId, quantity}), not constructed
-- items — modules/lootPopup.lua builds real items from them only when the popup
-- opens. So the name comes from the template, and constructing an item here
-- (which allocates canvases) is avoided.
local function itemText(entry, templates)
if type(entry) ~= "table" then
return nil
end
local template = entry.templateId and templates and templates[entry.templateId]
local name = (template and template.name) or entry.name or entry.templateId
if not name then
return nil
end
local quantity = tonumber(entry.quantity) or 1
-- Quantities can be fractional (distilling yields x0.2), and floatfix leaves
-- long tails behind. Two decimals is what the game's own quantity label shows.
quantity = math.floor(quantity * 100 + 0.5) / 100
if quantity == 1 then
return tostring(name)
end
return tostring(name) .. ", " .. tostring(quantity)
end
-- The tiles the overlay is drawing an icon on, nearest first.
-- Each entry: {x, y, distance, container, items = {text, ...}}
function lootoverlay.scan()
local level = getLevel()
local actor = level and level.playerActor
if not actor or not actor.x or not actor.y then
return nil
end
local templates = itemTemplates()
local px, py = actor.x, actor.y
local xMin = math.max(1, px - config.radius)
local xMax = math.min(level.xMax or 0, px + config.radius)
local yMin = math.max(1, py - config.radius)
local yMax = math.min(level.yMax or 0, py + config.radius)
local found = {}
for x = xMin, xMax do
if level.grid[x] then
for y = yMin, yMax do
local visible = level.FoV and level.FoV[x] and level.FoV[x][y]
local stack = visible and level.grid[x][y]
if stack then
-- Top down, and stop at the first loot-bearing tile: that is the
-- one whose icon levelWindow draws.
for i = #stack, 1, -1 do
local tile = stack[i]
if tile and not tile.isHidden and tile.visibilityAlpha ~= 0
and tile.loot and tile.loot[1]
then
local items = {}
for _, entry in ipairs(tile.loot) do
local text = itemText(entry, templates)
if text then
items[#items + 1] = text
end
end
found[#found + 1] = {
x = x,
y = y,
distance = describe.distance(x - px, y - py),
container = tile.description or tile.name,
unlootable = tile.unlootable and true or false,
items = items,
}
break
end
end
end
end
end
end
table.sort(found, function(a, b)
if a.distance ~= b.distance then
return a.distance < b.distance
end
if a.x ~= b.x then
return a.x < b.x
end
return a.y < b.y
end)
return found, px, py
end
--------------------------------------------------------------------------------
-- Phrasing
--
-- Pure and exposed, so the testbed can assert the wording without capturing
-- speech.
--------------------------------------------------------------------------------
-- One block per container, so the console history can walk them back with [ and ]
-- after the utterance has gone by — a chest with six reagents in it is more than
-- anyone holds in their head on one hearing.
function lootoverlay.blocks(found, px, py)
if not found then
return nil
end
if #found == 0 then
return {"No loot in view"}
end
local blocks = {}
local shown = math.min(#found, config.maxTiles)
for i = 1, shown do
local entry = found[i]
local parts = {}
if entry.container then
-- Descriptions are sentences ("A wooden chest."); the trailing stop would
-- land mid-clause once we append the position.
parts[#parts + 1] = (entry.container:gsub("%.%s*$", ""))
else
parts[#parts + 1] = "something"
end
parts[#parts + 1] = describe.offset(entry.x - px, entry.y - py)
if entry.unlootable then
parts[#parts + 1] = "cannot be taken"
end
if #entry.items > 0 then
parts[#parts + 1] = table.concat(entry.items, ", ")
else
parts[#parts + 1] = "contents unknown"
end
blocks[#blocks + 1] = table.concat(parts, ", ")
end
if #found > shown then
blocks[#blocks + 1] = string.format("and %d more", #found - shown)
end
return blocks
end
function lootoverlay.speak()
local found, px, py = lootoverlay.scan()
if not found then
return
end
local blocks = lootoverlay.blocks(found, px, py)
-- Interrupting: this is a direct answer to a key the player just pressed.
speech.say(table.concat(blocks, ". "), true)
if #found > 0 then
for _, block in ipairs(blocks) do
pcall(history.append, block, "loot")
end
end
end
--------------------------------------------------------------------------------
-- Watching the game's flag
--------------------------------------------------------------------------------
local levelWindow
-- The instance is a file-local of states/ingame, created once in open() and
-- handed to the other ingame states through params. Cached, because
-- getAnyUpvalue walks every function in the state table.
local function getLevelWindow()
if levelWindow then
return levelWindow
end
local stateTable = menus.getStateTable("ingame")
levelWindow = menus.getAnyUpvalue(stateTable, "levelWindow")
return levelWindow
end
-- Only the ingame family draws the overlay, and only there is levelWindow even
-- assigned — so this also keeps getAnyUpvalue from being retried every frame
-- while the player is sitting in the main menu.
local function inGameplay()
local ok, states = pcall(require, "states")
if not ok then
return false
end
local current = states.getCurrent()
return type(current) == "string" and current:sub(1, 6) == "ingame"
end
-- Version-bump canary for the startup self-test. If `levelWindow` is ever
-- renamed in states/ingame.lua this reports it at startup, instead of the loot
-- key going quietly silent halfway through a playthrough.
function lootoverlay.probeStatus()
local stateTable = menus.getStateTable("ingame")
if not stateTable then
return "BROKEN: cannot load states/ingame"
end
if not menus.hasUpvalue(stateTable, "levelWindow") then
return "PROBE BROKEN: no upvalue 'levelWindow' in states/ingame"
end
return menus.getAnyUpvalue(stateTable, "levelWindow")
and "ok" or "ok (unset until gameplay starts)"
end
function lootoverlay.update()
if not inGameplay() then
return
end
local window = getLevelWindow()
if not window then
return
end
local enabled = window.itemsHighlightEnabled and true or false
if enabled and not wasEnabled then
lootoverlay.speak()
end
wasEnabled = enabled
end
return lootoverlay
+107
View File
@@ -753,6 +753,113 @@ local function scenario()
print("[testbed] viewwatch: " .. tostring(text)) print("[testbed] viewwatch: " .. tostring(text))
end end
------------------------------------------------------------------
-- Loot overlay ("s"): speak the contents of visible containers
--
-- The tutorial house is the awkward case: chest_tutorial has no loot
-- by design ("you find nothing of use for now"), so a live scan here
-- may legitimately be empty. The phrasing is therefore asserted on
-- the pure seam with a synthetic container, and the live scan is used
-- for the FoV invariant and for "it does not throw".
------------------------------------------------------------------
if currentState() == "ingame" then
local lootoverlay = require("accessibility.lootoverlay")
local found, px, py = lootoverlay.scan()
check("loot overlay scan returns a list", type(found) == "table",
type(found) .. ", " .. tostring(found and #found) .. " containers")
if found then
local ok, level = pcall(require, "models.world")
level = ok and level.currentLevel
-- Invariant 4: the overlay draws only inside current FoV, and so
-- must we -- this must never become an x-ray of sealed rooms.
local leaked
for _, entry in ipairs(found) do
if not (level and level.FoV and level.FoV[entry.x]
and level.FoV[entry.x][entry.y]) then
leaked = entry.container
end
end
check("loot overlay reports nothing outside FoV", leaked == nil,
leaked and ("leaked " .. tostring(leaked)) or "clean")
local liveBlocks = lootoverlay.blocks(found, px, py)
check("loot overlay always says something",
type(liveBlocks) == "table" and #liveBlocks > 0,
table.concat(liveBlocks or {}, " | "))
print("[testbed] loot overlay: " .. table.concat(liveBlocks or {}, ". "))
end
-- Synthetic container, two tiles east of the player: name, quantity,
-- position, and the trailing full stop stripped off the description.
local synthetic = {{
x = px + 2, y = py, distance = 2,
container = "A wooden chest.",
items = {"Ivy leaves, 3", "A torch"},
}}
local blocks = lootoverlay.blocks(synthetic, px, py)
local text = table.concat(blocks, ". ")
check("loot overlay names the container and where it is",
text:find("A wooden chest, 2 tiles east", 1, true) ~= nil, text)
check("loot overlay lists the contents, not just the top item",
text:find("Ivy leaves, 3", 1, true) and text:find("A torch", 1, true) ~= nil,
text)
check("loot overlay says so when there is nothing to see",
lootoverlay.blocks({}, px, py)[1] == "No loot in view",
tostring(lootoverlay.blocks({}, px, py)[1]))
-- Live scan of the REAL grid, which the prologue cannot otherwise
-- exercise: chest_tutorial is empty by design, so without this the
-- only path ever run is the "nothing here" one. Loot is planted on
-- the tile the player is standing on (guaranteed visible) and put
-- back immediately -- this must not alter the playthrough.
do
local ok, world = pcall(require, "models.world")
local level = ok and world.currentLevel
local tile = level and level.grid[px] and level.grid[px][py]
and level.grid[px][py][1]
if tile then
local realLoot = tile.loot
tile.loot = {{templateId = "ivy_leaves", quantity = 3}}
local planted, ppx, ppy = lootoverlay.scan()
local plantedText = table.concat(
lootoverlay.blocks(planted, ppx, ppy) or {}, ". ")
tile.loot = realLoot
check("loot overlay finds loot on the real grid",
#planted == 1, #planted .. " containers")
-- Loot entries are item PARAMS, not items: the name has to come
-- from the template table, which is the part most likely to move.
check("loot overlay resolves item names from templates",
plantedText:lower():find("ivy", 1, true) ~= nil, plantedText)
check("loot overlay reports the quantity",
plantedText:find(", 3", 1, true) ~= nil, plantedText)
check("loot overlay restored the tile it borrowed",
tile.loot == realLoot, "restored")
end
end
-- The probe the whole feature hangs on: states/ingame's levelWindow.
check("loot overlay can still reach levelWindow",
lootoverlay.probeStatus() == "ok", tostring(lootoverlay.probeStatus()))
-- And end to end: the game's own key must reach the rising edge.
local history = require("accessibility.history")
local baseline = history.count()
press("s", "loot overlay key")
check("the loot key did not leave gameplay", currentState() == "ingame",
"got " .. currentState())
print("[testbed] loot key added " .. (history.count() - baseline) ..
" history lines (0 is correct when nothing is lootable nearby)")
end
if sliderTested then if sliderTested then
check("slider announces value changes", true, "via ingame_wait") check("slider announces value changes", true, "via ingame_wait")
end end