research: read what a node costs, unlocks and does

The research tree spoke only the node's name and whether you could afford
it. Everything that tells you whether it is worth affording -- the
knowledge cost, the recipes it unlocks, the description -- is drawn in
the hint windows beside the tree and was never spoken, so "cannot afford"
gave no way to find out what was missing or by how much.

states/research.lua now implements details(), the same = / Shift+= hook
the item grids use: cost with the shortfall spelled out, what it unlocks,
then the description, one block each so [ and ] walk them back. Shift+=
adds the name line and the whole knowledge purse across all four types.
The focus line also takes its name from the node's title instead of
humanising the id, so it is "Sewing techniques", not "sewing techniques".

Everything is read from the state's currentNode upvalue -- the display
table updateCurrentNode() assembles for the hint window -- rather than
models/research.lua, whose nodes table is file-local and whose unlocks
are recipe ids, which would have named the recipe instead of the item it
produces. The shortfall is computed against journalModel.knowledge,
required lazily because models.journal drags in much of the game.

The tree has no hotkey and the prologue may hold no writing desk, so the
testbed enters it the way the desk does: ingame.interface("research"),
the exact call in the desk's tryInteractFunction. Require it as
"states/ingame" and not "states.ingame" -- states.lua uses the slash
form, and the dot form loads a second copy of the module whose
levelWindow is nil, which research.draw indexes on its first frame.

