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.
**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 |
+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.
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
+3
View File
@@ -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
+2
View File
@@ -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()
+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
-- `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
+84 -1
View File
@@ -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 {"<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
-- 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