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 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-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 = {
|
|
|
|
|
{key = "j", state = "journal", nav = {"down", "right"}},
|
|
|
|
|
{key = "c", state = "character", nav = {"right", "right"}},
|
|
|
|
|
{key = "a", state = "actions_menu", nav = {"down"}},
|
|
|
|
|
{key = "i", state = "inventory", nav = {"right", "down"}},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()))
|
|
|
|
|
press("escape", "close " .. screen.state)
|
|
|
|
|
wait(15)
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
if sliderTested then
|
|
|
|
|
check("slider announces value changes", true, "via ingame_wait")
|
|
|
|
|
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-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
|