2026-08-01 20:53:29 +02:00
|
|
|
-- 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.
|
2026-08-01 21:26:35 +02:00
|
|
|
local function press(scancode, note, mods)
|
2026-08-01 20:53:29 +02:00
|
|
|
local before = focusText()
|
2026-08-01 21:26:35 +02:00
|
|
|
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
|
2026-08-01 20:53:29 +02:00
|
|
|
|
|
|
|
|
love.event.push("keypressed", scancode, scancode, false)
|
|
|
|
|
love.event.push("keyreleased", scancode, scancode)
|
|
|
|
|
|
|
|
|
|
wait(6)
|
|
|
|
|
|
2026-08-01 21:26:35 +02:00
|
|
|
hotkeys.isCtrlDown, hotkeys.isShiftDown = realCtrl, realShift
|
|
|
|
|
|
2026-08-01 20:53:29 +02:00
|
|
|
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
|
|
|
|
|
|
2026-08-01 22:12:56 +02:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-01 20:53:29 +02:00
|
|
|
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")
|
|
|
|
|
|
2026-08-01 22:12:56 +02:00
|
|
|
-- 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())
|
|
|
|
|
|
2026-08-01 20:53:29 +02:00
|
|
|
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.
|
2026-08-01 20:59:04 +02:00
|
|
|
local sawDialogue = false
|
|
|
|
|
local dialogueText
|
|
|
|
|
|
2026-08-01 20:53:29 +02:00
|
|
|
for i = 1, 20 do
|
|
|
|
|
if currentState() == "ingame" then
|
|
|
|
|
break
|
|
|
|
|
end
|
2026-08-01 20:59:04 +02:00
|
|
|
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
|
2026-08-01 20:53:29 +02:00
|
|
|
press("return", "advancing intro (" .. currentState() .. ") " .. i)
|
|
|
|
|
wait(10)
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-01 20:59:04 +02:00
|
|
|
check("dialogue was described", sawDialogue and dialogueText ~= nil,
|
|
|
|
|
"text=" .. tostring(dialogueText))
|
|
|
|
|
|
2026-08-01 20:53:29 +02:00
|
|
|
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")
|
2026-08-01 21:26:35 +02:00
|
|
|
|
|
|
|
|
-- 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})
|
|
|
|
|
|
2026-08-01 22:20:50 +02:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-01 21:34:13 +02:00
|
|
|
-- 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")
|
|
|
|
|
|
2026-08-02 01:00:25 +02:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-03 16:05:15 +02:00
|
|
|
------------------------------------------------------------------
|
|
|
|
|
-- 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
|
|
|
|
|
|
2026-08-02 02:59:21 +02:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-03 02:19:51 +02:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-03 01:31:58 +02:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-02 01:04:03 +02:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-03 01:31:58 +02:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-01 22:00:52 +02:00
|
|
|
-- 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 = {
|
2026-08-02 01:04:03 +02:00
|
|
|
{key = "j", state = "journal", nav = {"down", "right"}, check = checkJournalReadsPage},
|
2026-08-01 22:00:52 +02:00
|
|
|
{key = "c", state = "character", nav = {"right", "right"}},
|
|
|
|
|
{key = "a", state = "actions_menu", nav = {"down"}},
|
2026-08-03 01:31:58 +02:00
|
|
|
{key = "i", state = "inventory", nav = {"right", "down"}, check = checkDetailsKey},
|
2026-08-01 22:00:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()))
|
2026-08-02 01:00:25 +02:00
|
|
|
|
2026-08-02 01:04:03 +02:00
|
|
|
if screen.check then
|
|
|
|
|
screen.check()
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-01 22:00:52 +02:00
|
|
|
press("escape", "close " .. screen.state)
|
|
|
|
|
wait(15)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-03 13:57:40 +02:00
|
|
|
------------------------------------------------------------------
|
|
|
|
|
-- 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
|
|
|
|
|
|
2026-08-03 16:05:15 +02:00
|
|
|
------------------------------------------------------------------
|
|
|
|
|
-- 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
|
|
|
|
|
|
2026-08-01 22:12:56 +02:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-02 02:03:32 +02:00
|
|
|
------------------------------------------------------------------
|
|
|
|
|
-- 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
|
|
|
|
|
|
2026-08-03 01:45:18 +02:00
|
|
|
|
|
|
|
|
------------------------------------------------------------------
|
|
|
|
|
-- 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
|
|
|
|
|
|
2026-08-01 22:12:56 +02:00
|
|
|
if sliderTested then
|
|
|
|
|
check("slider announces value changes", true, "via ingame_wait")
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-03 02:29:01 +02:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-01 21:34:13 +02:00
|
|
|
-- The game's own examine mode.
|
2026-08-01 21:26:35 +02:00
|
|
|
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
|
2026-08-03 16:38:21 +02:00
|
|
|
|
|
|
|
|
-- 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
|
2026-08-01 20:53:29 +02:00
|
|
|
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
|