Both aiming modes announced their name and then went silent, so there was no way to know what the arrow keys were pointing at. Prompted by "it says targetting, melee, but when I press the arrow keys it does not tell me what I am currently selecting." They look like a pair and are not, which is the whole shape of the fix. Throw has a real free-roaming cursor on level.cursorX/cursorY, so its descriptor is ingame_observe plus one clause: whether the tile is a legal target. That clause is recomputed from level.FoV, level.interactableArea and level.canThrowOnCell rather than read off the state's targetAllowed upvalue, because ingame_targeting_throw.lua:228 sets targetAllowed false for the whole 0.3s cursor glide and only the animation's onFinish restores it -- and mid-glide is exactly when we speak, so it would have said "blocked" on every single move. The same class of trap as the examine-cursor popup lag, and the same answer: read the model, not the view. Melee has no cursor and no target state at all. ingame_targeting_melee.lua:184-195 takes one direction key and fires the callback in that same keypress, so nothing is ever "currently selected" and there is no focus for a read() to return. Its descriptor speaks only on entry, listing the eight adjacent tiles -- which are precisely the legal targets, the game's rule being Chebyshev <= 1. Arrows still commit immediately; the game plays as it did. A two-step confirm cursor was considered and rejected for that reason. That entry line is surroundings.scan()'s sentence, so scan() splits into describeNeighbours() returning the text and scan() speaking it. onOpen grows the ability to be a function rather than a fixed string, since the neighbours differ every time. menus.selfTest needed a branch for a descriptor with neither read nor probe -- melee is the first -- or it would index a nil probe and take the whole self test down with it, which is the one report that must never fail quietly. Neither mode is reachable by its key in the prologue: b is filtered out by the room's level.allowedActions, and t falls through to gotoInventory() when there is nothing throwable. Found that by writing the testbed block as keypresses and watching it land in the inventory. It now drives both states directly, borrowing levelWindow, hpbar and levelDescriptionWindow off the ingame state's upvalues the way gotoAction would pass them. 129 testbed checks, 0 failures. The throw block asserts the focus text CHANGED across a cursor move rather than merely being non-nil, which is the assertion that would have caught the targetAllowed mistake had it been made. Still unverified against real content: only "can throw" has been heard, not "blocked", "out of range" or "no line of sight". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1561 lines
57 KiB
Lua
1561 lines
57 KiB
Lua
-- testbed.lua — drives the game by code so mod behaviour can be verified without
|
|
-- a human at the keyboard.
|
|
--
|
|
-- Enable with: lovec.exe . --a11y-test
|
|
--
|
|
-- Key events go through love.event.push, so input travels the exact same path as
|
|
-- a human keypress (love.keypressed -> states.keypressed -> our wrapper -> the
|
|
-- game's handler). Nothing is stubbed.
|
|
--
|
|
-- The scenario is a coroutine: it yields to wait for frames, so it can *read* the
|
|
-- game's actual focus and navigate accordingly rather than counting keypresses
|
|
-- blind. Blind counting drifts the moment an option is disabled or a screen
|
|
-- inserts itself.
|
|
|
|
local menus = require("accessibility.menus")
|
|
|
|
local testbed = {}
|
|
|
|
local co
|
|
local waitFrames = 0
|
|
local finished = false
|
|
local failures = 0
|
|
local checks = 0
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Primitives (called from inside the scenario coroutine)
|
|
--------------------------------------------------------------------------------
|
|
|
|
local function wait(frames)
|
|
for _ = 1, frames or 1 do
|
|
coroutine.yield()
|
|
end
|
|
end
|
|
|
|
local function currentState()
|
|
local ok, states = pcall(require, "states")
|
|
if not ok then
|
|
return "?"
|
|
end
|
|
return states.getCurrent()
|
|
end
|
|
|
|
local function focusText()
|
|
local ok, text = pcall(menus.readFocus, currentState())
|
|
if not ok then
|
|
return nil
|
|
end
|
|
return text
|
|
end
|
|
|
|
-- Presses a key and waits long enough for the event to be pumped, handled, and
|
|
-- for our end-of-frame speech flush to run.
|
|
local function press(scancode, note, mods)
|
|
local before = focusText()
|
|
print(string.format("[testbed] (%s) press %-8s%s focus_before=%s %s",
|
|
currentState(), scancode,
|
|
mods and (mods.ctrl and " +ctrl" or mods.shift and " +shift" or "") or "",
|
|
before and ('"' .. before .. '"') or "nil", note or ""))
|
|
|
|
-- Modifier state is read from real hardware, which synthetic events cannot
|
|
-- affect -- so override the hotkey layer's modifier probes for this press.
|
|
local hotkeys = require("accessibility.hotkeys")
|
|
local realCtrl, realShift = hotkeys.isCtrlDown, hotkeys.isShiftDown
|
|
|
|
if mods then
|
|
hotkeys.isCtrlDown = function() return mods.ctrl == true end
|
|
hotkeys.isShiftDown = function() return mods.shift == true end
|
|
end
|
|
|
|
love.event.push("keypressed", scancode, scancode, false)
|
|
love.event.push("keyreleased", scancode, scancode)
|
|
|
|
wait(6)
|
|
|
|
hotkeys.isCtrlDown, hotkeys.isShiftDown = realCtrl, realShift
|
|
|
|
local after = focusText()
|
|
print(string.format("[testbed] -> state=%s focus_after=%s",
|
|
currentState(), after and ('"' .. after .. '"') or "nil"))
|
|
end
|
|
|
|
local function typeText(text)
|
|
print(string.format("[testbed] (%s) type %q", currentState(), text))
|
|
for char in text:gmatch(".") do
|
|
love.event.push("textinput", char)
|
|
wait(3)
|
|
end
|
|
wait(3)
|
|
end
|
|
|
|
local function check(label, condition, detail)
|
|
checks = checks + 1
|
|
if not condition then
|
|
failures = failures + 1
|
|
end
|
|
print(string.format("[testbed] %s %s%s",
|
|
condition and "PASS" or "FAIL", label, detail and (" -- " .. detail) or ""))
|
|
end
|
|
|
|
-- Walk to a tile using the game's own pathfinder, pressing one direction key per
|
|
-- step. Hardcoded direction sequences drift as soon as anything moves the player,
|
|
-- so navigate by path instead.
|
|
local directionKeys = {
|
|
["0,-1"] = "kp8", ["1,-1"] = "kp9", ["1,0"] = "kp6", ["1,1"] = "kp3",
|
|
["0,1"] = "kp2", ["-1,1"] = "kp1", ["-1,0"] = "kp4", ["-1,-1"] = "kp7",
|
|
}
|
|
|
|
local function walkTo(x, y, note, stopWhenStateChanges)
|
|
local ok, world = pcall(require, "models.world")
|
|
if not ok or not world.currentLevel then
|
|
return false
|
|
end
|
|
|
|
local level = world.currentLevel
|
|
local startState = currentState()
|
|
|
|
for _ = 1, 30 do
|
|
if stopWhenStateChanges and currentState() ~= startState then
|
|
return true
|
|
end
|
|
|
|
local pathOk, path = pcall(level.calculatePathFromPlayer, x, y)
|
|
if not pathOk or not path or #path == 0 then
|
|
return currentState() ~= startState
|
|
end
|
|
|
|
local step = path[1]
|
|
local key = directionKeys[step.dx .. "," .. step.dy]
|
|
if not key then
|
|
return false
|
|
end
|
|
|
|
press(key, note or ("walking to " .. x .. "," .. y))
|
|
wait(8)
|
|
end
|
|
|
|
return currentState() ~= startState
|
|
end
|
|
|
|
local function waitForState(stateName, maxFrames)
|
|
local waited = 0
|
|
while currentState() ~= stateName and waited < (maxFrames or 300) do
|
|
wait(1)
|
|
waited = waited + 1
|
|
end
|
|
return currentState() == stateName
|
|
end
|
|
|
|
-- Navigate a list menu to the option whose spoken text matches `pattern`, by
|
|
-- pressing down and reading focus each time. Robust to disabled options and to
|
|
-- the list changing length between versions.
|
|
local function selectByText(pattern, maxSteps)
|
|
for _ = 1, maxSteps or 15 do
|
|
local text = focusText()
|
|
if text and text:lower():find(pattern:lower(), 1, true) then
|
|
return true
|
|
end
|
|
press("down", "searching for '" .. pattern .. "'")
|
|
end
|
|
return false
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- The scenario
|
|
--------------------------------------------------------------------------------
|
|
|
|
local function scenario()
|
|
wait(30) -- let the main menu settle
|
|
|
|
check("reached main menu", currentState() == "main_menu", "got " .. currentState())
|
|
|
|
-- Regression guard for the bug where love.keypressed was still bound to the
|
|
-- pre-wrapper states.keypressed, so arrow keys announced nothing.
|
|
local before = focusText()
|
|
press("down", "first arrow key after startup")
|
|
local after = focusText()
|
|
check("first arrow key changes focus", before ~= after,
|
|
string.format("before=%s after=%s", tostring(before), tostring(after)))
|
|
|
|
press("up", "back up")
|
|
|
|
-- Options -> config_menu. Verified read-only where it matters: changing
|
|
-- settings here has real consequences (resolution above all), so we only
|
|
-- adjust a value when the focused option is demonstrably safe, and we put it
|
|
-- straight back.
|
|
if selectByText("Options") then
|
|
press("return", "open settings")
|
|
wait(15)
|
|
|
|
if currentState() == "settings_menu" then
|
|
-- Pick "Gameplay" specifically. The first entry is "Keybindings",
|
|
-- which opens keybindings_menu rather than config_menu, and "System"
|
|
-- holds resolution -- not something to poke at in an automated run.
|
|
selectByText("Gameplay")
|
|
press("return", "open Gameplay settings")
|
|
wait(15)
|
|
|
|
if currentState() == "config_menu" then
|
|
check("config_menu describes option", focusText() ~= nil,
|
|
"focus=" .. tostring(focusText()))
|
|
|
|
press("down", "config: next option")
|
|
press("down", "config: next option")
|
|
|
|
local text = focusText() or ""
|
|
local risky = text:lower():find("resolution")
|
|
or text:lower():find("fullscreen")
|
|
|
|
if risky then
|
|
print("[testbed] skipping value change, focused option is risky: " .. text)
|
|
else
|
|
local before = focusText()
|
|
press("right", "config: increase value")
|
|
local after = focusText()
|
|
check("config value change is announced", before ~= after,
|
|
string.format("before=%s after=%s", tostring(before), tostring(after)))
|
|
press("left", "config: restore original value")
|
|
check("config value restored", focusText() == before,
|
|
"now=" .. tostring(focusText()))
|
|
end
|
|
|
|
press("escape", "leave config_menu")
|
|
wait(15)
|
|
end
|
|
end
|
|
|
|
-- Unwind whatever depth we ended at, rather than assuming a fixed number
|
|
-- of escapes -- keybindings_menu and config_menu nest differently.
|
|
for _ = 1, 4 do
|
|
if currentState() == "main_menu" then
|
|
break
|
|
end
|
|
press("escape", "unwind to main menu (" .. currentState() .. ")")
|
|
wait(15)
|
|
end
|
|
end
|
|
|
|
check("back at main menu", currentState() == "main_menu", "got " .. currentState())
|
|
|
|
check("found New game", selectByText("New game"), "menu navigation by text")
|
|
press("return", "confirm New game")
|
|
|
|
check("reached new_game", waitForState("new_game"), "got " .. currentState())
|
|
|
|
typeText("Tester")
|
|
local nameFocus = focusText()
|
|
check("name field reads back typed text",
|
|
nameFocus ~= nil and nameFocus:find("Tester") ~= nil,
|
|
"focus=" .. tostring(nameFocus))
|
|
|
|
press("return", "confirm name")
|
|
wait(30)
|
|
|
|
-- Intro sequence: narration / dialogue / message screens vary, so just advance
|
|
-- until we land in gameplay.
|
|
local sawDialogue = false
|
|
local dialogueText
|
|
|
|
for i = 1, 20 do
|
|
if currentState() == "ingame" then
|
|
break
|
|
end
|
|
if currentState() == "ingame_dialogue" and not sawDialogue then
|
|
sawDialogue = true
|
|
dialogueText = focusText()
|
|
-- Exercise response navigation before advancing.
|
|
press("down", "dialogue: next response")
|
|
press("up", "dialogue: previous response")
|
|
end
|
|
press("return", "advancing intro (" .. currentState() .. ") " .. i)
|
|
wait(10)
|
|
end
|
|
|
|
check("dialogue was described", sawDialogue and dialogueText ~= nil,
|
|
"text=" .. tostring(dialogueText))
|
|
|
|
check("reached gameplay", currentState() == "ingame", "got " .. currentState())
|
|
|
|
if currentState() == "ingame" then
|
|
-- Movement drives console messages and wall tones.
|
|
press("right", "move east")
|
|
press("right", "move east")
|
|
press("down", "move south")
|
|
press("left", "move west")
|
|
press("up", "move north")
|
|
|
|
-- Try a wider set of directions: the start position may be walled in, and a
|
|
-- blocked move produces no console output.
|
|
for _, dir in ipairs({"up", "left", "down", "right", "up", "right", "down", "left"}) do
|
|
press(dir, "probing movement")
|
|
end
|
|
|
|
-- Direct smoke test of the console.print patch.
|
|
--
|
|
-- Plain movement is silent in this game (actions/move.lua only prints when
|
|
-- the move FAILS -- bound, immobilized), so walking around proves nothing
|
|
-- about the wrapper. Drive console.print itself instead.
|
|
local history = require("accessibility.history")
|
|
local console = require("modules.console")
|
|
local baseline = history.count()
|
|
|
|
console.print("Accessibility smoke test message.")
|
|
wait(3)
|
|
check("console.print reaches history", history.count() == baseline + 1,
|
|
"count " .. baseline .. " -> " .. history.count())
|
|
|
|
-- The game folds a repeated identical message into "(x2)"; we must match.
|
|
console.print("Accessibility smoke test message.")
|
|
wait(3)
|
|
check("repeat folds instead of appending", history.count() == baseline + 1,
|
|
"count = " .. history.count())
|
|
|
|
-- Console review keys.
|
|
press("[", "review previous")
|
|
press("]", "review next")
|
|
press("\\", "repeat current")
|
|
|
|
-- Phase 2: surroundings probing (no state change, no game side effects).
|
|
press("/", "scan all eight neighbours")
|
|
press(";", "where am I")
|
|
press("kp8", "probe north", {ctrl = true})
|
|
press("kp6", "probe east", {ctrl = true})
|
|
press("kp5", "probe standing-on", {ctrl = true})
|
|
|
|
-- Phase 5: status keys and damage alerts.
|
|
press("'", "announce health and statuses")
|
|
press(",", "announce current action")
|
|
|
|
-- Damage alert is polled from hp, so drive it by actually wounding the
|
|
-- player rather than trusting the code path by inspection.
|
|
local statusModule = require("accessibility.status")
|
|
local _, world = pcall(require, "models.world")
|
|
local playerActor = world and world.currentLevel and world.currentLevel.playerActor
|
|
|
|
if playerActor and playerActor.hp then
|
|
local originalHp = playerActor.hp
|
|
playerActor.hp = math.max(1, originalHp - 3)
|
|
wait(3)
|
|
check("damage alert fired", true, "hp " .. originalHp .. " -> " .. playerActor.hp)
|
|
|
|
playerActor.hp = originalHp
|
|
wait(3)
|
|
statusModule.update()
|
|
end
|
|
|
|
-- Phase 3: object tracker.
|
|
local tracker = require("accessibility.tracker")
|
|
local scanData = tracker.rescan(true)
|
|
check("tracker scan returns data", scanData ~= nil,
|
|
"scan = " .. tostring(scanData))
|
|
|
|
if scanData then
|
|
local summary = {}
|
|
for _, category in ipairs(require("accessibility.categories").list) do
|
|
local bucket = scanData[category.key]
|
|
local kinds = bucket and #bucket.kinds or 0
|
|
local instances = 0
|
|
for _, kind in ipairs(bucket and bucket.kinds or {}) do
|
|
instances = instances + #kind.instances
|
|
end
|
|
summary[#summary + 1] = string.format("%s=%dk/%di", category.key, kinds, instances)
|
|
|
|
if kinds > 0 then
|
|
local names = {}
|
|
for _, kind in ipairs(bucket.kinds) do
|
|
names[#names + 1] = kind.name .. "(" .. kind.key .. ")x" .. #kind.instances
|
|
end
|
|
print("[testbed] " .. category.key .. ": " .. table.concat(names, ", "))
|
|
end
|
|
end
|
|
print("[testbed] tracker contents: " .. table.concat(summary, " "))
|
|
|
|
local found = false
|
|
for _, category in ipairs(require("accessibility.categories").list) do
|
|
local bucket = scanData[category.key]
|
|
if bucket and #bucket.kinds > 0 then
|
|
found = true
|
|
end
|
|
end
|
|
check("tracker found something", found, "at least one non-empty category")
|
|
end
|
|
|
|
-- Cycle every category so each announcement path runs, including empties.
|
|
for i = 1, 6 do
|
|
press("pagedown", "tracker: category " .. i, {ctrl = true})
|
|
end
|
|
-- Land on a populated category before exercising kind/instance navigation,
|
|
-- otherwise every announcement is just "none" and proves nothing.
|
|
local trackerCategories = require("accessibility.categories").list
|
|
for i, category in ipairs(trackerCategories) do
|
|
local bucket = scanData and scanData[category.key]
|
|
if bucket and #bucket.kinds > 0 then
|
|
-- Cycling wrapped back to index 1, so step forward to this one.
|
|
for _ = 2, i do
|
|
press("pagedown", "tracker: to " .. category.key, {ctrl = true})
|
|
end
|
|
break
|
|
end
|
|
end
|
|
|
|
press("pagedown", "tracker: next kind")
|
|
press("pagedown", "tracker: next kind")
|
|
press("pagedown", "tracker: next instance", {shift = true})
|
|
press("pagedown", "tracker: next instance", {shift = true})
|
|
press("home", "tracker: announce location")
|
|
|
|
-- Selection stickiness: the scan is rebuilt from scratch and re-sorted by
|
|
-- distance every turn, so re-deriving the selection by index would silently
|
|
-- land on a different object. Force a rescan and check we are still on the
|
|
-- same one.
|
|
local beforeSelection = tracker.currentSelection()
|
|
if beforeSelection then
|
|
tracker.rescan(true)
|
|
local afterSelection = tracker.currentSelection()
|
|
check("tracker keeps its selection across a rescan",
|
|
afterSelection ~= nil
|
|
and afterSelection.kind == beforeSelection.kind
|
|
and afterSelection.x == beforeSelection.x
|
|
and afterSelection.y == beforeSelection.y,
|
|
beforeSelection.kind .. " at " .. beforeSelection.x .. "," .. beforeSelection.y)
|
|
|
|
-- Ctrl+Home walks to the selection, the way a left click does.
|
|
local _, trackerWorld = pcall(require, "models.world")
|
|
local level = trackerWorld and trackerWorld.currentLevel
|
|
local walkFrom = level and level.playerActor
|
|
local fromX, fromY = walkFrom and walkFrom.x, walkFrom and walkFrom.y
|
|
press("home", "tracker: walk to selection", {ctrl = true})
|
|
wait(20)
|
|
check("ctrl+home did not break out of the map", currentState() == "ingame",
|
|
"state is " .. tostring(currentState()))
|
|
if fromX then
|
|
local now = level.playerActor
|
|
print(string.format("[testbed] ctrl+home moved player %d,%d -> %d,%d",
|
|
fromX, fromY, now.x, now.y))
|
|
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
|
|
-- cannot be opened for real here -- but the fragile part is the upvalue
|
|
-- NAMES, and those can be checked by building a menu directly. This is the
|
|
-- version-bump canary for that descriptor.
|
|
do
|
|
local newItemMenu = require("modules.item.itemMenu")
|
|
local fakeItem = {name = "a test item", onUse = function() end}
|
|
local itemMenu = newItemMenu(fakeItem, 0, 0)
|
|
|
|
check("item action menu builds for a usable item", itemMenu ~= nil)
|
|
|
|
if itemMenu then
|
|
local lines = menus.getUpvalue(itemMenu.draw, "lines")
|
|
local selected = menus.getUpvalue(itemMenu.draw, "currentSelectedAction")
|
|
|
|
check("item action menu exposes its action list by upvalue",
|
|
type(lines) == "table" and lines[1] and lines[1].text ~= nil,
|
|
"lines=" .. tostring(lines))
|
|
check("item action menu exposes its selection by upvalue",
|
|
selected == 1, "currentSelectedAction=" .. tostring(selected))
|
|
|
|
itemMenu.adjustSelectedAction(1)
|
|
check("item action menu selection is readable after it moves",
|
|
menus.getUpvalue(itemMenu.draw, "currentSelectedAction") ~= nil)
|
|
end
|
|
end
|
|
|
|
-- The HOTBAR keys. patches/hotbar.lua has to work out which itemAction a
|
|
-- hotbar equipment command will resolve to *before* the action runs (the
|
|
-- item's isEquipped flips, and afterwards the conditions pick the opposite
|
|
-- verb). That mirrors a file-local table in models/hotkey.lua, so this is
|
|
-- its version-bump canary: every id must still exist and still have a
|
|
-- header, and a real item must still resolve to the expected verb.
|
|
do
|
|
local hotbarPatch = require("accessibility.patches.hotbar")
|
|
local itemActions = require("modules.item.itemActions")
|
|
local newItem = require("modules.item")
|
|
|
|
local missing = {}
|
|
for command, ids in pairs(hotbarPatch.actionIdsFor) do
|
|
for _, id in ipairs(ids) do
|
|
local action = itemActions[id]
|
|
if not (action and action.header) then
|
|
missing[#missing + 1] = command .. "/" .. id
|
|
end
|
|
end
|
|
end
|
|
check("every hotbar item action id still has a header",
|
|
#missing == 0, "missing: " .. table.concat(missing, ", "))
|
|
|
|
-- A torch is onWield/onUnequip, so equipUnequip must flip between the
|
|
-- two verbs with isEquipped -- exactly what the announcement says.
|
|
local torch = newItem{templateId = "torch", quantity = 1}
|
|
check("hotbar names the verb for an unequipped item",
|
|
hotbarPatch.headerFor(torch, "equipUnequip") == "Wield",
|
|
tostring(hotbarPatch.headerFor(torch, "equipUnequip")))
|
|
|
|
torch.isEquipped = true
|
|
check("hotbar names the opposite verb once equipped",
|
|
hotbarPatch.headerFor(torch, "equipUnequip") == "Unwield",
|
|
tostring(hotbarPatch.headerFor(torch, "equipUnequip")))
|
|
torch.isEquipped = false
|
|
|
|
local cannotDrink = hotbarPatch.headerFor(torch, "drink")
|
|
check("hotbar stays quiet for a command the item cannot do",
|
|
cannotDrink == nil, tostring(cannotDrink))
|
|
end
|
|
|
|
-- End to end: bind a real item to slot 1 and press it. The prologue gives
|
|
-- the player nothing, so plant a torch, bind it, press "1" twice (wield,
|
|
-- then unwield) and put everything back. This is the only check that
|
|
-- proves the whole chain -- patch applied, keybinding resolved, model
|
|
-- looked up, pack() read, verb chosen -- rather than one link of it.
|
|
do
|
|
local speech = require("accessibility.speech")
|
|
local player = require("models.player")
|
|
local world = require("models.world")
|
|
|
|
local spoken = {}
|
|
local realSay = speech.say
|
|
speech.say = function(text, ...)
|
|
spoken[#spoken + 1] = tostring(text)
|
|
return realSay(text, ...)
|
|
end
|
|
|
|
local hotbarModel = world.hotbars and world.hotbars[1]
|
|
local hadHotkey = hotbarModel and hotbarModel.hotkeys[1]
|
|
|
|
-- An empty slot is silent in game -- press before binding anything.
|
|
if hotbarModel and not hadHotkey then
|
|
spoken = {}
|
|
press("1", nil, "hotbar: empty slot")
|
|
check("hotbar says when a slot is empty",
|
|
table.concat(spoken, " | "):find("empty", 1, true) ~= nil,
|
|
table.concat(spoken, " | "))
|
|
end
|
|
|
|
if hotbarModel then
|
|
player.addItem{templateId = "torch", quantity = 1}
|
|
hotbarModel.addHotkey{
|
|
type = "equipment",
|
|
command = "equipUnequip",
|
|
itemTemplateId = "torch",
|
|
index = 1,
|
|
}
|
|
|
|
spoken = {}
|
|
press("1", nil, "hotbar: wield the torch")
|
|
local said = table.concat(spoken, " | ")
|
|
check("hotbar names the item and what it did to it",
|
|
said:find("Torch", 1, true) ~= nil and said:find("Wield", 1, true) ~= nil,
|
|
said)
|
|
|
|
spoken = {}
|
|
press("1", nil, "hotbar: put the torch away")
|
|
local saidAgain = table.concat(spoken, " | ")
|
|
check("a second press announces the opposite verb",
|
|
saidAgain:find("Unwield", 1, true) ~= nil, saidAgain)
|
|
|
|
-- Put the world back: a lit torch changes FoV and light, which the
|
|
-- viewwatch and loot-overlay checks below would then inherit.
|
|
hotbarModel.hotkeys[1] = hadHotkey
|
|
local planted = player.inventory.findItemByTemplateId("torch")
|
|
if planted then
|
|
planted.remove(1)
|
|
end
|
|
end
|
|
|
|
speech.say = realSay
|
|
end
|
|
|
|
-- Item DETAILS (the `=` key and the settle timer). Same problem as the
|
|
-- action menu above: the prologue inventory is empty, so there is no real
|
|
-- item to focus. The fragile parts are (a) item.getPopupMessage still
|
|
-- existing and still carrying the description and the refine yields, and
|
|
-- (b) the hint-panel locals in craft/refine/trade keeping their names.
|
|
-- Both are checkable without a live screen.
|
|
do
|
|
local itemdetails = require("accessibility.itemdetails")
|
|
local newItem = require("modules.item")
|
|
|
|
-- ivy_leaves has a description AND both a grind and a distill recipe,
|
|
-- so one item exercises every branch of getPopupMessage worth having.
|
|
local item = newItem{templateId = "ivy_leaves", quantity = 3}
|
|
local blocks = itemdetails.blocks(item)
|
|
local joined = table.concat(blocks or {"<nil>"}, " | ")
|
|
|
|
check("item details produce blocks", type(blocks) == "table" and #blocks > 0,
|
|
"blocks=" .. joined)
|
|
check("item details include the description",
|
|
joined:find("weak poison", 1, true) ~= nil, joined)
|
|
check("item details include the refinement yields",
|
|
joined:find("Yields", 1, true) ~= nil
|
|
and joined:find("mortar", 1, true) ~= nil, joined)
|
|
check("item details drop the name line already spoken by read()",
|
|
blocks ~= nil and blocks[1] ~= nil
|
|
and blocks[1]:find("bunch of ivy", 1, true) == nil,
|
|
tostring(blocks and blocks[1]))
|
|
check("verbose item details keep the name line",
|
|
(itemdetails.blocks(item, true) or {""})[1]:find("ivy", 1, true) ~= nil)
|
|
|
|
-- Hint-panel canary. These locals hold the recipe cost, the refinement
|
|
-- output and (in trade) the price, none of which appear in the cursor
|
|
-- popup -- a rename would make them silently vanish.
|
|
local hintLocals = {
|
|
craft = {"hintDescription", "componentHints"},
|
|
refine = {"hintDescription", "outputName", "outputQuantityText",
|
|
"budgetNameText", "budgetQuantityText"},
|
|
trade = {"componentHints", "budgetNameText", "budgetQuantityText"},
|
|
}
|
|
|
|
for stateName, names in pairs(hintLocals) do
|
|
local stateTable = menus.getStateTable(stateName)
|
|
for _, name in ipairs(names) do
|
|
check(stateName .. " hint panel still has local '" .. name .. "'",
|
|
stateTable ~= nil and menus.hasUpvalue(stateTable, name))
|
|
end
|
|
end
|
|
|
|
-- The settle timer: due exactly once per focus key, and re-armed by a
|
|
-- change. Without "exactly once" a stationary cursor would re-speak the
|
|
-- description every frame.
|
|
local details = require("accessibility.details")
|
|
details.reset()
|
|
check("settle does not fire before the delay",
|
|
details.settleStep("a", 0.1) == false)
|
|
check("settle fires once the delay has passed",
|
|
details.settleStep("a", details.config.settleDelay) == true)
|
|
check("settle does not fire twice for the same focus",
|
|
details.settleStep("a", 10) == false)
|
|
check("a focus change re-arms the settle",
|
|
details.settleStep("b", 0.01) == false
|
|
and details.settleStep("b", 10) == true)
|
|
details.reset()
|
|
end
|
|
|
|
-- The journal's whole point is the page of text on the right-hand side; the
|
|
-- list of titles on the left is just an index into it. Announcing only the
|
|
-- title (which is what it used to do) reads as "there is an entry here"
|
|
-- with no way to find out what it says, so assert the body specifically.
|
|
local function checkJournalReadsPage()
|
|
local spoken = focusText()
|
|
local stateTable = menus.getStateTable("journal")
|
|
local options = stateTable and menus.getAnyUpvalue(stateTable, "options")
|
|
local selected = stateTable and menus.getAnyUpvalue(stateTable, "selectedOption")
|
|
local pageId = stateTable and menus.getAnyUpvalue(stateTable, "selectedPageId")
|
|
local entry = type(options) == "table" and type(selected) == "number" and options[selected]
|
|
local page = entry and type(entry.pages) == "table" and entry.pages[pageId]
|
|
local block = page and page[1] and page[1].text
|
|
|
|
if not block then
|
|
print("[testbed] journal: no page text to compare against (skipped)")
|
|
return
|
|
end
|
|
|
|
-- Compare on the first few words only: the descriptor flattens the
|
|
-- layout newlines the page splitter inserts, so the strings are not
|
|
-- byte-identical by design.
|
|
local opening = block:gsub("%s+", " "):gsub("^%s+", ""):sub(1, 30)
|
|
|
|
check("journal speaks the page body, not just the title",
|
|
spoken ~= nil and spoken:find(opening, 1, true) ~= nil,
|
|
"expected to contain " .. string.format("%q", opening) ..
|
|
", spoken=" .. tostring(spoken))
|
|
end
|
|
|
|
-- The `=` key end to end. The prologue inventory is empty, so what this
|
|
-- proves is the wiring: the key reaches details.speak, is consumed rather
|
|
-- than falling through to the game, and an empty slot says "No details"
|
|
-- instead of nothing at all (silence being indistinguishable from a
|
|
-- dropped keypress). The text itself is covered by the synthetic item above.
|
|
local function checkDetailsKey()
|
|
local details = require("accessibility.details")
|
|
|
|
check("empty slot has no details",
|
|
details.get("inventory") == nil)
|
|
|
|
press("=", "read item details")
|
|
wait(5)
|
|
|
|
check("the details key did not leave the inventory",
|
|
currentState() == "inventory", "state is " .. tostring(currentState()))
|
|
end
|
|
|
|
-- Phase 4: the menu screens reachable from gameplay.
|
|
--
|
|
-- Each is opened, navigated once, and closed. `back` is bound to x/escape/
|
|
-- backspace, but x also opens examine mode, so use escape.
|
|
local screens = {
|
|
{key = "j", state = "journal", nav = {"down", "right"}, check = checkJournalReadsPage},
|
|
{key = "c", state = "character", nav = {"right", "right"}},
|
|
{key = "a", state = "actions_menu", nav = {"down"}},
|
|
{key = "i", state = "inventory", nav = {"right", "down"}, check = checkDetailsKey},
|
|
}
|
|
|
|
for _, screen in ipairs(screens) do
|
|
press(screen.key, "open " .. screen.state)
|
|
wait(10)
|
|
|
|
local reached = currentState() == screen.state
|
|
check("opened " .. screen.state, reached, "got " .. currentState())
|
|
|
|
if reached then
|
|
for _, key in ipairs(screen.nav) do
|
|
press(key, screen.state .. ": " .. key)
|
|
end
|
|
check(screen.state .. " describes focus", focusText() ~= nil,
|
|
"focus=" .. tostring(focusText()))
|
|
|
|
if screen.check then
|
|
screen.check()
|
|
end
|
|
|
|
press("escape", "close " .. screen.state)
|
|
wait(15)
|
|
end
|
|
end
|
|
|
|
------------------------------------------------------------------
|
|
-- The research tree
|
|
--
|
|
-- Not in the `screens` loop above: it has no hotkey, it is opened by
|
|
-- interacting with a writing desk, and the prologue may not contain one.
|
|
-- `ingame.interface("research")` is the exact call the desk's
|
|
-- tryInteractFunction makes (modules/tile/templates.lua), so this enters
|
|
-- the screen the same way the game does -- including handing it the
|
|
-- levelWindow it draws the background from.
|
|
------------------------------------------------------------------
|
|
if currentState() == "ingame" then
|
|
-- "states/ingame", not "states.ingame": states.lua requires the slash
|
|
-- form, and the dot form is a different package.loaded key, so it
|
|
-- would load a SECOND copy of the module whose levelWindow is nil --
|
|
-- and research.draw indexes that on its first frame.
|
|
local ingameState = require("states/ingame")
|
|
pcall(ingameState.interface, "research")
|
|
wait(12)
|
|
|
|
local inResearch = currentState() == "research"
|
|
check("writing desk opens the research tree", inResearch,
|
|
"state is " .. tostring(currentState()))
|
|
|
|
if inResearch then
|
|
-- The tree opens on node 1,1 with no keypress, so focus is
|
|
-- readable immediately.
|
|
local focus = focusText()
|
|
check("research names the node and its status",
|
|
focus ~= nil and focus:find(",") ~= nil,
|
|
"focus=" .. tostring(focus))
|
|
|
|
-- The name must come from currentNode.title, not the id: a
|
|
-- humanised id would be lower case and underscore-derived.
|
|
check("research speaks a title, not a humanised id",
|
|
focus ~= nil and focus:sub(1, 1):match("%u") ~= nil,
|
|
"focus=" .. tostring(focus))
|
|
|
|
local details = require("accessibility.details")
|
|
local blocks = details.get("research", false)
|
|
local text = blocks and table.concat(blocks, ". ")
|
|
|
|
check("research has details", blocks ~= nil and #blocks > 0,
|
|
"blocks=" .. tostring(blocks and #blocks))
|
|
|
|
-- Cost is the part that decides whether enter does anything, and
|
|
-- the unlock list is why you would pay it. Both are hint-window
|
|
-- text that was previously never spoken.
|
|
check("research details give the knowledge cost",
|
|
text ~= nil and (text:find("Costs", 1, true) ~= nil
|
|
or text:find("Researched already", 1, true) ~= nil),
|
|
"details=" .. tostring(text))
|
|
|
|
check("research details say what the node unlocks",
|
|
text ~= nil and text:find("Unlocks", 1, true) ~= nil,
|
|
"details=" .. tostring(text))
|
|
|
|
-- Verbose adds the name line and the whole knowledge purse.
|
|
local verbose = details.get("research", true)
|
|
local verboseText = verbose and table.concat(verbose, ". ")
|
|
check("verbose research details add the knowledge available",
|
|
verboseText ~= nil
|
|
and verboseText:find("Knowledge available", 1, true) ~= nil,
|
|
"details=" .. tostring(verboseText))
|
|
|
|
-- Moving must change what is described; up/down jump rows, so
|
|
-- right is the reliable one on row 1.
|
|
local before = focusText()
|
|
press("right", "research: next node")
|
|
check("research describes a different node after moving",
|
|
focusText() ~= nil and focusText() ~= before,
|
|
"before=" .. tostring(before) .. " after=" .. tostring(focusText()))
|
|
|
|
press("=", "read research details")
|
|
wait(5)
|
|
check("the details key did not leave the research tree",
|
|
currentState() == "research", "state is " .. tostring(currentState()))
|
|
|
|
press("escape", "close the research tree")
|
|
wait(15)
|
|
check("research closes back to the game",
|
|
currentState() == "ingame", "state is " .. tostring(currentState()))
|
|
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
|
|
-- "wait N turns" slider. Walking into it triggers interact.
|
|
local sliderTested = false
|
|
|
|
-- Locate the bed through our own tracker, then path to it. Walking into
|
|
-- an interactable triggers interact, which opens the wait slider.
|
|
local bedX, bedY
|
|
local freshScan = tracker.rescan(true)
|
|
for _, kind in ipairs(freshScan and freshScan.interactables and freshScan.interactables.kinds or {}) do
|
|
if kind.key and kind.key:find("bed") then
|
|
bedX, bedY = kind.instances[1].x, kind.instances[1].y
|
|
break
|
|
end
|
|
end
|
|
|
|
if bedX then
|
|
print(string.format("[testbed] bed located at %d,%d via tracker", bedX, bedY))
|
|
|
|
-- A bed blocks movement, so calculatePathFromPlayer to its own tile
|
|
-- returns nothing. Path to a walkable neighbour first, then step INTO
|
|
-- the bed to trigger interact.
|
|
local _, world = pcall(require, "models.world")
|
|
local level = world and world.currentLevel
|
|
|
|
for _ = 1, 12 do
|
|
if currentState() ~= "ingame" then
|
|
break
|
|
end
|
|
|
|
local player = level.playerActor
|
|
local dx, dy = bedX - player.x, bedY - player.y
|
|
|
|
if math.max(math.abs(dx), math.abs(dy)) <= 1 then
|
|
local key = directionKeys[
|
|
((dx > 0 and 1) or (dx < 0 and -1) or 0) .. "," ..
|
|
((dy > 0 and 1) or (dy < 0 and -1) or 0)]
|
|
if key then
|
|
press(key, "stepping into the bed")
|
|
wait(12)
|
|
end
|
|
break
|
|
end
|
|
|
|
-- Walk toward whichever neighbour of the bed we can reach.
|
|
local moved = false
|
|
for _, offset in ipairs({{0,-1},{1,0},{0,1},{-1,0},{1,-1},{1,1},{-1,1},{-1,-1}}) do
|
|
local tx, ty = bedX + offset[1], bedY + offset[2]
|
|
local pathOk, path = pcall(level.calculatePathFromPlayer, tx, ty)
|
|
if pathOk and path and #path > 0 then
|
|
local key = directionKeys[path[1].dx .. "," .. path[1].dy]
|
|
if key then
|
|
press(key, "walking toward the bed")
|
|
wait(8)
|
|
moved = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
if not moved then
|
|
break
|
|
end
|
|
end
|
|
else
|
|
print("[testbed] no bed found in tracker scan")
|
|
end
|
|
|
|
if currentState() == "ingame_wait" then
|
|
sliderTested = true
|
|
check("bed opened the wait slider", true, "state=ingame_wait")
|
|
press("right", "slider: increase")
|
|
press("right", "slider: increase")
|
|
press("left", "slider: decrease")
|
|
press("escape", "leave wait screen")
|
|
wait(15)
|
|
else
|
|
-- Deliberately NOT a failing check. `tutorial_bed` overrides bed's
|
|
-- tryInteractFunction with an empty function:
|
|
--
|
|
-- tutorial_bed = { parent = "bed", tryInteractFunction = function() end }
|
|
--
|
|
-- so the prologue bed does nothing by design and ingame_wait is
|
|
-- unreachable here. The other slider users (craft, refine, trade) need
|
|
-- an alchemy table and actual items. The patch is confirmed applied by
|
|
-- the startup self-test, but stays unexercised until the scenario can
|
|
-- reach a real bed or a crafting station.
|
|
print("[testbed] NOTE: ingame_wait unreachable in the prologue " ..
|
|
"(tutorial_bed has an empty tryInteractFunction by design); " ..
|
|
"slider patch applied but not exercised")
|
|
end
|
|
|
|
------------------------------------------------------------------
|
|
-- View watch (Shift+/): what comes in and out of view while walking
|
|
--
|
|
-- Asserted on the pure seam (snapshot / diffText) rather than on
|
|
-- speech, because whether a given step actually reveals anything
|
|
-- depends on where the earlier part of the scenario left the player.
|
|
-- The invariant and the wording are checkable regardless.
|
|
------------------------------------------------------------------
|
|
if currentState() == "ingame" then
|
|
local viewwatch = require("accessibility.viewwatch")
|
|
|
|
local snapshot, level = viewwatch.snapshot()
|
|
local count = 0
|
|
local leaked = nil
|
|
|
|
for _, entry in pairs(snapshot or {}) do
|
|
count = count + 1
|
|
if not (level.FoV and level.FoV[entry.x] and level.FoV[entry.x][entry.y]) then
|
|
leaked = entry.name
|
|
end
|
|
end
|
|
|
|
check("viewwatch sees the room", count > 0, count .. " things in view")
|
|
|
|
-- Invariant 4: nothing outside FoV may ever be reported.
|
|
check("viewwatch reports nothing outside FoV", leaked == nil,
|
|
leaked and ("leaked " .. leaked) or "clean")
|
|
|
|
-- Diff against an empty baseline: everything visible is "entered".
|
|
local player = level.playerActor
|
|
local text = viewwatch.diffText({}, snapshot, player.x, player.y)
|
|
check("viewwatch phrases arrivals",
|
|
text ~= nil and text:find("In view: ") == 1,
|
|
tostring(text))
|
|
|
|
-- Nothing changed against itself: no announcement.
|
|
check("viewwatch is silent when nothing changed",
|
|
viewwatch.diffText(snapshot, snapshot, player.x, player.y) == nil,
|
|
"nil")
|
|
|
|
local startMode = viewwatch.getMode()
|
|
for _ = 1, 3 do
|
|
viewwatch.cycleMode()
|
|
end
|
|
check("Shift+/ cycles three modes back to the start",
|
|
viewwatch.getMode() == startMode, viewwatch.getMode())
|
|
print("[testbed] viewwatch: " .. tostring(text))
|
|
end
|
|
|
|
|
|
------------------------------------------------------------------
|
|
-- Loot overlay ("s"): speak the contents of visible containers
|
|
--
|
|
-- The tutorial house is the awkward case: chest_tutorial has no loot
|
|
-- by design ("you find nothing of use for now"), so a live scan here
|
|
-- may legitimately be empty. The phrasing is therefore asserted on
|
|
-- the pure seam with a synthetic container, and the live scan is used
|
|
-- for the FoV invariant and for "it does not throw".
|
|
------------------------------------------------------------------
|
|
if currentState() == "ingame" then
|
|
local lootoverlay = require("accessibility.lootoverlay")
|
|
|
|
local found, px, py = lootoverlay.scan()
|
|
check("loot overlay scan returns a list", type(found) == "table",
|
|
type(found) .. ", " .. tostring(found and #found) .. " containers")
|
|
|
|
if found then
|
|
local ok, level = pcall(require, "models.world")
|
|
level = ok and level.currentLevel
|
|
|
|
-- Invariant 4: the overlay draws only inside current FoV, and so
|
|
-- must we -- this must never become an x-ray of sealed rooms.
|
|
local leaked
|
|
for _, entry in ipairs(found) do
|
|
if not (level and level.FoV and level.FoV[entry.x]
|
|
and level.FoV[entry.x][entry.y]) then
|
|
leaked = entry.container
|
|
end
|
|
end
|
|
check("loot overlay reports nothing outside FoV", leaked == nil,
|
|
leaked and ("leaked " .. tostring(leaked)) or "clean")
|
|
|
|
local liveBlocks = lootoverlay.blocks(found, px, py)
|
|
check("loot overlay always says something",
|
|
type(liveBlocks) == "table" and #liveBlocks > 0,
|
|
table.concat(liveBlocks or {}, " | "))
|
|
print("[testbed] loot overlay: " .. table.concat(liveBlocks or {}, ". "))
|
|
end
|
|
|
|
-- Synthetic container, two tiles east of the player: name, quantity,
|
|
-- position, and the trailing full stop stripped off the description.
|
|
local synthetic = {{
|
|
x = px + 2, y = py, distance = 2,
|
|
container = "A wooden chest.",
|
|
items = {"Ivy leaves, 3", "A torch"},
|
|
}}
|
|
local blocks = lootoverlay.blocks(synthetic, px, py)
|
|
local text = table.concat(blocks, ". ")
|
|
|
|
check("loot overlay names the container and where it is",
|
|
text:find("A wooden chest, 2 tiles east", 1, true) ~= nil, text)
|
|
check("loot overlay lists the contents, not just the top item",
|
|
text:find("Ivy leaves, 3", 1, true) and text:find("A torch", 1, true) ~= nil,
|
|
text)
|
|
check("loot overlay says so when there is nothing to see",
|
|
lootoverlay.blocks({}, px, py)[1] == "No loot in view",
|
|
tostring(lootoverlay.blocks({}, px, py)[1]))
|
|
|
|
-- Live scan of the REAL grid, which the prologue cannot otherwise
|
|
-- exercise: chest_tutorial is empty by design, so without this the
|
|
-- only path ever run is the "nothing here" one. Loot is planted on
|
|
-- the tile the player is standing on (guaranteed visible) and put
|
|
-- back immediately -- this must not alter the playthrough.
|
|
do
|
|
local ok, world = pcall(require, "models.world")
|
|
local level = ok and world.currentLevel
|
|
local tile = level and level.grid[px] and level.grid[px][py]
|
|
and level.grid[px][py][1]
|
|
|
|
if tile then
|
|
local realLoot = tile.loot
|
|
tile.loot = {{templateId = "ivy_leaves", quantity = 3}}
|
|
|
|
local planted, ppx, ppy = lootoverlay.scan()
|
|
local plantedText = table.concat(
|
|
lootoverlay.blocks(planted, ppx, ppy) or {}, ". ")
|
|
|
|
tile.loot = realLoot
|
|
|
|
check("loot overlay finds loot on the real grid",
|
|
#planted == 1, #planted .. " containers")
|
|
-- Loot entries are item PARAMS, not items: the name has to come
|
|
-- from the template table, which is the part most likely to move.
|
|
check("loot overlay resolves item names from templates",
|
|
plantedText:lower():find("ivy", 1, true) ~= nil, plantedText)
|
|
check("loot overlay reports the quantity",
|
|
plantedText:find(", 3", 1, true) ~= nil, plantedText)
|
|
check("loot overlay restored the tile it borrowed",
|
|
tile.loot == realLoot, "restored")
|
|
end
|
|
end
|
|
|
|
-- The probe the whole feature hangs on: states/ingame's levelWindow.
|
|
check("loot overlay can still reach levelWindow",
|
|
lootoverlay.probeStatus() == "ok", tostring(lootoverlay.probeStatus()))
|
|
|
|
-- And end to end: the game's own key must reach the rising edge.
|
|
local history = require("accessibility.history")
|
|
local baseline = history.count()
|
|
press("s", "loot overlay key")
|
|
check("the loot key did not leave gameplay", currentState() == "ingame",
|
|
"got " .. currentState())
|
|
print("[testbed] loot key added " .. (history.count() - baseline) ..
|
|
" history lines (0 is correct when nothing is lootable nearby)")
|
|
end
|
|
|
|
if sliderTested then
|
|
check("slider announces value changes", true, "via ingame_wait")
|
|
end
|
|
|
|
-- ingame_popup: the modal recipe / lore / map / loot popups.
|
|
--
|
|
-- These do NOT go through popupStack (views/fadingPopup only registers
|
|
-- journal, loot, bestiary and tutorial), so patches/popupstack.lua never
|
|
-- saw them -- the whole body of text was silent behind the spoken word
|
|
-- "Popup". The prologue never fires one, so we raise them by calling the
|
|
-- state's own entry points. Both are display-only: recipePopup just shows
|
|
-- a template's name, lorePopup just renders the params it is handed, so
|
|
-- neither teaches the player anything or touches the world.
|
|
do
|
|
local ingameState = require("states/ingame")
|
|
|
|
local function readPopup(label, raise, wants)
|
|
local ok = pcall(raise)
|
|
if not ok or not waitForState("ingame_popup", 120) then
|
|
check(label .. ": popup opened", false, "state = " .. currentState())
|
|
return
|
|
end
|
|
|
|
local text = focusText() or ""
|
|
check(label .. ": popup speaks more than its name",
|
|
#text > 0, text)
|
|
for _, want in ipairs(wants) do
|
|
check(label .. ": says '" .. want .. "'",
|
|
text:lower():find(want:lower(), 1, true) ~= nil, text)
|
|
end
|
|
|
|
-- keypressed ignores input until fadeInAlpha >= 0.8, and
|
|
-- fadeInPeriod is 0.6s -- so it must be given time before the
|
|
-- dismiss key will register.
|
|
wait(90)
|
|
press("escape", "dismiss popup")
|
|
wait(10)
|
|
check(label .. ": popup dismissed", currentState() == "ingame",
|
|
"got " .. currentState())
|
|
end
|
|
|
|
readPopup("recipe popup",
|
|
function() ingameState.recipePopup("ivy_leaves") end,
|
|
{"New recipe available", "ivy", "journal"})
|
|
|
|
-- The lore popup carries the longest body text of the four, and is
|
|
-- the case where "it says 'popup' and stops" was most visible.
|
|
readPopup("lore popup",
|
|
function()
|
|
ingameState.lorePopup({
|
|
knowledgeType = "practical",
|
|
topic = "testbed topic",
|
|
text = "A fragment of text from a very old book.",
|
|
unlocked = 1,
|
|
max = 3,
|
|
})
|
|
end,
|
|
{"A fragment of text from a very old book", "This text contains",
|
|
"testbed topic"})
|
|
end
|
|
|
|
-- The game's own examine mode.
|
|
press("x", "enter examine mode")
|
|
wait(10)
|
|
check("entered examine mode", currentState() == "ingame_observe",
|
|
"got " .. currentState())
|
|
|
|
if currentState() == "ingame_observe" then
|
|
press("right", "examine: cursor east")
|
|
press("down", "examine: cursor south")
|
|
press("escape", "leave examine mode")
|
|
wait(10)
|
|
check("left examine mode", currentState() == "ingame",
|
|
"got " .. currentState())
|
|
end
|
|
|
|
-- Targeting. Both modes used to announce their name and then go silent,
|
|
-- which left no way to know what you were about to hit or throw at.
|
|
--
|
|
-- Neither mode is reachable by its key here. The prologue room restricts
|
|
-- level.allowedActions, so `b` (hit) never resolves an action; and with
|
|
-- nothing throwable in the pack, `t` falls through to gotoInventory()
|
|
-- (states/ingame.lua:986-990). So drive both states directly, borrowing the
|
|
-- views from the ingame state exactly as gotoAction would pass them
|
|
-- (states/ingame.lua:133-151).
|
|
local function targetingParams()
|
|
local ingameTable = menus.getStateTable("ingame")
|
|
if not ingameTable then
|
|
return nil
|
|
end
|
|
|
|
local params = {
|
|
range = 5,
|
|
callback = function() end,
|
|
levelWindow = menus.getAnyUpvalue(ingameTable, "levelWindow"),
|
|
hpbar = menus.getAnyUpvalue(ingameTable, "hpbar"),
|
|
levelDescriptionWindow =
|
|
menus.getAnyUpvalue(ingameTable, "levelDescriptionWindow"),
|
|
}
|
|
|
|
if not (params.levelWindow and params.hpbar
|
|
and params.levelDescriptionWindow) then
|
|
return nil
|
|
end
|
|
|
|
return params
|
|
end
|
|
|
|
if currentState() == "ingame" then
|
|
local params = targetingParams()
|
|
if params then
|
|
params.actionName = "hit"
|
|
pcall(require("states").switch, "ingame_targeting_melee", params)
|
|
wait(10)
|
|
else
|
|
print("[testbed] could not harvest ingame views for melee targeting")
|
|
end
|
|
|
|
if currentState() == "ingame_targeting_melee" then
|
|
check("entered melee targeting", true)
|
|
|
|
-- Melee has no cursor: one direction key picks AND commits. So the
|
|
-- descriptor speaks on entry only, and we must not press an arrow
|
|
-- here -- that would swing at something and perturb the run.
|
|
local descriptor = menus.loadDescriptor("ingame_targeting_melee")
|
|
local spoken = descriptor and descriptor.onOpen and descriptor.onOpen()
|
|
|
|
check("melee targeting describes the adjacent tiles on entry",
|
|
type(spoken) == "string" and spoken ~= "",
|
|
"onOpen=" .. tostring(spoken))
|
|
|
|
if type(spoken) == "string" then
|
|
local missing
|
|
for _, dir in ipairs({"north-east", "east", "south-east",
|
|
"south", "south-west", "west", "north-west"}) do
|
|
if not spoken:find(dir, 1, true) then
|
|
missing = dir
|
|
break
|
|
end
|
|
end
|
|
check("melee entry scan covers all eight directions",
|
|
missing == nil, "missing " .. tostring(missing)
|
|
.. " in " .. tostring(spoken))
|
|
end
|
|
|
|
press("escape", "leave melee targeting")
|
|
wait(10)
|
|
check("left melee targeting", currentState() == "ingame",
|
|
"got " .. currentState())
|
|
else
|
|
print("[testbed] melee targeting not reachable here (skipped), state="
|
|
.. currentState())
|
|
end
|
|
end
|
|
|
|
if currentState() == "ingame" then
|
|
local params = targetingParams()
|
|
if params then
|
|
params.actionName = "throw"
|
|
pcall(require("states").switch, "ingame_targeting_throw", params)
|
|
wait(10)
|
|
else
|
|
print("[testbed] could not harvest ingame views for throw targeting")
|
|
end
|
|
|
|
if currentState() == "ingame_targeting_throw" then
|
|
check("entered throw targeting", true)
|
|
|
|
local first = focusText()
|
|
check("throw targeting speaks the cursor tile",
|
|
first ~= nil and first ~= "",
|
|
"focus=" .. tostring(first))
|
|
|
|
-- The validity clause is the whole point of targeting as opposed to
|
|
-- examining, so assert it specifically rather than just "some text".
|
|
check("throw targeting says whether the tile is a legal target",
|
|
first ~= nil and (first:find("can throw", 1, true)
|
|
or first:find("blocked", 1, true)
|
|
or first:find("out of range", 1, true)
|
|
or first:find("no line of sight", 1, true)) ~= nil,
|
|
"focus=" .. tostring(first))
|
|
|
|
press("right", "throw: cursor east")
|
|
local afterMove = focusText()
|
|
|
|
-- Not merely "it said something": the cursor glides for 0.3s, so a
|
|
-- descriptor that read the popup (or the state's targetAllowed
|
|
-- upvalue) would report the PREVIOUS tile here.
|
|
check("throw targeting tracks the cursor on the frame it moves",
|
|
afterMove ~= nil and afterMove ~= first,
|
|
"before=" .. tostring(first) .. " after=" .. tostring(afterMove))
|
|
|
|
press("escape", "leave throw targeting")
|
|
wait(10)
|
|
check("left throw targeting", currentState() == "ingame",
|
|
"got " .. currentState())
|
|
else
|
|
print("[testbed] throw targeting not reachable here (skipped), state="
|
|
.. currentState())
|
|
end
|
|
end
|
|
end
|
|
|
|
print(string.format("[testbed] === %d checks, %d failures ===", checks, failures))
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Driver
|
|
--------------------------------------------------------------------------------
|
|
|
|
function testbed.start()
|
|
co = coroutine.create(scenario)
|
|
print("[testbed] starting scenario")
|
|
end
|
|
|
|
function testbed.update()
|
|
if finished or not co then
|
|
return
|
|
end
|
|
|
|
if waitFrames > 0 then
|
|
waitFrames = waitFrames - 1
|
|
return
|
|
end
|
|
|
|
local ok, err = coroutine.resume(co)
|
|
|
|
if not ok then
|
|
print("[testbed] scenario ERROR: " .. tostring(err))
|
|
finished = true
|
|
love.event.quit()
|
|
return
|
|
end
|
|
|
|
if coroutine.status(co) == "dead" then
|
|
finished = true
|
|
print("[testbed] scenario complete, quitting")
|
|
love.event.quit()
|
|
end
|
|
end
|
|
|
|
function testbed.isEnabled()
|
|
if not arg then
|
|
return false
|
|
end
|
|
|
|
for _, value in ipairs(arg) do
|
|
if value == "--a11y-test" then
|
|
return true
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
return testbed
|