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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user