From 865b04ef9352a070fd286bfec1ba542aef6b8fd0 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 20:11:22 +0200 Subject: [PATCH] test: cover tests.biomesForDepthCyclesThroughTheCatalogInOrder --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 2a4cb73..df9f000 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -6703,6 +6703,179 @@ end -- Biomes: a fixed, seed-independent theme per depth (contrast with -- Atmosphere's own dice-rolled weather above), read by the builder's biome -- pass to occasionally reinforce a floor with one more native monster or +-- bloom a small pocket of its own hazard kind. + +function tests.biomesForDepthCyclesThroughTheCatalogInOrder() + for i, biome in ipairs(Biomes.CATALOG) do + eq(Biomes.forDepth(i), biome, "depth " .. i .. " maps to the catalog's own entry in order") + end + eq(Biomes.forDepth(#Biomes.CATALOG + 1), Biomes.CATALOG[1], + "depth wraps back to the first biome once the catalog is exhausted") + eq(Biomes.forDepth(3 * #Biomes.CATALOG), Biomes.CATALOG[#Biomes.CATALOG], + "depth keeps cycling any number of catalog lengths deep") +end + +function tests.biomesDescribeNamesTheBiomeAndItsAmbientText() + eq(Biomes.describe(Biomes.byKey.warrens), "the mossy warrens: damp stone and skittering claws", + "describe joins a biome's own name and ambient line") +end + +function tests.biomesCatalogEveryEntryNamesARealFactionAndHazardKind() + local realFactions = {} + for _, faction in pairs(Factions) do + if type(faction) == "string" then realFactions[faction] = true end + end + for _, biome in ipairs(Biomes.CATALOG) do + ok(realFactions[biome.favoredFaction], biome.key .. " names a real faction") + ok(biome.hazardKind == Hazards.FIRE or biome.hazardKind == Hazards.GAS, + biome.key .. " names a real hazard kind") + end +end + +function tests.biomesNativesAmongFiltersToTheFavoredFactionAndExcludesRangedSpecs() + local crypts = Biomes.byKey.crypts + local deepRoster = Monsters.forDepth(6) + local natives = Biomes.nativesAmong(crypts, deepRoster) + ok(#natives > 0, "crypts finds at least one native among a deep enough roster") + for _, spec in ipairs(natives) do + eq(spec.faction, Factions.UNDEAD, "every native belongs to the biome's own faction") + ok(spec.ai ~= "ranged", "no native reinforcement is ever a ranged specialist") + end + local shallowRoster = Monsters.forDepth(1) + eq(#Biomes.nativesAmong(crypts, shallowRoster), 0, + "no undead have surfaced yet this shallow, so crypts finds no native at depth one") +end + +function tests.builderLevelsCarryTheBiomeMatchingTheirDepthRegardlessOfSeed() + for depth = 1, 8 do + local expected = Biomes.forDepth(depth).key + for seed = 1, 3 do + local level = Builder.build(depth, Rng.new(seed), {}) + eq(level.biome, expected, "depth " .. depth .. " always carries its own fixed biome no matter the seed") + end + end +end + +function tests.builderOccasionallyReinforcesAFloorWithADormantNativeMonster() + local level = Builder.build(1, Rng.new(7), {}) + ok(#level.entities > 7, "this seed's entrance floor carries one more monster than the ordinary roster ever places") + local reinforcement = level.entities[#level.entities] + eq(reinforcement.faction, Factions.VERMIN, "the extra monster belongs to the entrance floor's own biome") + eq(reinforcement.awareness, Stealth.DORMANT, "the reinforcement starts dormant like every other wild monster") +end + +function tests.builderOccasionallyBloomsAHazardMatchingTheFloorSBiome() + local warrens = Builder.build(1, Rng.new(7), {}) + eq(warrens.hazards[1].kind, Hazards.GAS, "a gas-favoring biome only ever blooms gas at generation") + local warcamp = Builder.build(4, Rng.new(11), {}) + eq(warcamp.hazards[1].kind, Hazards.FIRE, "a fire-favoring biome only ever blooms fire at generation") +end + +function tests.biomeFieldSurvivesSerialization() + local level = arena(21, 11) + level.biome = "crypts" + local restored = Level.fromData(level:toData()) + eq(restored.biome, "crypts", "a floor's biome survives a round trip") +end + +-- Stealth: monster alertness (asleep/hunting/searching), sneak attacks. + +local function wallSplitArena() + local map = Map.new(21, 11) + for y = 2, 10 do for x = 2, 20 do map:set(x, y, Map.FLOOR) end end + for y = 2, 10 do map:set(11, y, Map.WALL) end + return Level.new({ + depth = 1, generator = "arena", map = map, + entities = {}, floorItems = {}, traps = {}, lights = {}, dark = false, explored = {}, + upX = 2, upY = 2, downX = 20, downY = 10, + }) +end + +function tests.stealthHandBuiltMonsterDefaultsToAlertWhenAwarenessIsUnset() + local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 5, 1) + eq(orc.awareness, nil, "a monster built directly carries no awareness field at all") + ok(not Stealth.isUnaware(orc), "an unset awareness field is treated as already alert, not asleep") + eq(Stealth.sneakMultiplier(orc), 1, "no sneak bonus applies against an already-alert target") +end + +function tests.builderPlacesWildDepthOneMonstersAsleepByDefault() + local game = Game.new(9) + ok(#game.level.entities > 0, "the opening floor spawns at least one wild monster") + for _, entity in ipairs(game.level.entities) do + eq(entity.awareness, Stealth.DORMANT, "every builder-placed wild monster on the opening floor starts asleep") + end +end + +function tests.stealthBossesStartFullyAlertRatherThanDormant() + local warlord = Bosses.create(Bosses.byKey.warlord, 1, 5, 5) + ok(not Stealth.isUnaware(warlord), "a named boss is never asleep, unlike a wild monster") + eq(warlord.awareness, Stealth.HUNTING, "a boss starts directly in the hunting state") +end + +function tests.stealthSpawnedMinionsStartHuntingRatherThanDormant() + local game = stagedGame(1, arena(21, 11), 3, 6) + local minion = game:spawnMinion("zombie", 10, 6) + eq(minion.awareness, Stealth.HUNTING, "a minion summoned or split mid-fight is already aware, never asleep") +end + +function tests.stealthDormantMonsterWakesImmediatelyWhenThePlayerStandsNextToIt() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.awareness = Stealth.DORMANT + game.level.entities = { orc } + local hpBefore = game.player.hp + game:takeMonsterTurn(orc) + eq(orc.awareness, Stealth.HUNTING, "a monster right next to the player always notices it") + ok(game.player.hp < hpBefore, "the freshly woken monster still gets to act on the very same turn") +end + +function tests.stealthDormantMonsterBeyondItsWakeRadiusNeverStirs() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 19, 6, 1) + orc.awareness = Stealth.DORMANT + game.level.entities = { orc } + for _ = 1, 10 do + game:takeMonsterTurn(orc) + end + eq(orc.awareness, Stealth.DORMANT, "a monster far outside its wake radius stays asleep no matter how many turns pass") + eq(orc.x, 19, "an asleep monster that never wakes does not move") + eq(orc.y, 6, "an asleep monster that never wakes does not move either") +end + +function tests.stealthDormantMonsterWithinRangeEventuallyWakesOnASuccessfulRoll() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + orc.awareness = Stealth.DORMANT + game.level.entities = { orc } + local woke = false + for _ = 1, 20 do + game:takeMonsterTurn(orc) + if orc.awareness ~= Stealth.DORMANT then woke = true; break end + end + ok(woke, "a monster well within its wake radius eventually notices the player across enough turns") +end + +function tests.stealthMeleeAttackOnADormantMonsterDealsATripledSneakBonus() + local sneakGame = stagedGame(1, arena(21, 11), 3, 6) + local sneakOrc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + sneakOrc.awareness = Stealth.DORMANT + sneakGame.level.entities = { sneakOrc } + local before = sneakOrc.hp + sneakGame:playerAttack(sneakOrc) + local sneakDamage = before - sneakOrc.hp + eq(sneakOrc.awareness, Stealth.HUNTING, "landing the blow wakes the target immediately") + ok(contains(table.concat(sneakGame.log:recent(6), "\n"), "unaware"), "the log calls out the sneak attack") + + local plainGame = stagedGame(1, arena(21, 11), 3, 6) + local plainOrc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + plainGame.level.entities = { plainOrc } + local plainBefore = plainOrc.hp + plainGame:playerAttack(plainOrc) + local plainDamage = plainBefore - plainOrc.hp + eq(sneakDamage, plainDamage * 3, "a sneak attack deals exactly triple the damage of the same hit thrown while already noticed") +end + +function tests.stealthHuntingMonsterLosesTrackAndSwitchesToSearching() end local order = {