diff --git a/accessibility/README.md b/accessibility/README.md index 1535023..6969bcd 100644 --- a/accessibility/README.md +++ b/accessibility/README.md @@ -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. +#### 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 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. diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index 95a968d..2cee2e2 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, **59/59 checks**. +verified by the testbed, **71/71 checks**. **Not set up yet, or unsure how the two trees relate?** → [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 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) - **Phase 5**: player status key (HP, statuses, current action), popup 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 ### 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`). 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 ``` 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 + ``` @@ -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 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. +- **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 did not fire during the prologue run. diff --git a/accessibility/init.lua b/accessibility/init.lua index b1dc80d..5bf624c 100644 --- a/accessibility/init.lua +++ b/accessibility/init.lua @@ -99,6 +99,9 @@ function accessibility.selfTest() 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 ---") end @@ -165,6 +168,7 @@ local function hookStates() pcall(require("accessibility.viewwatch").update, dt) pcall(require("accessibility.status").update) pcall(require("accessibility.details").update, dt) + pcall(require("accessibility.lootoverlay").update) pcall(speech.flush) if testbedActive then pcall(testbed.update, dt) @@ -190,6 +194,12 @@ local function hookStates() 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. pcall(function() menus.announceFocusIfChanged(states.getCurrent()) diff --git a/accessibility/lootoverlay.lua b/accessibility/lootoverlay.lua new file mode 100644 index 0000000..e65649c --- /dev/null +++ b/accessibility/lootoverlay.lua @@ -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 diff --git a/accessibility/testbed.lua b/accessibility/testbed.lua index f51b2d1..b397d01 100644 --- a/accessibility/testbed.lua +++ b/accessibility/testbed.lua @@ -753,6 +753,113 @@ local function scenario() print("[testbed] viewwatch: " .. tostring(text)) 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 check("slider announces value changes", true, "via ingame_wait") end