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:
co-authored by
Claude Opus 5
parent
c841bcc68d
commit
a00f327c68
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user