diff --git a/tests/run.lua b/tests/run.lua index f36d937..6581fc3 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -9298,6 +9298,179 @@ function tests.trapDetectedFieldSurvivesSerialization() os.remove(path) eq(restored.level.traps[1].detected, true, "a trap's detected flag survives a save/load round trip") end + +function tests.secretsSweepDiscoversAnAdjacentHiddenDoorEventuallyAcrossAHandfulOfSeeds() + local discovered = false + for seed = 1, 100 do + local level = arena(21, 11) + level.secretDoors = { { x = 5, y = 5, discovered = false } } + if #Secrets.sweep(level, Rng.new(seed), 5, 6) > 0 then discovered = true break end + end + ok(discovered, "an adjacent secret door is eventually discovered across a handful of seeds") +end + +function tests.secretsSweepNeverDiscoversADoorTwoStepsAway() + for seed = 1, 200 do + local level = arena(21, 11) + level.secretDoors = { { x = 5, y = 5, discovered = false } } + eq(#Secrets.sweep(level, Rng.new(seed), 7, 7), 0, + "a secret door two steps away is never discovered regardless of the roll") + end +end + +function tests.secretsSweepNeverRediscoversAnAlreadyDiscoveredDoor() + local level = arena(21, 11) + level.secretDoors = { { x = 5, y = 5, discovered = true } } + for seed = 1, 50 do + eq(#Secrets.sweep(level, Rng.new(seed), 5, 5), 0, "an already-discovered door is never swept again") + end +end + +function tests.secretsSweepOpensTheWallIntoAPlainClosedDoorOnDiscovery() + local level = arena(21, 11) + level.map:set(5, 5, Tiles.WALL) + level.secretDoors = { { x = 5, y = 5, discovered = false } } + local found + for seed = 1, 100 do + local door = Secrets.sweep(level, Rng.new(seed), 5, 6)[1] + if door then found = door break end + end + ok(found ~= nil, "the door is eventually discovered") + eq(found.discovered, true, "the discovered door's own flag is flipped") + eq(level.map:get(5, 5), Tiles.DOOR_CLOSED, "the wall tile becomes a plain closed door once discovered") +end + +local function orthogonalNeighbours(x, y) + return { { x + 1, y }, { x - 1, y }, { x, y + 1 }, { x, y - 1 } } +end + +local function reachableFrom(map, fromX, fromY, x, y) + return Astar.find(map, fromX, fromY, x, y, function(cx, cy) + return not map:isPassable(cx, cy, false) + end) ~= nil +end + +function tests.builderOccasionallyPlacesASecretDoorGuardingAGenuinelyUnreachableAlcove() + local checked = 0 + for seed = 1, 200 do + local level = Builder.build(3, Rng.new(seed), {}) + if #level.secretDoors == 1 then + checked = checked + 1 + local door = level.secretDoors[1] + eq(door.discovered, false, "a freshly built floor's secret door always starts undiscovered") + eq(level.map:get(door.x, door.y), Tiles.WALL, "an undiscovered secret door still reads as an ordinary wall") + + -- Exactly one of the door's own orthogonal neighbours is the alcove + -- itself: real floor, yet unreachable from the stairs while the door + -- between them is still a plain, impassable wall. + local alcoveX, alcoveY + for _, n in ipairs(orthogonalNeighbours(door.x, door.y)) do + local nx, ny = n[1], n[2] + if level.map:isWalkable(nx, ny) and not reachableFrom(level.map, level.upX, level.upY, nx, ny) then + alcoveX, alcoveY = nx, ny + end + end + ok(alcoveX ~= nil, "seed " .. seed .. ": the secret door hides a genuinely unreachable alcove cell") + if alcoveX then + level.map:set(door.x, door.y, Tiles.DOOR_CLOSED) + ok(reachableFrom(level.map, level.upX, level.upY, alcoveX, alcoveY), + "seed " .. seed .. ": the same alcove opens up the instant its door is discovered") + end + if checked >= 10 then break end + end + end + ok(checked > 0, "at least one of the sampled seeds actually places a secret door") +end + +function tests.builderNeverPlacesASecretDoorAboveDepthTwo() + for seed = 1, 10 do + local level = Builder.build(1, Rng.new(seed), {}) + eq(#level.secretDoors, 0, "no secret door appears on the first floor") + end +end + +function tests.gameEndPlayerTurnEventuallyDiscoversAnAdjacentSecretDoorAcrossAHandfulOfSeeds() + local discovered = false + for seed = 1, 100 do + local game = stagedGame(1, arena(21, 11), 3, 6) + game.rng = Rng.new(seed) + game.level.map:set(4, 6, Tiles.WALL) + game.level.secretDoors = { { x = 4, y = 6, discovered = false } } + game:endPlayerTurn() + if game.level.secretDoors[1].discovered then + local reported = false + for _, line in ipairs(game.log:recent(6)) do + if contains(line, "hidden door") then reported = true end + end + ok(reported, "a freshly discovered secret door is reported somewhere in the turn's log lines") + eq(game.level.map:get(4, 6), Tiles.DOOR_CLOSED, "the discovered door's tile is a plain closed door") + discovered = true + break + end + end + ok(discovered, "an adjacent secret door is eventually discovered over a handful of seeded turns") +end + +function tests.secretDoorFieldsSurviveSerialization() + local game = Game.new(5) + game.level.secretDoors = { { x = 3, y = 3, discovered = false } } + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(#restored.level.secretDoors, 1, "the secret door list survives a save/load round trip") + eq(restored.level.secretDoors[1].discovered, false, "a secret door's discovered flag survives a round trip") +end + +function tests.chestsNewLootAlwaysIncludesGoldAndExactlyOneGearPiece() + for seed = 1, 30 do + local chest = Chests.new(Rng.new(seed), 3) + local goldCount, gearCount = 0, 0 + for _, item in ipairs(chest.loot) do + if item.kind == Items.GOLD then goldCount = goldCount + 1 end + if item.kind == Items.WEAPON or item.kind == Items.ARMOR then gearCount = gearCount + 1 end + end + eq(goldCount, 1, "every chest carries exactly one pile of gold") + eq(gearCount, 1, "every chest carries exactly one weapon or armor piece") + end +end + +function tests.chestsNewNeverRollsTrappedOnAnUnlockedChest() + local sawLockedTrapped, sawLockedUntrapped = false, false + for seed = 1, 100 do + local chest = Chests.new(Rng.new(seed), 3) + ok(chest.locked or not chest.trapped, "an unlocked chest is never also trapped") + if chest.locked and chest.trapped then sawLockedTrapped = true end + if chest.locked and not chest.trapped then sawLockedUntrapped = true end + end + ok(sawLockedTrapped, "a locked chest eventually rolls trapped across a handful of seeds") + ok(sawLockedUntrapped, "a locked chest eventually rolls untrapped across a handful of seeds") +end + +function tests.chestsIsLockedIsTrueForALockedUnopenedChestAndFalseOnceOpened() + local chest = { locked = true, trapped = false, opened = false, loot = { Items.gold(5) } } + eq(Chests.isLocked(chest), true, "a locked, unopened chest reports locked") + chest.opened = true + eq(Chests.isLocked(chest), false, "an opened chest never reports locked, whatever its own flag says") +end + +function tests.chestsOpenGrantsEveryLootItemAndEmptiesTheChest() + local player = { inventory = {} } + local chest = { locked = false, trapped = false, opened = false, loot = { Items.gold(5), Items.potionOf("heal") } } + local success, line = Chests.open(player, chest) + ok(success, "opening an unlocked chest succeeds") + eq(#player.inventory, 2, "every loot item lands in the inventory") + eq(chest.opened, true, "the chest is marked opened") + eq(#chest.loot, 0, "the chest's own loot list is emptied") + ok(contains(line, "open"), "the opening is reported") +end + +function tests.chestsOpenRefusesAnAlreadyEmptiedChest() + local player = { inventory = {} } + local chest = { locked = false, trapped = false, opened = true, loot = {} } + local success, line = Chests.open(player, chest) + eq(success, false, "opening an already-emptied chest fails") + eq(#player.inventory, 0, "nothing is granted from an already-emptied chest") end local order = {