Learning a recipe spoke the word "Popup" and then nothing.
There are two separate popup mechanisms and only one was covered.
patches/popupstack.lua wraps popupStack.add, but views/fadingPopup.lua
registers just four types -- journal, loot, bestiary and tutorial -- so
the recipe/lore/map keys in that patch could never fire. Those three are
modal instead: states/{ingame,inventory,research} build one from
modules/{recipe,lore,map}Popup.lua and switch to states/ingame_popup.
ingame_popup had a name in stateNames.lua and no descriptor, so
announceScreen said "Popup" and the focus poll had nothing to add.
The popup modules are factories exposing only draw(), so every string is
a closure local. The new descriptor takes popupObject off the state and
reads them as upvalues of popupObject.draw, detecting the type by which
locals exist since the object carries no type tag. Reading order follows
draw order, newlines are flattened (the lore footer is one sentence with
hard breaks in it), and the blocks go into the console history so [ and ]
walk a long fragment back; = re-reads it.
The prologue fires no popup, so the testbed raises them itself via
ingame.recipePopup and ingame.lorePopup -- both display-only, so nothing
is learned and the world is untouched. That check is this descriptor's
version-bump canary: the self-test can only report "custom read()" here,
because the probe is on the popup object rather than the state.
88 testbed checks, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
180 lines
5.8 KiB
Lua
180 lines
5.8 KiB
Lua
-- Descriptor for `ingame_popup` — the modal popup shown when you learn a recipe,
|
|
-- read a lore fragment, unfold a map, or loot from a menu.
|
|
--
|
|
-- WHY THIS EXISTS
|
|
--
|
|
-- `patches/popupstack.lua` covers the *fading corner* popups, and it looked like
|
|
-- that was all of them. It isn't. `views/fadingPopup.lua` registers only four
|
|
-- types (journal / loot / bestiary / tutorial); recipe, lore and map popups never
|
|
-- touch popupStack at all. They are built directly by `modules/{recipe,lore,map,
|
|
-- loot}Popup.lua` and shown by `states.switch("ingame_popup", {popup = ...})`
|
|
-- from `states/{ingame,inventory,research}.lua`.
|
|
--
|
|
-- So `ingame_popup` had a spoken name in stateNames ("Popup") and no descriptor:
|
|
-- the mod announced the word "Popup" and then went silent over the entire body
|
|
-- of text. That is exactly the reported symptom.
|
|
--
|
|
-- HOW THE TEXT IS REACHED
|
|
--
|
|
-- Each popup module is a factory returning a table whose only member is `draw`
|
|
-- (recipe/lore/map) — every string is a closure local. So we take `popupObject`
|
|
-- off the state and read the strings as upvalues of `popupObject.draw`. That is
|
|
-- the same probe technique the menu descriptors use. Because it probes the popup
|
|
-- object rather than the state, the startup self-test can only report
|
|
-- "custom read()" here — the version-bump canary is the testbed check, which
|
|
-- builds each popup type and asserts its text still comes out.
|
|
--
|
|
-- Blocks are pushed into the console history so `[` / `]` / `\` walk a long lore
|
|
-- fragment back afterwards — a popup can easily be several paragraphs, which is
|
|
-- too much to catch in one utterance.
|
|
|
|
local history = require("accessibility.history")
|
|
|
|
-- Layout newlines are pure presentation: `lorePopup`'s footer is one sentence
|
|
-- with hard breaks in it, and a reader pauses mid-thought at every one. Same
|
|
-- flattening states/journal.lua does to journal pages.
|
|
local function flatten(text)
|
|
if type(text) ~= "string" then
|
|
return nil
|
|
end
|
|
text = text:gsub("%s*\n%s*", " "):gsub("^%s+", ""):gsub("%s+$", "")
|
|
if text == "" then
|
|
return nil
|
|
end
|
|
return text
|
|
end
|
|
|
|
local function add(blocks, text)
|
|
text = flatten(text)
|
|
if text then
|
|
blocks[#blocks + 1] = text
|
|
end
|
|
end
|
|
|
|
-- Name a list entry. `recipePopup` builds {icon, title}; `lootPopup` builds real
|
|
-- items, which carry `name` and may stack.
|
|
local function entryName(entry)
|
|
if type(entry) ~= "table" then
|
|
return nil
|
|
end
|
|
local name = entry.title or entry.name
|
|
if type(name) ~= "string" then
|
|
return nil
|
|
end
|
|
local quantity = tonumber(entry.quantity)
|
|
if quantity and quantity > 1 then
|
|
return name .. ", " .. quantity
|
|
end
|
|
return name
|
|
end
|
|
|
|
-- The four popup modules differ only in which locals they hold and in what order
|
|
-- draw() paints them. Reading order follows draw order, so what is spoken matches
|
|
-- what is on screen top to bottom.
|
|
--
|
|
-- Detection is by which locals exist, since the popup object itself carries no
|
|
-- type tag:
|
|
-- itemsList -> loot (real items)
|
|
-- items -> recipe ({title} rows)
|
|
-- title + subHeader -> lore
|
|
-- title -> map
|
|
local function shape(get)
|
|
if get("itemsList") ~= nil then
|
|
return "loot", { "flavourText", "header", "@itemsList" }
|
|
end
|
|
if get("items") ~= nil then
|
|
return "recipe", { "header", "subHeader", "@items", "footer" }
|
|
end
|
|
if get("title") ~= nil then
|
|
if get("subHeader") ~= nil then
|
|
return "lore", { "text", "header", "title", "subHeader", "footer" }
|
|
end
|
|
return "map", { "text", "title", "footer" }
|
|
end
|
|
-- Unknown popup type (a future game version adding one): read whatever of the
|
|
-- usual fields it has rather than falling back to silence.
|
|
return "unknown", { "text", "header", "subHeader", "title", "footer" }
|
|
end
|
|
|
|
local function blocksFor(stateTable, menus)
|
|
local popupObject = menus.getAnyUpvalue(stateTable, "popupObject")
|
|
if type(popupObject) ~= "table" or type(popupObject.draw) ~= "function" then
|
|
return nil, nil
|
|
end
|
|
|
|
local function get(name)
|
|
return menus.getUpvalue(popupObject.draw, name)
|
|
end
|
|
|
|
local _, fields = shape(get)
|
|
local blocks = {}
|
|
|
|
for _, field in ipairs(fields) do
|
|
if field:sub(1, 1) == "@" then
|
|
local list = get(field:sub(2))
|
|
if type(list) == "table" then
|
|
for _, entry in ipairs(list) do
|
|
add(blocks, entryName(entry))
|
|
end
|
|
end
|
|
else
|
|
add(blocks, get(field))
|
|
end
|
|
end
|
|
|
|
if #blocks == 0 then
|
|
return nil, nil
|
|
end
|
|
|
|
-- The popup table is unique per popup, so its identity is a sound focus key:
|
|
-- it changes exactly when a new popup is shown and never within one.
|
|
return blocks, tostring(popupObject)
|
|
end
|
|
|
|
-- Blocks are separate lines on screen, so they need a sentence break between them
|
|
-- when spoken -- but most already end in one ("...very old book."), and a blind
|
|
-- "." join gives NVDA "book.. This", which it reads with an audible stumble.
|
|
local function join(blocks)
|
|
local out = blocks[1]
|
|
for i = 2, #blocks do
|
|
local sep = out:find("[%.%?!:,]$") and " " or ". "
|
|
out = out .. sep .. blocks[i]
|
|
end
|
|
return out
|
|
end
|
|
|
|
local lastKey
|
|
|
|
return {
|
|
reset = function()
|
|
lastKey = nil
|
|
end,
|
|
|
|
-- read() is called from the poll, from the post-keypress announce and from
|
|
-- menus.repeatFocus, so it must be idempotent (see the warning in
|
|
-- states/journal.lua). It returns the same text for the same key every time;
|
|
-- only the history push is guarded, since duplicating a popup's paragraphs in
|
|
-- the review buffer on every frame would swamp it.
|
|
read = function(stateTable, menus)
|
|
local blocks, key = blocksFor(stateTable, menus)
|
|
if not blocks then
|
|
return nil, nil
|
|
end
|
|
|
|
if key ~= lastKey then
|
|
lastKey = key
|
|
for _, block in ipairs(blocks) do
|
|
pcall(history.append, block, "text")
|
|
end
|
|
end
|
|
|
|
return join(blocks), key
|
|
end,
|
|
|
|
-- `=` re-reads the popup on demand, for when the utterance went by.
|
|
details = function(stateTable, menus)
|
|
local blocks = blocksFor(stateTable, menus)
|
|
return blocks
|
|
end,
|
|
}
|