Commit Graph
9 Commits
Author SHA1 Message Date
Accessibility ModandClaude Opus 5 5778e86e8e orientation: the travel map, unexplored ways, the nearest exit
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>
2026-08-03 16:05:15 +02:00
Accessibility ModandClaude Opus 5 ce0f375a10 research: read what a node costs, unlocks and does
The research tree spoke only the node's name and whether you could afford
it. Everything that tells you whether it is worth affording -- the
knowledge cost, the recipes it unlocks, the description -- is drawn in
the hint windows beside the tree and was never spoken, so "cannot afford"
gave no way to find out what was missing or by how much.

states/research.lua now implements details(), the same = / Shift+= hook
the item grids use: cost with the shortfall spelled out, what it unlocks,
then the description, one block each so [ and ] walk them back. Shift+=
adds the name line and the whole knowledge purse across all four types.
The focus line also takes its name from the node's title instead of
humanising the id, so it is "Sewing techniques", not "sewing techniques".

Everything is read from the state's currentNode upvalue -- the display
table updateCurrentNode() assembles for the hint window -- rather than
models/research.lua, whose nodes table is file-local and whose unlocks
are recipe ids, which would have named the recipe instead of the item it
produces. The shortfall is computed against journalModel.knowledge,
required lazily because models.journal drags in much of the game.

The tree has no hotkey and the prologue may hold no writing desk, so the
testbed enters it the way the desk does: ingame.interface("research"),
the exact call in the desk's tryInteractFunction. Require it as
"states/ingame" and not "states.ingame" -- states.lua uses the slash
form, and the dot form loads a second copy of the module whose
levelWindow is nil, which research.draw indexes on its first frame.

98 testbed checks, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 13:57:40 +02:00
Accessibility ModandClaude Opus 5 93ef26349a loot overlay: speak the contents of visible containers
The tutorial teaches "press s to see the contents of containers", and it was
silent. For a sighted player s sets levelWindow.itemsHighlightEnabled, which
draws the top item's icon on every loot-bearing tile in FoV.

We watch that flag rather than binding s ourselves -- it is game-claimed, and
keybindings.getCommand() ignores modifiers -- and speak on its rising edge, so
the player's own binding, hold vs toggle mode and the toolbar icon all work.
The flag is checked both at end of frame and inside the keypressed wrapper,
because in hold mode a quick tap goes up and down within one frame.

Reads the whole tile.loot list, not just the icon's top entry, nearest first,
with each container pushed to the console history for [ ] review.

71 testbed checks, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 01:45:18 +02:00
Accessibility ModandClaude Opus 5 a00f327c68 items: read the details of the focused item
The item grids said which item you were on ("a torch, slot 3") but nothing
about what it was -- no description, no condition, and no "what does this
refine into", which is the whole of alchemy.

The game already composes all of it in one place: item.getPopupMessage()
(modules/item.lua:199) returns the cursor popup's line list, so read that
rather than re-deriving anything from templates.

It also already gates it behind a dwell -- the popup only fades in after
examinationPopupFadeInDuration (0.4s), so a sighted player who arrows past a
slot never sees the details either. Mirror that instead of inventing a
modality: stop on an item for 0.6s and the details speak themselves, queued.
"=" reads them on demand, Shift+= keeps the name line too. ("r" was the
obvious mnemonic but is a game-claimed scancode, and getCommand() dispatches
on scancode ignoring modifiers.)

New generic hook descriptor.details() returning a LIST of blocks, so each is
also pushed into the console history and [ ] \ walk a long description back
afterwards -- the journal page trick. Only _itemgrid.lua implements it so far.

The other half is the hint panel craft/refine/trade draw permanently beside
the grid, which the popup never contains: recipe components, and price, which
appears nowhere else in the game. The three panels share a shape, so one
reader covers them. Affordability survives only as a red colour in the hint
table and decides whether enter does anything, so it is decoded and spoken.

59 testbed checks, 0 failures: a synthetic ivy_leaves item as the version-bump
canary for getPopupMessage, hasUpvalue over every hint-panel local, four
assertions on the pure settleStep, and an end-to-end "=" press. Not yet
exercised against a live item -- the prologue inventory is empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 01:31:58 +02:00
Accessibility ModandClaude Opus 5 0df3842bf1 inventory: speak the item action menu
Pressing enter/z on an inventory item plays a click and, until now, said
nothing. The click is sound.select: the menu WAS opening every time.

inventory.keypressed -> inventoryWindow.enableItemMenu() builds a second,
nested selection over the grid (modules/item/itemMenu.lua) listing the
actions that item currently allows, filtered from itemActions.lua by
per-action conditions: Activate, Wield/Unwield, Wear/Take off, Switch
on/off, Drink, Eat, Apply, Read, Open, Prepare for throwing/Pack away.
Up/down move in it, enter activates, any other key cancels. None of it
goes through console.print or the cursor popup, so it was silent -- and
since the game has no separate equipment screen, this menu is the only
way to equip anything. Reported by Talon: the torch quest was unplayable.

