diff --git a/tests/run.lua b/tests/run.lua index 164fc30..c0b3c98 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -14142,6 +14142,179 @@ function tests.gameTravelToWalksTheShortestPathAndArrivesAtTheDestination() eq(game.turn, turnBefore + expectedSteps, "exactly the shortest path's own number of steps were spent") end +function tests.gameTravelToRefusesWithoutSpendingATurnWhenAlreadyThere() + local level = arena(11, 9) + local game = stagedGame(1, level, level.downX, level.downY) + local turnBefore = game.turn + local consumesTurn, message = game:travelTo(level.downX, level.downY) + eq(consumesTurn, false, "already standing on the destination spends no turn") + eq(message, "you are already there", "the refusal explains why") + eq(game.turn, turnBefore, "the turn counter never advances") +end + +function tests.gameTravelToRefusesWithoutSpendingATurnWhenNoRouteExists() + local level = splitArena(21, 9, 11) + local game = stagedGame(1, level, 3, 5) + local turnBefore = game.turn + local consumesTurn, message = game:travelTo(level.downX, level.downY) + eq(consumesTurn, false, "an unreachable destination refuses to even start") + eq(message, "you cannot find a path there", "the refusal names the reason") + eq(game.turn, turnBefore, "the turn counter never advances") +end + +function tests.gameTravelToRefusesWithoutSpendingATurnWhileAHostileIsAlreadyInSight() + local level = arena(21, 11) + local game = stagedGame(1, level, 5, 5) + local orc = Monsters.create(Monsters.byKey.orc, 1, 7, 5, 1) + orc.awareness = Stealth.DORMANT + level.entities = { orc } + game:computeVisible() + local turnBefore = game.turn + local consumesTurn, message = game:travelTo(level.downX, level.downY) + eq(consumesTurn, false, "a hostile already visible refuses to even start") + eq(message, "a monster is already in sight", "the refusal names the reason") + eq(game.turn, turnBefore, "the turn counter never advances") +end + +function tests.gameTravelToHaltsWithoutSpendingATurnOnceTheRunIsAlreadyWon() + local level = arena(21, 11) + local game = stagedGame(1, level, 5, 5) + game.won = true + local turnBefore = game.turn + local consumesTurn, message = game:travelTo(level.downX, level.downY) + eq(consumesTurn, false, "a travel attempt after victory spends no turn") + eq(message, "there is nothing left to do", "the refusal matches Game:manualStep's own halted message") + eq(game.turn, turnBefore, "the turn counter never advances once the run is already won") +end + +function tests.gameTravelToStopsTheInstantThePlayerTakesDamage() + local level = arena(25, 15) + local game = stagedGame(1, level, 3, 3) + Status.apply(game.player, Status.POISON, 5, 4) + local turnBefore = game.turn + local hpBefore = game.player.hp + local consumesTurn, message = game:travelTo(level.downX, level.downY) + eq(consumesTurn, true, "the interrupted turn itself still spends") + eq(message, "you stop traveling - you have taken damage", "the interrupt explains itself") + eq(game.turn, turnBefore + 1, "exactly the one turn that took the damage was spent") + eq(game.player.hp, hpBefore - 4, "the poison tick's own damage lands normally before the interrupt fires") +end + +-- Same row-placement trick as Game:autoExplore's own sight-interrupt test +-- (see its comment for the exact reasoning: both the room's height and the +-- orc's row sit well within the player's initial vision radius (8), and +-- Stealth.WAKE_RADIUS (6) is smaller than that, so the orc is always seen +-- before it could ever wake on its own). The destination (28, 6) sits well +-- past the orc's own cell (25, 6) on the same row, but the interrupt fires +-- purely from Game:anyHostileVisible at the top of the loop - before that +-- turn's own move (and any path recomputation to route around the now- +-- occupied cell) is ever attempted - so the two extra columns behind the orc +-- never come into play; empirically confirmed to land on the exact same +-- turn count as the analogous autoExplore test. +function tests.gameTravelToStopsTheMomentAHostileComesIntoSightWithoutEngagingIt() + local level = arena(30, 11) + local game = stagedGame(2, level, 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 25, 6, 1) + orc.awareness = Stealth.DORMANT + level.entities = { orc } + local turnBefore = game.turn + local hpBefore = game.player.hp + local orcHpBefore = orc.hp + local consumesTurn, message = game:travelTo(28, 6) + eq(consumesTurn, true, "real ground was covered before the interrupt fired") + eq(message, "you stop traveling - a monster is in sight", "the interrupt names a sighted hostile") + eq(game.turn, turnBefore + 14, "exactly the steps needed to bring the orc within vision range 8 were taken") + eq(game.player.hp, hpBefore, "no damage was taken - travel stops on sight, not on contact") + eq(orc.hp, orcHpBefore, "the orc is only spotted, never engaged") + ok(game.visible[level.map:index(orc.x, orc.y)], "the orc really is the thing that got seen") + eq(orc.x, 25, "the still-dormant orc never took a single step of its own") + eq(orc.awareness, Stealth.DORMANT, "and never woke up at all") +end + +function tests.gameManualStepDrivenTravelSpendsExactlyAsManyTurnsAsCallingGameTravelToDirectly() + local level1 = arena(21, 11) + local direct = stagedGame(9, level1, 3, 6) + direct:travelTo(level1.downX, level1.downY) + local level2 = arena(21, 11) + local viaManualStep = stagedGame(9, level2, 3, 6) + viaManualStep:manualStep("travel down") + eq(viaManualStep.turn, direct.turn, + "routing travel through Game:manualStep must not end the turn a second time on top of what " .. + "travelTo already closed itself") + eq(viaManualStep.player.x, direct.player.x, "both paths reach the same destination x") + eq(viaManualStep.player.y, direct.player.y, "both paths reach the same destination y") +end + +function tests.commandsApplyTravelDownDrivesGameTravelToTowardTheDownStairs() + local level = arena(21, 11) + local game = stagedGame(1, level, level.downX, level.downY) + local consumesTurn, message = Commands.apply(game, "travel down") + eq(consumesTurn, false, "already standing on the down stairs spends no turn") + eq(message, "you are already there", "the command surfaces Game:travelTo's own message verbatim") +end + +function tests.commandsApplyTravelUpDrivesGameTravelToTowardTheUpStairs() + local level = arena(21, 11) + local game = stagedGame(1, level, level.upX, level.upY) + local consumesTurn, message = Commands.apply(game, "travel up") + eq(consumesTurn, false, "already standing on the up stairs spends no turn") + eq(message, "you are already there", "the command surfaces Game:travelTo's own message verbatim") +end + +function tests.commandsApplyTravelWithoutAnArgumentFailsWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local consumesTurn, message = Commands.apply(game, "travel") + eq(consumesTurn, false, "an unrecognized destination spends no turn") + eq(message, "travel where? down or up", "the failure explains the two valid destinations") +end + +function tests.commandsApplyHelpMentionsTravel() + local game = stagedGame(1, arena(21, 11), 5, 5) + local _, message = Commands.apply(game, "help") + ok(contains(message, "travel down/up"), "help mentions travel down/up") +end + +-- Every test above exercises the engine in-process, by requiring src/ modules +-- directly - never the actual `lua warren.lua ...` entry point a real player +-- (or the --play REPL) invokes. That leaves argument parsing, the playLoop +-- stdin/stdout loop, and the CLI-level wiring between flags and Game/Meta +-- entirely unverified by automation (three prior sessions flagged this and +-- checked it by hand instead). This section closes that gap: it spawns the +-- real interpreter against the real warren.lua as a subprocess, the same way +-- a player or a shell script would, and asserts on what actually comes back. +local warrenEntry = scriptDir .. "/../warren.lua" +local luaExe = arg[-1] or "lua" + +local function shellQuote(s) + return "'" .. tostring(s):gsub("'", "'\\''") .. "'" +end + +-- Runs `lua warren.lua `, optionally piping stdinText in (for +-- --play), and returns (combinedOutput, exitCode). stderr is folded into +-- stdout so a crash's traceback shows up in the captured text instead of +-- vanishing into the terminal. +local function runWarrenCli(argv, stdinText) + local parts = { shellQuote(luaExe), shellQuote(warrenEntry) } + for _, a in ipairs(argv) do parts[#parts + 1] = shellQuote(a) end + local inPath + if stdinText then + inPath = os.tmpname() + local input = io.open(inPath, "w") + input:write(stdinText) + input:close() + parts[#parts + 1] = "<" + parts[#parts + 1] = shellQuote(inPath) + end + parts[#parts + 1] = "2>&1" + local proc = io.popen(table.concat(parts, " "), "r") + local output = proc:read("a") + local success, exitType, code = proc:close() + if inPath then os.remove(inPath) end + local exitCode = success and 0 or (exitType == "exit" and code or -1) + return output, exitCode +end + +function tests.cliHeadlessAutoRunPrintsTheStatusBarAndExitsZero() end local order = {