From ce0f375a104a92f241452c4368035cd043adebc4 Mon Sep 17 00:00:00 2001 From: Accessibility Mod Date: Mon, 3 Aug 2026 13:57:40 +0200 Subject: [PATCH] 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 --- accessibility/README.md | 12 +++ accessibility/details.lua | 6 +- accessibility/docs/scratch.md | 51 +++++++++-- accessibility/states/research.lua | 141 ++++++++++++++++++++++++++++-- accessibility/testbed.lua | 84 ++++++++++++++++++ 5 files changed, 279 insertions(+), 15 deletions(-) diff --git a/accessibility/README.md b/accessibility/README.md index 6969bcd..56cf01b 100644 --- a/accessibility/README.md +++ b/accessibility/README.md @@ -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 **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 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 diff --git a/accessibility/details.lua b/accessibility/details.lua index c208371..e8d5333 100644 --- a/accessibility/details.lua +++ b/accessibility/details.lua @@ -28,9 +28,9 @@ -- 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. +-- Implemented by the four item-grid screens (_itemgrid.lua) and by the research +-- tree (states/research.lua). Any other descriptor can gain it for free: +-- examine-mode tiles are the obvious next one. local speech = require("accessibility.speech") local history = require("accessibility.history") diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index ec6733f..9de8a3c 100644 --- a/accessibility/docs/scratch.md +++ b/accessibility/docs/scratch.md @@ -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 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?** → [00-dev-setup.md](00-dev-setup.md). @@ -443,10 +443,16 @@ Final two descriptors: selection; `currentlyActiveWindow` says which has focus. Announcing the wrong 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 - 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 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 = }`. `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`:** @@ -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 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. +`settleStep`). Implemented by `_itemgrid.lua` and by `research` (cost with the +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.** 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 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 = }`), `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) - **Phase 5**: player status key (HP, statuses, current action), popup 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 ### 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`). 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 ``` 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 + ``` diff --git a/accessibility/states/research.lua b/accessibility/states/research.lua index 3a3166f..382a12f 100644 --- a/accessibility/states/research.lua +++ b/accessibility/states/research.lua @@ -2,20 +2,73 @@ -- -- 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 --- text label, so there is no on-screen name to relay -- the id is the only --- identifier available, and the useful facts are whether the node is already --- unlocked, whether it CAN be unlocked yet, and whether you can afford it. +-- text label, so the focus line has to carry the name itself, and the useful +-- facts are whether the node is already unlocked, whether it CAN be unlocked +-- yet, and whether you can afford it. -- -- Without that last part the screen is unusable by ear: pressing enter on an -- 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") --- "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) return (tostring(id):gsub("_", " ")) 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 { read = function(stateTable, menus) local nodeId = menus.getAnyUpvalue(stateTable, "currentNodeId") @@ -24,7 +77,8 @@ return { return nil, nil 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] @@ -45,4 +99,81 @@ return { return table.concat(parts, ", "), tostring(nodeId) .. ":" .. tostring(unlocked) 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, } diff --git a/accessibility/testbed.lua b/accessibility/testbed.lua index 624e8f9..f760ec1 100644 --- a/accessibility/testbed.lua +++ b/accessibility/testbed.lua @@ -715,6 +715,90 @@ local function scenario() 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, -- trade and ingame_wait. Only the last is reachable this early: the -- tutorial house has a bed, and interacting with one opens the