_itemgrid.lua now checks window.itemMenu first and keeps the grid cell
quiet underneath, since the menu is modal over it. The menu is a closure
with no getters, so lines and currentSelectedAction are read as upvalues
of its own draw(). The item name leads only when the menu opens, and the
"just opened" memory is the menu table itself rather than a flag, which
keeps read() idempotent -- a flag would be consumed by the focus poll and
the real announcement would come out headless.

Items also now read "equipped", which is part of the focus key on
purpose: after Switch on the menu closes with the cursor unmoved, so
otherwise the re-read is suppressed as unchanged and the action still
feels silent. Now: "Switch on" -> "a torch, equipped, slot 3".

New patch (the 6th) wraps the inventoryWindow factory for the dead case:
when getActionsList returns nil -- an item with no applicable actions --
the game does literally nothing, not even the click, which by ear is
indistinguishable from a dropped keypress. Says "<item>, no actions".

The prologue inventory is empty, so the live path is unverified; the
testbed instead builds an itemMenu from a synthetic item and asserts the
two upvalue names still resolve. That is the version-bump canary for this
descriptor, not proof of the wording. 38 checks, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 02:59:21 +02:00
Accessibility ModandClaude Opus 5 973e62c8fe viewwatch: announce objects entering and leaving view while walking
Walking blind, the hard part is not "what is next to me" -- that is `/` --
but "what changed when I moved". Rounding a corner reveals a room, stepping
back hides it, a rat walks into sight behind you, and none of it is spoken.

Shift+/ cycles three modes:

  settled (default)  changes accumulate while moving, spoken once you have
                     stood still for 0.4s
  always             every step reports its own change
  off

`settled` diffs against the state at the last ANNOUNCEMENT, not the last
step, so a long walk gives one combined summary that is a genuine net
change: something that came into view mid-walk and left again before you
stopped is not mentioned, and something visible throughout is mentioned
once. Summing per-step diffs would report both. A single step is the
degenerate case -- you are already standing still.

Arrivals and departures are deliberately asymmetric. Membership is current
FoV only (never rememberedFoV), but furniture drops out of FoV constantly
indoors as walls come between you and it, and "the bed is out of view"
every other step is noise. Arrivals are reported for every watched
category; departures only for creatures.

Terrain is unwatched by default -- water arrives dozens of tiles at a time.
Step detection polls playerActor.x/y as walltones does, so pathing, portals
and pushes are covered with no game function wrapped; the scan is a 14-tile
box and runs only on a step or a due settle, never per frame.

snapshot() and diffText() are pure and exposed so the testbed can assert
the wording and the FoV invariant directly instead of capturing speech.
34/34 testbed checks, 0 failures. Game footprint unchanged: main.lua | 1 +.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 02:03:32 +02:00
Accessibility ModandClaude Opus 5 e1d9995c9f 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>
2026-08-02 01:04:03 +02:00
Accessibility ModandClaude Opus 5 81a209b600 tracker: sticky selection and Ctrl+Home walk-to; walltones: conventional volume
Tracker selection now survives a rescan. The scan is rebuilt every turn and
both lists are distance-sorted, so holding a bare index meant one step by the
player silently re-pointed the selection at a different rat. The tracker now
remembers identity instead -- the actor table for creatures, coordinates for
tiles -- and re-derives the indices in reanchor() after each rescan. Selection
is dropped only when the thing genuinely stops being reported (out of FoV,
killed, picked up), and the existing clamp then falls back to the nearest.

Ctrl+Home walks to the selection, reusing the exact shape of the game's own
click-to-path in ingame.mousepressed: calculatePathFromPlayer, then either
bufferInput or playerInput for a one-step path. Interruptions and threatened-
player truncation therefore behave the way a mouse click does. Most things
worth tracking stand on impassable tiles, which calculatePathFromPlayer refuses
outright, so we fall back to the cheapest of the 8 neighbours and say "next to"
rather than "to". Guarded on states.getCurrent() == "ingame": elsewhere the
input buffer belongs to another state and feeding it moves would be a bug.

Wall tones now follow the usual proximity convention -- a near wall is loud,
one out at radius is quiet. It was inverted, which read backwards in play since
a nearby wall is the more urgent fact. config.louderWhenFurther still flips it.

Testbed covers both: selection identity across a forced rescan, and that
Ctrl+Home moves the player without breaking out of the map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 01:00:25 +02:00
Accessibility ModandClaude Opus 5 6380998376 docs: final handoff pass
Adds accessibility/README.md, the player-facing setup and keymap reference --
the thing to read before playtesting.

Brings the developer docs in line with what was actually built:

- docs/README.md rewritten as an index with current status and a one-paragraph
  architecture summary.
- docs/scratch.md gains a HANDOFF section: state of play, the commands to run
  after any change, the five traps that each cost real debugging time, what is
  NOT verified and why, deliberate omissions, and how to rebase onto a future
  game version.
- docs/03-implementation-plan.md gains a status banner listing where the
  implementation deliberately diverged from the plan (menu narration pulled
  forward as a blocker, levelCursorPopup not patched, tryInteract insufficient
  as a tracker filter, testbed added, no settings UI built).
- docs/04-open-questions.md gains a resolution banner: Q6 and Q8 remain open.

Also records that the testbed check count varies between runs (23-26) because
some blocks are conditional, so what matters is zero failures rather than the
total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:25:09 +02:00