test: cover tests.monstersCreateNeverAliasesTheSpeciesCatalogsInnateR...

This commit is contained in:
dualfroz committed 2026-09-10 17:44:51 +02:00
1 parent aca2e06b3c
commit 6e216940ac
1 file changed
+173
+173
View File
@@ -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 = {