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
+136 -5
View File
@@ -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,
}