journal: speak the page text, not just the entry title
Opening the journal and selecting an entry announced its title and stopped, which is an index into content the player could never reach. Page flipping was silent too. The screen is three widgets at once: the tab row (q/e), the list of entry titles down the left (up/down), and the page on the right holding the actual text (left/right). The descriptor read only the first two. It now reads options[selectedOption].pages[selectedPageId] and speaks its text blocks. A page is bounded by what fits on screen, so it is safe as a single utterance. Layout newlines are flattened first -- splitTextByPages joins the game's own wrapped lines with \n, so left in they make the reader pause mid-sentence at every wrap point. Page position is announced only when there is more than one page, and the blocks are pushed into the console history so [ and ] walk back through a long lore entry after the utterance has gone by. The tab leads only when the tab changed and the title only when the entry changed, so flipping pages does not bury the new page under a repeated heading. That suppression made read() stateful, which is a trap: it is called for the same focus from four places -- the per-frame poll, the post-keypress announce, repeatFocus, and the testbed's before/after probes -- so a poll would consume the "changed" flag and the real announcement would come out headless. The descriptor now caches its last (key, text) and replays it whenever the key is unchanged, making read() idempotent; the suppression state advances exactly once per genuine focus change. Adds a generic descriptor.reset hook, called from menus.announceScreen. The memory lives in file-locals that outlive the screen, so without it reopening the journal on the entry you left it on would read the page with no title. Testbed asserts the spoken text contains the page body rather than merely being non-nil. 29 checks, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
81a209b600
commit
e1d9995c9f
@@ -96,6 +96,26 @@ genuinely stops being visible.
|
||||
|
||||
The tracker only ever reports things you have actually seen.
|
||||
|
||||
### The journal (`j`)
|
||||
|
||||
The journal has three parts, and all three are spoken:
|
||||
|
||||
| Key | Moves |
|
||||
|---|---|
|
||||
| `q` / `e` | Between tabs — Quests, Lore, Bestiary, Components, Recipes, Tutorials |
|
||||
| Up / Down | Between entries in the current tab |
|
||||
| Left / Right | Between pages of the selected entry |
|
||||
|
||||
Selecting an entry reads its title, its position in the list, and then the text
|
||||
of the page itself. The tab name is only spoken when you change tabs, and the
|
||||
title only when you change entry, so flipping pages doesn't make you sit through
|
||||
the heading again.
|
||||
|
||||
Long entries run to several pages; you are told `page 2 of 3` when there is more
|
||||
than one. The page text also goes into the message history, so `[` and `]` will
|
||||
walk back through it and `\` re-reads a line — useful for a long lore entry you
|
||||
want to take in a piece at a time.
|
||||
|
||||
### Your character
|
||||
|
||||
| Key | Action |
|
||||
|
||||
@@ -7,7 +7,7 @@ playtesting.** Remaining work is polish, mod settings and distribution.
|
||||
|
||||
**Game is playable start-to-finish by ear** from main menu through the intro
|
||||
conversation into gameplay, plus journal, character, actions and inventory —
|
||||
verified by the testbed, **26/26 checks**.
|
||||
verified by the testbed, **29/29 checks**.
|
||||
|
||||
### Run the testbed (do this after any change)
|
||||
```
|
||||
@@ -463,6 +463,48 @@ so it also bypassed the flush-time logger — meaning damage alerts fired but we
|
||||
invisible in the testbed transcript. Log prefixes: `!` interrupt, `*` immediate,
|
||||
` ` queued.
|
||||
|
||||
### Done — journal page text (bug fix, reported by Talon)
|
||||
|
||||
**Symptom:** open the journal with `j`, select an entry, hear nothing useful.
|
||||
|
||||
**Cause:** the journal is three widgets at once — the tab row, the list of entry
|
||||
titles on the left, and the **page** on the right that holds the actual text.
|
||||
The descriptor read only the first two. So it announced
|
||||
`Quests, A simple commission, 1 of 1` and stopped, which is an index into
|
||||
content you could never reach. Page flipping (`left`/`right`) was silent too.
|
||||
|
||||
**Fix** — `accessibility/states/journal.lua`:
|
||||
- Reads `options[selectedOption].pages[selectedPageId]`, a list of text blocks,
|
||||
and speaks their `text`. Page size is bounded by what fits on screen, so a
|
||||
page is safe to speak as one utterance.
|
||||
- **Flattens the newlines** in each block. `journalModel.splitTextByPages`
|
||||
concatenates the game's own wrapped lines with `\n`, so those are pure layout —
|
||||
left in, the reader pauses mid-sentence at every wrap point.
|
||||
- Says `page 2 of 3` only when there is more than one page.
|
||||
- Pushes the blocks into the console history, so `[` / `]` / `\` re-read the
|
||||
page after the utterance has gone by — the same trick `_textscreen.lua` uses
|
||||
for the manual.
|
||||
- Title leads only when the *entry* changed, tab only when the *tab* changed, so
|
||||
flipping pages doesn't bury the page you just turned to under a repeated title.
|
||||
|
||||
⚠ **`read()` must be idempotent.** It is called for the same focus from four
|
||||
places: the per-frame poll, the post-keypress announce, `menus.repeatFocus`, and
|
||||
the testbed's before/after probes. Any "only say this when it changed" state
|
||||
inside a descriptor is therefore a trap — the poll consumes the flag and the
|
||||
real announcement comes out headless. The journal descriptor caches its last
|
||||
`(key, text)` and replays it whenever the key is unchanged, so the suppression
|
||||
state advances exactly once per genuine focus change.
|
||||
|
||||
**New generic hook: `descriptor.reset`**, called by `menus.announceScreen`.
|
||||
Change-suppression memory lives in file-locals that outlive the screen, so
|
||||
without it, reopening the journal on the entry you left it on would read the
|
||||
page with no title in front of it.
|
||||
|
||||
Verified live: `Journal. Quests, A simple commission, 1 of 1, Someone's banging
|
||||
at my door. Whoever it is, this person is very persistent and doesn't know about
|
||||
doorbells.` **29/29 testbed checks**, including a new one that asserts the spoken
|
||||
text actually contains the page body rather than merely being non-nil.
|
||||
|
||||
### Known gaps (next up)
|
||||
- **Phase 5**: player status key (HP, statuses, current action), popup
|
||||
announcements (`popupStack.add`), damage alerts
|
||||
@@ -527,7 +569,7 @@ utterance — speech is verifiable from the log, not only by ear. `!` after
|
||||
## HANDOFF — read this if you are resuming
|
||||
|
||||
### State of play
|
||||
Phases 0–5 complete. **23 state descriptors, 5 patches, 26/26 testbed checks**,
|
||||
Phases 0–5 complete. **23 state descriptors, 5 patches, 29/29 testbed checks**,
|
||||
history unit tests passing, **one line changed in the game** (`main.lua`).
|
||||
|
||||
Everything in the original brief is implemented: console speech with
|
||||
@@ -538,7 +580,7 @@ exploration, wall tones, and keyboard+speech access to every screen.
|
||||
```
|
||||
lua accessibility/tests/test_history.lua # pure Lua, instant
|
||||
rm -rf ~/AppData/Roaming/LOVE/Alchemist # clean save
|
||||
"C:/Program Files/LOVE/lovec.exe" . --a11y-test # ~4 min, 26 checks
|
||||
"C:/Program Files/LOVE/lovec.exe" . --a11y-test # ~4 min, 29 checks
|
||||
git diff --stat vanilla-0.4.2 -- . ':!accessibility' ':!.gitignore' # must stay: main.lua | 1 +
|
||||
```
|
||||
|
||||
|
||||
@@ -271,6 +271,15 @@ function menus.announceScreen(stateName)
|
||||
lastKey = nil
|
||||
justSwitched = true
|
||||
|
||||
-- Descriptors that suppress repetition across reads (journal only leads with
|
||||
-- the tab or the entry title when it CHANGED) keep that memory in file-locals,
|
||||
-- which outlive the screen. Without this, reopening the journal on the entry
|
||||
-- you left it on would read the page text with no title in front of it.
|
||||
local descriptor = loadDescriptor(stateName)
|
||||
if descriptor and descriptor.reset then
|
||||
pcall(descriptor.reset)
|
||||
end
|
||||
|
||||
speech.say(stateNames[stateName] or stateName, true)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,25 +1,100 @@
|
||||
-- states/journal.lua — quests, lore, bestiary, knowledge, research, tutorials.
|
||||
-- states/journal.lua — quests, lore, bestiary, components, recipes, tutorials.
|
||||
--
|
||||
-- Two axes move independently: the tab (left/right) and the entry within it
|
||||
-- (up/down). Both must be announced, and the tab header must lead when it
|
||||
-- changes, since "Quests" and "Bestiary" list very different things.
|
||||
-- Visually the screen is three things at once:
|
||||
--
|
||||
-- Uses getAnyUpvalue because activeTabIndex, options and selectedOption are
|
||||
-- touched by different functions here (gotoTab, refreshOptions, keypressed).
|
||||
-- * a row of TABS across the top (Quests / Lore / Bestiary / Components /
|
||||
-- Recipes / Tutorials), changed with tabLeft / tabRight;
|
||||
-- * a scrolling LIST of entries down the left, each an icon plus a title,
|
||||
-- changed with up / down;
|
||||
-- * the selected entry's TEXT filling the right-hand page, paginated, with a
|
||||
-- "< 2 / 3 >" counter under it and left / right flipping pages.
|
||||
--
|
||||
-- Only the first two were ever announced, so selecting an entry read its title
|
||||
-- and then nothing — the page holding the actual content was silent, and page
|
||||
-- flipping was silent too. That is the whole point of the screen.
|
||||
--
|
||||
-- The page is `options[selectedOption].pages[selectedPageId]`, a list of text
|
||||
-- blocks (`{text = ..., color = ..., icon = ...}`). Its length is bounded by
|
||||
-- what fits on screen, so a page is safe to speak in one go; it is also pushed
|
||||
-- into the console history so [ and ] can walk it back, the same trick
|
||||
-- _textscreen.lua uses for the manual.
|
||||
--
|
||||
-- Uses getAnyUpvalue because activeTabIndex, options, selectedOption and
|
||||
-- selectedPageId are touched by different functions here (gotoTab,
|
||||
-- prepareOptions, selectPage, keypressed).
|
||||
|
||||
local history = require("accessibility.history")
|
||||
|
||||
local lastTab
|
||||
local lastEntry
|
||||
local lastPushedPage
|
||||
local lastKey
|
||||
local lastText
|
||||
|
||||
-- The page splitter concatenates the game's own wrapped lines with "\n", so a
|
||||
-- block's text carries hard newlines that are pure layout. Flatten them, or the
|
||||
-- screen reader pauses mid-sentence at every wrap point.
|
||||
local function flatten(text)
|
||||
return (tostring(text):gsub("%s*\n%s*", " "):gsub("^%s+", ""):gsub("%s+$", ""))
|
||||
end
|
||||
|
||||
local function pageBlocks(page)
|
||||
local blocks = {}
|
||||
|
||||
for _, textBlock in ipairs(page) do
|
||||
if type(textBlock) == "table" and type(textBlock.text) == "string" then
|
||||
local text = flatten(textBlock.text)
|
||||
if text ~= "" then
|
||||
blocks[#blocks + 1] = text
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return blocks
|
||||
end
|
||||
|
||||
return {
|
||||
-- Called by menus.announceScreen each time the journal is opened, so the tab
|
||||
-- and title lead again rather than being suppressed as "unchanged" from the
|
||||
-- last visit.
|
||||
reset = function()
|
||||
lastTab = nil
|
||||
lastEntry = nil
|
||||
lastPushedPage = nil
|
||||
lastKey = nil
|
||||
lastText = nil
|
||||
end,
|
||||
|
||||
read = function(stateTable, menus)
|
||||
local tabIndex = menus.getAnyUpvalue(stateTable, "activeTabIndex")
|
||||
local tabsList = menus.getAnyUpvalue(stateTable, "tabsList")
|
||||
local options = menus.getAnyUpvalue(stateTable, "options")
|
||||
local selected = menus.getAnyUpvalue(stateTable, "selectedOption")
|
||||
local pageId = menus.getAnyUpvalue(stateTable, "selectedPageId")
|
||||
|
||||
if type(tabIndex) ~= "number" or type(tabsList) ~= "table" then
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
local optionCount = type(options) == "table" and #options or -1
|
||||
local entry = type(options) == "table" and type(selected) == "number"
|
||||
and options[selected] or nil
|
||||
|
||||
local key = table.concat({
|
||||
tostring(tabIndex), tostring(selected), tostring(pageId), tostring(optionCount)
|
||||
}, ":")
|
||||
|
||||
-- read() is called from several places for the same focus: the per-frame
|
||||
-- poll, the post-keypress announce, the "repeat what's focused" key, and
|
||||
-- the testbed's before/after probes. Only the FIRST read of a given focus
|
||||
-- may advance the suppression state below -- otherwise a poll would
|
||||
-- consume the "tab changed" flag and the announcement that follows would
|
||||
-- come out without its heading. So later reads of the same key replay the
|
||||
-- text verbatim, which also makes the repeat key say the full thing.
|
||||
if key == lastKey then
|
||||
return lastText, key
|
||||
end
|
||||
|
||||
local tab = tabsList[tabIndex]
|
||||
local tabName = tab and tab.header or ("tab " .. tabIndex)
|
||||
|
||||
@@ -28,23 +103,58 @@ return {
|
||||
-- Lead with the tab only when it just changed, so stepping through a long
|
||||
-- quest list doesn't repeat "Quests" on every line.
|
||||
if tabIndex ~= lastTab then
|
||||
lastTab = tabIndex
|
||||
parts[#parts + 1] = tabName
|
||||
end
|
||||
|
||||
if type(options) == "table" and type(selected) == "number" and options[selected] then
|
||||
local entry = options[selected]
|
||||
parts[#parts + 1] = entry.title or entry.header or entry.name or "?"
|
||||
parts[#parts + 1] = selected .. " of " .. #options
|
||||
elseif type(options) == "table" and #options == 0 then
|
||||
if optionCount == 0 then
|
||||
parts[#parts + 1] = "empty"
|
||||
elseif entry then
|
||||
-- Repeating the title on every page flip would bury the page you just
|
||||
-- turned to, so the title leads only when the entry itself changed.
|
||||
local entryKey = tostring(tabIndex) .. ":" .. tostring(selected)
|
||||
|
||||
if entryKey ~= lastEntry then
|
||||
parts[#parts + 1] = entry.title or entry.header or entry.name or "?"
|
||||
parts[#parts + 1] = selected .. " of " .. optionCount
|
||||
end
|
||||
|
||||
if #parts == 0 then
|
||||
return nil, nil
|
||||
local pages = entry.pages
|
||||
local page = type(pages) == "table" and type(pageId) == "number" and pages[pageId]
|
||||
|
||||
if page then
|
||||
-- Only worth saying which page you are on when there is more than one.
|
||||
if #pages > 1 then
|
||||
parts[#parts + 1] = "page " .. pageId .. " of " .. #pages
|
||||
end
|
||||
|
||||
return table.concat(parts, ", "),
|
||||
tostring(tabIndex) .. ":" .. tostring(selected) .. ":" .. tostring(options and #options)
|
||||
local blocks = pageBlocks(page)
|
||||
|
||||
for _, block in ipairs(blocks) do
|
||||
parts[#parts + 1] = block
|
||||
end
|
||||
|
||||
if #blocks == 0 then
|
||||
parts[#parts + 1] = "no text"
|
||||
end
|
||||
|
||||
-- Mirror the page into the review buffer so [ and ] can walk it
|
||||
-- block by block after the utterance has gone by.
|
||||
local pageKey = entryKey .. ":" .. tostring(pageId)
|
||||
if pageKey ~= lastPushedPage then
|
||||
lastPushedPage = pageKey
|
||||
for _, block in ipairs(blocks) do
|
||||
pcall(history.append, block, "journal")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
lastEntry = entryKey
|
||||
end
|
||||
|
||||
lastTab = tabIndex
|
||||
lastKey = key
|
||||
lastText = #parts > 0 and table.concat(parts, ", ") or nil
|
||||
|
||||
return lastText, key
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -435,12 +435,42 @@ local function scenario()
|
||||
end
|
||||
end
|
||||
|
||||
-- The journal's whole point is the page of text on the right-hand side; the
|
||||
-- list of titles on the left is just an index into it. Announcing only the
|
||||
-- title (which is what it used to do) reads as "there is an entry here"
|
||||
-- with no way to find out what it says, so assert the body specifically.
|
||||
local function checkJournalReadsPage()
|
||||
local spoken = focusText()
|
||||
local stateTable = menus.getStateTable("journal")
|
||||
local options = stateTable and menus.getAnyUpvalue(stateTable, "options")
|
||||
local selected = stateTable and menus.getAnyUpvalue(stateTable, "selectedOption")
|
||||
local pageId = stateTable and menus.getAnyUpvalue(stateTable, "selectedPageId")
|
||||
local entry = type(options) == "table" and type(selected) == "number" and options[selected]
|
||||
local page = entry and type(entry.pages) == "table" and entry.pages[pageId]
|
||||
local block = page and page[1] and page[1].text
|
||||
|
||||
if not block then
|
||||
print("[testbed] journal: no page text to compare against (skipped)")
|
||||
return
|
||||
end
|
||||
|
||||
-- Compare on the first few words only: the descriptor flattens the
|
||||
-- layout newlines the page splitter inserts, so the strings are not
|
||||
-- byte-identical by design.
|
||||
local opening = block:gsub("%s+", " "):gsub("^%s+", ""):sub(1, 30)
|
||||
|
||||
check("journal speaks the page body, not just the title",
|
||||
spoken ~= nil and spoken:find(opening, 1, true) ~= nil,
|
||||
"expected to contain " .. string.format("%q", opening) ..
|
||||
", spoken=" .. tostring(spoken))
|
||||
end
|
||||
|
||||
-- Phase 4: the menu screens reachable from gameplay.
|
||||
--
|
||||
-- Each is opened, navigated once, and closed. `back` is bound to x/escape/
|
||||
-- backspace, but x also opens examine mode, so use escape.
|
||||
local screens = {
|
||||
{key = "j", state = "journal", nav = {"down", "right"}},
|
||||
{key = "j", state = "journal", nav = {"down", "right"}, check = checkJournalReadsPage},
|
||||
{key = "c", state = "character", nav = {"right", "right"}},
|
||||
{key = "a", state = "actions_menu", nav = {"down"}},
|
||||
{key = "i", state = "inventory", nav = {"right", "down"}},
|
||||
@@ -460,6 +490,10 @@ local function scenario()
|
||||
check(screen.state .. " describes focus", focusText() ~= nil,
|
||||
"focus=" .. tostring(focusText()))
|
||||
|
||||
if screen.check then
|
||||
screen.check()
|
||||
end
|
||||
|
||||
press("escape", "close " .. screen.state)
|
||||
wait(15)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user