diff --git a/tests/run.lua b/tests/run.lua index 20dbd1a..ee63909 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -6184,6 +6184,179 @@ function tests.eliteFieldsSurviveSerialization() eq(restored.speed, orc.speed, "the swift affix's speed bonus survives serialization") end +-- monsters.lua: Monsters.create must hand every entity its own resist +-- table, never Monsters.byKey[species].resist itself - applyAffix above +-- mutates a promoted elite's entity.resist[element] in place the instant it +-- rolls "warded", and a shared reference there would leak that bump onto +-- every future spawn of the same species (and the catalog itself) for the +-- rest of the process, not just the one entity actually promoted. Found via +-- examine.lua's own test suite: a plain, unpromoted troll built late in a +-- long test run reported "fire resist" instead of its catalog "fire weak" +-- because an earlier test's elite promotion (through Builder.build, not +-- shown here) had already corrupted the shared table. +function tests.monstersCreateNeverAliasesTheSpeciesCatalogsInnateResistTable() + local first = Monsters.create(Monsters.byKey.troll, 1, 5, 5, 1) + local second = Monsters.create(Monsters.byKey.troll, 2, 6, 5, 1) + ok(first.resist ~= Monsters.byKey.troll.resist, "an entity's resist table is its own, not the catalog's") + first.resist.fire = Resistances.RESIST + eq(second.resist.fire, "weak", "mutating one troll's resist never leaks onto another troll of the same species") + eq(Monsters.byKey.troll.resist.fire, "weak", "mutating an entity's resist never corrupts the species catalog itself") +end + +function tests.builderOccasionallyPromotesAMonsterToEliteFromDepthThree() + local level = Builder.build(3, Rng.new(1), {}) + local found + for _, e in ipairs(level.entities) do + if e.elite then found = e end + end + ok(found ~= nil, "this seed's depth-3 floor promotes a monster to elite") + eq(found.eliteTier, "elite", "a depth-3 promotion is a plain elite, never a champion") +end + +function tests.elitesNeverAppearOnTheShallowestTwoFloors() + for seed = 1, 20 do + for depth = 1, 2 do + local level = Builder.build(depth, Rng.new(seed), {}) + for _, e in ipairs(level.entities) do + ok(not e.elite, "no elite appears above depth 3") + end + end + end +end + +function tests.eliteVampiricMonsterDrainsHpFromThePlayerOnAMeleeHit() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.elite, orc.eliteAffixes = true, { "vampiric" } + orc.hp = orc.maxHp - 5 + game.level.entities = { orc } + local before = orc.hp + game:monsterMelee(orc) + ok(orc.hp > before, "a vampiric elite heals on the melee hit it lands") +end + +function tests.eliteChillingMonsterSlowsThePlayerOnAMeleeHit() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.elite, orc.eliteAffixes = true, { "chilling" } + game.level.entities = { orc } + ok(not Status.has(game.player, Status.SLOW), "the player starts unslowed") + game:monsterMelee(orc) + ok(Status.has(game.player, Status.SLOW), "a chilling elite's hit leaves the player slowed") +end + +function tests.eliteVolatileMonsterExplodesOnDeathDamagingAnAdjacentPlayer() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + rat.elite, rat.eliteAffixes = true, { "volatile" } + rat.hp = 0 + game.level.entities = { rat } + local before = game.player.hp + game:awardKill(rat) + ok(game.player.hp < before, "a volatile elite's corpse bursts, damaging the adjacent player") + ok(contains(table.concat(game.log:recent(10), " "), "bursts"), "the burst is logged") +end + +function tests.eliteVolatileMonsterDoesNotBurstAnythingBeyondItsBlastRadius() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 8, 6, 1) + rat.elite, rat.eliteAffixes = true, { "volatile" } + rat.hp = 0 + game.level.entities = { rat } + local before = game.player.hp + game:awardKill(rat) + eq(game.player.hp, before, "a volatile corpse two cells away leaves the player untouched") +end + +function tests.eliteKillGrantsBonusXpOverThePlainSpeciesReward() + local game = stagedGame(9, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + rat.hp = 1 + rat.elite, rat.eliteTier, rat.eliteAffixes = true, "elite", { "brutal" } + game.level.entities = { rat } + local baseXp = Monsters.xpFor(rat) + local bonus = Elites.xpBonus(rat, baseXp) + ok(bonus > 0, "the elite bonus is a real, nonzero amount") + local xpBefore = game.player.xp + game:playerAttack(rat) + eq(game.player.xp, xpBefore + baseXp + bonus, "felling an elite grants a bonus over its plain species xp") +end + +-- Vaults (vaults.lua): a lever puzzle gating a guaranteed treasure. Levers +-- and the door they share an id with are plain data on the level, exactly +-- like shrines and cauldrons above, so most of these tests build that data +-- directly rather than waiting on a builder roll. + +function tests.vaultsPullThrowsALeverAndOpensASingleLeverVaultImmediately() + local level = { vaultLevers = {}, vaultDoors = {} } + local lever = { x = 1, y = 1, id = "v1", pulled = false } + level.vaultLevers = { lever } + level.vaultDoors = { { x = 2, y = 2, id = "v1", opened = false } } + local pulled, door = Vaults.pull(level, lever) + eq(pulled, true, "pulling a fresh lever succeeds") + eq(lever.pulled, true, "the lever is marked pulled") + ok(door ~= nil, "a single-lever vault's door opens on its only pull") + eq(door.id, "v1", "the returned door is the one this puzzle actually gates") + local pulledAgain, doorAgain = Vaults.pull(level, lever) + eq(pulledAgain, false, "pulling an already-thrown lever reports failure") + eq(doorAgain, nil, "a no-op pull reports no door") +end + +function tests.vaultsPullOpensTheDoorOnlyOnceEveryLeverIsThrown() + local level = { vaultLevers = {}, vaultDoors = {} } + local leverA = { x = 1, y = 1, id = "v2", pulled = false } + local leverB = { x = 1, y = 2, id = "v2", pulled = false } + level.vaultLevers = { leverA, leverB } + level.vaultDoors = { { x = 3, y = 3, id = "v2", opened = false } } + local pulledA, doorA = Vaults.pull(level, leverA) + eq(pulledA, true, "pulling the first of two levers succeeds") + eq(doorA, nil, "the door stays shut until every lever sharing its id is thrown") + local pulledB, doorB = Vaults.pull(level, leverB) + eq(pulledB, true, "pulling the second lever succeeds") + ok(doorB ~= nil, "the door opens the instant the last lever is thrown") +end + +function tests.vaultsTreasureRollsAGuaranteedAffixOnABaseStrongerThanAnyFloorDrop() + for seed = 1, 20 do + local item = Vaults.treasure(Rng.new(seed), 5) + ok(item.kind == Items.WEAPON or item.kind == Items.ARMOR, + "vault treasure is always a weapon or a suit of armor") + ok(item.affix ~= nil, "vault treasure always carries a guaranteed affix") + ok(item.power >= 7, "vault treasure's power exceeds anything the depth-5 floor loot table can roll") + end +end + +function tests.gamePullLeverOpensASingleLeverVaultAndTracksTheStat() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(6, 6, Tiles.LEVER) + game.level.map:set(10, 6, Tiles.VAULT_DOOR) + local lever = { x = 6, y = 6, id = "v3", pulled = false } + game.level.vaultLevers = { lever } + game.level.vaultDoors = { { x = 10, y = 6, id = "v3", opened = false } } + eq(game.stats.vaultsOpened, 0, "a fresh run has opened no vaults") + local pulled = game:pullLever(lever) + eq(pulled, true, "pulling the lever succeeds") + eq(game.level.map:get(6, 6), Tiles.LEVER_PULLED, "the lever's own tile flips to its pulled glyph") + eq(game.level.map:get(10, 6), Tiles.DOOR_OPEN, "the vault door swings open once its only lever is thrown") + eq(game.stats.vaultsOpened, 1, "opening a vault advances the run's vault tally") + ok(contains(game.log:last(), "grinds open"), "opening the vault is logged") +end + +function tests.gamePullLeverOnATwoLeverVaultWaitsForTheSecondPull() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(6, 6, Tiles.LEVER) + game.level.map:set(7, 6, Tiles.LEVER) + game.level.map:set(10, 6, Tiles.VAULT_DOOR) + local leverA = { x = 6, y = 6, id = "v4", pulled = false } + local leverB = { x = 7, y = 6, id = "v4", pulled = false } + game.level.vaultLevers = { leverA, leverB } + game.level.vaultDoors = { { x = 10, y = 6, id = "v4", opened = false } } + game:pullLever(leverA) + eq(game.level.map:get(10, 6), Tiles.VAULT_DOOR, "the door stays sealed with a lever still unthrown") + eq(game.stats.vaultsOpened, 0, "the vault tally does not advance until the puzzle is fully solved") + ok(contains(game.log:last(), "clunks into place"), "a partial pull is logged as a clunk, not an opening") + game:pullLever(leverB) + eq(game.level.map:get(10, 6), Tiles.DOOR_OPEN, "the door opens the instant the last lever is thrown") end local order = {