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
+48
View File
@@ -59,6 +59,30 @@ the cursor away — press `Shift+]` to return to the newest.
| `;` | Where am I — area name, in-game date and time, coordinates |
| `Ctrl` + numpad `1`–`9` | Describe the tile in that direction without moving |
| `Ctrl` + numpad `5` | Describe the tile you are standing on |
| `End` | Unexplored ways — where there is still somewhere to go |
| `Shift+End` | The nearest way out of this level |
#### Getting your bearings on a whole floor (`End`)
`/` and the numpad probes tell you about the tiles beside you. `End` tells you
about the *floor*: it looks at everywhere you have already been, finds the places
where explored ground runs up against ground you have never seen, and reads them
out nearest first.
> `3 unexplored ways: 6 tiles north, 11 tiles south-west, 14 tiles east`
Each "way" is one opening, so a wide doorway counts once rather than three times.
When there is nowhere left to go you get `No unexplored ways from here`, which is
usually the moment to take `Shift+End` and leave.
This never tells you anything about ground you have not seen — only that there is
still a way through. It is the same thing a sighted player reads off the edge of
the explored area on screen.
`Shift+End` names the nearest exit you know about, how far away it is, and how
many steps it takes to walk there. Exits are also a tracker category (below), so
`Shift+End` is a shortcut rather than the only route — and it deliberately leaves
whatever you were tracking untouched.
#### Announcing things as they come into view (`Shift+/`)
@@ -126,6 +150,7 @@ Finds everything you have seen, sorted by distance.
| `Shift+PageUp` / `Shift+PageDown` | Previous / next **instance** of that kind |
| `Home` | Distance, direction, and number of steps by path |
| `Ctrl+Home` | Walk to it |
| `Shift+End` | The nearest exit, without changing your selection |
Categories: Creatures, Items, Alchemy, Interactables, Exits, Terrain features.
@@ -241,6 +266,29 @@ names, exactly as a sighted player sees only the icons.
Details also go into the message history, so `[` and `]` walk back through a
long description a piece at a time and `\` re-reads a line.
### The travel map
The map of Rimesia you get when you leave an area is a picture: icons scattered
across a painting, with a panel beside them. Arrowing through the list now reads
what the picture says.
```
Blackreed Mire, swamp, 2 of 9, north-east, Travel will take 1 day and 6 hours,
Visited 3 days ago
```
- **what kind of place it is** — town, crypt, mine, shipwreck, witchwood — which
on screen is only the shape of the icon;
- **which way it lies** from where you are standing now;
- **how long the journey takes**, which is the number the decision actually turns
on and which was previously only in the side panel;
- **when you were last there**, which on a map full of similar-sounding names is
often the only way to tell them apart.
The area's description is longer, so it is not read every time you move the
cursor. `=` reads it, and stopping on an area for half a second reads it by
itself — the same way item details work.
### Your character
| Key | Action |
+9 -3
View File
@@ -175,15 +175,21 @@ function describe.tile(level, x, y, originX, originY)
if facts.water then
parts[#parts + 1] = "water"
end
-- "remembered" is a safety flag, not a nicety, so it earns its place at
-- normal verbosity. tileFacts reads actors only when the tile is
-- currently VISIBLE (see the `if visible` guard above), so a remembered
-- tile is silent about whatever may be standing on it now. Without this
-- word that silence is indistinguishable from "nothing there".
if not facts.visible then
parts[#parts + 1] = "remembered"
end
end
if describe.verbosity == "verbose" then
if facts.interactable then
parts[#parts + 1] = "can interact"
end
if not facts.visible then
parts[#parts + 1] = "remembered"
end
end
if where then
+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.
+266
View File
@@ -0,0 +1,266 @@
-- frontier.lua — "where haven't I been yet?"
--
-- The level view IS the map. level.rememberedFoV (modules/level.lua:576) keeps
-- every tile the player has ever seen drawn on screen, and a sighted player pans
-- over it with Ctrl+arrows and reads the shape of the fog edge at a glance: that
-- corridor dead-ends, that one carries on, I never went back for the door in the
-- north wall.
--
-- Nothing in the mod answered that. The surroundings scan is local, the examine
-- cursor is one tile at a time, and the tracker only finds things it already
-- knows about -- none of them say where there is still to GO. This does, in one
-- keypress and with no mode to enter.
--
--------------------------------------------------------------------------------
-- What counts as a frontier
--------------------------------------------------------------------------------
--
-- A frontier tile is one that is seen (FoV or rememberedFoV), NOT blocking
-- movement, and has at least one neighbour that is in bounds and not seen.
--
-- Both restrictions are load-bearing:
--
-- * in bounds -- otherwise every walkable tile along the grid boundary is a
-- frontier, and the report is nothing but map edge.
-- * CARDINAL neighbours only, not all eight -- the diagonal test fires on the
-- unseen tile tucked behind a wall corner, which is not anywhere you can
-- walk. Standing in a lit corridor would otherwise report an "unexplored
-- way" for every corner you can see. Cardinal removes almost all of those.
--
-- Clustering is 8-connected, so a wide doorway is one way rather than three, but
-- two separate openings that both happen to lie north stay two ways.
--
--------------------------------------------------------------------------------
-- Not a cheat
--------------------------------------------------------------------------------
--
-- This reads only FoV and rememberedFoV, and reports only THAT an opening exists
-- -- never what lies beyond it. That is exactly the information the fog edge
-- gives a sighted player. Nothing about an unseen tile is ever spoken.
local speech = require("accessibility.speech")
local describe = require("accessibility.describe")
local frontier = {}
frontier.config = {
-- How many ways to name before falling back to "and N more". Four is about
-- as much as fits in one utterance and still be held in the head.
maxSpoken = 4,
}
--------------------------------------------------------------------------------
-- Level access
--------------------------------------------------------------------------------
local function getLevel()
local ok, world = pcall(require, "models.world")
if not ok or not world.currentLevel then
return nil
end
return world.currentLevel
end
local function seenAt(level, x, y)
if level.FoV and level.FoV[x] and level.FoV[x][y] then
return true
end
if level.rememberedFoV and level.rememberedFoV[x] and level.rememberedFoV[x][y] then
return true
end
return false
end
local function inBounds(level, x, y)
return x >= 1 and y >= 1
and x <= (level.xMax or 0) and y <= (level.yMax or 0)
and level.grid[x] and level.grid[x][y] and true or false
end
local function blocksMovement(level, x, y)
local stack = level.grid[x][y]
if type(stack) ~= "table" then
return true
end
for i = #stack, 1, -1 do
local tile = stack[i]
if tile and not tile.isHidden and tile.blocksMovement then
return true
end
end
return false
end
--------------------------------------------------------------------------------
-- Scanning
--------------------------------------------------------------------------------
local CARDINALS = {{0, -1}, {1, 0}, {0, 1}, {-1, 0}}
local function isFrontier(level, x, y)
if not seenAt(level, x, y) or blocksMovement(level, x, y) then
return false
end
for _, dir in ipairs(CARDINALS) do
local nx, ny = x + dir[1], y + dir[2]
if inBounds(level, nx, ny) and not seenAt(level, nx, ny) then
return true
end
end
return false
end
-- Every frontier tile on the level, as a set keyed "x,y" plus a flat list.
local function collect(level)
local set, list = {}, {}
for x = 1, level.xMax or 0 do
if level.grid[x] then
for y = 1, level.yMax or 0 do
if level.grid[x][y] and isFrontier(level, x, y) then
set[x .. "," .. y] = true
list[#list + 1] = {x = x, y = y}
end
end
end
end
return set, list
end
--------------------------------------------------------------------------------
-- Clustering
--------------------------------------------------------------------------------
-- 8-connected components, flood filled with an explicit stack rather than
-- recursion: a long wall of frontier tiles would otherwise be deep enough to
-- matter, and LuaJIT's C stack is not worth betting on here.
--
-- Each cluster is reduced to its member nearest the player, which is the one the
-- player would actually walk to.
local function cluster(set, list, px, py)
local clusters = {}
for _, seed in ipairs(list) do
local seedKey = seed.x .. "," .. seed.y
if set[seedKey] then
set[seedKey] = nil
local best = seed
local bestDistance = describe.distance(seed.x - px, seed.y - py)
local size = 0
local stack = {seed}
while #stack > 0 do
local tile = table.remove(stack)
size = size + 1
local distance = describe.distance(tile.x - px, tile.y - py)
if distance < bestDistance then
best, bestDistance = tile, distance
end
for dx = -1, 1 do
for dy = -1, 1 do
local key = (tile.x + dx) .. "," .. (tile.y + dy)
if set[key] then
set[key] = nil
stack[#stack + 1] = {x = tile.x + dx, y = tile.y + dy}
end
end
end
end
clusters[#clusters + 1] = {
x = best.x,
y = best.y,
distance = bestDistance,
size = size,
}
end
end
table.sort(clusters, function(a, b)
if a.distance ~= b.distance then
return a.distance < b.distance
end
-- Stable tie-break, so repeating the key repeats the sentence.
if a.x ~= b.x then
return a.x < b.x
end
return a.y < b.y
end)
return clusters
end
--------------------------------------------------------------------------------
-- Public
--------------------------------------------------------------------------------
-- Returns the cluster list, nearest first, or nil if there is no level.
function frontier.scan()
local level = getLevel()
local actor = level and level.playerActor
if not level or not level.grid or not actor then
return nil
end
local set, list = collect(level)
return cluster(set, list, actor.x, actor.y)
end
-- The spoken sentence, or nil if there is no level to report on.
function frontier.report()
local clusters = frontier.scan()
if not clusters then
return nil
end
if #clusters == 0 then
return "No unexplored ways from here"
end
local spoken = math.min(#clusters, frontier.config.maxSpoken)
local actor = getLevel().playerActor
local parts = {}
for i = 1, spoken do
local entry = clusters[i]
parts[#parts + 1] = describe.offset(entry.x - actor.x, entry.y - actor.y)
end
local sentence = string.format("%d unexplored %s: %s",
#clusters, #clusters == 1 and "way" or "ways", table.concat(parts, ", "))
if #clusters > spoken then
sentence = sentence .. ", and " .. (#clusters - spoken) .. " more"
end
return sentence
end
-- End: say it.
function frontier.announce()
local sentence = frontier.report()
if not sentence then
speech.say("Not on a map", true)
return
end
speech.say(sentence, true)
end
function frontier.registerKeys(hotkeys)
hotkeys.bind("end", nil, frontier.announce)
end
return frontier
+1
View File
@@ -260,6 +260,7 @@ function accessibility.initialize()
require("accessibility.status").registerKeys(hotkeys)
require("accessibility.viewwatch").registerKeys(hotkeys)
require("accessibility.details").registerKeys(hotkeys)
require("accessibility.frontier").registerKeys(hotkeys)
end)
local ok, err = pcall(function()
+30 -1
View File
@@ -334,7 +334,36 @@ function menus.selfTest(stateList)
if not descriptor then
results[stateName] = "no descriptor"
elseif descriptor.read then
results[stateName] = "custom read()"
-- A read() descriptor has no probe table, so it would otherwise lose
-- the version-bump canary entirely -- yet those are exactly the
-- screens that reach for the most upvalues. `probeNames` lets one
-- declare which game locals it depends on and get the same check.
local names = descriptor.probeNames
if type(names) ~= "table" then
results[stateName] = "custom read()"
else
local stateTable = getStateTable(stateName)
if not stateTable then
results[stateName] = "STATE NOT LOADABLE"
else
local missing
for _, name in ipairs(names) do
if not menus.hasUpvalue(stateTable, name) then
missing = name
break
end
end
if missing then
results[stateName] = "PROBE BROKEN: no upvalue '" .. tostring(missing) .. "'"
else
local first = menus.getAnyUpvalue(stateTable, names[1])
results[stateName] = first == nil
and "custom read(), ok (unset until opened)"
or "custom read(), ok"
end
end
end
else
local stateTable = getStateTable(stateName)
if not stateTable then
+2
View File
@@ -70,6 +70,8 @@ local function costPhrase(cost)
end
return {
probeNames = {"currentNodeId", "currentNode"},
read = function(stateTable, menus)
local nodeId = menus.getAnyUpvalue(stateTable, "currentNodeId")
+165 -14
View File
@@ -1,32 +1,183 @@
-- states/worldmap.lua — travel destinations.
--
-- Entries are areas, sorted by the screen's own comparator. `visitedMessage`
-- ("Visited 3 days ago") is the game's own string and worth speaking: on a map of
-- similar-sounding place names it is the main way to tell where you have been.
-- The screen is a painted map of Rimesia with area icons at real coordinates,
-- plus a description window carrying the area's blurb and the travel time. The
-- list on the side is only half of it: the icon says what KIND of place each area
-- is, the position says where it lies, and the description window says how long
-- getting there costs. All three were silent.
--
-- Why read() rather than probe/render: travel time needs the screen's
-- `currentAreaId`, and render(entry, index, total) is handed no state table.
-- `options`, `selectedOption` and `currentAreaId` are all file-locals in the same
-- chunk (states/worldmap.lua) and all are upvalues of exported functions, so
-- menus.getAnyUpvalue reaches them. `currentAreaId` is nil until the screen has
-- been opened once -- treat that as "omit the travel time", never as an error.
--
-- The blurb goes through details() on `=` rather than the focus line: it runs to
-- several sentences, and arrowing through nine areas would be unbearable.
local describe = require("accessibility.describe")
local worldmapModel = require("models.worldmap")
-- The 20 icon ids (models/interface/mapIcons.lua) are already readable words once
-- the underscores go: town, ruined_house, pirate_grave, witchwood... so this is a
-- transform rather than a lookup table, which also means a new id in a future
-- game version reads sensibly instead of vanishing.
--
-- Deliberately NOT an addition to mapIcons: icons.preprocess iterates that table
-- with pairs() and would try to treat any new table in it as an image.
local function areaType(iconId)
if type(iconId) ~= "string" then
return nil
end
return (iconId:gsub("_", " "))
end
-- The model area behind a list entry, or nil. worldmap.areas holds `false` in the
-- slots of erased areas (models/worldmap.lua:173), hence the type check.
local function modelArea(areaId)
if type(areaId) ~= "number" then
return nil
end
local area = worldmapModel.areas and worldmapModel.areas[areaId]
if type(area) ~= "table" then
return nil
end
return area
end
-- Compass bearing from one area to another in 0..1 map space.
--
-- describe.direction only tests the sign of each delta, so on floating-point
-- coordinates it would call almost everything diagonal. The deadzone below
-- suppresses a component that is small next to the other one, so a place almost
-- due north reads as "north" rather than "north-east". The 4/3 on x matches the
-- aspect correction the game's own distance maths uses
-- (models/worldmap.lua:64) so the bearing agrees with the travel time.
local DEADZONE = 0.25
local function bearing(fromArea, toArea)
local dx = (toArea.x - fromArea.x) * 4 / 3
local dy = toArea.y - fromArea.y
local ax, ay = math.abs(dx), math.abs(dy)
if ax < ay * DEADZONE then
dx = 0
elseif ay < ax * DEADZONE then
dy = 0
end
if dx == 0 and dy == 0 then
return nil
end
return describe.direction(dx, dy)
end
-- The focused list entry, plus the id of the area the player is standing in.
local function focus(stateTable, menus)
local options = menus.getAnyUpvalue(stateTable, "options")
local selected = menus.getAnyUpvalue(stateTable, "selectedOption")
if type(options) ~= "table" or type(selected) ~= "number" then
return nil
end
local entry = options[selected]
if type(entry) ~= "table" then
return nil
end
return entry, selected, #options, menus.getAnyUpvalue(stateTable, "currentAreaId")
end
return {
probe = {
fn = "keypressed",
focus = "selectedOption",
list = "options",
focusMayBeNil = true,
},
-- Checked by menus.selfTest at startup: a game version that renames any of
-- these says so on the console instead of going quiet on the travel screen.
probeNames = {"options", "selectedOption", "currentAreaId"},
read = function(stateTable, menus)
local entry, index, total, currentAreaId = focus(stateTable, menus)
if not entry then
return nil, nil
end
render = function(entry, index, total)
local parts = {entry.name or "Unknown area"}
-- entry.icon is already an image object (states/worldmap.lua:240); the id
-- string lives on the model area.
local area = modelArea(entry.id)
local kind = area and areaType(area.icon)
if kind then
parts[#parts + 1] = kind
end
parts[#parts + 1] = index .. " of " .. total
local here = modelArea(currentAreaId)
if here and area and entry.id ~= currentAreaId then
local which = bearing(here, area)
if which then
parts[#parts + 1] = which
end
end
-- The game's own string, already finished English, including "You are
-- currently here." Same call the screen makes at states/worldmap.lua:360.
local travel
if currentAreaId and entry.id then
local ok, message = pcall(worldmapModel.getDistanceMessage, currentAreaId, entry.id)
if ok and message and message ~= "" then
travel = message
parts[#parts + 1] = travel
end
end
-- "Visited 3 days ago": on a map of similar-sounding place names this is
-- the main way to tell where you have been.
--
-- Skipped when it duplicates the travel line. For the area you are
-- standing in, getDistanceMessage and getLastVisitedMessage return the
-- SAME sentence -- "You are currently here." (models/worldmap.lua:75-77
-- and :106-107) -- which the screen gets away with because they are drawn
-- in different panels, and which by ear is just said twice.
if entry.visitedMessage and entry.visitedMessage ~= ""
and entry.visitedMessage ~= travel
then
parts[#parts + 1] = entry.visitedMessage
end
if entry.isCompleted then
parts[#parts + 1] = "completed"
end
if entry.isFavourite then
parts[#parts + 1] = "favourite"
end
if entry.visitedMessage and entry.visitedMessage ~= "" then
parts[#parts + 1] = entry.visitedMessage
return table.concat(parts, ", "), index .. "/" .. total .. ":" .. tostring(entry.id)
end,
details = function(stateTable, menus, verbose)
local entry = focus(stateTable, menus)
if not entry then
return nil
end
parts[#parts + 1] = index .. " of " .. total
local blocks = {}
return table.concat(parts, ", ")
if verbose then
blocks[#blocks + 1] = entry.name or "Unknown area"
end
if entry.description and entry.description ~= "" then
blocks[#blocks + 1] = entry.description
end
if #blocks == 0 then
return nil
end
return blocks
end,
}
+254
View File
@@ -435,6 +435,165 @@ local function scenario()
end
end
------------------------------------------------------------------
-- Orientation: the frontier report and the nearest exit.
--
-- These are the two whole-floor questions -- "where haven't I been"
-- and "where is the way out" -- that the local scans and the tracker
-- could not answer.
------------------------------------------------------------------
do
local frontier = require("accessibility.frontier")
local _, frontierWorld = pcall(require, "models.world")
local level = frontierWorld and frontierWorld.currentLevel
local clusters = frontier.scan()
check("frontier scan returns a list", type(clusters) == "table",
"got " .. tostring(clusters))
if clusters and level then
print("[testbed] frontier clusters: " .. #clusters)
-- The invariant that matters: it must never name a tile the
-- player has not seen, and never one you could not stand on.
local leaked, impassable = nil, nil
for _, entry in ipairs(clusters) do
local seen = (level.FoV and level.FoV[entry.x] and level.FoV[entry.x][entry.y])
or (level.rememberedFoV and level.rememberedFoV[entry.x]
and level.rememberedFoV[entry.x][entry.y])
if not seen then
leaked = entry.x .. "," .. entry.y
end
local stack = level.grid[entry.x] and level.grid[entry.x][entry.y]
for i = 1, #(stack or {}) do
local tile = stack[i]
if tile and not tile.isHidden and tile.blocksMovement then
impassable = entry.x .. "," .. entry.y
end
end
end
check("frontier never reports an unseen tile", leaked == nil,
"leaked " .. tostring(leaked))
check("frontier never reports a tile that blocks movement", impassable == nil,
"impassable " .. tostring(impassable))
-- Every cluster must be a real neighbour of the unknown: a seen
-- tile with an in-bounds unseen tile beside it.
local bogus
for _, entry in ipairs(clusters) do
local touchesUnknown = false
for _, dir in ipairs({{0, -1}, {1, 0}, {0, 1}, {-1, 0}}) do
local nx, ny = entry.x + dir[1], entry.y + dir[2]
local within = level.grid[nx] and level.grid[nx][ny]
local seen = (level.FoV and level.FoV[nx] and level.FoV[nx][ny])
or (level.rememberedFoV and level.rememberedFoV[nx]
and level.rememberedFoV[nx][ny])
if within and not seen then
touchesUnknown = true
end
end
if not touchesUnknown then
bogus = entry.x .. "," .. entry.y
end
end
check("every frontier cluster touches unexplored ground", bogus == nil,
"bogus " .. tostring(bogus))
end
local sentence = frontier.report()
check("frontier produces a sentence", type(sentence) == "string",
"got " .. tostring(sentence))
if sentence then
print("[testbed] frontier: " .. sentence)
check("frontier sentence names a direction or says there are none",
sentence:find("north") or sentence:find("south")
or sentence:find("east") or sentence:find("west")
or sentence:find("No unexplored") ~= nil,
sentence)
end
press("end", "frontier report")
-- The report must be measured from where the player is standing NOW.
--
-- Deliberately not "walk a step and expect the numbers to change":
-- the step can be blocked by a wall, and even when it lands a nearby
-- opening can keep the same Chebyshev distance. That check failed on
-- a run where nothing was wrong. Comparing every reported distance
-- against the player's real position tests the same property and is
-- deterministic.
local beforeCount = clusters and #clusters
local fromX = level and level.playerActor and level.playerActor.x
local fromY = level and level.playerActor and level.playerActor.y
press("kp6", "step east to change the frontier")
wait(12)
local after = frontier.scan()
local nowActor = level and level.playerActor
if after and nowActor then
local moved = nowActor.x ~= fromX or nowActor.y ~= fromY
print(string.format("[testbed] frontier after a step: %d clusters (was %s), player %s",
#after, tostring(beforeCount), moved and "moved" or "was blocked"))
local wrong
for _, entry in ipairs(after) do
local expected = math.max(math.abs(entry.x - nowActor.x),
math.abs(entry.y - nowActor.y))
if entry.distance ~= expected then
wrong = string.format("%d,%d said %d expected %d",
entry.x, entry.y, entry.distance, expected)
end
end
check("frontier distances are measured from the player's real position",
wrong == nil, tostring(wrong))
-- Nearest first, or the "3 tiles north, 11 tiles east" ordering
-- means nothing.
local unsorted
for i = 2, #after do
if after[i].distance < after[i - 1].distance then
unsorted = i
end
end
check("frontier clusters come out nearest first", unsorted == nil,
"out of order at " .. tostring(unsorted))
end
-- Nearest exit. Must not disturb the tracker's own selection: that is
-- the whole reason it is a separate key rather than a category jump.
local trackerBefore = tracker.currentSelection()
press("end", "nearest exit", {shift = true})
wait(3)
local trackerAfter = tracker.currentSelection()
if trackerBefore then
check("nearest exit leaves the tracker selection alone",
trackerAfter ~= nil
and trackerAfter.kind == trackerBefore.kind
and trackerAfter.x == trackerBefore.x
and trackerAfter.y == trackerBefore.y,
"before " .. tostring(trackerBefore.kind)
.. " after " .. tostring(trackerAfter and trackerAfter.kind))
end
-- getListByTag is the game's own FoV-gated primitive; if it ever stops
-- answering to "exit" the key goes silently useless.
if level then
local okTag, exitList = pcall(level.getListByTag, "exit")
check("the game still answers getListByTag('exit')",
okTag and type(exitList) == "table",
"ok=" .. tostring(okTag) .. " list=" .. tostring(exitList))
if okTag and exitList then
print("[testbed] known exits: " .. #exitList)
end
end
end
-- The item action menu (enter/z on an inventory item) is a closure with no
-- getters, so _itemgrid.lua reads its `lines` and `currentSelectedAction`
-- as upvalues of its draw(). The prologue inventory is empty, so the menu
@@ -799,6 +958,101 @@ local function scenario()
end
end
------------------------------------------------------------------
-- The world map (travel screen)
--
-- Opened the way an overworld-travel tile opens it
-- (modules/tile/overworldTravel.lua:4): interface("worldmap",
-- {locationId = id}). Reached no other way this early -- the prologue
-- has no travel tile -- but the screen itself is fully functional.
--
-- Nothing here presses enter: that would travel, and the run would
-- leave the prologue.
------------------------------------------------------------------
if currentState() == "ingame" then
-- A read() descriptor has no probe table, so menus.selfTest can only
-- check it through probeNames. That makes probeNames the entire
-- version-bump canary for these two screens -- worth asserting here
-- as well as printing it at startup.
local menusModule = require("accessibility.menus")
local probeResults = menusModule.selfTest({"worldmap", "research"})
for _, stateName in ipairs({"worldmap", "research"}) do
local result = tostring(probeResults[stateName])
print("[testbed] probe " .. stateName .. ": " .. result)
check(stateName .. " upvalue probes still resolve",
result:find("PROBE BROKEN") == nil and result:find("NOT LOADABLE") == nil,
result)
end
local ingameState = require("states/ingame")
-- The area id normally comes from the travel tile that opened the
-- screen (modules/tile/overworldTravel.lua); it is not held anywhere
-- on models.world. Area 1 is Vyshberek, the one area that always
-- exists (models/worldmap.lua areasDefault), so it is the right
-- "where you are standing" for a prologue run.
pcall(ingameState.interface, "worldmap", {locationId = 1})
wait(12)
local inMap = currentState() == "worldmap"
check("the world map opens", inMap, "state is " .. tostring(currentState()))
if inMap then
local focus = focusText()
print("[testbed] world map focus: " .. tostring(focus))
check("the world map describes the focused area", focus ~= nil,
"focus=" .. tostring(focus))
-- The area TYPE comes from the icon id, which is how a sighted
-- player tells a settlement from a ruin. Vyshberek is a 'town'
-- and is the one area that always exists (models/worldmap.lua).
check("the world map says what kind of place it is",
focus ~= nil and focus:find("town", 1, true) ~= nil,
"focus=" .. tostring(focus))
-- Travel time is the game's own string. With one area on the map
-- it is "You are currently here."; with more it is a duration.
check("the world map speaks the travel time",
focus ~= nil and (focus:find("Travel will take", 1, true) ~= nil
or focus:find("currently here", 1, true) ~= nil),
"focus=" .. tostring(focus))
-- For the area you are standing in, getDistanceMessage and
-- getLastVisitedMessage return the same sentence. On screen they
-- are two panels; by ear it was said twice.
local _, currentlyHere = focus:gsub("currently here", "")
check("the world map does not say the same thing twice",
currentlyHere <= 1, "'currently here' x" .. currentlyHere)
check("the world map counts the entries",
focus ~= nil and focus:find("%d+ of %d+") ~= nil,
"focus=" .. tostring(focus))
-- The blurb is deliberately not on the focus line -- it runs to
-- several sentences -- so it must be reachable through details.
local details = require("accessibility.details")
local blocks = details.get("worldmap", false)
check("the world map has a description on demand",
type(blocks) == "table" and #blocks > 0 and #blocks[1] > 40,
"blocks=" .. tostring(blocks and #blocks))
check("the world map focus line omits the blurb",
focus ~= nil and blocks ~= nil and not focus:find(blocks[1], 1, true),
"focus=" .. tostring(focus))
press("=", "read the area description")
wait(5)
check("the details key did not leave the world map",
currentState() == "worldmap", "state is " .. tostring(currentState()))
press("escape", "close the world map")
wait(15)
check("the world map closes back to the game",
currentState() == "ingame", "state is " .. tostring(currentState()))
end
end
-- The quantity slider (patches/slider.lua) is shared by craft, refine,
-- trade and ingame_wait. Only the last is reachable this early: the
-- tutorial house has a bed, and interacting with one opens the
+92
View File
@@ -7,6 +7,7 @@
-- Shift+PageUp / Shift+PageDown instance that specific rat
-- Home announce distance + direction
-- Ctrl+Home walk there (what a left click does)
-- Shift+End nearest known exit, without touching the above
--
-- Two hard rules:
--
@@ -443,6 +444,95 @@ function tracker.announceLocation()
speech.say(table.concat(parts, ", "), true)
end
--------------------------------------------------------------------------------
-- Nearest exit
--------------------------------------------------------------------------------
--
-- Exits are already a tracker category, but reaching them costs several keypresses
-- of category cycling and clobbers whatever you were tracking. "Where is the way
-- out" is asked often enough, and under enough pressure, to deserve one key that
-- answers without disturbing the selection.
--
-- This does NOT use the tracker's own scan. The game has the exact primitive
-- already: level.getListByTag("exit") (modules/level.lua:1382) walks the grid,
-- gates on FoV or rememberedFoV, skips hidden tiles and returns the results
-- sorted by distance to the player. Reusing it means the FoV rule is enforced by
-- vanilla rather than restated here.
--
-- Note for anyone tempted by level.exits: it does not exist at runtime. It is
-- packed as nil (modules/level.lua:416) and never assigned; `exits` is a
-- generator parameter that gets baked into individual tiles.
local exitTemplates
local function getExitTemplates()
if exitTemplates then
return exitTemplates
end
local ok, templatesByTag = pcall(require, "modules.tile.templatesByTag")
exitTemplates = (ok and templatesByTag and templatesByTag.exit) or {}
return exitTemplates
end
-- getListByTag returns bare {x, y}, so the name has to come back off the stack.
local function exitNameAt(level, x, y)
local stack = level.grid and level.grid[x] and level.grid[x][y]
if type(stack) ~= "table" then
return "exit"
end
local templates = getExitTemplates()
for i = #stack, 1, -1 do
local tile = stack[i]
if tile and not tile.isHidden and templates[tile.templateId] then
return categories.tileName(tile)
end
end
return "exit"
end
-- Shift+End: the nearest way off this level.
function tracker.announceNearestExit()
local level = getLevel()
local actor = level and level.playerActor
if not level or not actor then
speech.say("No exits known", true)
return
end
local ok, list = pcall(level.getListByTag, "exit")
if not ok or type(list) ~= "table" or not list[1] then
speech.say("No exits known", true)
return
end
local nearest = list[1]
local parts = {
string.format("%s, %s", exitNameAt(level, nearest.x, nearest.y),
describe.offset(nearest.x - actor.x, nearest.y - actor.y)),
}
-- Same reasoning as announceLocation: straight-line offset is misleading
-- indoors, and the path count truncates to one step while threatened.
local okPath, path = pcall(level.calculatePathFromPlayer, nearest.x, nearest.y)
if okPath and path and #path > 0 then
parts[#parts + 1] = #path .. (#path == 1 and " step by path" or " steps by path")
elseif okPath then
parts[#parts + 1] = "no path"
end
if #list > 1 then
parts[#parts + 1] = #list .. " known"
end
speech.say(table.concat(parts, ", "), true)
end
-- Ctrl+Home: actually go there.
--
-- This is the keyboard equivalent of a left click on the tile (states/ingame.lua
@@ -557,6 +647,8 @@ function tracker.registerKeys(hotkeys)
hotkeys.bind("home", nil, tracker.announceLocation)
hotkeys.bind("home", "ctrl", tracker.walkToSelection)
hotkeys.bind("end", "shift", tracker.announceNearestExit)
end
return tracker