From a00f327c6823cb90d649c3ee33a104a6adf7fd0d Mon Sep 17 00:00:00 2001 From: Accessibility Mod Date: Mon, 3 Aug 2026 01:31:58 +0200 Subject: [PATCH] items: read the details of the focused item The item grids said which item you were on ("a torch, slot 3") but nothing about what it was -- no description, no condition, and no "what does this refine into", which is the whole of alchemy. The game already composes all of it in one place: item.getPopupMessage() (modules/item.lua:199) returns the cursor popup's line list, so read that rather than re-deriving anything from templates. It also already gates it behind a dwell -- the popup only fades in after examinationPopupFadeInDuration (0.4s), so a sighted player who arrows past a slot never sees the details either. Mirror that instead of inventing a modality: stop on an item for 0.6s and the details speak themselves, queued. "=" reads them on demand, Shift+= keeps the name line too. ("r" was the obvious mnemonic but is a game-claimed scancode, and getCommand() dispatches on scancode ignoring modifiers.) New generic hook descriptor.details() returning a LIST of blocks, so each is also pushed into the console history and [ ] \ walk a long description back afterwards -- the journal page trick. Only _itemgrid.lua implements it so far. The other half is the hint panel craft/refine/trade draw permanently beside the grid, which the popup never contains: recipe components, and price, which appears nowhere else in the game. The three panels share a shape, so one reader covers them. Affordability survives only as a red colour in the hint table and decides whether enter does anything, so it is decoded and spoken. 59 testbed checks, 0 failures: a synthetic ivy_leaves item as the version-bump canary for getPopupMessage, hasUpvalue over every hint-panel local, four assertions on the pure settleStep, and an end-to-end "=" press. Not yet exercised against a live item -- the prologue inventory is empty. Co-Authored-By: Claude Opus 5 --- accessibility/README.md | 32 +++++ accessibility/details.lua | 207 +++++++++++++++++++++++++++++ accessibility/docs/scratch.md | 85 +++++++++++- accessibility/hotkeys.lua | 3 + accessibility/init.lua | 2 + accessibility/itemdetails.lua | 89 +++++++++++++ accessibility/states/_itemgrid.lua | 122 ++++++++++++++++- accessibility/testbed.lua | 85 +++++++++++- 8 files changed, 617 insertions(+), 8 deletions(-) create mode 100644 accessibility/details.lua create mode 100644 accessibility/itemdetails.lua diff --git a/accessibility/README.md b/accessibility/README.md index c84ff85..1535023 100644 --- a/accessibility/README.md +++ b/accessibility/README.md @@ -173,6 +173,38 @@ Common actions include Activate, Wield, Unwield, Wear, Take off, Switch on, Switch off, Drink, Eat, Apply, Read, Open, and Prepare for throwing / Pack away. **Equipping is done from here** — there is no separate equipment screen. +### Item details (`=`) + +| Key | Action | +|---|---| +| `=` | Read the full details of the item under the cursor | +| `Shift+=` | The same, including the item's name | + +Reading the cursor only tells you *which* item you are on. The details tell you +what it is: what kind of thing it is, its condition if it can wear out, its +description, and — the useful part for alchemy — **what it turns into**, listed +per refinement method: + +``` +It is a raw material. Some fresh green leaves from ivy. They contain weak +poison. Yields: x1 when ground in a mortar. x0.2 when distilled. +``` + +On the crafting, refining and trading screens the details also read the panel +beside the grid, which is where the game keeps the things you most need before +committing: a recipe's components and **whether you can afford them**, what a +refinement produces and how much of it you already have, and — in a shop — the +**price**, which appears nowhere else. + +You do not have to press `=` every time. **Stop on an item for about half a +second and the details are read to you automatically**, which is what the game +does for a sighted player: its own detail popup only appears after you have +hovered a slot for a moment. Arrow through a tab quickly and you hear only the +names, exactly as a sighted player sees only the icons. + +Details also go into the message history, so `[` and `]` walk back through a +long description a piece at a time and `\` re-reads a line. + ### Your character | Key | Action | diff --git a/accessibility/details.lua b/accessibility/details.lua new file mode 100644 index 0000000..c208371 --- /dev/null +++ b/accessibility/details.lua @@ -0,0 +1,207 @@ +-- details.lua — "tell me more about what is focused". +-- +-- Focus announcements are deliberately terse: "a torch, slot 1". Everything that +-- says what an item actually IS -- description, condition, what it refines into, +-- what a recipe costs, what it sells for -- is on screen but was never spoken. +-- +-------------------------------------------------------------------------------- +-- Why details settle rather than always speaking +-------------------------------------------------------------------------------- +-- +-- The game itself gates this text behind a dwell: the cursor popup only fades in +-- after config.get("examinationPopupFadeInDuration") (default 0.4s). Arrow past a +-- slot quickly and a SIGHTED player never sees the details either -- they see them +-- by stopping. So we mirror that rather than inventing a modality: stop on a slot +-- and the details speak themselves; keep moving and they don't. +-- +-- `=` reads them on demand and re-reads them, for when you don't want to wait or +-- the utterance went by. Shift+= is verbose (keeps the name line too). +-- +-------------------------------------------------------------------------------- +-- The descriptor hook +-------------------------------------------------------------------------------- +-- +-- descriptor.details(stateTable, menus, verbose) -> { "block", "block", ... } +-- +-- Optional, alongside read/reset. Returning a LIST rather than one string is what +-- lets each block be pushed into the console history separately, so [ and ] walk +-- the detail text back afterwards -- the same trick states/journal.lua uses for +-- journal pages. A long description therefore never has to be caught first time. +-- +-- Implemented so far by the four item-grid screens (_itemgrid.lua). Any other +-- descriptor can gain it for free: research nodes and examine-mode tiles are the +-- obvious next ones. + +local speech = require("accessibility.speech") +local history = require("accessibility.history") +local menus = require("accessibility.menus") + +local details = {} + +details.config = { + -- How long focus must stay still before details speak, in seconds. Longer + -- than the game's 0.4s popup delay because speech takes longer than a glance, + -- and arrowing through a full tab shouldn't leave a backlog behind you. + settleDelay = 0.6, + + -- Set false to make details key-only. + autoSpeak = true, +} + +-------------------------------------------------------------------------------- +-- Fetching +-------------------------------------------------------------------------------- + +-- Returns the detail blocks for a state, or nil if it has no details to give. +function details.get(stateName, verbose) + local descriptor = menus.loadDescriptor(stateName) + if not descriptor or not descriptor.details then + return nil + end + + local stateTable = menus.getStateTable(stateName) + if not stateTable then + return nil + end + + local ok, blocks = pcall(descriptor.details, stateTable, menus, verbose) + if not ok or type(blocks) ~= "table" or #blocks == 0 then + return nil + end + + return blocks +end + +-------------------------------------------------------------------------------- +-- Speaking +-------------------------------------------------------------------------------- + +local function currentState() + local ok, states = pcall(require, "states") + if not ok then + return nil + end + return states.getCurrent() +end + +-- Mirror into the review buffer so [ and ] can walk the blocks after the +-- utterance has gone by. +local function pushToHistory(blocks) + for _, block in ipairs(blocks) do + pcall(history.append, block, "details") + end +end + +local function speakBlocks(blocks, interrupt) + pushToHistory(blocks) + speech.say(table.concat(blocks, ". "), interrupt) +end + +-- The `=` key. Interrupting, because the player explicitly asked. +function details.speak(verbose) + local stateName = currentState() + local blocks = stateName and details.get(stateName, verbose) + + if not blocks then + -- Never a silent no-op: silence is indistinguishable from a dropped key. + speech.say("No details", true) + return + end + + speakBlocks(blocks, true) +end + +-------------------------------------------------------------------------------- +-- The settle timer +-------------------------------------------------------------------------------- + +local settledKey -- focus key currently being timed +local settledTime -- how long it has been unchanged +local spokenKey -- focus key we have already spoken details for +local lastState -- so leaving a screen and coming back speaks again + +-- Pure, and exposed for the testbed: advance the timer for `key` by `dt` and say +-- whether details are due now. Due exactly once per focus key -- without that, +-- a stationary cursor would re-speak every frame. +function details.settleStep(key, dt) + if key ~= settledKey then + settledKey = key + settledTime = 0 + return false + end + + if key == nil or key == spokenKey then + return false + end + + settledTime = (settledTime or 0) + (dt or 0) + + if settledTime < details.config.settleDelay then + return false + end + + spokenKey = key + return true +end + +function details.reset() + settledKey = nil + settledTime = nil + spokenKey = nil +end + +-- Called once per frame from init.lua's update hook. +function details.update(dt) + if not details.config.autoSpeak then + return + end + + local stateName = currentState() + if not stateName then + return + end + + -- The focus key is state-qualified, so without this, leaving a screen and + -- returning to the same slot would be "already spoken" and stay silent. + if stateName ~= lastState then + lastState = stateName + details.reset() + end + + local descriptor = menus.loadDescriptor(stateName) + if not descriptor or not descriptor.details then + details.reset() + return + end + + -- Only the focus KEY is wanted here; readFocus's text is what read() already + -- announced and must not be repeated. + local _, key = menus.readFocus(stateName) + + -- No focus key means the descriptor could not read the screen this frame + -- (mid-transition, window not built yet). Don't time an unknown. + if key == nil then + details.reset() + return + end + + if not details.settleStep(stateName .. "|" .. key, dt) then + return + end + + local blocks = details.get(stateName, false) + if not blocks then + return + end + + -- Queued, not interrupting: this is background awareness the player did not + -- ask for, so anything they DID ask for outranks it. + speakBlocks(blocks, false) +end + +function details.registerKeys(hotkeys) + hotkeys.bind("=", nil, function() details.speak(false) end) + hotkeys.bind("=", "shift", function() details.speak(true) end) +end + +return details diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index a60f2ea..95a968d 100644 --- a/accessibility/docs/scratch.md +++ b/accessibility/docs/scratch.md @@ -2,12 +2,12 @@ **Read this first on any handoff.** Living document: update it whenever state changes, before ending a turn. -Last updated: 2026-08-02 · Currently: **Phases 0–5 COMPLETE. In playtesting** — +Last updated: 2026-08-03 · Currently: **Phases 0–5 COMPLETE. In playtesting** — 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, **38/38 checks**. +verified by the testbed, **59/59 checks**. **Not set up yet, or unsure how the two trees relate?** → [00-dev-setup.md](00-dev-setup.md). @@ -152,6 +152,7 @@ and by Talon at the keyboard (wall tones). | `Ctrl+Home` | Tracker: walk to current instance (the left-click path) | | `'` | Health and active effects | | `,` | Current selected action | +| `=` / `Shift+=` | Read details of the focused thing (Shift = keep the name line) | Game keys worth knowing: **`x`** enters examine mode (not `e`), `f` finds exits while examining, `j` journal, `i` inventory, `c` character, `a` actions menu. @@ -612,6 +613,77 @@ asserts the two upvalue names still resolve — the version-bump canary for this descriptor. **38/38 checks.** The live path (an actual torch, actually equipped) is Talon's to confirm. +### Done — item details: `=` and the settle timer (Talon request) + +**Symptom:** on the item grids you could hear *which* item you were on +(`a torch, slot 3`) but nothing about what it was — no description, no condition, +and crucially no "what does this refine into", which is the whole of alchemy. +Talon proposed a key. Two findings changed the shape of the answer. + +**1. The game already composes the whole detail text in one place.** +`item.getPopupMessage()` (`modules/item.lua:199`) returns the popup's line list: +name+quantity, category, condition (only when `canDegrade`), the free-text +description, a `Yields:` block with one line per refinement type, and +lore-fragment progress. It is a plain method on the item, callable any time — so +`accessibility/itemdetails.lua` reads it rather than re-deriving anything from +templates. Verified text: +`It is a raw material. Some fresh green leaves from ivy. They contain weak +poison. Yields: x1 when ground in a mortar. x0.2 when distilled.` + +**2. The game gates this behind a dwell, and so do we.** The popup only fades in +after `config.get("examinationPopupFadeInDuration")` (default 0.4 s). Arrow past +a slot quickly and a *sighted* player never sees the details either. So details +**settle**: stop for `details.config.settleDelay` (0.6 s — longer than 0.4 +because speech is slower than a glance) and they speak themselves, queued not +interrupting. `=` reads them on demand; `Shift+=` keeps the name line too. +This mirrors an existing game behaviour instead of inventing a modality. + +⚠ **`r` is unusable** even though it was the obvious mnemonic — it is in the +game-claimed scancode list, and `keybindings.getCommand()` dispatches on +scancode ignoring modifiers, so any binding on it can fire a game command. `=` +is from the verified free list and sits by the `[ ] \` review cluster. + +**New generic hook: `descriptor.details(stateTable, menus, verbose)`**, returning +a **list of blocks**, not one string. Blocks are spoken joined *and* pushed +individually into the console history, so `[` / `]` / `\` walk a long +description back afterwards — the same trick `states/journal.lua` uses for pages. +Driven by `accessibility/details.lua` (`get` / `speak` / `update` / the pure +`settleStep`). Implemented so far only by `_itemgrid.lua`; `research`, +`ingame_observe` and `journal` are the obvious cheap follow-ups. + +**The hint panels are the other half, and one reader covers all three.** +craft / refine / trade each draw a permanent panel beside the grid that the +cursor popup never contains — and **price exists only there** +(`states/trade.lua:156`), as do a recipe's component costs. The three panels turn +out to share a shape (`hintDescription`, a `componentHints` list of +`{nameText, descriptionText, color}`, and a budget line), so `hintBlocks()` in +`_itemgrid.lua` reads all three by upvalue rather than needing per-state +callbacks; unused fields are simply nil. **Affordability is carried only by the +red `{0.9,0.2,0.2}` colour**, and it decides whether enter does anything at all, +so it is decoded from the colour and spoken as `cannot afford`. + +Notes: +- The action menu is deliberately **not** excluded from `details` the way it is + from `read()`. The menu is modal so the *verb* is what focus announces, but the + thing you want described is still the item — which is exactly when you want to + know what "Drink" would be drinking. +- The settle key is state-qualified and fires **once per focus key**; leaving a + screen and returning resets, or the same slot would be "already spoken". +- `patches/popups.lua` still stands down on all four grids. That is now + correct-by-design rather than a gap: `details` is the sanctioned route to that + same content, on demand and with history review. + +**59 testbed checks, 0 failures** (was 38). New: a synthetic `ivy_leaves` item +asserting description + `Yields` survive (the version-bump canary for +`getPopupMessage`), a `hasUpvalue` canary over all the hint-panel locals in +craft/refine/trade, four assertions on the pure `settleStep`, and an end-to-end +`=` press in the inventory (`No details` on an empty slot — silence would be +indistinguishable from a dropped key). + +⚠ **Not exercised against a real item.** Same bucket as the action menu: the +prologue inventory is empty and craft/refine/trade need an alchemy table. The +live path is Talon's to confirm. + ### Known gaps (next up) - **Phase 5**: player status key (HP, statuses, current action), popup announcements (`popupStack.add`), damage alerts @@ -676,7 +748,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, 38/38 testbed checks**, +Phases 0–5 complete. **23 state descriptors, 6 patches, 59/59 testbed checks**, history unit tests passing, **one line changed in the game** (`main.lua`). Everything in the original brief is implemented: console speech with @@ -686,7 +758,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, 38 checks +bash accessibility/tests/run-testbed.sh # ~4 min, 59 checks git diff --stat vanilla-0.4.2 -- . ':!accessibility' ':!.gitignore' ':!CLAUDE.md' # must stay: main.lua | 1 + ``` @@ -721,6 +793,11 @@ These are blocked on content the prologue does not contain, **not** known broken - **Inventory item names, the `equipped` suffix and the item ACTION MENU** — inventory is empty in the prologue, so only the "empty slot" path has run live. The action-menu upvalue names are covered by a synthetic testbed check. +- **Item details** (`=`, `Shift+=`, and the 0.6 s settle) against a real item, + and the craft / refine / trade hint panels. The synthetic `ivy_leaves` check + covers `getPopupMessage`, and `hasUpvalue` covers the panel locals, but no + live item has been focused. The settle delay in particular wants tuning by + ear — `details.config.settleDelay`. - **The quantity slider** (`patches/slider.lua`). Confirmed *applied* by the self-test but never exercised: `ingame_wait` is unreachable because `tutorial_bed` has an empty `tryInteractFunction` by design, and diff --git a/accessibility/hotkeys.lua b/accessibility/hotkeys.lua index d9ff484..7bccc55 100644 --- a/accessibility/hotkeys.lua +++ b/accessibility/hotkeys.lua @@ -90,6 +90,9 @@ bind("]", "shift", history.reviewLast) bind("\\", nil, history.repeatCurrent) bind("\\", "shift", history.copyCurrent) +-- Other keys are registered by their own modules via hotkeys.bind, from +-- init.lua: surroundings, tracker, status, viewwatch, details. + function hotkeys.bind(scancode, mods, action) bind(scancode, mods, action) end diff --git a/accessibility/init.lua b/accessibility/init.lua index 879e03b..b1dc80d 100644 --- a/accessibility/init.lua +++ b/accessibility/init.lua @@ -164,6 +164,7 @@ local function hookStates() pcall(walltones.update) pcall(require("accessibility.viewwatch").update, dt) pcall(require("accessibility.status").update) + pcall(require("accessibility.details").update, dt) pcall(speech.flush) if testbedActive then pcall(testbed.update, dt) @@ -246,6 +247,7 @@ function accessibility.initialize() require("accessibility.tracker").registerKeys(hotkeys) require("accessibility.status").registerKeys(hotkeys) require("accessibility.viewwatch").registerKeys(hotkeys) + require("accessibility.details").registerKeys(hotkeys) end) local ok, err = pcall(function() diff --git a/accessibility/itemdetails.lua b/accessibility/itemdetails.lua new file mode 100644 index 0000000..b6de431 --- /dev/null +++ b/accessibility/itemdetails.lua @@ -0,0 +1,89 @@ +-- itemdetails.lua — turn an item into speakable detail blocks. +-- +-- The game already composes the whole of an item's detail text in one place: +-- `item.getPopupMessage()` (modules/item.lua:199). It returns a list of +-- `{text =, icon =, font =}` lines that the cursor popup renders: +-- +-- a torch. <- name, with (quantity) if stackable +-- It is a tool. <- category, from item/categories.lua +-- It is pretty worn (43% condition).<- only when canDegrade +-- +-- Yields: <- only when the item can be refined +-- x1 when ground in a mortar. <- one per refinement type +-- Contained knowledge: <- only when containedLore +-- (2/5 fragments learned so far) +-- +-- That is exactly what a player needs to evaluate an item, including "what does +-- this turn into", and it is already assembled -- so we read it rather than +-- re-deriving any of it from templates. +-- +-- The icons are dropped: every icon line's `text` already names the thing in +-- words ("x1 when ground in a mortar"), so nothing is lost by ear. +-- +-- Pure formatting, no LÖVE and no game state, so the testbed can exercise it +-- against a synthetic item. + +local itemdetails = {} + +-- The game wraps its own text and joins the wrapped lines with "\n", so those +-- newlines are pure layout. Left in, the reader pauses mid-sentence at every +-- wrap point. Same treatment as journal pages (states/journal.lua). +local function flatten(text) + return (tostring(text):gsub("%s*\n%s*", " "):gsub("^%s+", ""):gsub("%s+$", "")) +end + +itemdetails.flatten = flatten + +-- Does this popup line merely repeat the name the focus announcement just said? +-- getPopupMessage's first line is "." or " (3)." -- speaking it again +-- immediately after "a torch, slot 1" is pure repetition. +local function isNameLine(text, item) + local name = item and item.name + if type(name) ~= "string" then + return false + end + return text:sub(1, #name) == name +end + +-- Returns a list of speakable blocks for `item`, or nil if there is nothing to +-- say. `verbose` keeps the leading name line as well. +function itemdetails.blocks(item, verbose) + if type(item) ~= "table" then + return nil + end + + local blocks = {} + + local ok, lines = pcall(function() + return item.getPopupMessage and item.getPopupMessage() + end) + + if ok and type(lines) == "table" then + for index, line in ipairs(lines) do + local text = type(line) == "table" and line.text or (type(line) == "string" and line) + + if type(text) == "string" then + text = flatten(text) + + if text ~= "" and not (index == 1 and not verbose and isNameLine(text, item)) then + blocks[#blocks + 1] = text + end + end + end + elseif type(item.description) == "string" then + -- getPopupMessage missing or raising means the game changed shape. Degrade + -- to the raw description rather than going silent. + local text = flatten(item.description) + if text ~= "" then + blocks[#blocks + 1] = text + end + end + + if #blocks == 0 then + return nil + end + + return blocks +end + +return itemdetails diff --git a/accessibility/states/_itemgrid.lua b/accessibility/states/_itemgrid.lua index 6117cbf..b16ebe0 100644 --- a/accessibility/states/_itemgrid.lua +++ b/accessibility/states/_itemgrid.lua @@ -20,7 +20,79 @@ -- The menu is a closure (modules/item/itemMenu.lua) with no getters, so its -- `lines` and `currentSelectedAction` are read as upvalues of its own draw(). +-- Third thing this owns: DETAILS (the `=` key and the settle timer, see +-- accessibility/details.lua). Two sources, both already composed by the game: +-- +-- * item.getPopupMessage() -- description, category, condition, refine yields, +-- lore progress. Read through accessibility/itemdetails.lua. +-- * the HINT PANEL that craft, refine and trade each draw permanently beside +-- the grid, and which the cursor popup never contains. Notably PRICE exists +-- only there (states/trade.lua:156), as do a recipe's component costs and +-- whether you can afford them. +-- +-- The three panels turn out to share a shape -- hintDescription, a componentHints +-- list of {nameText, descriptionText, color}, and a budget line -- so one reader +-- covers all three rather than three per-state callbacks. Fields a given screen +-- doesn't use are simply nil (craft has no budget line; trade has no +-- hintDescription; refine names an output instead of components). + local itemCategories = require("modules.item.categories") +local itemdetails = require("accessibility.itemdetails") + +-- The game marks an unaffordable component by colouring it red. That is the one +-- fact which decides whether pressing enter does anything at all, so it has to be +-- spoken -- and the colour is the only place it survives into the hint table. +local function isUnaffordableColor(color) + return type(color) == "table" and color[1] and color[1] > 0.8 + and color[2] and color[2] < 0.5 +end + +-- craft / refine / trade draw a permanent hint panel beside the grid. +local function hintBlocks(stateTable, menus) + local blocks = {} + + local function add(text) + if type(text) == "string" then + text = itemdetails.flatten(text) + if text ~= "" then + blocks[#blocks + 1] = text + end + end + end + + -- craft: the recipe's brewing instructions. refine: how this refinement works. + add(menus.getAnyUpvalue(stateTable, "hintDescription")) + + -- refine: what you get out, and how much of it you already have. + local outputName = menus.getAnyUpvalue(stateTable, "outputName") + if outputName then + local quantity = menus.getAnyUpvalue(stateTable, "outputQuantityText") + add("Yields " .. outputName .. (quantity and (", " .. quantity) or "")) + end + + -- craft: one per recipe component. trade: the price, as a single "component". + local componentHints = menus.getAnyUpvalue(stateTable, "componentHints") + if type(componentHints) == "table" then + for _, hint in ipairs(componentHints) do + if type(hint) == "table" and hint.nameText then + add("Costs " .. hint.nameText + .. (hint.descriptionText and (", " .. hint.descriptionText) or "") + .. (isUnaffordableColor(hint.color) and ", cannot afford" or "")) + end + end + end + + -- refine / trade: "You already have: x2.00 green paste". + local budgetName = menus.getAnyUpvalue(stateTable, "budgetNameText") + if budgetName then + local budgetDescription = menus.getAnyUpvalue(stateTable, "budgetDescription") + local budgetQuantity = menus.getAnyUpvalue(stateTable, "budgetQuantityText") + add((budgetDescription or "You have:") .. " " + .. (budgetQuantity or "") .. " " .. budgetName) + end + + return blocks +end return function(options) options = options or {} @@ -71,16 +143,60 @@ return function(options) "itemmenu:" .. tostring(selected) .. "/" .. tostring(#lines) end + -- The window closure, or nil if the screen isn't built yet. + local function getWindow(stateTable, menus) + local window = menus.getAnyUpvalue(stateTable, windowName) + if type(window) ~= "table" or not window.getItemOnCursor then + return nil + end + return window + end + return { reset = function() lastTab = nil lastMenu = nil end, - read = function(stateTable, menus) - local window = menus.getAnyUpvalue(stateTable, windowName) + -- Everything the focus announcement is too terse to say. See details.lua. + details = function(stateTable, menus, verbose) + local window = getWindow(stateTable, menus) + if not window then + return nil + end - if type(window) ~= "table" or not window.getItemOnCursor then + -- The slider owns speech during quantity selection (patches/slider.lua); + -- reading a description over the number being adjusted would fight it. + if menus.getAnyUpvalue(stateTable, "confirmationWindowEnabled") then + return nil + end + + -- Note the item action menu is NOT excluded here, unlike in read(). The + -- menu is modal over the grid so the highlighted VERB is what read() + -- announces, but the thing you want described is still the item the menu + -- belongs to -- and that is exactly when you want to know what "Drink" + -- would be drinking. + local ok, item = pcall(window.getItemOnCursor) + if not ok or not item then + return nil + end + + local blocks = itemdetails.blocks(item, verbose) or {} + + for _, block in ipairs(hintBlocks(stateTable, menus)) do + blocks[#blocks + 1] = block + end + + if #blocks == 0 then + return nil + end + + return blocks + end, + + read = function(stateTable, menus) + local window = getWindow(stateTable, menus) + if not window then return nil, nil end diff --git a/accessibility/testbed.lua b/accessibility/testbed.lua index 0f9740b..f51b2d1 100644 --- a/accessibility/testbed.lua +++ b/accessibility/testbed.lua @@ -464,6 +464,71 @@ local function scenario() end 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 + -- existing and still carrying the description and the refine yields, and + -- (b) the hint-panel locals in craft/refine/trade keeping their names. + -- Both are checkable without a live screen. + do + local itemdetails = require("accessibility.itemdetails") + local newItem = require("modules.item") + + -- ivy_leaves has a description AND both a grind and a distill recipe, + -- so one item exercises every branch of getPopupMessage worth having. + local item = newItem{templateId = "ivy_leaves", quantity = 3} + local blocks = itemdetails.blocks(item) + local joined = table.concat(blocks or {""}, " | ") + + check("item details produce blocks", type(blocks) == "table" and #blocks > 0, + "blocks=" .. joined) + check("item details include the description", + joined:find("weak poison", 1, true) ~= nil, joined) + check("item details include the refinement yields", + joined:find("Yields", 1, true) ~= nil + and joined:find("mortar", 1, true) ~= nil, joined) + check("item details drop the name line already spoken by read()", + blocks ~= nil and blocks[1] ~= nil + and blocks[1]:find("bunch of ivy", 1, true) == nil, + tostring(blocks and blocks[1])) + check("verbose item details keep the name line", + (itemdetails.blocks(item, true) or {""})[1]:find("ivy", 1, true) ~= nil) + + -- Hint-panel canary. These locals hold the recipe cost, the refinement + -- output and (in trade) the price, none of which appear in the cursor + -- popup -- a rename would make them silently vanish. + local hintLocals = { + craft = {"hintDescription", "componentHints"}, + refine = {"hintDescription", "outputName", "outputQuantityText", + "budgetNameText", "budgetQuantityText"}, + trade = {"componentHints", "budgetNameText", "budgetQuantityText"}, + } + + for stateName, names in pairs(hintLocals) do + local stateTable = menus.getStateTable(stateName) + for _, name in ipairs(names) do + check(stateName .. " hint panel still has local '" .. name .. "'", + stateTable ~= nil and menus.hasUpvalue(stateTable, name)) + end + end + + -- The settle timer: due exactly once per focus key, and re-armed by a + -- change. Without "exactly once" a stationary cursor would re-speak the + -- description every frame. + local details = require("accessibility.details") + details.reset() + check("settle does not fire before the delay", + details.settleStep("a", 0.1) == false) + check("settle fires once the delay has passed", + details.settleStep("a", details.config.settleDelay) == true) + check("settle does not fire twice for the same focus", + details.settleStep("a", 10) == false) + check("a focus change re-arms the settle", + details.settleStep("b", 0.01) == false + and details.settleStep("b", 10) == true) + details.reset() + end + -- The journal's whole point is the page of text on the right-hand side; the -- list of titles on the left is just an index into it. Announcing only the -- title (which is what it used to do) reads as "there is an entry here" @@ -494,6 +559,24 @@ local function scenario() ", spoken=" .. tostring(spoken)) end + -- The `=` key end to end. The prologue inventory is empty, so what this + -- proves is the wiring: the key reaches details.speak, is consumed rather + -- than falling through to the game, and an empty slot says "No details" + -- instead of nothing at all (silence being indistinguishable from a + -- dropped keypress). The text itself is covered by the synthetic item above. + local function checkDetailsKey() + local details = require("accessibility.details") + + check("empty slot has no details", + details.get("inventory") == nil) + + press("=", "read item details") + wait(5) + + check("the details key did not leave the inventory", + currentState() == "inventory", "state is " .. tostring(currentState())) + end + -- Phase 4: the menu screens reachable from gameplay. -- -- Each is opened, navigated once, and closed. `back` is bound to x/escape/ @@ -502,7 +585,7 @@ local function scenario() {key = "j", state = "journal", nav = {"down", "right"}, check = checkJournalReadsPage}, {key = "c", state = "character", nav = {"right", "right"}}, {key = "a", state = "actions_menu", nav = {"down"}}, - {key = "i", state = "inventory", nav = {"right", "down"}}, + {key = "i", state = "inventory", nav = {"right", "down"}, check = checkDetailsKey}, } for _, screen in ipairs(screens) do