diff --git a/tests/run.lua b/tests/run.lua index 6a92952..20dbd1a 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -6011,6 +6011,179 @@ function tests.terrainIceSlidesATravellerUntilSomethingStopsIt() eq(game.player.y, 6, "sliding on ice does not drift off the axis of travel") end +function tests.terrainIceSlideStopsBeforeAWall() + local game = stagedGame(1, arena(21, 11), 2, 6) + game.level.map:set(3, 6, Tiles.ICE) + game.level.map:set(4, 6, Tiles.ICE) + game.level.map:set(5, 6, Tiles.WALL) + game:movePlayer(1, 0) + eq(game.player.x, 4, "an ice slide stops on the last ice tile before a wall") +end + +function tests.terrainIceSlideStopsAtAnOccupiedCell() + local game = stagedGame(1, arena(21, 11), 2, 6) + game.level.map:set(3, 6, Tiles.ICE) + game.level.map:set(4, 6, Tiles.ICE) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + game.level.entities = { rat } + game:movePlayer(1, 0) + eq(game.player.x, 4, "an ice slide stops just short of another actor") +end + +-- Status.LEVITATE (see status.lua and Game:checkTerrainFor) short-circuits +-- every branch below before any of it runs - no burn, no fall, no slide - +-- so these mirror the three terrain tests directly above/below them with +-- one difference: the mover carries the status first. +function tests.checkTerrainForSkipsLavaDamageAndIgnitionForALevitatingPlayer() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(4, 6, Tiles.LAVA) + Status.apply(game.player, Status.LEVITATE, 20, 1) + local hpBefore = game.player.hp + game:movePlayer(1, 0) + eq(game.player.hp, hpBefore, "a levitating player takes no damage crossing lava") + eq(Status.has(game.player, Status.BURNING), false, "a levitating player is never set alight by lava") + ok(contains(game.log:last(), "drift"), "the skip over a visible hazard is still logged, not silent") +end + +function tests.checkTerrainForSkipsTheChasmFallForALevitatingPlayer() + local game = stagedGame(3, arena(21, 11), 5, 6) + game.level.map:set(6, 6, Tiles.CHASM) + Status.apply(game.player, Status.LEVITATE, 20, 1) + local hpBefore = game.player.hp + local depthBefore = game.depth + game:movePlayer(1, 0) + eq(game.player.x, 6, "a levitating player actually reaches the chasm cell instead of refusing to enter it") + eq(game.player.hp, hpBefore, "a levitating player takes no fall damage over a chasm") + eq(game.depth, depthBefore, "a levitating player drifts over a chasm instead of dropping to the floor below") +end + +function tests.checkTerrainForSkipsTheIceSlideForALevitatingPlayer() + local game = stagedGame(1, arena(21, 11), 2, 6) + for x = 3, 6 do game.level.map:set(x, 6, Tiles.ICE) end + Status.apply(game.player, Status.LEVITATE, 20, 1) + game:movePlayer(1, 0) + eq(game.player.x, 3, "a levitating player enters the first ice cell but never slides across the rest") +end + +function tests.checkTerrainForSkipsLavaForALevitatingMonsterToo() + local game = stagedGame(1, arena(21, 11), 2, 2) + game.level.map:set(6, 6, Tiles.LAVA) + local bat = Monsters.create(Monsters.byKey.bat, 1, 5, 6, 1) + Status.apply(bat, Status.LEVITATE, 20, 1) + game.level.entities = { bat } + local hpBefore = bat.hp + game:moveMonster(bat, 1, 0) + eq(bat.x, 6, "the levitating monster still moves onto the lava cell") + eq(bat.hp, hpBefore, "a levitating monster crossing lava takes no damage either - the guard is not player-only") +end + +function tests.terrainGlyphsSurviveMapSerialization() + local map = Map.new(10, 6) + for y = 2, 5 do for x = 2, 9 do map:set(x, y, Map.FLOOR) end end + map:set(4, 4, Tiles.LAVA) + map:set(5, 4, Tiles.CHASM) + map:set(6, 4, Tiles.ICE) + local restored = Map.fromData(map:toData()) + eq(restored:get(4, 4), Tiles.LAVA, "a lava tile survives a map round trip") + eq(restored:get(5, 4), Tiles.CHASM, "a chasm tile survives a map round trip") + eq(restored:get(6, 4), Tiles.ICE, "an ice tile survives a map round trip") +end + +function tests.chasmFallFieldsSurviveGameSaveLoad() + local game = stagedGame(3, arena(21, 11), 5, 6) + game.level.map:set(6, 6, Tiles.CHASM) + game:movePlayer(1, 0) + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.depth, game.depth, "the depth reached by falling survives a save/load round trip") + eq(restored.player.hp, game.player.hp, "hp after a chasm fall survives a save/load round trip") +end + +function tests.lavaChasmAndIceStayOffTheShallowFloors() + for seed = 1, 5 do + for depth = 1, 3 do + local level = Builder.build(depth, Rng.new(seed), {}) + for _, tile in ipairs(level.map.tiles) do + ok(tile ~= Tiles.LAVA and tile ~= Tiles.CHASM and tile ~= Tiles.ICE, + "no volatile terrain appears above depth 4") + end + end + end +end + +function tests.builderOccasionallyPlacesLavaChasmAndIceFromDepthFour() + local sawLava, sawChasm, sawIce = false, false, false + for seed = 1, 60 do + local level = Builder.build(4, Rng.new(seed), {}) + for _, tile in ipairs(level.map.tiles) do + if tile == Tiles.LAVA then sawLava = true end + if tile == Tiles.CHASM then sawChasm = true end + if tile == Tiles.ICE then sawIce = true end + end + if sawLava and sawChasm and sawIce then break end + end + ok(sawLava, "a depth-4 floor eventually places lava on some seed") + ok(sawChasm, "a depth-4 floor eventually places a chasm on some seed") + ok(sawIce, "a depth-4 floor eventually places ice on some seed") +end + +function tests.chasmNeverAppearsOnTheFinalFloor() + for seed = 1, 30 do + local level = Builder.build(Builder.MAX_DEPTH, Rng.new(seed), {}) + for _, tile in ipairs(level.map.tiles) do + ok(tile ~= Tiles.CHASM, "no chasm appears on the final floor since there is no floor below it") + end + end +end + +function tests.eliteNeverPromotesBelowMinDepth() + for seed = 1, 40 do + local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 5, 2) + Elites.maybePromote(Rng.new(seed), orc, 2) + ok(not orc.elite, "no promotion below depth 3 regardless of the roll") + end +end + +function tests.eliteEligibleExcludesBossesAndTheShopkeeper() + local boss = Bosses.create(Bosses.byKey.warlord, 1, 5, 5) + local keeper = Monsters.create(Monsters.byKey.shopkeeper, 2, 6, 5, 4) + local orc = Monsters.create(Monsters.byKey.orc, 3, 7, 5, 3) + ok(not Elites.eligible(boss), "a boss is never eligible for elite promotion") + ok(not Elites.eligible(keeper), "the shopkeeper is never eligible for elite promotion") + ok(Elites.eligible(orc), "a plain bestiary spawn is eligible for elite promotion") +end + +function tests.eliteArmoredAffixAddsFlatDefenseAndPrefixesTheName() + local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 5, 3) + local baseDefense = orc.defense + Elites.maybePromote(Rng.new(8), orc, 3) + eq(orc.eliteTier, "elite", "a single-affix promotion is a plain elite, not a champion") + eq(orc.eliteAffixes[1], "armored", "seed 8 rolls the armored affix at depth 3") + eq(orc.defense, baseDefense + 3, "armored adds its flat defense bonus") + eq(orc.baseDefense, baseDefense + 3, "armored's bonus also lands on baseDefense") + eq(orc.name, "armored orc", "the affix name prefixes the species name") +end + +function tests.eliteChampionTierAtDeepDepthRollsTwoDistinctAffixes() + local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 5, 7) + Elites.maybePromote(Rng.new(9), orc, 7) + eq(orc.eliteTier, "champion", "seed 9 at depth 7 rolls a champion") + eq(#orc.eliteAffixes, 2, "a champion carries exactly two affixes") + ok(orc.eliteAffixes[1] ~= orc.eliteAffixes[2], "a champion never doubles up on the same affix") +end + +function tests.eliteFieldsSurviveSerialization() + local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 5, 3) + Elites.maybePromote(Rng.new(2), orc, 3) + local restored = Entity.fromData(orc:toData()) + eq(restored.elite, true, "the elite flag survives serialization") + eq(restored.eliteTier, "elite", "the elite tier survives serialization") + eq(restored.eliteAffixes[1], "swift", "the rolled affix list survives serialization") + eq(restored.speed, orc.speed, "the swift affix's speed bonus survives serialization") +end + end local order = {