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 <noreply@anthropic.com>
This commit is contained in:
Accessibility Mod
2026-08-03 01:31:58 +02:00
co-authored by Claude Opus 5
parent c841bcc68d
commit a00f327c68
8 changed files with 617 additions and 8 deletions
+32
View File
@@ -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. Switch off, Drink, Eat, Apply, Read, Open, and Prepare for throwing / Pack away.
**Equipping is done from here** — there is no separate equipment screen. **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 ### Your character
| Key | Action | | Key | Action |
+207
View File
@@ -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
+81 -4
View File
@@ -2,12 +2,12 @@
**Read this first on any handoff.** Living document: update it whenever state changes, before ending a turn. **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. 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, **38/38 checks**. verified by the testbed, **59/59 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).
@@ -152,6 +152,7 @@ and by Talon at the keyboard (wall tones).
| `Ctrl+Home` | Tracker: walk to current instance (the left-click path) | | `Ctrl+Home` | Tracker: walk to current instance (the left-click path) |
| `'` | Health and active effects | | `'` | Health and active effects |
| `,` | Current selected action | | `,` | 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 Game keys worth knowing: **`x`** enters examine mode (not `e`), `f` finds exits
while examining, `j` journal, `i` inventory, `c` character, `a` actions menu. 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) descriptor. **38/38 checks.** The live path (an actual torch, actually equipped)
is Talon's to confirm. 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) ### 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
@@ -676,7 +748,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, 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`). 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
@@ -686,7 +758,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, 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 + 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 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. 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. 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 - **The quantity slider** (`patches/slider.lua`). Confirmed *applied* by the
self-test but never exercised: `ingame_wait` is unreachable because self-test but never exercised: `ingame_wait` is unreachable because
`tutorial_bed` has an empty `tryInteractFunction` by design, and `tutorial_bed` has an empty `tryInteractFunction` by design, and
+3
View File
@@ -90,6 +90,9 @@ bind("]", "shift", history.reviewLast)
bind("\\", nil, history.repeatCurrent) bind("\\", nil, history.repeatCurrent)
bind("\\", "shift", history.copyCurrent) 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) function hotkeys.bind(scancode, mods, action)
bind(scancode, mods, action) bind(scancode, mods, action)
end end
+2
View File
@@ -164,6 +164,7 @@ local function hookStates()
pcall(walltones.update) pcall(walltones.update)
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(speech.flush) pcall(speech.flush)
if testbedActive then if testbedActive then
pcall(testbed.update, dt) pcall(testbed.update, dt)
@@ -246,6 +247,7 @@ function accessibility.initialize()
require("accessibility.tracker").registerKeys(hotkeys) require("accessibility.tracker").registerKeys(hotkeys)
require("accessibility.status").registerKeys(hotkeys) require("accessibility.status").registerKeys(hotkeys)
require("accessibility.viewwatch").registerKeys(hotkeys) require("accessibility.viewwatch").registerKeys(hotkeys)
require("accessibility.details").registerKeys(hotkeys)
end) end)
local ok, err = pcall(function() local ok, err = pcall(function()
+89
View File
@@ -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
-- <the free-text description>
-- 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 "<name>." or "<name> (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
+119 -3
View File
@@ -20,7 +20,79 @@
-- The menu is a closure (modules/item/itemMenu.lua) with no getters, so its -- 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(). -- `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 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) return function(options)
options = options or {} options = options or {}
@@ -71,16 +143,60 @@ return function(options)
"itemmenu:" .. tostring(selected) .. "/" .. tostring(#lines) "itemmenu:" .. tostring(selected) .. "/" .. tostring(#lines)
end 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 { return {
reset = function() reset = function()
lastTab = nil lastTab = nil
lastMenu = nil lastMenu = nil
end, end,
read = function(stateTable, menus) -- Everything the focus announcement is too terse to say. See details.lua.
local window = menus.getAnyUpvalue(stateTable, windowName) 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 return nil, nil
end end
+84 -1
View File
@@ -464,6 +464,71 @@ local function scenario()
end end
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 {"<nil>"}, " | ")
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 -- 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 -- 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" -- 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)) ", spoken=" .. tostring(spoken))
end 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. -- Phase 4: the menu screens reachable from gameplay.
-- --
-- Each is opened, navigated once, and closed. `back` is bound to x/escape/ -- 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 = "j", state = "journal", nav = {"down", "right"}, check = checkJournalReadsPage},
{key = "c", state = "character", nav = {"right", "right"}}, {key = "c", state = "character", nav = {"right", "right"}},
{key = "a", state = "actions_menu", nav = {"down"}}, {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 for _, screen in ipairs(screens) do