diff --git a/accessibility/README.md b/accessibility/README.md index 39b5824..3e04e82 100644 --- a/accessibility/README.md +++ b/accessibility/README.md @@ -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 | diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index 1787349..91b5d14 100644 --- a/accessibility/docs/scratch.md +++ b/accessibility/docs/scratch.md @@ -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 + ``` diff --git a/accessibility/menus.lua b/accessibility/menus.lua index 057070b..1ffd1a9 100644 --- a/accessibility/menus.lua +++ b/accessibility/menus.lua @@ -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 diff --git a/accessibility/states/journal.lua b/accessibility/states/journal.lua index d2130f2..39f37a4 100644 --- a/accessibility/states/journal.lua +++ b/accessibility/states/journal.lua @@ -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 + + 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 + + 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 - if #parts == 0 then - return nil, nil - end + lastTab = tabIndex + lastKey = key + lastText = #parts > 0 and table.concat(parts, ", ") or nil - return table.concat(parts, ", "), - tostring(tabIndex) .. ":" .. tostring(selected) .. ":" .. tostring(options and #options) + return lastText, key end, } diff --git a/accessibility/testbed.lua b/accessibility/testbed.lua index efd5c6f..7f33299 100644 --- a/accessibility/testbed.lua +++ b/accessibility/testbed.lua @@ -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