diff --git a/accessibility/README.md b/accessibility/README.md index 56cf01b..52042b8 100644 --- a/accessibility/README.md +++ b/accessibility/README.md @@ -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 | diff --git a/accessibility/describe.lua b/accessibility/describe.lua index ab10513..56daf8a 100644 --- a/accessibility/describe.lua +++ b/accessibility/describe.lua @@ -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 diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index 9de8a3c..84d19e0 100644 --- a/accessibility/docs/scratch.md +++ b/accessibility/docs/scratch.md @@ -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. diff --git a/accessibility/frontier.lua b/accessibility/frontier.lua new file mode 100644 index 0000000..7506a4d --- /dev/null +++ b/accessibility/frontier.lua @@ -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 diff --git a/accessibility/init.lua b/accessibility/init.lua index 47e7a2a..31354ef 100644 --- a/accessibility/init.lua +++ b/accessibility/init.lua @@ -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() diff --git a/accessibility/menus.lua b/accessibility/menus.lua index 1ffd1a9..b988da7 100644 --- a/accessibility/menus.lua +++ b/accessibility/menus.lua @@ -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 diff --git a/accessibility/states/research.lua b/accessibility/states/research.lua index 382a12f..2fcee8c 100644 --- a/accessibility/states/research.lua +++ b/accessibility/states/research.lua @@ -70,6 +70,8 @@ local function costPhrase(cost) end return { + probeNames = {"currentNodeId", "currentNode"}, + read = function(stateTable, menus) local nodeId = menus.getAnyUpvalue(stateTable, "currentNodeId") diff --git a/accessibility/states/worldmap.lua b/accessibility/states/worldmap.lua index f138931..6df8345 100644 --- a/accessibility/states/worldmap.lua +++ b/accessibility/states/worldmap.lua @@ -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, } diff --git a/accessibility/testbed.lua b/accessibility/testbed.lua index f760ec1..6747b9f 100644 --- a/accessibility/testbed.lua +++ b/accessibility/testbed.lua @@ -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 diff --git a/accessibility/tracker.lua b/accessibility/tracker.lua index c932f63..1a48166 100644 --- a/accessibility/tracker.lua +++ b/accessibility/tracker.lua @@ -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