98 testbed checks, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Accessibility Mod
2026-08-03 13:57:40 +02:00
co-authored by Claude Opus 5
parent a1ad7f26c2
commit ce0f375a10
5 changed files with 279 additions and 15 deletions
+12
View File
@@ -220,6 +220,18 @@ 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 refinement produces and how much of it you already have, and — in a shop — the
**price**, which appears nowhere else. **price**, which appears nowhere else.
The same key works on the **research tree** at the writing desk, where the focus
line only tells you the node's name and whether you can afford it. The details
read the rest of what is on screen: what it costs in knowledge and how much you
are short by, which recipes it unlocks, and its description. `Shift+=` adds the
node's name and your full knowledge stock across all four types.
```
Costs 5 practical, 2 academic, 2 occult knowledge, short 2 occult. Unlocks
Sinister gauntlets, Verdant sandals. Clothing with supernatural attributes can
be created by infusing the weave with magical powers.
```
You do not have to press `=` every time. **Stop on an item for about half a 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 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 does for a sighted player: its own detail popup only appears after you have
+3 -3
View File
@@ -28,9 +28,9 @@
-- the detail text back afterwards -- the same trick states/journal.lua uses for -- 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. -- 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 -- Implemented by the four item-grid screens (_itemgrid.lua) and by the research
-- descriptor can gain it for free: research nodes and examine-mode tiles are the -- tree (states/research.lua). Any other descriptor can gain it for free:
-- obvious next ones. -- examine-mode tiles are the obvious next one.
local speech = require("accessibility.speech") local speech = require("accessibility.speech")
local history = require("accessibility.history") local history = require("accessibility.history")
+44 -7
View File
@@ -7,7 +7,7 @@ fixing what Talon hits. Remaining work is polish, mod settings and distribution.
**Game is playable start-to-finish by ear** from main menu through the intro **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, **88/88 checks**. verified by the testbed, **98/98 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).
@@ -443,10 +443,16 @@ Final two descriptors:
selection; `currentlyActiveWindow` says which has focus. Announcing the wrong selection; `currentlyActiveWindow` says which has focus. Announcing the wrong
panel's row would be actively misleading, as the lists are unrelated. panel's row would be actively misleading, as the lists are unrelated.
- **`research`** — a 2D node graph, not a list. Nodes are icons with **no text - **`research`** — a 2D node graph, not a list. Nodes are icons with **no text
label**, so the id is the only identifier; the useful facts are label**, so the focus line must carry the name; the useful facts are
researched / locked / cannot afford / available. Without the affordability researched / locked / cannot afford / available. Without the affordability
part the screen is unusable by ear, since enter on an unaffordable node part the screen is unusable by ear, since enter on an unaffordable node
silently does nothing. silently does nothing. Name, cost, unlocks and description all come from the
state's **`currentNode`** upvalue — the display table `updateCurrentNode()`
builds for the hint window. Read that rather than `models/research.lua`: its
`nodes` table is file-local, and its `unlocks` are *recipe* ids, whereas
`currentNode.unlocks` is already `{title = <item name>}`. `title` also beats
humanising the id ("Alchemical fire", not "alchemical fire"). Cost, unlocks
and description are served by `details()` on `=`; see below.
**Phase 5 — `accessibility/status.lua`:** **Phase 5 — `accessibility/status.lua`:**
@@ -648,8 +654,10 @@ a **list of blocks**, not one string. Blocks are spoken joined *and* pushed
individually into the console history, so `[` / `]` / `\` walk a long individually into the console history, so `[` / `]` / `\` walk a long
description back afterwards — the same trick `states/journal.lua` uses for pages. description back afterwards — the same trick `states/journal.lua` uses for pages.
Driven by `accessibility/details.lua` (`get` / `speak` / `update` / the pure Driven by `accessibility/details.lua` (`get` / `speak` / `update` / the pure
`settleStep`). Implemented so far only by `_itemgrid.lua`; `research`, `settleStep`). Implemented by `_itemgrid.lua` and by `research` (cost with the
`ingame_observe` and `journal` are the obvious cheap follow-ups. shortfall spelled out, what it unlocks, the description; verbose adds the name
line and the full knowledge purse). `ingame_observe` and `journal` are the
obvious cheap follow-ups.
**The hint panels are the other half, and one reader covers all three.** **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 craft / refine / trade each draw a permanent panel beside the grid that the
@@ -844,6 +852,35 @@ testbed check is this descriptor's version-bump canary**.
Still unexercised: the **map** popup (needs `worldmap.getLastAreaIcon`, i.e. a Still unexercised: the **map** popup (needs `worldmap.getLastAreaIcon`, i.e. a
discovered area) and the modal **loot** popup from `inventory.lootPopup`. discovered area) and the modal **loot** popup from `inventory.lootPopup`.
### Done — research tree details (Talon request)
The research screen read only the node name and whether it was affordable. Cost,
what it unlocks and the description are all drawn in the hint windows beside the
tree and were never spoken, so "cannot afford" gave no way to find out *what* was
missing. `states/research.lua` now implements `details()` — the same `=` /
`Shift+=` hook the item grids use — and the focus line takes its name from
`currentNode.title` instead of humanising the node id.
Everything comes from the state's **`currentNode`** upvalue, the display table
`updateCurrentNode()` assembles for the hint window: `title`, `knowledgeCost`,
`unlocks` (already `{title = <item name>}`), `description`. Not from
`models/research.lua` — its `nodes` table is file-local and its `unlocks` are
*recipe* ids, so it would name the recipe rather than the item. The shortfall is
computed against `journalModel.knowledge`, required lazily.
Verified live: `Sewing techniques, cannot afford` then
`Costs 4 practical knowledge, short 4 practical. Unlocks Protective gloves, Tall
boots. Simple craft of needle and string, …`; verbose adds the name line and
`Knowledge available, 0 practical, 0 ecclesiastic, 0 academic, 0 occult`.
**98 testbed checks, 0 failures** (was 88). The tree has no hotkey and the
prologue may have no writing desk, so the testbed enters it the way the desk
does: `ingame.interface("research")`, the exact call in the desk's
`tryInteractFunction`. ⚠ Require it as **`states/ingame`**, not `states.ingame` —
`states.lua` uses the slash form, and the dot form loads a second copy of the
module whose `levelWindow` is nil, which `research.draw` indexes on its first
frame.
### 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
@@ -908,7 +945,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. **24 state descriptors, 7 patches, 88/88 testbed checks**, Phases 0–5 complete. **24 state descriptors, 7 patches, 98/98 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
@@ -918,7 +955,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, 88 checks bash accessibility/tests/run-testbed.sh # ~4 min, 98 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 +
``` ```
+136 -5
View File
@@ -2,20 +2,73 @@
-- --
-- Not a list: a 2D graph of nodes navigated with the arrow keys, where up/down -- Not a list: a 2D graph of nodes navigated with the arrow keys, where up/down
-- jump to the nearest node on the adjacent row. Nodes are drawn as icons with no -- jump to the nearest node on the adjacent row. Nodes are drawn as icons with no
-- text label, so there is no on-screen name to relay -- the id is the only -- text label, so the focus line has to carry the name itself, and the useful
-- identifier available, and the useful facts are whether the node is already -- facts are whether the node is already unlocked, whether it CAN be unlocked
-- unlocked, whether it CAN be unlocked yet, and whether you can afford it. -- yet, and whether you can afford it.
-- --
-- Without that last part the screen is unusable by ear: pressing enter on an -- Without that last part the screen is unusable by ear: pressing enter on an
-- unaffordable node simply does nothing. -- unaffordable node simply does nothing.
--
-- Everything else a sighted player gets -- what the node costs in knowledge,
-- which recipes it unlocks, the flavour description, and their own knowledge
-- stock -- is drawn in the hint windows down the side, and is served here through
-- details() on `=`, the same key the item grids use. The state assembles all of
-- it into one `currentNode` table (states/research.lua, updateCurrentNode), so
-- that upvalue is the single place to read rather than reassembling it from
-- models.research, whose `nodes` table is file-local and whose unlock ids are
-- recipe ids rather than the item names the window shows.
local researchModel = require("models.research") local researchModel = require("models.research")
-- "alchemy_basics_2" -> "alchemy basics 2" -- The order the game draws the knowledge icons in, so cost and stock read in a
-- stable order rather than pairs() order, which varies between runs.
local knowledgeTypes = {"practical", "ecclesiastic", "academic", "occult"}
-- "alchemy_basics_2" -> "alchemy basics 2". Only a fallback now: currentNode.title
-- is the real name, and this is what is left if the hint has not been built yet.
local function humanise(id) local function humanise(id)
return (tostring(id):gsub("_", " ")) return (tostring(id):gsub("_", " "))
end end
local function currentNode(stateTable, menus)
local node = menus.getAnyUpvalue(stateTable, "currentNode")
if type(node) ~= "table" then
return nil
end
return node
end
-- The player's knowledge stock. Required lazily: models.journal drags in a lot of
-- the game, and a require at load time would run before the game is up.
local function knowledgeStock()
local ok, journalModel = pcall(require, "models.journal")
if not ok or type(journalModel) ~= "table" then
return {}
end
return journalModel.knowledge or {}
end
-- "4 practical, 2 occult", in draw order, or nil if the node is free.
local function costPhrase(cost)
if type(cost) ~= "table" then
return nil
end
local parts = {}
for _, knowledgeType in ipairs(knowledgeTypes) do
local amount = cost[knowledgeType]
if amount and amount > 0 then
parts[#parts + 1] = amount .. " " .. knowledgeType
end
end
if #parts == 0 then
return nil
end
return table.concat(parts, ", ")
end
return { return {
read = function(stateTable, menus) read = function(stateTable, menus)
local nodeId = menus.getAnyUpvalue(stateTable, "currentNodeId") local nodeId = menus.getAnyUpvalue(stateTable, "currentNodeId")
@@ -24,7 +77,8 @@ return {
return nil, nil return nil, nil
end end
local parts = {humanise(nodeId)} local node = currentNode(stateTable, menus)
local parts = {(node and node.title) or humanise(nodeId)}
local unlocked = researchModel.unlockedNodes and researchModel.unlockedNodes[nodeId] local unlocked = researchModel.unlockedNodes and researchModel.unlockedNodes[nodeId]
@@ -45,4 +99,81 @@ return {
return table.concat(parts, ", "), tostring(nodeId) .. ":" .. tostring(unlocked) return table.concat(parts, ", "), tostring(nodeId) .. ":" .. tostring(unlocked)
end, end,
-- One block per part of the hint window -- cost, unlocks, description -- so
-- that [ and ] can walk back through them after the utterance has gone by.
details = function(stateTable, menus, verbose)
local node = currentNode(stateTable, menus)
if not node then
return nil
end
local blocks = {}
if verbose then
blocks[#blocks + 1] = node.title or humanise(node.id)
end
local unlocked = researchModel.unlockedNodes and researchModel.unlockedNodes[node.id]
local cost = costPhrase(node.knowledgeCost)
if unlocked then
blocks[#blocks + 1] = "Researched already"
elseif cost then
-- Say the shortfall, not just the price: "cannot afford" on the focus
-- line doesn't say by how much, and the stock it is being compared
-- against is drawn in a separate window the player would otherwise
-- have to hold in their head.
local stock = knowledgeStock()
local missing = {}
for _, knowledgeType in ipairs(knowledgeTypes) do
local amount = node.knowledgeCost[knowledgeType]
if amount and amount > 0 then
local have = stock[knowledgeType] or 0
if have < amount then
missing[#missing + 1] = (amount - have) .. " " .. knowledgeType
end
end
end
if #missing > 0 then
blocks[#blocks + 1] = "Costs " .. cost .. " knowledge, short " .. table.concat(missing, ", ")
else
blocks[#blocks + 1] = "Costs " .. cost .. " knowledge, affordable"
end
else
blocks[#blocks + 1] = "Costs nothing"
end
-- node.unlocks is already the display list the hint window draws under
-- "Unlocks": the ITEM each recipe produces, named for display.
if type(node.unlocks) == "table" and #node.unlocks > 0 then
local names = {}
for _, unlock in ipairs(node.unlocks) do
names[#names + 1] = unlock.title or "?"
end
blocks[#blocks + 1] = "Unlocks " .. table.concat(names, ", ")
end
if node.description then
blocks[#blocks + 1] = node.description
end
-- Verbose only: the whole knowledge purse, including types this node does
-- not want. Worth having when planning, noise the rest of the time.
if verbose then
local stock = knowledgeStock()
local held = {}
for _, knowledgeType in ipairs(knowledgeTypes) do
held[#held + 1] = (stock[knowledgeType] or 0) .. " " .. knowledgeType
end
blocks[#blocks + 1] = "Knowledge available, " .. table.concat(held, ", ")
end
if #blocks == 0 then
return nil
end
return blocks
end,
} }
+84
View File
@@ -715,6 +715,90 @@ local function scenario()
end end
end end
------------------------------------------------------------------
-- The research tree
--
-- Not in the `screens` loop above: it has no hotkey, it is opened by
-- interacting with a writing desk, and the prologue may not contain one.
-- `ingame.interface("research")` is the exact call the desk's
-- tryInteractFunction makes (modules/tile/templates.lua), so this enters
-- the screen the same way the game does -- including handing it the
-- levelWindow it draws the background from.
------------------------------------------------------------------
if currentState() == "ingame" then
-- "states/ingame", not "states.ingame": states.lua requires the slash
-- form, and the dot form is a different package.loaded key, so it
-- would load a SECOND copy of the module whose levelWindow is nil --
-- and research.draw indexes that on its first frame.
local ingameState = require("states/ingame")
pcall(ingameState.interface, "research")
wait(12)
local inResearch = currentState() == "research"
check("writing desk opens the research tree", inResearch,
"state is " .. tostring(currentState()))
if inResearch then
-- The tree opens on node 1,1 with no keypress, so focus is
-- readable immediately.
local focus = focusText()
check("research names the node and its status",
focus ~= nil and focus:find(",") ~= nil,
"focus=" .. tostring(focus))
-- The name must come from currentNode.title, not the id: a
-- humanised id would be lower case and underscore-derived.
check("research speaks a title, not a humanised id",
focus ~= nil and focus:sub(1, 1):match("%u") ~= nil,
"focus=" .. tostring(focus))
local details = require("accessibility.details")
local blocks = details.get("research", false)
local text = blocks and table.concat(blocks, ". ")
check("research has details", blocks ~= nil and #blocks > 0,
"blocks=" .. tostring(blocks and #blocks))
-- Cost is the part that decides whether enter does anything, and
-- the unlock list is why you would pay it. Both are hint-window
-- text that was previously never spoken.
check("research details give the knowledge cost",
text ~= nil and (text:find("Costs", 1, true) ~= nil
or text:find("Researched already", 1, true) ~= nil),
"details=" .. tostring(text))
check("research details say what the node unlocks",
text ~= nil and text:find("Unlocks", 1, true) ~= nil,
"details=" .. tostring(text))
-- Verbose adds the name line and the whole knowledge purse.
local verbose = details.get("research", true)
local verboseText = verbose and table.concat(verbose, ". ")
check("verbose research details add the knowledge available",
verboseText ~= nil
and verboseText:find("Knowledge available", 1, true) ~= nil,
"details=" .. tostring(verboseText))
-- Moving must change what is described; up/down jump rows, so
-- right is the reliable one on row 1.
local before = focusText()
press("right", "research: next node")
check("research describes a different node after moving",
focusText() ~= nil and focusText() ~= before,
"before=" .. tostring(before) .. " after=" .. tostring(focusText()))
press("=", "read research details")
wait(5)
check("the details key did not leave the research tree",
currentState() == "research", "state is " .. tostring(currentState()))
press("escape", "close the research tree")
wait(15)
check("research closes back to the game",
currentState() == "ingame", "state is " .. tostring(currentState()))
end
end
-- The quantity slider (patches/slider.lua) is shared by craft, refine, -- The quantity slider (patches/slider.lua) is shared by craft, refine,
-- trade and ingame_wait. Only the last is reachable this early: the -- trade and ingame_wait. Only the last is reachable this early: the
-- tutorial house has a bed, and interacting with one opens the -- tutorial house has a bed, and interacting with one opens the