diff --git a/tests/run.lua b/tests/run.lua index 26bfc25..d5fd8bf 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -11028,6 +11028,179 @@ function tests.builderCanRollATomeOfTheCleanseSpellSomewhereInARangeOfSeeds() local found = false for seed = 1, 200 do local level = Builder.build(6, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.TOME and ground.item.spell == "cleanse" then found = true end + end + if found then break end + end + ok(found, "the cleanse tome is reachable dungeon loot within a reasonable seed range") +end + +function tests.spellsLevitateAppliesTheLevitateStatusToTheCasterAndSpendsItsListedManaCost() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "levitate") + local manaBefore = game.player.mana + local success = game:castSpell("levitate") + ok(success, "casting a learned, self-targeted levitate succeeds") + ok(Status.has(game.player, Status.LEVITATE), "the caster now carries the levitate status") + eq(game.player.mana, manaBefore - Spells.catalog.levitate.cost, "casting levitate spends exactly its listed mana cost") +end + +function tests.builderCanRollATomeOfTheLevitateSpellSomewhereInARangeOfSeeds() + -- Range widened from 100 to 200: adding "weaken" to Builder's `learnable` + -- list shifted rng:range(1, #learnable)'s result for effectively every + -- seed (a longer list moves the modulo, not just adds a new bucket at the + -- end), and the nearest levitate tome under the new list happens to sit + -- at seed 173 - still well within "a reasonable seed range", just a + -- different one than before this list grew by one entry. + local found = false + for seed = 1, 200 do + local level = Builder.build(6, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.TOME and ground.item.spell == "levitate" then found = true end + end + if found then break end + end + ok(found, "the levitate tome is reachable dungeon loot within a reasonable seed range") +end + +function tests.polymorphPoolExcludesTheShopkeeperAndRespectsDepth() + local shallow = Polymorph.pool(1) + local shallowKeys = {} + for _, spec in ipairs(shallow) do shallowKeys[spec.key] = true end + ok(shallowKeys.rat and shallowKeys.bat and shallowKeys.kobold, "a depth-one pool offers every depth-one species") + ok(not shallowKeys.orc, "a depth-one pool excludes a species that first appears deeper") + + local deep = Polymorph.pool(4) + local deepKeys = {} + for _, spec in ipairs(deep) do deepKeys[spec.key] = true end + ok(deepKeys.zombie and deepKeys.necromancer, "a depth-four pool offers species that first appear at depth four") + ok(not deepKeys.shopkeeper, "the shopkeeper is never offered by a polymorph, at any depth") +end + +function tests.polymorphTransformPicksADifferentSpeciesAndPreservesTheHpFraction() + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + rat.hp = 2 + local pool = Polymorph.pool(1) + local spec = Polymorph.transform(rat, Rng.new(7), pool) + ok(spec ~= nil, "a pool with other species on offer always yields a transform") + ok(rat.species ~= "rat", "the entity is no longer its original species") + eq(rat.species, spec.key, "the entity's species now matches whatever transform returned") + eq(rat.maxHp, spec.hp, "max hp is replaced wholesale from the new species") + local expectedHp = math.max(1, math.floor(spec.hp * 0.5 + 0.5)) + eq(rat.hp, expectedHp, "current hp is rescaled to the same fraction the old body carried") + eq(rat.attack, spec.attack, "attack is replaced wholesale from the new species") + eq(rat.ai, spec.ai, "ai behaviour is replaced wholesale from the new species") +end + +function tests.polymorphTransformReturnsNilWhenThePoolOffersNothingButTheCurrentSpecies() + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + local spec = Polymorph.transform(rat, Rng.new(3), { Monsters.byKey.rat }) + ok(spec == nil, "a pool with nothing but the entity's own species transforms nothing") + eq(rat.species, "rat", "the entity is left untouched when there is nowhere else to go") +end + +function tests.spellsPolymorphTransformsTheTargetAndSpendsItsListedManaCost() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "polymorph") + local orc = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + game.level.entities = { orc } + local manaBefore = game.player.mana + local success = game:castSpell("polymorph", orc) + ok(success, "casting a learned, in-range polymorph succeeds") + ok(orc.species ~= "orc", "the target's species actually changed") + eq(game.player.mana, manaBefore - Spells.catalog.polymorph.cost, "casting polymorph spends exactly its listed mana cost") +end + +function tests.spellsPolymorphFailsOutOfRangeAndSpendsNothing() + local game = stagedGame(1, arena(21, 11), 3, 6) + Spells.learn(game.player, "polymorph") + local farOrc = Monsters.create(Monsters.byKey.orc, 1, 19, 6, 1) + game.level.entities = { farOrc } + local manaBefore = game.player.mana + local success = game:castSpell("polymorph", farOrc) + ok(not success, "polymorph cast beyond its range fails") + eq(farOrc.species, "orc", "a failed cast never transforms the target") + eq(game.player.mana, manaBefore, "a failed cast spends no mana") +end + +function tests.builderCanRollATomeOfThePolymorphSpellSomewhereInARangeOfSeeds() + -- Range widened from 100 to 200: adding "cleanse" to Builder's `learnable` + -- list shifted rng:range(1, #learnable)'s result for effectively every + -- seed, and the nearest polymorph tome under the new list happens to sit + -- at seed 183 - still well within "a reasonable seed range". + local found = false + for seed = 1, 200 do + local level = Builder.build(6, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.TOME and ground.item.spell == "polymorph" then found = true end + end + if found then break end + end + ok(found, "the polymorph tome is reachable dungeon loot within a reasonable seed range") +end + +function tests.blocksCanReceiveRefusesWallsDoorsVaultsGatesBlocksAndEntities() + local level = arena(9, 9) + level.map:set(4, 4, Tiles.WALL) + eq(Blocks.canReceive(level, 4, 4), false, "a wall cannot receive a shoved block") + level.map:set(4, 5, Tiles.DOOR_CLOSED) + eq(Blocks.canReceive(level, 4, 5), false, "a closed door cannot receive a shoved block") + level.map:set(4, 6, Tiles.VAULT_DOOR) + eq(Blocks.canReceive(level, 4, 6), false, "a sealed vault door cannot receive a shoved block") + level.map:set(4, 7, Tiles.GATE_CLOSED) + eq(Blocks.canReceive(level, 4, 7), false, "a closed gate cannot receive a shoved block") + level.blocks = { { x = 3, y = 3 } } + eq(Blocks.canReceive(level, 3, 3), false, "a cell already holding another block cannot receive a shove") + level.entities = { Entity.new({ x = 3, y = 4, glyph = "r", name = "rat", kind = "monster", hp = 5, attack = 1, defense = 0 }) } + eq(Blocks.canReceive(level, 3, 4), false, "a cell occupied by a living entity cannot receive a shove") + eq(Blocks.canReceive(level, 5, 5), true, "plain open floor can receive a shoved block") + eq(Blocks.canReceive(level, 20, 20), false, "an out-of-bounds cell cannot receive a shove") +end + +function tests.blocksPushRelocatesABlockOntoPlainFloor() + local level = arena(9, 9) + local block = { x = 3, y = 3 } + level.blocks = { block } + local pushed, line = Blocks.push(level, block, 1, 0) + eq(pushed, true, "a shove onto open floor succeeds") + eq(block.x, 4, "the block's own x relocates by the shove") + eq(block.y, 3, "the block's own y is unchanged by a purely horizontal shove") + ok(contains(line, "shove"), "a plain shove is logged as a shove") +end + +function tests.blocksPushIntoLavaSolidifiesTheCellAndConsumesTheBlock() + local level = arena(9, 9) + level.map:set(4, 3, Tiles.LAVA) + local block = { x = 3, y = 3 } + level.blocks = { block } + local pushed, line = Blocks.push(level, block, 1, 0) + eq(pushed, true, "a shove into lava still succeeds") + eq(level.map:get(4, 3), Tiles.RUBBLE, "the lava cell solidifies into rubble") + eq(#level.blocks, 0, "the block is consumed by the lava it just quenched") + ok(contains(line, "lava"), "quenching lava is logged") +end + +function tests.blocksPushIntoAChasmFillsTheGapAndConsumesTheBlock() + local level = arena(9, 9) + level.map:set(4, 3, Tiles.CHASM) + local block = { x = 3, y = 3 } + level.blocks = { block } + local pushed, line = Blocks.push(level, block, 1, 0) + eq(pushed, true, "a shove into a chasm still succeeds") + eq(level.map:get(4, 3), Tiles.FLOOR, "the chasm cell fills into plain floor") + eq(#level.blocks, 0, "the block is consumed filling in the chasm") + ok(contains(line, "chasm"), "filling a chasm is logged") +end + +function tests.blocksPushFailsAgainstAWallOrAnotherBlockOrAnEntity() + local level = arena(9, 9) + level.map:set(4, 3, Tiles.WALL) + local block = { x = 3, y = 3 } + level.blocks = { block } + eq(Blocks.push(level, block, 1, 0), false, "a shove into a wall fails outright") + eq(block.x, 3, "a failed shove leaves the block exactly where it was") + end local order = {