Prompted by "do sighted players have a minimap we don't have access to?"
No widget, but two map surfaces were only half exposed: the world map
screen, and the level view itself, which IS the explored-floor map --
rememberedFoV keeps everything ever seen drawn, and a sighted player pans
over it and reads the fog edge at a glance.
The world map dropped the three things the picture carries: what kind of
place each area is (the icon shape), which way it lies, and how long
travelling there takes. The last is the number the decision turns on and
was only ever in the side panel. states/worldmap.lua is now a read() plus
details() rather than probe/render -- necessarily, because render() gets
no state table and the travel time needs the screen's currentAreaId, an
upvalue like options and selectedOption. Travel time is the game's own
getDistanceMessage, the same call the screen makes. The blurb runs to
several sentences, so it goes on = and the settle timer instead of the
focus line.
Two gotchas: options[i].icon is already an image object, so the icon id
string has to come off the model area; and describe.direction only tests
the sign of each delta, which on the map's floating-point coordinates
called almost everything diagonal, hence a 25% deadzone carrying the same
4/3 x-correction the game's own distance maths uses.
New frontier.lua answers "where haven't I been", which nothing did:
surroundings is local, examine is one tile, and the tracker only finds
things it already knows about. A frontier tile is seen, not blocking
movement, with an in-bounds unseen CARDINAL neighbour -- cardinal because
the diagonal test fires on the unseen tile behind every wall corner, so a
lit corridor would report an unexplored way per corner. Clustered
8-connected and reduced to each cluster's nearest member, so a wide
doorway is one way but two openings that both lie north stay two. Reads
only FoV/rememberedFoV and reports only THAT an opening exists, never
what is beyond it -- exactly what the fog edge shows.
Shift+End names the nearest exit without disturbing the tracker
selection. Note for later: level.exits does not exist at runtime, it is
packed as nil and never assigned. The primitive is the game's own
getListByTag("exit"), which already gates on FoV or rememberedFoV, skips
hidden tiles and sorts by distance. Exits were already a tracker
category, so this is a shortcut rather than a new capability.
"remembered" moves from verbose to normal verbosity. tileFacts reads
actors only when a tile is currently VISIBLE, so a remembered tile is
silent about whatever may be standing on it now, and at normal verbosity
that silence was indistinguishable from "nothing there". A safety flag,
not a nicety.
Also adds descriptor.probeNames: a read() descriptor has no probe table,
so menus.selfTest could only report "custom read()" and the version-bump
canary was lost on exactly the screens reaching for the most upvalues.
worldmap and research now declare their game locals and get the same
PROBE BROKEN check.
120 testbed checks, 0 failures. It caught two things review had not. The
world map said "You are currently here." twice, because
getDistanceMessage and getLastVisitedMessage return the same sentence for
the area you are standing in -- fine as two panels on screen, a stutter
in one utterance. And the first frontier check walked a step east and
demanded the numbers change; it failed on a run where nothing was wrong,
the step landing but both openings keeping the same Chebyshev distance.
Replaced with the deterministic property: every reported distance must
equal Chebyshev from the player's current position, nearest first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
5.9 KiB
Lua
182 lines
5.9 KiB
Lua
-- 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 {
|
|
probeNames = {"currentNodeId", "currentNode"},
|
|
|
|
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,
|
|
}
|