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