-- 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 -- 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") -- 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") if nodeId == nil then return nil, nil end local node = currentNode(stateTable, menus) local parts = {(node and node.title) or humanise(nodeId)} 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, -- 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, }