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:
co-authored by
Claude Opus 5
parent
ce0f375a10
commit
5778e86e8e
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -70,6 +70,8 @@ local function costPhrase(cost)
|
||||
end
|
||||
|
||||
return {
|
||||
probeNames = {"currentNodeId", "currentNode"},
|
||||
|
||||
read = function(stateTable, menus)
|
||||
local nodeId = menus.getAnyUpvalue(stateTable, "currentNodeId")
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user