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>
This commit is contained in:
Accessibility Mod
2026-08-03 16:05:15 +02:00
co-authored by Claude Opus 5
parent ce0f375a10
commit 5778e86e8e
10 changed files with 1004 additions and 23 deletions
+137 -5
View File
@@ -7,7 +7,7 @@ fixing what Talon hits. 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, **98/98 checks**.
verified by the testbed, **120 checks, 0 failures**.
**Not set up yet, or unsure how the two trees relate?** →
[00-dev-setup.md](00-dev-setup.md).
@@ -153,14 +153,17 @@ and by Talon at the keyboard (wall tones).
| `'` | Health and active effects |
| `,` | Current selected action |
| `=` / `Shift+=` | Read details of the focused thing (Shift = keep the name line) |
| `End` | Frontier report: unexplored ways, nearest first |
| `Shift+End` | Nearest known exit (does not touch the tracker selection) |
Game keys worth knowing: **`x`** enters examine mode (not `e`), `f` finds exits
while examining, `j` journal, `i` inventory, `c` character, `a` actions menu.
Reserved for the tracker (Phase 3): `PageUp`/`PageDown`, `Home`, with Ctrl/Shift.
**Free scancodes** (game binds none of these): `[ ] \ ; ' , / - =`, `h k l m n o
p u v w`, `F1`–`F12`, `pageup pagedown home end insert`, `kp0 kp. kp+ kp- kp* kp/`.
**Free scancodes** (game binds none of these): `- `, `h k l m n o p u v w`,
`F1`–`F12`, `insert`, `kp0 kp. kp+ kp- kp* kp/`.
*Now taken by the mod:* `[ ] \ ; ' , / =` `pageup pagedown home end`.
**Game-claimed scancodes — never bind these unmodified:**
`. 0-9 \` a b backspace c d delete down e escape f g i j kp1-kp9 kpenter lalt
@@ -881,6 +884,117 @@ does: `ingame.interface("research")`, the exact call in the desk's
module whose `levelWindow` is nil, which `research.draw` indexes on its first
frame.
### Done — orientation: world map, frontier report, nearest exit (Talon request)
Prompted by "do sighted players have a minimap we don't have access to?" The
answer is no widget, but two map *surfaces* were only half exposed.
**Two corrections worth not rediscovering:**
- **`level.exits` does not exist at runtime.** It is packed as nil
(`modules/level.lua:416`) and never assigned anywhere; `exits` is a *generator*
parameter baked into individual tiles. The primitive to use is the game's own
**`level.getListByTag("exit")`** (`modules/level.lua:1382`) — it walks the grid,
gates on `FoV or rememberedFoV`, skips hidden tiles, and returns `{x, y}` sorted
by distance to the player. The FoV rule is then enforced by vanilla rather than
restated by us. The tag set is `way_out`, `building_door`, `horse`, `ladder_up`,
`ladder_down` (`modules/tile/templatesByTag.lua` — the only tag the game defines).
- **Exits were already a tracker category** (`categories.lua:41`, classified
*before* the lootable and structural filters so nothing can steal them). The new
key is a shortcut, not a new capability.
**1. World map (`accessibility/states/worldmap.lua`, rewritten).** Was speaking
name / completed / favourite / `visitedMessage` only, dropping the three things
the *picture* carries: area type, geography, travel time.
Switched from `probe`/`render` to `read()` + `details()` — necessarily, because
`render(entry, index, total)` gets no state table and the travel time needs the
screen's `currentAreaId`. All three locals (`options`, `selectedOption`,
`currentAreaId`, `states/worldmap.lua:112-114`) are upvalues of exported
functions, so `menus.getAnyUpvalue` reaches them. Travel time is the game's own
`worldmapModel.getDistanceMessage`, the same call the screen makes at
`states/worldmap.lua:360` — already finished English, including "You are
currently here."
Two gotchas found on the way:
- `options[i].icon` is already an **image object** (`states/worldmap.lua:240`).
The icon *id* string lives on the model area, `worldmapModel.areas[id].icon`.
Do **not** add a name table to `models/interface/mapIcons.lua`:
`icons.preprocess` iterates it with `pairs` and would treat a new table as an
image. All 20 ids are readable once the underscores go, so it is a `gsub`
rather than a lookup — new ids in a future version read sensibly instead of
vanishing.
- `describe.direction` only tests the **sign** of each delta, so on the map's
floating-point 0..1 coordinates it called almost everything diagonal. There is
now a 25% deadzone, with the same `4/3` x-correction the game's own distance
maths uses (`models/worldmap.lua:64`), so the bearing agrees with the travel
time.
**New: `descriptor.probeNames`** (`menus.lua`, selfTest). A `read()` descriptor
has no probe table, so it previously reported a bare `custom read()` and lost the
version-bump canary entirely — on exactly the screens that reach for the most
upvalues. A descriptor can now declare the game locals it depends on and get the
same `PROBE BROKEN: no upvalue 'x'` check. `worldmap` and `research` both declare
theirs.
**2. Frontier report, `End` (`accessibility/frontier.lua`, new).** The level view
*is* the map: `rememberedFoV` keeps everything ever seen on screen, and a sighted
player pans over it and reads the fog edge. Nothing in the mod answered "where
haven't I been" — surroundings is local, examine is one tile, the tracker only
finds things it already knows about.
A frontier tile is: seen, **not** `blocksMovement`, with at least one **cardinal**
neighbour that is **in bounds** and **not seen**. Both restrictions are
load-bearing —
- *in bounds*, or every walkable tile on the grid boundary is a frontier and the
report is nothing but map edge;
- *cardinal, not all 8*, or the diagonal test fires on the unseen tile behind
every wall corner, which is not anywhere you can walk. Standing in a lit
corridor would report an "unexplored way" per corner.
Clustered 8-connected (so a wide doorway is one way, but two separate openings
that both lie north stay two), reduced to each cluster's nearest member, sorted,
capped at 4 with "and N more". Flood fill uses an explicit stack, not recursion.
**Not a cheat:** reads only `FoV`/`rememberedFoV` and reports only *that* an
opening exists, never what is beyond it — exactly what the fog edge shows.
**3. Nearest exit, `Shift+End`** (`tracker.announceNearestExit`). Lives in
`tracker.lua` for the level access, `offsetFromPlayer` and the path-length logic,
but deliberately **does not touch the category/kind/instance selection** — that is
the whole reason it is a separate key rather than a category jump. Uses
`getListByTag` rather than the tracker's own scan.
**4. "remembered" promoted to normal verbosity** (`describe.lua`). It was
verbose-only. `tileFacts` reads actors **only when the 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". It is a safety flag, not a nicety.
**120 testbed checks, 0 failures** (was 98). Two bugs the testbed caught that
review had not:
- The world map said **"You are currently here." twice**. `getDistanceMessage`
and `getLastVisitedMessage` return the *same sentence* for the area you are
standing in (`models/worldmap.lua:75-77` and `:106-107`). On screen they are two
separate panels and nobody notices; by ear it is a stutter. `read()` now drops
`visitedMessage` when it duplicates the travel line. **General lesson: two
game strings that are fine side by side on screen can collide when concatenated
into one utterance.**
- The first version of the frontier test walked one step east and demanded the
numbers change. It failed on a run where nothing was wrong — the step landed,
but both openings kept the same Chebyshev distance. Replaced with the property
that actually matters and is deterministic: every reported distance must equal
Chebyshev from the player's *current* position, and the list must be sorted
nearest first.
The testbed opens the world map the way a travel tile does —
`ingame.interface("worldmap", {locationId = 1})` — and never presses enter, which
would travel and take the run out of the prologue. Same `states/ingame` slash-form
require trap as the research tree.
### Known gaps (next up)
- **Phase 5**: player status key (HP, statuses, current action), popup
announcements (`popupStack.add`), damage alerts
@@ -945,7 +1059,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. **24 state descriptors, 7 patches, 98/98 testbed checks**,
Phases 0–5 complete. **24 state descriptors, 7 patches, 120 testbed checks (0 failures)**,
history unit tests passing, **one line changed in the game** (`main.lua`).
Everything in the original brief is implemented: console speech with
@@ -955,7 +1069,7 @@ exploration, wall tones, and keyboard+speech access to every screen.
### First thing to do after any change
```
lua accessibility/tests/test_history.lua # pure Lua, instant
bash accessibility/tests/run-testbed.sh # ~4 min, 98 checks
bash accessibility/tests/run-testbed.sh # ~4 min, 120 checks
git diff --stat vanilla-0.4.2 -- . ':!accessibility' ':!.gitignore' ':!CLAUDE.md' # must stay: main.lua | 1 +
```
@@ -1008,6 +1122,24 @@ These are blocked on content the prologue does not contain, **not** known broken
recipe / lore / map popups are a separate mechanism and are now covered by the
`ingame_popup` descriptor and the testbed.)
- **The nearest-exit key (`Shift+End`) against a real exit.** The tutorial house
has no exit-tagged tile in view, so `getListByTag("exit")` returns an empty list
and live it has only ever said `No exits known`. The testbed proves the call
still answers and that the key leaves the tracker selection alone; it has not
proven the sentence.
- **The frontier report (`End`) on a real dungeon.** In the prologue house it
finds 2 clusters, both south-west, which is the right shape for one door but is
not much of a test of the clustering. The invariants (never an unseen tile,
never an impassable one, every cluster really touches unexplored ground,
distances measured from the player, nearest first) are all checked, but the
*phrasing* wants judging by ear on a level with several branches. In
particular: whether connected components read better than bucketing by bearing,
and whether a bare direction is enough or each way wants a hint of what it is.
- **The world map with more than one area.** The prologue has only Vyshberek, so
the bearing clause and a real travel duration have never been spoken — every
run so far says "You are currently here." The deadzone in `bearing()` (25%)
is therefore untuned by ear.
### Known-missing features (deliberate, not bugs)
- **No mod settings UI.** Verbosity (`describe.verbosity`), wall-tone config
(`walltones.config`) and `speech.logToConsole` are code-level fields only.