2026-08-01 22:20:50 +02:00
|
|
|
-- states/research.lua — the research tree.
|
|
|
|
|
--
|
|
|
|
|
-- 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
|
2026-08-03 13:57:40 +02:00
|
|
|
-- 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.
|
2026-08-01 22:20:50 +02:00
|
|
|
--
|
|
|
|
|
-- Without that last part the screen is unusable by ear: pressing enter on an
|
|
|
|
|
-- unaffordable node simply does nothing.
|
2026-08-03 13:57:40 +02:00
|
|
|
--
|
|
|
|
|
-- 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.
|
2026-08-01 22:20:50 +02:00
|
|
|
|
|
|
|
|
local researchModel = require("models.research")
|
|
|
|
|
|
2026-08-03 13:57:40 +02:00
|
|
|
-- 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.
|
2026-08-01 22:20:50 +02:00
|
|
|
local function humanise(id)
|
|
|
|
|
return (tostring(id):gsub("_", " "))
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-03 13:57:40 +02:00
|
|
|
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
|
|
|
|
|
|
2026-08-01 22:20:50 +02:00
|
|
|
return {
|
2026-08-03 16:05:15 +02:00
|
|
|
probeNames = {"currentNodeId", "currentNode"},
|
|
|
|
|
|
2026-08-01 22:20:50 +02:00
|
|
|
read = function(stateTable, menus)
|
|
|
|
|
local nodeId = menus.getAnyUpvalue(stateTable, "currentNodeId")
|
|
|
|
|
|
|
|
|
|
if nodeId == nil then
|
|
|
|
|
return nil, nil
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-03 13:57:40 +02:00
|
|
|
local node = currentNode(stateTable, menus)
|
|
|
|
|
local parts = {(node and node.title) or humanise(nodeId)}
|
2026-08-01 22:20:50 +02:00
|
|
|
|
|
|
|
|
local unlocked = researchModel.unlockedNodes and researchModel.unlockedNodes[nodeId]
|
|
|
|
|
|
|
|
|
|
if unlocked then
|
|
|
|
|
parts[#parts + 1] = "researched"
|
|
|
|
|
else
|
|
|
|
|
local okUnlockable, unlockable = pcall(researchModel.isUnlockable, nodeId)
|
|
|
|
|
local okAfford, affordable = pcall(researchModel.canAffordNode, nodeId)
|
|
|
|
|
|
|
|
|
|
if okUnlockable and not unlockable then
|
|
|
|
|
parts[#parts + 1] = "locked"
|
|
|
|
|
elseif okAfford and not affordable then
|
|
|
|
|
parts[#parts + 1] = "cannot afford"
|
|
|
|
|
else
|
|
|
|
|
parts[#parts + 1] = "available"
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return table.concat(parts, ", "), tostring(nodeId) .. ":" .. tostring(unlocked)
|
|
|
|
|
end,
|
2026-08-03 13:57:40 +02:00
|
|
|
|
|
|
|
|
-- 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,
|
2026-08-01 22:20:50 +02:00
|
|
|
}
|