-- Dependency-free assert runner. Exit 0 iff every assertion passes. Every engine -- system is exercised on a fixed seed so a regression flips a concrete assertion. local scriptDir = arg[0]:match("^(.*)[/\\]") or "." package.path = scriptDir .. "/../src/?.lua;" .. scriptDir .. "/src/?.lua;" .. package.path local Rng = require("rng") local Tiles = require("tiles") local Map = require("map") local Fov = require("fov") local Los = require("los") local Light = require("light") local Astar = require("astar") local Entity = require("entity") local Combat = require("combat") local Items = require("items") local Affixes = require("affixes") local Shop = require("shop") local Identify = require("identify") local Effects = require("effects") local Status = require("status") local Scheduler = require("scheduler") local MessageLog = require("messagelog") local Monsters = require("monsters") local Ai = require("ai") local Gen = require("gen.init") local Connect = require("gen.connect") local Level = require("level") local Dungeon = require("dungeon") local Builder = require("builder") local Serialize = require("serialize") local Game = require("game") local Progression = require("progression") local Spells = require("spells") local Abilities = require("abilities") local Hazards = require("hazards") local Shrines = require("shrines") local Classes = require("classes") local Skills = require("skills") local Crafting = require("crafting") local Companions = require("companions") local Artifacts = require("artifacts") local Bosses = require("bosses") local Elites = require("elites") local Quests = require("quests") local Meta = require("meta") local Alchemy = require("alchemy") local Resistances = require("resistances") local Factions = require("factions") local Encumbrance = require("encumbrance") local Render = require("render") local Terrain = require("terrain") local Vaults = require("vaults") local Runewords = require("runewords") local Atmosphere = require("atmosphere") local Stealth = require("stealth") local Compendium = require("compendium") local Nodes = require("nodes") local Sets = require("sets") local Mimics = require("mimics") local Curses = require("curses") local Momentum = require("momentum") local Conductivity = require("conductivity") local Corpses = require("corpses") local Digging = require("digging") local Sanity = require("sanity") local Hunger = require("hunger") local Events = require("events") local Graves = require("graves") local Proficiency = require("proficiency") local Ascension = require("ascension") local Durability = require("durability") local Disarm = require("disarm") local Chests = require("chests") local Reanimation = require("reanimation") local Secrets = require("secrets") local Mercenaries = require("mercenaries") local Morgue = require("morgue") local Leaderboard = require("leaderboard") local Replay = require("replay") local Challenges = require("challenges") local Analytics = require("analytics") local Titles = require("titles") local Export = require("export") local Streaks = require("streaks") local Nemesis = require("nemesis") local Biomes = require("biomes") local Scavenging = require("scavenging") local Infighting = require("infighting") local Blocks = require("blocks") local SaveGame = require("savegame") local Polymorph = require("polymorph") local Prefab = require("gen.prefab") local Noise = require("noise") local Disease = require("disease") local Captives = require("captives") local Horde = require("horde") local Branches = require("branches") local Phases = require("phases") local Commands = require("commands") local Examine = require("examine") local passed, failed = 0, 0 local failures = {} local function ok(condition, name) if condition then passed = passed + 1 else failed = failed + 1 failures[#failures + 1] = name end end local function eq(actual, expected, name) ok(actual == expected, string.format("%s (expected %s, got %s)", name, tostring(expected), tostring(actual))) end local function contains(haystack, needle) return haystack:find(needle, 1, true) ~= nil end local tests = {} function tests.rngDeterminism() local a, b = Rng.new(12345), Rng.new(12345) for i = 1, 100 do eq(a:nextUint(), b:nextUint(), "rng stream matches at step " .. i) end local c = Rng.new(99) ok(a:nextUint() ~= c:nextUint(), "different seed diverges") local low, high, within = math.huge, -math.huge, true local ranged = Rng.new(7) for _ = 1, 500 do local v = ranged:range(3, 9) if v < 3 or v > 9 then within = false end low, high = math.min(low, v), math.max(high, v) end ok(within, "range stays inside bounds") eq(low, 3, "range reaches its low bound") eq(high, 9, "range reaches its high bound") end function tests.mapDeterminism() local first = Map.generate(60, 24, Rng.new(2024)) local firstAgain = Map.generate(60, 24, Rng.new(2024)) eq(table.concat(first.tiles), table.concat(firstAgain.tiles), "same seed same grid") local other = Map.generate(60, 24, Rng.new(2025)) ok(table.concat(first.tiles) ~= table.concat(other.tiles), "different seed different grid") ok(#first.rooms >= 2, "generation places multiple rooms") eq(first:get(first.stairsX, first.stairsY), Map.STAIRS, "stairs tile is placed") eq(first:isWalkable(first.startX, first.startY), true, "player start is walkable") end function tests.tilesRegistry() eq(Tiles.isWalkable(Tiles.FLOOR), true, "floor is walkable") eq(Tiles.isWalkable(Tiles.WALL), false, "wall is not walkable") eq(Tiles.isWalkable(Tiles.DOOR_CLOSED), false, "closed door is not walkable") eq(Tiles.isWalkable(Tiles.DOOR_OPEN), true, "open door is walkable") eq(Tiles.blocksSight(Tiles.DOOR_CLOSED), true, "closed door blocks sight") eq(Tiles.blocksSight(Tiles.DOOR_OPEN), false, "open door lets sight through") eq(Tiles.isClosedDoor(Tiles.DOOR_LOCKED), true, "locked door counts as a closed door") local map = Map.new(5, 3) map:set(2, 2, Tiles.DOOR_LOCKED) eq(map:isPassable(2, 2, false), false, "locked door blocks pathing without a key") eq(map:isPassable(2, 2, true), true, "locked door opens to pathing with a key") map:set(3, 2, Tiles.DOOR_CLOSED) eq(map:isPassable(3, 2, false), true, "closed door is passable for pathing") end function tests.fovKnownScene() local map = Map.new(9, 7) for y = 2, 6 do for x = 2, 8 do map:set(x, y, Map.FLOOR) end end for y = 1, 7 do map:set(5, y, Map.WALL) end local visible = Fov.compute(map, 2, 4, 10) ok(visible[map:index(2, 4)], "origin is visible") ok(visible[map:index(4, 2)], "open cell in the room is visible") ok(visible[map:index(5, 4)], "dividing wall face is visible") local leaked = false for y = 1, 7 do for x = 6, 9 do if visible[map:index(x, y)] then leaked = true end end end ok(not leaked, "nothing behind the solid wall is visible") local pillar = Map.new(11, 3) for x = 2, 10 do pillar:set(x, 2, Map.FLOOR) end pillar:set(6, 2, Map.WALL) local pillarVis = Fov.compute(pillar, 2, 2, 10) ok(pillarVis[pillar:index(5, 2)], "cell before pillar is visible") ok(not pillarVis[pillar:index(7, 2)], "cell directly behind pillar is shadowed") end function tests.losSightLine() local map = Map.new(9, 3) for x = 2, 8 do map:set(x, 2, Map.FLOOR) end ok(Los.clear(map, 2, 2, 8, 2), "open corridor gives a clear shot") map:set(5, 2, Map.WALL) ok(not Los.clear(map, 2, 2, 8, 2), "a wall between endpoints breaks the shot") ok(Los.clear(map, 2, 2, 4, 2), "shot before the wall is still clear") end function tests.astarPathing() local map = Map.new(10, 5) for y = 2, 4 do for x = 2, 9 do map:set(x, y, Map.FLOOR) end end local path = Astar.find(map, 2, 3, 9, 3) ok(path ~= nil, "path is found across open floor") eq(path[1].x, 2, "path starts at origin x") eq(path[#path].x, 9, "path ends at goal x") eq(#path, 8, "shortest path length is manhattan distance plus one") local sealed = Map.new(10, 5) for y = 2, 4 do for x = 2, 6 do sealed:set(x, y, Map.FLOOR) end end sealed:set(8, 3, Map.FLOOR) eq(Astar.find(sealed, 2, 3, 8, 3), nil, "no path when goal is cut off") end function tests.combatResolution() local hero = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 2 }) local foe = Entity.new({ x = 2, y = 1, glyph = "r", name = "rat", kind = "monster", hp = 10, attack = 3, defense = 1 }) local first = Combat.attack(hero, foe) eq(first.damage, 5, "damage is attack minus defense") eq(foe.hp, 5, "defender hp drops by damage") Combat.attack(hero, foe) eq(foe:isAlive(), false, "dead entity reports not alive") local tank = Entity.new({ x = 1, y = 1, glyph = "T", name = "troll", kind = "monster", hp = 5, attack = 1, defense = 20 }) eq(Combat.attack(foe, tank).damage, 1, "damage is floored at one against heavy armor") end function tests.itemsAndEquipment() local hero = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, maxHp = 30, attack = 5, defense = 1 }) eq(Items.use(Items.potion(8), hero), 8, "potion reports amount healed") eq(hero.hp, 28, "potion restores hp") hero.inventory[1] = Items.potion(50) Items.useFromInventory(hero, 1) eq(hero.hp, hero.maxHp, "healing never exceeds maxHp") Items.equipWeapon(Items.weapon("sword", 4), hero) eq(hero.attack, 9, "weapon adds to attack") Items.equipWeapon(Items.weapon("axe", 7), hero) eq(hero.attack, 12, "swapping weapon adjusts attack correctly") eq(#hero.inventory, 1, "displaced weapon returns to inventory") Items.equipArmor(Items.armor("plate", 3), hero) eq(hero.defense, 4, "armor adds to defense") Items.equipRing(Items.ring("power", 2, 1), hero) eq(hero.attack, 14, "ring adds to attack") eq(hero.defense, 5, "ring adds to defense") end function tests.affixes() local blade = Affixes.decorate(Items.weapon("dagger", 2), Affixes.weapon[3]) eq(blade.power, 3, "flaming affix raises weapon power") eq(blade.name, "flaming dagger", "affix prefixes the item name") eq(blade.affix.onHit.status, "burning", "flaming carries an on-hit rider") local a = Affixes.rollWeapon(Rng.new(5)) local b = Affixes.rollWeapon(Rng.new(5)) eq(a and a.key, b and b.key, "affix rolls are deterministic per seed") end function tests.statusEffects() local victim = Entity.new({ x = 1, y = 1, glyph = "r", name = "rat", kind = "monster", hp = 10, maxHp = 10, attack = 2, defense = 0 }) Status.apply(victim, Status.POISON, 3, 2) local lines = Status.tick(victim) eq(victim.hp, 8, "poison drains hp each tick") ok(#lines == 1, "poison tick emits a message") Status.tick(victim); Status.tick(victim) eq(Status.has(victim, Status.POISON), false, "poison expires after its duration") local hurt = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 5, maxHp = 10, attack = 1, defense = 0 }) Status.apply(hurt, Status.REGEN, 2, 3) Status.tick(hurt) eq(hurt.hp, 8, "regeneration restores hp each tick") local warrior = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 10, attack = 5, defense = 1 }) eq(warrior:effectiveAttack(), 5, "no status means base attack") Status.apply(warrior, Status.STRENGTH, 5, 3) eq(warrior:effectiveAttack(), 8, "strength raises effective attack") Status.apply(warrior, Status.HASTE, 5, 1) eq(warrior:effectiveSpeed(), 160, "haste raises effective speed") Status.apply(warrior, Status.STUN, 2, 1) eq(Status.skipsTurn(warrior), true, "stun makes an actor skip its turn") Status.apply(warrior, Status.STRENGTH, 99, 1) local strength for _, s in ipairs(warrior.statuses) do if s.kind == Status.STRENGTH then strength = s end end eq(strength.duration, 99, "re-applying a status refreshes to the longer duration") end function tests.scheduler() local player = Entity.new({ id = 0, x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 10, attack = 1, defense = 0, speed = 100 }) local slow = Entity.new({ id = 1, x = 2, y = 1, glyph = "o", name = "orc", kind = "monster", hp = 10, attack = 1, defense = 0, speed = 100 }) local fast = Entity.new({ id = 2, x = 3, y = 1, glyph = "v", name = "bat", kind = "monster", hp = 10, attack = 1, defense = 0, speed = 200 }) local stub = { player = player } function stub:currentEntities() return { slow, fast } end local function run() player.energy, slow.energy, fast.energy = 0, 0, 0 local tally = { [0] = 0, [1] = 0, [2] = 0 } local firstId for step = 1, 60 do local actor = Scheduler.next(stub) if step == 1 then firstId = actor.id end tally[actor.id] = tally[actor.id] + 1 Scheduler.spend(actor) end return tally, firstId end local tally, firstId = run() eq(firstId, 2, "the fastest actor acts first") ok(tally[2] > tally[0], "a double-speed actor acts more often than the player") eq(tally[0], tally[1], "equal-speed actors act equally often") local tally2 = run() ok(tally[0] == tally2[0] and tally[2] == tally2[2], "scheduling is deterministic") end function tests.generators() for _, name in ipairs(Gen.order) do local m1, reach1 = Gen.build(name, 60, 24, Rng.new(2024)) local m2 = Gen.build(name, 60, 24, Rng.new(2024)) eq(table.concat(m1.tiles), table.concat(m2.tiles), name .. " is deterministic") local other = Gen.build(name, 60, 24, Rng.new(2025)) ok(table.concat(m1.tiles) ~= table.concat(other.tiles), name .. " differs across seeds") local walkable = 0 for y = 1, m1.height do for x = 1, m1.width do if m1:isWalkable(x, y) then walkable = walkable + 1 end end end ok(walkable > 0, name .. " carves open floor") eq(#reach1, walkable, name .. " leaves every floor cell reachable") end end function tests.mazeGeneratorIsDeterministicDiffersAcrossSeedsAndLeavesEveryFloorCellReachable() local m1, reach1 = Gen.build("maze", 60, 24, Rng.new(2024)) local m2 = Gen.build("maze", 60, 24, Rng.new(2024)) eq(table.concat(m1.tiles), table.concat(m2.tiles), "maze is deterministic") local other = Gen.build("maze", 60, 24, Rng.new(2025)) ok(table.concat(m1.tiles) ~= table.concat(other.tiles), "maze differs across seeds") local walkable = 0 for y = 1, m1.height do for x = 1, m1.width do if m1:isWalkable(x, y) then walkable = walkable + 1 end end end ok(walkable > 0, "maze carves open floor") eq(#reach1, walkable, "maze leaves every floor cell reachable without gen.connect stitching anything extra") end function tests.mazeGeneratorIsRegisteredButExcludedFromTheDepthCycle() ok(Gen.has("maze"), "maze is a selectable generator") local found = false for _, name in ipairs(Gen.order) do if name == "maze" then found = true end end ok(not found, "maze is deliberately absent from the auto-cycled depth order") end function tests.mazeGeneratorProducesOneWideCorridorsRatherThanOpenRooms() -- Braiding deliberately opens a handful of the diagonal cells a pure -- backtracker never touches (see mazeGeneratorBraidingOpensSomeDeadEnds... -- below), so this checks the unbraided core algorithm's own shape rather -- than the default, braided output. local map = Gen.generators.maze(60, 24, Rng.new(7), { braidNumerator = 0, braidDenominator = 1 }) local openTwoByTwoBlocks = 0 for y = 2, map.height - 2 do for x = 2, map.width - 2 do if map:isWalkable(x, y) and map:isWalkable(x + 1, y) and map:isWalkable(x, y + 1) and map:isWalkable(x + 1, y + 1) then openTwoByTwoBlocks = openTwoByTwoBlocks + 1 end end end eq(openTwoByTwoBlocks, 0, "a maze never opens a 2x2 block of floor the way a room-based generator does") end function tests.mazeGeneratorBraidingOpensSomeDeadEndsWithoutTouchingConnectivity() local braided = Gen.generators.maze(60, 24, Rng.new(3), { braidNumerator = 1, braidDenominator = 1 }) local unbraided = Gen.generators.maze(60, 24, Rng.new(3), { braidNumerator = 0, braidDenominator = 1 }) local function walkableCount(map) local total = 0 for y = 1, map.height do for x = 1, map.width do if map:isWalkable(x, y) then total = total + 1 end end end return total end ok(walkableCount(braided) > walkableCount(unbraided), "raising the braid odds opens strictly more floor") local reachable = Connect.ensure(unbraided, Rng.new(3)) eq(#reachable, walkableCount(unbraided), "an unbraided perfect maze is already fully connected on its own") end function tests.prefabVaultSizeMatchesItsWidestRowAndItsRowCount() local vault = { rows = { "####", "#..#", "####" } } local w, h = Prefab.size(vault) eq(w, 4, "size reports the width of a template row") eq(h, 3, "size reports the number of template rows") end function tests.prefabVaultFitsAcceptsAnAnchorWithAWallGapToEveryBorderAndRejectsOneWithout() local map = Map.new(20, 15) local vault = { rows = { "###", "#.#", "###" } } ok(Prefab.fits(map, vault, 2, 2), "an anchor two cells in on every side leaves a wall gap to the border") ok(not Prefab.fits(map, vault, 1, 2), "an anchor flush against the west border is rejected") ok(not Prefab.fits(map, vault, 2, 1), "an anchor flush against the north border is rejected") local w, h = Prefab.size(vault) ok(not Prefab.fits(map, vault, map.width - w + 2, 2), "an anchor that spills past the east border is rejected") ok(not Prefab.fits(map, vault, 2, map.height - h + 2), "an anchor that spills past the south border is rejected") end function tests.prefabVaultStampWritesEveryGlyphAndCollectsTreasureMonsterAndLootMarkersInMapOrder() local map = Map.new(20, 15) local vault = { name = "testVault", rows = { "#####", "#*M+#", "#.L.#", "#####", }, } local result = Prefab.stamp(map, vault, 3, 4) eq(result.name, "testVault", "the stamp result remembers the template's own name") eq(map:get(3, 4), Tiles.WALL, "a '#' glyph becomes a wall tile") eq(map:get(4, 5), Tiles.FLOOR, "a '*' treasure marker still becomes walkable floor") eq(map:get(5, 5), Tiles.FLOOR, "an 'M' monster marker still becomes walkable floor") eq(map:get(6, 5), Tiles.DOOR_CLOSED, "a '+' glyph becomes a closed door") eq(map:get(5, 6), Tiles.FLOOR, "an 'L' loot marker still becomes walkable floor") eq(#result.treasure, 1, "exactly one treasure anchor is collected") eq(result.treasure[1].x, 4, "the treasure anchor's x is offset by the stamp's own anchor") eq(result.treasure[1].y, 5, "the treasure anchor's y is offset by the stamp's own anchor") eq(#result.monsters, 1, "exactly one monster anchor is collected") eq(result.monsters[1].x, 5, "the monster anchor's x is offset by the stamp's own anchor") eq(#result.loot, 1, "exactly one loot anchor is collected") eq(result.loot[1].x, 5, "the loot anchor's x is offset by the stamp's own anchor") eq(result.loot[1].y, 6, "the loot anchor's y is offset by the stamp's own anchor") end function tests.prefabVaultStampIsDeterministicForAFixedAnchor() local vault = Prefab.vaults[1] local m1, m2 = Map.new(20, 15), Map.new(20, 15) Prefab.stamp(m1, vault, 3, 3) Prefab.stamp(m2, vault, 3, 3) eq(table.concat(m1.tiles), table.concat(m2.tiles), "stamping the same vault at the same anchor twice is byte-identical") end function tests.builderCanBeExplicitlySteeredToAMazeFloorAndStillYieldsAPlayableLevel() local level = Builder.build(3, Rng.new(11), { generator = "maze" }) eq(level.generator, "maze", "the level remembers it was built with the maze generator") eq(level.map:get(level.upX, level.upY), Tiles.STAIRS_UP, "a maze floor still gets its up stairs") eq(level.map:get(level.downX, level.downY), Tiles.STAIRS_DOWN, "a maze floor still gets its down stairs") local path = Astar.find(level.map, level.upX, level.upY, level.downX, level.downY, function(x, y) return not level.map:isPassable(x, y, true) end) ok(path ~= nil, "the down stairs are reachable from the up stairs on a maze floor") end function tests.connectivityRepair() local map = Map.new(15, 7) for y = 2, 4 do for x = 2, 4 do map:set(x, y, Map.FLOOR) end end for y = 2, 4 do for x = 10, 12 do map:set(x, y, Map.FLOOR) end end local function walkableCount() local total = 0 for y = 1, map.height do for x = 1, map.width do if map:isWalkable(x, y) then total = total + 1 end end end return total end eq(walkableCount(), 18, "the two rooms start disconnected") local reachable = Connect.ensure(map, Rng.new(1)) ok(walkableCount() > 18, "connect carves new corridor to join regions") eq(#reachable, walkableCount(), "after repair every floor cell is reachable") end function tests.dungeonPersistence() local dungeon = Dungeon.new(4242) local level3 = dungeon:get(3) local tiles3 = table.concat(level3.map.tiles) local fresh = Dungeon.new(4242) eq(table.concat(fresh:get(3).map.tiles), tiles3, "layout depends only on world seed and depth") ok(dungeon:get(3) == level3, "revisiting returns the cached level object") local before = #level3.entities ok(before > 0, "levels spawn monsters") table.remove(level3.entities) eq(#dungeon:get(3).entities, before - 1, "level state persists across revisits") ok(table.concat(dungeon:get(4).map.tiles) ~= tiles3, "different depth yields a different layout") ok(table.concat(Dungeon.new(9999):get(3).map.tiles) ~= tiles3, "different world seed yields a different layout") eq(level3.map:get(level3.upX, level3.upY), Tiles.STAIRS_UP, "up stairs are cut") eq(level3.map:get(level3.downX, level3.downY), Tiles.STAIRS_DOWN, "down stairs are cut") end local function arena(w, h) local map = Map.new(w, h) for y = 2, h - 1 do for x = 2, w - 1 do map:set(x, y, Map.FLOOR) end end return Level.new({ depth = 1, generator = "arena", map = map, entities = {}, floorItems = {}, traps = {}, lights = {}, dark = false, explored = {}, upX = 2, upY = 2, downX = w - 1, downY = h - 1, }) end local function stagedGame(seed, level, px, py) local game = Game.new(seed) game.level = level game.player.x, game.player.y = px, py game:computeVisible() return game end function tests.monsterAiChase() local game = stagedGame(1, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 12, 6, 1) game.level.entities = { orc } Ai.act(game, orc) eq(orc.x, 11, "a chaser steps one cell toward a visible player") end function tests.monsterAiRanged() local shootGame = stagedGame(1, arena(21, 11), 3, 6) local archer = Monsters.create(Monsters.byKey.archer, 1, 8, 6, 1) shootGame.level.entities = { archer } local hpBefore = shootGame.player.hp Ai.act(shootGame, archer) ok(shootGame.player.hp < hpBefore, "an archer in range and line of sight shoots") eq(archer.x, 8, "an archer that can shoot holds its ground") local kiteGame = stagedGame(1, arena(21, 11), 3, 6) local adjacent = Monsters.create(Monsters.byKey.archer, 1, 4, 6, 1) kiteGame.level.entities = { adjacent } Ai.act(kiteGame, adjacent) eq(adjacent.x, 5, "an archer kites away when the player closes in") end function tests.monsterAiFlee() local game = stagedGame(1, arena(21, 11), 3, 6) local wretch = Monsters.create(Monsters.byKey.wretch, 1, 5, 6, 1) game.level.entities = { wretch } Ai.act(game, wretch) eq(wretch.x, 6, "a coward flees away from the player") end function tests.monsterAiDeterministic() local function batRun() local game = stagedGame(55, arena(21, 11), 3, 6) local bat = Monsters.create(Monsters.byKey.bat, 1, 10, 6, 1) game.level.entities = { bat } game.rng = Rng.new(55) Ai.act(game, bat) return bat.x, bat.y end local x1, y1 = batRun() local x2, y2 = batRun() ok(x1 >= 2 and x1 <= 20 and y1 >= 2 and y1 <= 10, "erratic bat lands on a valid cell") ok(x1 == x2 and y1 == y2, "erratic behaviour is deterministic per seed") end -- A confused chaser rolls the exact same override chance every turn that a -- confused player rolls in Game:movePlayer (see status.lua's shared -- CONFUSE_NUMERATOR/DENOMINATOR) - some seeds let its own chase behaviour -- through untouched, others send it stumbling in a random direction instead. function tests.aiActConfusedMonsterSometimesStumblesInsteadOfChasing() local sawChase, sawStumble = false, false for seed = 1, 200 do local game = stagedGame(seed, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 12, 6, 1) game.level.entities = { orc } Status.apply(orc, Status.CONFUSE, 5, 1) Ai.act(game, orc) if orc.x == 11 and orc.y == 6 then sawChase = true else sawStumble = true end if sawChase and sawStumble then break end end ok(sawChase, "a confused monster's roll sometimes lets its normal behaviour run") ok(sawStumble, "a confused monster's roll sometimes overrides its normal behaviour") end -- Without the status at all, a chaser's step toward the player is exactly as -- deterministic and direct as tests.monsterAiChase already established - -- confusion introduces randomness only once Status.CONFUSE is actually -- present, never as a side effect of the roll/pick machinery merely existing. function tests.aiActWithoutConfusionAlwaysRunsTheNormalBehaviour() for seed = 1, 20 do local game = stagedGame(seed, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 12, 6, 1) game.level.entities = { orc } Ai.act(game, orc) eq(orc.x, 11, "an unconfused chaser always steps straight toward the player") eq(orc.y, 6, "an unconfused chaser never drifts off its own row") end end -- Every ai.lua behaviour (chase/pack/ranged/flee/erratic/tunnel) resolves -- through Game:canSeePlayer, so blinding a monster is a single choke point -- that mutes every one of them at once - no per-behaviour special casing -- needed. An archer is the sharpest demonstration: with sight it holds its -- ground and snipes from range (tests.monsterAiRanged); blinded at the exact -- same distance it neither shoots nor closes in, it just idles in place. function tests.aiActBlindedArcherNeitherShootsNorChasesEvenWithAClearShot() local game = stagedGame(1, arena(21, 11), 3, 6) local archer = Monsters.create(Monsters.byKey.archer, 1, 8, 6, 1) game.level.entities = { archer } Status.apply(archer, Status.BLIND, 6, 1) local hpBefore = game.player.hp Ai.act(game, archer) eq(game.player.hp, hpBefore, "a blinded archer with an otherwise clear shot does not fire") eq(archer.x, 8, "a blinded archer does not close the distance either") eq(archer.y, 6, "a blinded archer does not drift off its own row") end function tests.gameAdjacentAllyFindsACompanionStandingRightNextToAMonster() local game = stagedGame(1, arena(21, 11), 3, 6) local golem = Companions.summon(game, "golem") local orc = Monsters.create(Monsters.byKey.orc, 10, golem.x + 1, golem.y, 5) game.level.entities[#game.level.entities + 1] = orc eq(game:adjacentAlly(orc), golem, "a companion standing right next to a monster is found") end function tests.gameAdjacentAllyIgnoresAHostileMonsterAndOnlyCountsCardinalAdjacency() local game = stagedGame(1, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 10, 10, 6, 1) local rat = Monsters.create(Monsters.byKey.rat, 5, 11, 6, 2) game.level.entities = { orc, rat } eq(game:adjacentAlly(orc), nil, "a hostile monster standing adjacent is never mistaken for an ally") local golem = Companions.summon(game, "golem") golem.x, golem.y = 9, 7 eq(game:adjacentAlly(orc), nil, "a companion only diagonally adjacent does not count - cardinal-only, like Game:playerAdjacent") golem.x, golem.y = 9, 6 eq(game:adjacentAlly(orc), golem, "moving the same companion to cardinal adjacency makes it found") end function tests.gameAdjacentAllyBreaksTiesByTheLowerEntityIdAndIgnoresADeadAlly() local game = stagedGame(1, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 10, 10, 6, 999) game.level.entities = { orc } local golem = Companions.summon(game, "golem") local hireling = Mercenaries.hire(game, "sellsword") golem.x, golem.y = 9, 6 hireling.x, hireling.y = 11, 6 ok(golem.id < hireling.id, "sanity check: the companion summoned first has the lower entity id") eq(game:adjacentAlly(orc), golem, "ties between two equally adjacent allies break toward the lower entity id") golem.hp = 0 eq(game:adjacentAlly(orc), hireling, "a dead ally is skipped even though it is still standing adjacent") end -- Reproduces the exact shape Game:aStarNeverRoutesAMonsterThroughABlockedCell -- already proved for a pushable block: a wall spanning every row except one -- floor gap leaves stepMonsterToward with no route at all once something is -- standing in that gap. The difference is what stands there - a living -- companion, not an inert block - so where a block earns a permanent, -- correct stalemate, a companion should be fought, not stonewalled forever. function tests.aiChaseAttacksAnAllyBlockingTheOnlyCorridorToThePlayerInsteadOfIdlingForever() local game = stagedGame(1, arena(21, 11), 3, 6) for y = 2, 10 do game.level.map:set(11, y, Tiles.WALL) end game.level.map:set(11, 6, Tiles.FLOOR) local golem = Companions.summon(game, "golem") golem.x, golem.y = 11, 6 local orc = Monsters.create(Monsters.byKey.orc, 10, 12, 6, 5) game.level.entities[#game.level.entities + 1] = orc local hpBefore = golem.hp Ai.act(game, orc) ok(golem.hp < hpBefore, "a chaser with no route around a blocking ally fights it instead of idling") eq(orc.x, 12, "the chaser never steps onto the ally's occupied cell") eq(orc.y, 6, "the chaser's y is unchanged - it attacked in place rather than moving") end function tests.aiChasePrefersAnAdjacentPlayerOverAnAdjacentAllyWhenBothAreInMeleeRange() local game = stagedGame(1, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 10, 4, 6, 1) game.level.entities = { orc } local golem = Companions.summon(game, "golem") golem.x, golem.y = 4, 7 local playerHpBefore, golemHpBefore = game.player.hp, golem.hp Ai.act(game, orc) ok(game.player.hp < playerHpBefore, "a monster adjacent to both the player and an ally still attacks the player") eq(golem.hp, golemHpBefore, "the ally adjacent to the same monster takes no damage when the player is also in range") end function tests.aiPackAttacksAnAllyBlockingTheOnlyCorridorToThePlayerInsteadOfIdlingForever() local game = stagedGame(1, arena(21, 11), 3, 6) for y = 2, 10 do game.level.map:set(11, y, Tiles.WALL) end game.level.map:set(11, 6, Tiles.FLOOR) local golem = Companions.summon(game, "golem") golem.x, golem.y = 11, 6 local kobold = Monsters.create(Monsters.byKey.kobold, 10, 12, 6, 5) game.level.entities[#game.level.entities + 1] = kobold local hpBefore = golem.hp Ai.act(game, kobold) ok(golem.hp < hpBefore, "a pack monster with no route around a blocking ally fights it instead of idling") end function tests.aiTunnelAttacksAnAllyBlockingItsOnlyGapInsteadOfIdling() local level = arena(21, 11) for y = 2, 10 do level.map:set(11, y, Tiles.WALL) end level.map:set(11, 6, Tiles.FLOOR) local game = stagedGame(1, level, 3, 6) local golem = Companions.summon(game, "golem") golem.x, golem.y = 11, 6 local burrower = Entity.new({ id = game:nextMonsterId(), x = 12, y = 6, glyph = "b", name = "rock burrower", kind = "monster", ai = "tunnel", hp = 20, maxHp = 20, attack = 5, defense = 0, speed = 100, }) game.level.entities[#game.level.entities + 1] = burrower local hpBefore = golem.hp Ai.act(game, burrower) ok(golem.hp < hpBefore, "a burrower blocked by a living ally fights it rather than trying (and failing) to path around") eq(burrower.x, 12, "the burrower stays put while it attacks - it never steps onto the ally's cell") end function tests.aiRangedAttacksABlockingAllyWhenItHasNoLineOfSightToThePlayerAtAll() local game = stagedGame(1, arena(21, 11), 3, 6) for y = 2, 10 do game.level.map:set(8, y, Tiles.WALL) end local golem = Companions.summon(game, "golem") golem.x, golem.y = 11, 6 local archer = Monsters.create(Monsters.byKey.archer, 6, 12, 6, 5) game.level.entities[#game.level.entities + 1] = archer local hpBefore, playerHpBefore = golem.hp, game.player.hp Ai.act(game, archer) ok(golem.hp < hpBefore, "an archer with no line of sight to the player still fights an ally standing next to it") eq(game.player.hp, playerHpBefore, "the archer never reaches or harms the player while its view is fully walled off") end function tests.gameMoveMonsterAttacksAnAllyItBumpsIntoInsteadOfDoingNothing() local game = stagedGame(1, arena(21, 11), 3, 6) local golem = Companions.summon(game, "golem") local orc = Monsters.create(Monsters.byKey.orc, 10, golem.x + 1, golem.y, 5) game.level.entities[#game.level.entities + 1] = orc local hpBefore = golem.hp game:moveMonster(orc, -1, 0) ok(golem.hp < hpBefore, "bumping into an ally's cell fights it rather than silently doing nothing") eq(orc.x, golem.x + 1, "the bumping monster does not step onto the ally's occupied cell") end -- Mirrors aiActConfusedMonsterSometimesStumblesInsteadOfChasing's own seed -- loop: a confused monster's random stumble (Ai.act -> Game:moveMonster) can -- land it squarely on a blocking ally's cell exactly as often as the -- player's, so the fix belongs in Game:moveMonster itself, not only in the -- deliberate per-behaviour pathing checks above. -- Isolates the stumble path from chase()'s own independent attackBlockingAlly -- check: Ai.behaviours.chase is swapped for a stub that only records whether -- it ran, so a turn where the confusion roll fails (falling through to real -- chase) is detectable and excluded - any hp drop counted here can only have -- come from the top-of-Ai.act stumble branch calling Game:moveMonster -- directly, never from chase's own fix a few lines below it. function tests.aiActConfusedMonsterCanStumbleIntoAttackingABlockingAllyInsteadOfIdling() local sawAttack = false local realChase = Ai.behaviours.chase for seed = 1, 200 do local game = stagedGame(seed, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 10, 12, 6, 1) local golem = Companions.summon(game, "golem") golem.x, golem.y = 11, 6 game.level.entities[#game.level.entities + 1] = orc Status.apply(orc, Status.CONFUSE, 5, 1) local hpBefore = golem.hp local behaviourRan = false Ai.behaviours.chase = function() behaviourRan = true end Ai.act(game, orc) Ai.behaviours.chase = realChase if not behaviourRan and golem.hp < hpBefore then sawAttack = true break end end ok(sawAttack, "a confused monster's stumble (Ai.act's own branch, not chase's) can land an attack on a blocking ally via Game:moveMonster") end function tests.doorsAndKeys() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.inventory = {} game.level.map:set(4, 6, Tiles.DOOR_CLOSED) game:movePlayer(1, 0) eq(game.level.map:get(4, 6), Tiles.DOOR_OPEN, "bumping a closed door opens it") eq(game.player.x, 3, "opening a door does not move the player") game.level.map:set(2, 6, Tiles.FLOOR) game.level.map:set(4, 6, Tiles.DOOR_LOCKED) game:movePlayer(1, 0) eq(game.level.map:get(4, 6), Tiles.DOOR_LOCKED, "a locked door stays shut without a key") eq(game.log:last(), "the door is locked", "the game reports a locked door") game.player.inventory[#game.player.inventory + 1] = Items.key() game:movePlayer(1, 0) eq(game.level.map:get(4, 6), Tiles.DOOR_OPEN, "a key unlocks the door") eq(#game.player.inventory, 0, "unlocking consumes the key") end function tests.traps() local dartGame = stagedGame(1, arena(21, 11), 3, 6) dartGame.level.traps = { { x = 4, y = 6, effect = "dart", revealed = false } } local hpBefore = dartGame.player.hp dartGame:movePlayer(1, 0) ok(dartGame.player.hp < hpBefore, "a dart trap wounds the stepper") eq(dartGame.level.traps[1].revealed, true, "a sprung trap becomes revealed") eq(dartGame.level.map:get(4, 6), Tiles.TRAP, "a sprung trap shows on the map") local poisonGame = stagedGame(1, arena(21, 11), 3, 6) poisonGame.level.traps = { { x = 4, y = 6, effect = "poison", revealed = false } } poisonGame:movePlayer(1, 0) eq(Status.has(poisonGame.player, Status.POISON), true, "a poison trap poisons the stepper") local confuseGame = stagedGame(1, arena(21, 11), 3, 6) confuseGame.level.traps = { { x = 4, y = 6, effect = "confuse", revealed = false } } confuseGame:movePlayer(1, 0) eq(Status.has(confuseGame.player, Status.CONFUSE), true, "a confuse trap confuses the stepper") eq(confuseGame.level.traps[1].revealed, true, "a sprung confuse trap becomes revealed too") local blindGame = stagedGame(1, arena(21, 11), 3, 6) blindGame.level.traps = { { x = 4, y = 6, effect = "blind", revealed = false } } blindGame:movePlayer(1, 0) eq(Status.has(blindGame.player, Status.BLIND), true, "a blind trap blinds the stepper") eq(blindGame.level.traps[1].revealed, true, "a sprung blind trap becomes revealed too") eq(blindGame:effectiveRadius(), 1, "the freshly blinded stepper's own sight radius is clamped straight away") local stunGame = stagedGame(1, arena(21, 11), 3, 6) stunGame.level.traps = { { x = 4, y = 6, effect = "stun", revealed = false } } stunGame:movePlayer(1, 0) eq(Status.has(stunGame.player, Status.STUN), true, "a stun trap stuns the stepper") eq(stunGame.level.traps[1].revealed, true, "a sprung stun trap becomes revealed too") end -- Game:springTrap is written to work against whatever `victim` it is handed, -- not just self.player - the same generic shape a builder-placed trap needs -- to also harm a monster that blunders into a player-planted trap kit. function tests.gameSpringTrapConfuseEffectAppliesToAnyVictimNotJustThePlayer() local game = stagedGame(1, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 10, 6, 1) game.level.entities = { rat } local trap = { x = 10, y = 6, effect = "confuse", revealed = false } game:springTrap(trap, rat) eq(Status.has(rat, Status.CONFUSE), true, "a confuse trap sprung on a monster confuses that monster") eq(Status.has(game.player, Status.CONFUSE), false, "the player is untouched by a trap sprung on someone else") end function tests.gameSpringTrapBlindEffectAppliesToAnyVictimNotJustThePlayer() local game = stagedGame(1, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 10, 6, 1) game.level.entities = { rat } local trap = { x = 10, y = 6, effect = "blind", revealed = false } game:springTrap(trap, rat) eq(Status.has(rat, Status.BLIND), true, "a blind trap sprung on a monster blinds that monster") eq(Status.has(game.player, Status.BLIND), false, "the player is untouched by a trap sprung on someone else") end -- STUN previously had the opposite gap from BLIND (see abilities.lua's -- tryShriek comment): a player's crafted weapon could already stun a -- monster, but nothing on the environment/monster side could ever stun the -- player. A trap is a fitting, low-risk source for that missing direction - -- rare and avoidable/disarmable, unlike say an elite's on-hit rider firing -- on every single blow it lands. function tests.gameSpringTrapStunEffectAppliesToAnyVictimNotJustThePlayer() local game = stagedGame(1, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 10, 6, 1) game.level.entities = { rat } local trap = { x = 10, y = 6, effect = "stun", revealed = false } game:springTrap(trap, rat) eq(Status.has(rat, Status.STUN), true, "a stun trap sprung on a monster stuns that monster") eq(Status.has(game.player, Status.STUN), false, "the player is untouched by a trap sprung on someone else") end -- A confused player's every attempted move rolls the same override chance a -- confused monster's Ai.act does (status.lua's shared CONFUSE_NUMERATOR/ -- DENOMINATOR): in the middle of an open arena every one of the 8 compass -- directions leads to open floor, so either outcome always still moves the -- player somewhere and always spends the turn. function tests.gameMovePlayerConfusionEitherFollowsTheChosenDirectionOrStumblesElsewhere() local sawStraight, sawStumble = false, false for seed = 1, 200 do local game = stagedGame(seed, arena(21, 11), 10, 6) Status.apply(game.player, Status.CONFUSE, 5, 1) local consumesTurn = game:movePlayer(1, 0) ok(consumesTurn, "a confused player's attempted move always spends the turn on open floor") if game.player.x == 11 and game.player.y == 6 then sawStraight = true else sawStumble = true end if sawStraight and sawStumble then break end end ok(sawStraight, "confusion sometimes lets the chosen direction through untouched") ok(sawStumble, "confusion sometimes overrides the chosen direction") end -- The turn-spending contract that makes confusion different from an ordinary -- rejected move: bumping a wall on purpose spends nothing (see -- tests.doorsAndKeys's locked-door case and moveHandler's own "you cannot go -- that way"), but a confused player who stumbles into a wall instead of their -- own chosen direction still spent the turn on the stumble itself. function tests.gameMovePlayerConfusionStumbleIntoAWallStillSpendsTheTurn() local level = arena(21, 11) -- Wall off every neighbour of (10, 6) except due east, so any redirect -- other than the player's own chosen direction bumps a wall in place. for _, cell in ipairs({ { 9, 5 }, { 10, 5 }, { 11, 5 }, { 9, 6 }, { 9, 7 }, { 10, 7 }, { 11, 7 } }) do level.map:set(cell[1], cell[2], Tiles.WALL) end local sawBlockedStumble = false for seed = 1, 500 do local game = stagedGame(seed, level, 10, 6) Status.apply(game.player, Status.CONFUSE, 5, 1) local consumesTurn = game:movePlayer(1, 0) ok(consumesTurn, "a confused move, straight or stumbled into this walled-off pocket, always spends the turn") if game.player.x == 10 and game.player.y == 6 then sawBlockedStumble = true end end ok(sawBlockedStumble, "at least one seed in range stumbled into a wall instead of moving east") end -- Without Status.CONFUSE present at all, Game:movePlayer never consults the -- rng for a redirect - the roll/pick machinery added for confusion must be a -- true no-op for every unconfused move the engine already relied on. function tests.gameMovePlayerWithoutConfusionNeverStumbles() for seed = 1, 20 do local game = stagedGame(seed, arena(21, 11), 10, 6) game:movePlayer(1, 0) eq(game.player.x, 11, "an unconfused move always lands exactly where it was aimed") eq(game.player.y, 6, "an unconfused move never drifts off its own row") end end -- Game:allyActsConfused is the companion/hireling/thrall counterpart of the -- confusion machinery above - unlike Game:movePlayer and Ai.act, it is never -- called at all unless a caller opts in, so an entity without Status.CONFUSE -- must bail out before it ever touches the rng (companionsAct*/mercenaryAct*/ -- reanimationAct* below cover the roll actually firing). function tests.gameAllyActsConfusedReturnsFalseImmediatelyWithoutTheStatus() local game = stagedGame(1, arena(21, 11), 5, 5) local golem = Companions.summon(game, "golem") local rollsBefore = game.rng.state eq(game:allyActsConfused(golem), false, "an ally without Status.CONFUSE never triggers a stumble") eq(game.rng.state, rollsBefore, "checking an unconfused ally never advances the rng") end function tests.lightAndDark() local map = Map.new(13, 3) for x = 2, 12 do map:set(x, 2, Map.FLOOR) end local dim = Light.compute(map, 2, 2, 2, {}) local bright = Light.compute(map, 2, 2, 8, {}) local dimCount, brightCount = 0, 0 for _ in pairs(dim) do dimCount = dimCount + 1 end for _ in pairs(bright) do brightCount = brightCount + 1 end ok(dimCount < brightCount, "a smaller light radius reveals fewer cells") ok(not dim[map:index(9, 2)], "a far cell is dark without light") local lit = Light.compute(map, 2, 2, 3, { { x = 5, y = 2, radius = 3 } }) ok(lit[map:index(8, 2)], "a visible emitter lights cells beyond the player's own radius") end function tests.identification() local a = Identify.new(Rng.new(7)) local b = Identify.new(Rng.new(7)) local healing = Items.potionOf("heal") eq(Identify.label(a, healing), Identify.label(b, healing), "appearances are deterministic per seed") ok(not contains(Identify.label(a, healing), "healing"), "an unidentified potion hides its true name") ok(contains(Identify.label(a, healing), "potion"), "an unidentified potion still reads as a potion") local c = Identify.new(Rng.new(8)) local differs = Identify.label(a, healing) ~= Identify.label(c, healing) or Identify.label(a, Items.potionOf("poison")) ~= Identify.label(c, Items.potionOf("poison")) ok(differs, "a different world scrambles appearances differently") Identify.reveal(a, healing) eq(Identify.label(a, healing), "potion of healing", "identifying reveals the true name") end function tests.identifyGivesEveryPotionTypeItsOwnDistinctUnidentifiedLook() local state = Identify.new(Rng.new(3)) local seen = {} for _, entry in ipairs(Items.potionTypes) do local label = Identify.label(state, Items.potionOf(entry.effect)) ok(seen[label] == nil, "no two potion types share an unidentified look (collided on " .. label .. ")") seen[label] = true end local count = 0 for _ in pairs(seen) do count = count + 1 end eq(count, #Items.potionTypes, "every potion type, including confuse, got its own look") end function tests.effects() local game = Game.new(31) game.player.hp = 10 Effects.apply("heal", game, game.player, { power = 8 }) eq(game.player.hp, 18, "heal effect restores hp") Effects.apply("poison", game, game.player, { power = 3 }) eq(Status.has(game.player, Status.POISON), true, "poison effect afflicts the target") Effects.apply("confuse", game, game.player, {}) eq(Status.has(game.player, Status.CONFUSE), true, "confuse effect afflicts the drinker just like poison") Effects.apply("blind", game, game.player, {}) eq(Status.has(game.player, Status.BLIND), true, "blind effect afflicts the drinker just like confuse") Effects.apply("mapping", game, game.player, {}) local map = game.level.map local exploredAll = true for idx = 1, map.width * map.height do if not game.level.explored[idx] then exploredAll = false end end ok(exploredAll, "magic mapping reveals the whole level") local unknown = Items.potionOf("strength") game.player.inventory = { unknown } Effects.apply("identify", game, game.player, {}) eq(Identify.isKnown(game.identify, unknown), true, "identify reveals an inventory item type") local ox, oy = game.player.x, game.player.y Effects.apply("teleport", game, game.player, {}) ok(game.player.x ~= ox or game.player.y ~= oy, "teleport relocates the player") eq(game.level.map:isWalkable(game.player.x, game.player.y), true, "teleport lands on walkable ground") local blastGame = stagedGame(1, arena(21, 11), 6, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 7, 6, 1) orc.hp, orc.maxHp = 20, 20 blastGame.level.entities = { orc } Effects.apply("fireball", blastGame, blastGame.player, {}) eq(orc.hp, 12, "fireball burns nearby monsters") eq(Status.has(orc, Status.BURNING), true, "fireball sets monsters alight") ok(Hazards.at(blastGame.level, orc.x, orc.y, Hazards.FIRE) ~= nil, "fireball leaves a lingering flame under a scorched monster") ok(Hazards.at(blastGame.level, blastGame.player.x, blastGame.player.y, Hazards.FIRE) ~= nil, "fireball also ignites the ground at its own point of origin") end -- Effects.apply's only real caller (Game:useItem) always hands it the -- player, whose entity.name is the pronoun "you" - a subject that takes the -- plain verb form English gives second person ("you heal"), not the -- third-person-singular form ("heals") a named monster like "orc" would take -- through the exact same handler. Before this fix every line below actually -- read "you heals 5", "you is poisoned", etc. on every real potion/scroll -- use in the game - these are the literal strings a real player sees. function tests.effectsMessagesAddressThePlayerInSecondPersonNotThirdPersonSingular() local game = Game.new(31) game.player.hp = 10 eq(Effects.apply("heal", game, game.player, { power = 5 })[1], "you heal 5", "heal addresses the player correctly, not 'you heals 5'") eq(Effects.apply("poison", game, game.player, { power = 3 })[1], "you are poisoned", "poison addresses the player correctly, not 'you is poisoned'") eq(Effects.apply("strength", game, game.player, {})[1], "you feel mightier", "strength addresses the player correctly, not 'you feels mightier'") eq(Effects.apply("haste", game, game.player, {})[1], "you speed up", "haste addresses the player correctly, not 'you speeds up'") eq(Effects.apply("fortify", game, game.player, {})[1], "you feel fortified", "fortify addresses the player correctly, not 'you feels fortified'") eq(Effects.apply("levitate", game, game.player, {})[1], "you rise off the ground", "levitate addresses the player correctly, not 'you rises off the ground'") eq(Effects.apply("confuse", game, game.player, {})[1], "your mind reels with confusion", "confuse addresses the player possessively, not \"you's mind reels with confusion\"") eq(Effects.apply("blind", game, game.player, {})[1], "you are struck blind", "blind addresses the player correctly, not 'you is struck blind'") eq(Effects.apply("teleport", game, game.player, {})[1], "you blink away", "teleport addresses the player correctly, not 'you blinks away'") end function tests.effectsClarityMessageAddressesThePlayerPossessively() local game = Game.new(31) Sanity.adjust(game.player, -20) local line = Effects.apply("clarity", game, game.player, { power = 10 })[1] ok(contains(line, "your mind steadies"), "clarity addresses the player possessively, not \"you's mind steadies\"") end function tests.effectsCureMessagesAddressThePlayerPossessivelyForAllFourOutcomes() local game = Game.new(31) Disease.infect(game.player, Disease.ROT) Status.apply(game.player, Status.POISON, 5, 2) eq(Effects.apply("cure", game, game.player, {})[1], "your affliction is cleansed away, and the poison fades", "curing both addresses the player possessively, not \"you's affliction ... you's poison\"") Disease.infect(game.player, Disease.ROT) eq(Effects.apply("cure", game, game.player, {})[1], "your affliction is cleansed away", "curing disease alone addresses the player possessively, not \"you's affliction\"") Status.apply(game.player, Status.POISON, 5, 2) eq(Effects.apply("cure", game, game.player, {})[1], "your poison fades", "curing a status alone addresses the player possessively, not \"you's poison fades\"") eq(Effects.apply("cure", game, game.player, {})[1], "you feel a faint tingle, but had nothing to cleanse", "curing nothing addresses the player correctly, not 'you feels a faint tingle'") end -- The exact same pronoun problem effects.lua had also lived in status.lua's -- own POISON/REGEN tick messages, disease.lua's tick messages, and -- terrain.lua's lava message - every one of them templated a -- third-person-singular verb onto entity.name with no branch at all for the -- player, whose name is "you". These four tests cover each of those sites -- directly against a real player entity. function tests.statusPoisonShrugAndRegenKnitMessagesAddressThePlayerInSecondPerson() local immunePlayer = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 10, maxHp = 10, attack = 1, defense = 0, }) immunePlayer.resist = { poison = Resistances.IMMUNE } Status.apply(immunePlayer, Status.POISON, 3, 4) eq(Status.tick(immunePlayer)[1], "you shrug off the poison", "an immune player shrugs off poison in second person, not 'you shrugs off the poison'") local hurtPlayer = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 5, maxHp = 10, attack = 1, defense = 0, }) Status.apply(hurtPlayer, Status.REGEN, 2, 3) eq(Status.tick(hurtPlayer)[1], "you knit flesh for 3", "a regenerating player knits flesh in second person, not 'you knits flesh for 3'") end function tests.diseaseTickMessagesAddressThePlayerInSecondPersonForEruptionDamageAndEscalation() local player = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 1000, maxHp = 1000, attack = 1, defense = 0, }) Disease.infect(player, Disease.PARASITES) local incubation = Disease.defs[Disease.PARASITES].incubation for _ = 1, incubation - 1 do Disease.tick(player) end eq(Disease.tick(player), "you break out in gut parasites", "eruption addresses the player in second person, not 'you breaks out in gut parasites'") eq(Disease.tick(player), "you are wracked by gut parasites for 1", "a damaging tick addresses the player in second person, not 'you is wracked by gut parasites for 1'") local stageTurns = Disease.defs[Disease.PARASITES].stageTurns for _ = 2, stageTurns - 1 do Disease.tick(player) end eq(Disease.tick(player), "your gut parasites worsens", "escalation addresses the player possessively, not \"you's gut parasites worsens\"") end function tests.terrainAfflictLavaUnharmedMessageAddressesThePlayerInSecondPerson() local level = arena(21, 11) level.map:set(5, 5, Tiles.LAVA) local game = stagedGame(1, level, 5, 5) game.player.resist = { fire = Resistances.IMMUNE } eq(Terrain.afflictLava(level, game.player), "you wade through the lava unharmed", "an immune player wading through lava is addressed in second person, not 'you wades through the lava unharmed'") end function tests.hazards() local level = arena(21, 11) local fire = Hazards.ignite(level, 5, 5, 3, 4) eq(fire.kind, Hazards.FIRE, "ignite drops a fire hazard") eq(Hazards.at(level, 5, 5, Hazards.FIRE), fire, "the hazard is found by its cell and kind") Hazards.ignite(level, 5, 5, 5, 1) eq(#level.hazards, 1, "re-igniting the same cell refreshes it instead of stacking a duplicate") eq(fire.duration, 5, "refreshing a hazard keeps the stronger of the two durations") eq(fire.potency, 4, "refreshing a hazard keeps the stronger of the two potencies") local victim = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) local hpBefore = victim.hp local line = Hazards.afflict(level, victim) ok(contains(line, "flames"), "afflict describes a fire hazard as flames") eq(victim.hp, hpBefore - fire.potency, "standing in fire deals its potency in damage") eq(Status.has(victim, Status.BURNING), true, "fire afflict also lays down a burning status") local elsewhere = Monsters.create(Monsters.byKey.rat, 2, 9, 9, 1) eq(Hazards.afflict(level, elsewhere), nil, "afflict does nothing to an entity off any hazard cell") local gas = Hazards.spawnGas(level, 12, 8, 4, 2) eq(gas.kind, Hazards.GAS, "spawnGas drops a gas hazard") local choker = Monsters.create(Monsters.byKey.rat, 3, 12, 8, 1) local gasLine = Hazards.afflict(level, choker) ok(contains(gasLine, "fumes"), "afflict describes a gas hazard as fumes") eq(Status.has(choker, Status.POISON), true, "gas afflict lays down a poison status") local wet = arena(21, 11) wet.map:set(6, 6, Tiles.WATER) Hazards.ignite(wet, 6, 6, 3, 2) Hazards.step(wet, Rng.new(1)) eq(Hazards.at(wet, 6, 6, Hazards.FIRE), nil, "fire sitting on water is doused on the very next environmental step") local fading = arena(21, 11) Hazards.spawn(fading, 15, 5, Hazards.FIRE, 1, 2) Hazards.step(fading, Rng.new(1)) eq(Hazards.at(fading, 15, 5, Hazards.FIRE), nil, "a hazard is removed once its duration runs out") local spread = false for seed = 1, 200 do local field = arena(21, 11) Hazards.ignite(field, 10, 5, 3, 2) Hazards.step(field, Rng.new(seed)) if Hazards.at(field, 11, 5) or Hazards.at(field, 9, 5) or Hazards.at(field, 10, 4) or Hazards.at(field, 10, 6) then spread = true break end end ok(spread, "fire eventually spreads into open neighbouring floor on some seed") local rendered = stagedGame(1, arena(21, 11), 3, 6) Hazards.ignite(rendered.level, 5, 6, 3, 2) rendered:computeVisible() ok(contains(rendered:render(), "`"), "a fire hazard on a visible cell renders as its glyph") end local function shrinePlayer(gold) return { gold = gold, attack = 5, baseAttack = 5, maxHp = 30, hp = 30, maxMana = 10, mana = 10, statuses = {} } end function tests.shrinePrayGrantsWarBlessingWhenFavorHolds() local player = shrinePlayer(50) local game = { player = player, rng = Rng.new(1) } local shrine = { x = 1, y = 1, kind = Shrines.WAR, uses = 0 } local success, lines = Shrines.pray(game, shrine) eq(success, true, "a prayer with enough gold always resolves") eq(player.gold, 30, "the shrine's base price is deducted from the wallet") eq(player.attack, 6, "a war blessing raises attack") eq(player.baseAttack, 6, "a war blessing raises base attack permanently") eq(shrine.uses, 1, "a prayer advances the shrine's use count") ok(contains(lines[2], "favor"), "the blessing is logged") end function tests.shrinePrayGrantsVitalityBlessing() local player = shrinePlayer(50) player.hp = 20 local game = { player = player, rng = Rng.new(1) } local shrine = { x = 1, y = 1, kind = Shrines.VITALITY, uses = 0 } Shrines.pray(game, shrine) eq(player.maxHp, 35, "a vitality blessing raises max hp") eq(player.hp, 25, "a vitality blessing heals by the same amount as the max hp gain") end function tests.shrinePrayGrantsWisdomBlessing() local player = shrinePlayer(50) player.mana = 2 local game = { player = player, rng = Rng.new(1) } local shrine = { x = 1, y = 1, kind = Shrines.WISDOM, uses = 0 } Shrines.pray(game, shrine) eq(player.maxMana, 13, "a wisdom blessing raises max mana") eq(player.mana, 13, "a wisdom blessing refills mana to the new max") end function tests.shrinePrayGrantsFortuneBlessingAndCleansesAilments() local player = shrinePlayer(50) Status.apply(player, Status.POISON, 5, 2) local game = { player = player, rng = Rng.new(1) } local shrine = { x = 1, y = 1, kind = Shrines.FORTUNE, uses = 0 } Shrines.pray(game, shrine) eq(Status.has(player, Status.POISON), false, "a fortune blessing cleanses poison") eq(Status.has(player, Status.SHIELD), true, "a fortune blessing also wards the petitioner") end function tests.shrinePrayCanCurseInsteadOfBless() local player = shrinePlayer(50) local game = { player = player, rng = Rng.new(2) } local shrine = { x = 1, y = 1, kind = Shrines.WAR, uses = 0 } local success, lines = Shrines.pray(game, shrine) eq(success, true, "a cursed prayer still resolves and spends its price") eq(player.gold, 30, "a curse still costs the shrine's price") eq(player.attack, 5, "a curse grants no blessing") eq(Status.has(player, Status.WEAKEN), true, "a curse weakens the petitioner") eq(Status.has(player, Status.SLOW), true, "a curse slows the petitioner") ok(contains(lines[2], "against you"), "the curse is logged") end function tests.shrinePrayFailsWithoutEnoughGold() local player = shrinePlayer(5) local game = { player = player, rng = Rng.new(1) } local shrine = { x = 1, y = 1, kind = Shrines.WAR, uses = 0 } local success, lines = Shrines.pray(game, shrine) eq(success, false, "a prayer the player cannot afford fails outright") eq(player.gold, 5, "a failed prayer spends no gold") eq(shrine.uses, 0, "a failed prayer does not advance the use count") ok(contains(lines[1], "afford"), "the failure is logged") end function tests.shrineCostAndCurseOddsRiseWithUse() eq(Shrines.costFor({ uses = 0 }), 20, "an untouched shrine charges its base price") eq(Shrines.costFor({ uses = 3 }), 50, "the price climbs by a fixed step per prior use") local n0, d0 = Shrines.curseChance({ uses = 0 }) eq(n0, 1, "an untouched shrine risks a curse on one face of the die") eq(d0, 6, "curse odds are read against six faces") local n5 = Shrines.curseChance({ uses = 10 }) eq(n5, 6, "curse odds cap out at a certainty rather than exceeding the die") end function tests.shrineFieldsSurviveSerialization() local level = arena(21, 11) level.shrines = { { x = 4, y = 4, kind = Shrines.FORTUNE, uses = 2 } } local restored = Level.fromData(level:toData()) eq(#restored.shrines, 1, "the shrine list survives a round trip") eq(restored.shrines[1].kind, Shrines.FORTUNE, "a shrine's kind survives a round trip") eq(restored.shrines[1].uses, 2, "a shrine's use count survives a round trip") end function tests.builderOccasionallyPlacesAShrineFromDepthFour() local level = Builder.build(4, Rng.new(1), {}) ok(#level.shrines >= 1, "this seed's depth-4 floor places a shrine") eq(level.shrines[1].uses, 0, "a freshly built shrine starts unused") end function tests.shrinesStayOffTheShallowFloors() for seed = 1, 5 do for depth = 1, 3 do local level = Builder.build(depth, Rng.new(seed), {}) eq(#level.shrines, 0, "no shrine appears above depth 4") end end end function tests.autopilotPraysAtAnUnspentShrineWhenAffordable() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.gold = 50 game.level.shrines = { { x = 5, y = 5, kind = Shrines.WAR, uses = 0 } } game:choosePlayerAction() eq(game.level.shrines[1].uses, 1, "the autopilot prays at an unspent shrine it stands on") ok(game.player.gold < 50, "the autopilot's prayer spends the shrine's price") end function tests.autopilotSkipsAnAlreadyUsedShrine() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.gold = 50 game.level.shrines = { { x = 5, y = 5, kind = Shrines.WAR, uses = 1 } } game:choosePlayerAction() eq(game.level.shrines[1].uses, 1, "the autopilot never re-prays at a shrine it has already used") end function tests.shrineRendersItsGlyphWhenVisible() local rendered = stagedGame(1, arena(21, 11), 3, 6) rendered.level.shrines = { { x = 5, y = 6, kind = Shrines.WAR, uses = 0 } } rendered:computeVisible() ok(contains(rendered:render(), "_"), "a shrine on a visible cell renders as its glyph") end function tests.messageLog() local log = MessageLog.new(3) for i = 1, 5 do log:add("line " .. i) end eq(#log.lines, 3, "the log keeps only its capacity") eq(log:last(), "line 5", "the newest line is retained") local recent = log:recent(2) eq(recent[1], "line 4", "recent returns the tail in order") eq(recent[2], "line 5", "recent ends on the newest line") end function tests.weaponAffixOnHit() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.weapon = Affixes.decorate(Items.weapon("dagger", 2), Affixes.weapon[3]) local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) orc.hp, orc.maxHp = 30, 30 game.level.entities = { orc } game:playerAttack(orc) eq(Status.has(orc, Status.BURNING), true, "a flaming weapon ignites what it hits") end function tests.progressionLeveling() local player = { level = 1, xp = 0, xpNext = Progression.xpToNext(1), hp = 20, maxHp = 20, attack = 5, baseAttack = 5, defense = 1, baseDefense = 1, } local lines = Progression.grantXp(player, 5) eq(#lines, 0, "an xp gain under the threshold produces no level-up") eq(player.xp, 5, "xp accumulates") lines = Progression.grantXp(player, 100) eq(player.level > 1, true, "a large xp gain levels the player up") eq(#lines, player.level - 1, "one log line is produced per level gained") eq(player.maxHp > 20, true, "leveling raises max hp") eq(player.hp, player.maxHp, "leveling tops up hp by the same amount as the max hp gain") eq(player.attack > 5, true, "leveling raises attack") ok(player.xp < player.xpNext, "leftover xp after leveling stays below the new threshold") end function tests.progressionMonsterKillGrantsXp() local game = stagedGame(9, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) rat.hp = 1 game.level.entities = { rat } local xpBefore = game.player.xp game:playerAttack(rat) eq(game.player.xp, xpBefore + Monsters.xpFor(rat), "felling a monster in melee grants its xp") eq(#game.level.entities, 0, "the dead monster is swept from the level") end function tests.spellCastMissileKillsAndGrantsXp() local game = stagedGame(5, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) game.level.entities = { rat } local manaBefore = game.player.mana local success = game:castSpell("missile", rat) eq(success, true, "a known spell in range with a clear shot casts successfully") eq(game.player.mana, manaBefore - Spells.catalog.missile.cost, "casting spends mana") eq(#game.level.entities, 0, "the missile kills the rat and sweeps it from the level") eq(game.player.xp, Monsters.xpFor(rat), "a spell kill grants xp same as a melee kill") end function tests.spellCastFailsWithoutSpendingState() local game = stagedGame(5, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) game.level.entities = { rat } game.player.mana = 0 local success = game:castSpell("missile", rat) eq(success, false, "casting without enough mana fails") eq(game.player.mana, 0, "a failed cast does not touch mana") eq(rat.hp, rat.maxHp, "a failed cast deals no damage") game.player.mana = 10 local success2 = game:castSpell("heal") eq(success2, false, "casting a spell that exists but was never learned fails") eq(game.player.mana, 10, "an unlearned spell cast spends no mana") local success3 = game:castSpell("fireball", rat) eq(success3, false, "casting a key that matches no spell in the catalog fails") end function tests.spellLearnFromTome() local game = stagedGame(1, arena(21, 11), 3, 6) eq(Spells.knows(game.player, "heal"), false, "the starting kit does not know heal") game.player.inventory[#game.player.inventory + 1] = Items.tome("heal") game:useItem(#game.player.inventory) eq(Spells.knows(game.player, "heal"), true, "reading a tome permanently learns its spell") local before = #game.player.inventory game.player.inventory[#game.player.inventory + 1] = Items.tome("heal") game:useItem(#game.player.inventory) eq(#game.player.inventory, before, "the tome is consumed whether or not the spell was new") end function tests.spellHealAndShield() local game = stagedGame(1, arena(21, 11), 3, 6) Spells.learn(game.player, "heal") Spells.learn(game.player, "shield") game.player.hp = 10 local manaBefore = game.player.mana game:castSpell("heal") eq(game.player.hp > 10, true, "the heal spell restores hp") eq(game.player.mana, manaBefore - Spells.catalog.heal.cost, "casting heal spends its mana cost") local defenseBefore = game.player:effectiveDefense() game:castSpell("shield") eq(game.player:effectiveDefense() > defenseBefore, true, "the shield spell raises effective defense") ok(Status.has(game.player, Status.SHIELD), "shield applies the shield status") end function tests.spellBlinkMovesWithinRadius() local game = stagedGame(3, arena(21, 11), 10, 6) Spells.learn(game.player, "blink") local startX, startY = game.player.x, game.player.y local success = game:castSpell("blink") eq(success, true, "blink casts when mana and known") local moved = game.player.x ~= startX or game.player.y ~= startY ok(moved, "blink relocates the caster") local dist = math.max(math.abs(game.player.x - startX), math.abs(game.player.y - startY)) ok(dist <= 5, "blink lands within its radius") eq(game.level.map:isWalkable(game.player.x, game.player.y), true, "blink lands on walkable ground") end function tests.manaRegeneratesEachTurn() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.mana = 0 game.level.entities = {} game:endPlayerTurn() eq(game.player.mana, 1, "mana regenerates by one per player turn") game.player.mana = game.player.maxMana game:endPlayerTurn() eq(game.player.mana, game.player.maxMana, "mana regeneration is capped at max mana") end function tests.throwWeaponHitsAndLands() local game = stagedGame(4, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) game.level.entities = { rat } local dagger = Items.weapon("dagger", 2) game.player.inventory[#game.player.inventory + 1] = dagger local index = #game.player.inventory local before = #game.player.inventory local success = game:throwAt(index, rat) eq(success, true, "a weapon throw with a clear line succeeds") eq(#game.player.inventory, before - 1, "the thrown weapon leaves the inventory") eq(rat.hp, rat.maxHp - dagger.power * 2, "a thrown weapon deals double its melee power") local landed = game.level:itemsAt(rat.x, rat.y) eq(#landed, 1, "the thrown weapon lands on the ground at the target") eq(landed[1].item, dagger, "the landed item is the same weapon that was thrown") end function tests.throwWeaponKillGrantsXp() local game = stagedGame(4, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) rat.hp = 1 game.level.entities = { rat } game.player.inventory[#game.player.inventory + 1] = Items.weapon("battle axe", 5) local xpBefore = game.player.xp game:throwAt(#game.player.inventory, rat) eq(#game.level.entities, 0, "a lethal throw sweeps the monster from the level") eq(game.player.xp, xpBefore + Monsters.xpFor(rat), "a thrown-weapon kill grants xp") end function tests.throwPotionAppliesHostileEffect() local game = stagedGame(4, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) game.level.entities = { rat } game.player.inventory[#game.player.inventory + 1] = Items.potionOf("poison", 3) local success = game:throwAt(#game.player.inventory, rat) eq(success, true, "a potion throw with a clear line succeeds") eq(Status.has(rat, Status.POISON), true, "a thrown poison potion poisons the target instead of the thrower") eq(Status.has(game.player, Status.POISON), false, "the thrower is untouched by their own thrown potion") eq(Hazards.at(game.level, rat.x, rat.y, Hazards.GAS) ~= nil, true, "a shattered poison potion leaves a lingering cloud of fumes") end function tests.throwConfusePotionAppliesTheStatusToTheTargetNotTheThrower() local game = stagedGame(4, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) game.level.entities = { rat } game.player.inventory[#game.player.inventory + 1] = Items.potionOf("confuse") local success = game:throwAt(#game.player.inventory, rat) eq(success, true, "a confuse potion throw with a clear line succeeds") eq(Status.has(rat, Status.CONFUSE), true, "a thrown confuse potion confuses the target instead of the thrower") eq(Status.has(game.player, Status.CONFUSE), false, "the thrower is untouched by their own thrown potion") end function tests.throwBlindPotionAppliesTheStatusToTheTargetNotTheThrower() local game = stagedGame(4, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) game.level.entities = { rat } game.player.inventory[#game.player.inventory + 1] = Items.potionOf("blind") local success = game:throwAt(#game.player.inventory, rat) eq(success, true, "a blind potion throw with a clear line succeeds") eq(Status.has(rat, Status.BLIND), true, "a thrown blind potion blinds the target instead of the thrower") eq(Status.has(game.player, Status.BLIND), false, "the thrower is untouched by their own thrown potion") end function tests.throwLevitatePotionAppliesTheStatusToTheTargetNotTheThrower() local game = stagedGame(4, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) game.level.entities = { rat } game.player.inventory[#game.player.inventory + 1] = Items.potionOf("levitate") local success = game:throwAt(#game.player.inventory, rat) eq(success, true, "a levitation potion throw with a clear line succeeds") eq(Status.has(rat, Status.LEVITATE), true, "a thrown levitation potion lifts the target instead of the thrower") eq(Status.has(game.player, Status.LEVITATE), false, "the thrower is untouched by their own thrown potion") end function tests.throwOutOfRangeFails() local game = stagedGame(4, arena(21, 11), 2, 2) local rat = Monsters.create(Monsters.byKey.rat, 1, 19, 9, 1) game.level.entities = { rat } game.player.inventory[#game.player.inventory + 1] = Items.weapon("dagger", 2) local index = #game.player.inventory local before = #game.player.inventory local success = game:throwAt(index, rat) eq(success, false, "a throw beyond range fails") eq(#game.player.inventory, before, "a failed throw does not consume the item") eq(rat.hp, rat.maxHp, "a failed throw deals no damage") end function tests.rangedShotHitsAtRangeAndConsumesAmmo() local game = stagedGame(4, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) game.level.entities = { orc } game.player.bow = Items.bow("short bow", 2, 6) game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 2) local expected = game.player.baseAttack + 2 - orc:effectiveDefense() local success = game:fireAt(orc) eq(success, true, "firing at a target within range and line of sight succeeds") eq(orc.hp, orc.maxHp - expected, "the shot deals baseAttack plus bow power minus target defense") local stack for _, item in ipairs(game.player.inventory) do if item.kind == Items.AMMO then stack = item end end eq(stack.count, 1, "firing consumes one arrow from the stack") local landed = game.level:itemsAt(orc.x, orc.y) eq(#landed, 1, "the spent arrow lands where the target stood") eq(landed[1].item.kind, Items.AMMO, "the landed item is a spent arrow") end function tests.rangedShotKillGrantsXp() local game = stagedGame(4, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 9, 6, 1) rat.hp = 1 game.level.entities = { rat } game.player.bow = Items.bow("long bow", 3, 7) game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 1) local xpBefore = game.player.xp game:fireAt(rat) eq(#game.level.entities, 0, "a lethal shot sweeps the monster from the level") eq(game.player.xp, xpBefore + Monsters.xpFor(rat), "a ranged kill grants xp") end function tests.rangedFailsWithoutAmmo() local game = stagedGame(4, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 9, 6, 1) game.level.entities = { rat } game.player.bow = Items.bow("short bow", 2, 6) local success = game:fireAt(rat) eq(success, false, "firing without arrows fails") eq(rat.hp, rat.maxHp, "a failed shot deals no damage") end function tests.rangedFailsOutOfRange() local game = stagedGame(4, arena(21, 11), 2, 2) local rat = Monsters.create(Monsters.byKey.rat, 1, 19, 9, 1) game.level.entities = { rat } game.player.bow = Items.bow("short bow", 2, 3) game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 3) local success = game:fireAt(rat) eq(success, false, "firing beyond the bow's range fails") local stack for _, item in ipairs(game.player.inventory) do if item.kind == Items.AMMO then stack = item end end eq(stack.count, 3, "a failed shot does not consume ammunition") end function tests.ammoStacksOnPickup() local game = stagedGame(4, arena(21, 11), 3, 3) game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 2) game.level.floorItems[#game.level.floorItems + 1] = { x = 3, y = 3, item = Items.ammo("arrow", 5) } game:pickupHere() local stacks, total = 0, 0 for _, item in ipairs(game.player.inventory) do if item.kind == Items.AMMO then stacks = stacks + 1 total = item.count end end eq(stacks, 1, "picking up matching ammo merges into a single stack") eq(total, 7, "the merged stack sums both counts") end function tests.trollRegenerateUnlessBurning() local troll = Monsters.create(Monsters.byKey.troll, 1, 5, 5, 1) troll.hp = troll.maxHp - 5 local line = Abilities.regenTick(troll) ok(line ~= nil, "a damaged troll regenerates and logs it") eq(troll.hp, troll.maxHp - 5 + Monsters.byKey.troll.regen, "regen heals by the species regen amount") troll.hp = troll.maxHp eq(Abilities.regenTick(troll), nil, "a troll already at full hp produces no regen line") troll.hp = troll.maxHp - 5 Status.apply(troll, Status.BURNING, 3, 2) eq(Abilities.regenTick(troll), nil, "burning suppresses troll regeneration") end function tests.ratHasNoRegen() local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) rat.hp = 1 eq(Abilities.regenTick(rat), nil, "a species without a regen tag never heals passively") end function tests.wretchDeathBurstsPoisonSpores() local game = stagedGame(1, arena(21, 11), 4, 6) local wretch = Monsters.create(Monsters.byKey.wretch, 1, 5, 6, 1) wretch.hp = 1 game.level.entities = { wretch } eq(Status.has(game.player, Status.POISON), false, "the player starts unpoisoned") game:playerAttack(wretch) eq(Status.has(game.player, Status.POISON), true, "a wretch's death burst poisons an adjacent player") ok(contains(game.log:recent(3)[1] or "", "spores") or contains(game.log:recent(3)[2] or "", "spores") or contains(game.log:recent(3)[3] or "", "spores"), "the spore burst is logged") end function tests.dragonBreathIgnitesPlayer() local game = stagedGame(1, arena(21, 11), 3, 6) local dragon = Monsters.create(Monsters.byKey.dragon, 1, 6, 6, 1) game.level.entities = { dragon } local hpBefore = game.player.hp Ai.act(game, dragon) ok(game.player.hp < hpBefore, "a dragon in range breathes fire for damage") eq(Status.has(game.player, Status.BURNING), true, "dragon breath ignites the player") end -- Layered special abilities: summon/curse/heal/rally run through -- Abilities.tryAct, which gates on cooldown and range before falling through -- to the species' ordinary ai.lua behaviour; split reacts to a survived hit; -- steal is a stateful chase-steal-flee sequence with no cooldown at all. function tests.necromancerSummonsZombie() local game = stagedGame(1, arena(21, 11), 3, 6) local necro = Monsters.create(Monsters.byKey.necromancer, 1, 6, 6, 4) game.level.entities = { necro } local acted = Abilities.tryAct(game, necro) eq(acted, true, "a necromancer in range summons instead of moving") eq(#game.level.entities, 2, "summoning adds a minion to the level") local minion = game.level.entities[2] eq(minion.species, "zombie", "the necromancer summons its configured species") eq(minion.summonedBy, necro.id, "the minion is tagged with its summoner's id") eq(necro.cooldown, 6, "summoning starts the ability's cooldown") ok(contains(game.log:last(), "calls forth"), "the summon is logged") end function tests.summonRespectsPopulationCap() local game = stagedGame(1, arena(21, 11), 3, 6) local necro = Monsters.create(Monsters.byKey.necromancer, 1, 6, 6, 4) local minionA = Monsters.create(Monsters.byKey.zombie, 2, 7, 6, 4) local minionB = Monsters.create(Monsters.byKey.zombie, 3, 6, 7, 4) minionA.summonedBy = necro.id minionB.summonedBy = necro.id game.level.entities = { necro, minionA, minionB } local acted = Abilities.tryAct(game, necro) eq(acted, false, "a necromancer already at its summon cap does not summon a third") eq(#game.level.entities, 3, "the level still has exactly the two existing minions") end function tests.curseAtRangeHurtsAndWeakens() local game = stagedGame(1, arena(21, 11), 3, 6) local shaman = Monsters.create(Monsters.byKey.shaman, 1, 7, 6, 4) game.level.entities = { shaman } local hpBefore = game.player.hp local acted = Abilities.tryAct(game, shaman) eq(acted, true, "a shaman with a clear shot curses instead of moving") eq(game.player.hp, hpBefore - 1, "the curse bolt runs through Combat.damage (shaman attack 2 minus the player's base defense 1)") eq(Status.has(game.player, Status.WEAKEN), true, "the curse also weakens the player") eq(shaman.cooldown, 4, "casting the curse starts its cooldown") ok(contains(game.log:last(), "curses you for 1"), "the logged amount matches the mitigated damage, not a flat number") end -- The curse bolt used to bypass Combat.damage entirely (a flat, unmitigated -- subtraction from hp), so a shaman hit for the same 3 no matter how armored -- the player was, never broke a melee combo, and never wore armor - every -- other monster-on-player attack (monsterMelee, monsterShoot, mimic/grave -- ambush) already ran through Combat.damage + Momentum.onDamaged + -- Durability.wearArmor. These three lock in the fix. function tests.curseDamageIsMitigatedByThePlayersDefenseLikeAnyOtherAttack() local game = stagedGame(1, arena(21, 11), 3, 6) local shaman = Monsters.create(Monsters.byKey.shaman, 1, 7, 6, 4) game.level.entities = { shaman } game.player.defense = game.player.defense + 10 local hpBefore = game.player.hp Abilities.tryAct(game, shaman) eq(game.player.hp, hpBefore - 1, "a shaman's attack (2) minus a heavily-defended player floors at Combat.damage's minimum of 1, not a flat 3") end function tests.curseBreaksThePlayersMeleeMomentumStack() local game = stagedGame(1, arena(21, 11), 3, 6) local shaman = Monsters.create(Monsters.byKey.shaman, 1, 7, 6, 4) game.level.entities = { shaman } game.player.combo = 4 Abilities.tryAct(game, shaman) eq(Momentum.stacks(game.player), 0, "a landed curse bolt snaps momentum to zero exactly like a melee hit or a breath attack") end function tests.curseWearsThePlayersEquippedArmor() local game = stagedGame(1, arena(21, 11), 3, 6) local shaman = Monsters.create(Monsters.byKey.shaman, 1, 7, 6, 4) game.level.entities = { shaman } game.player.armor = { name = "chainmail", kind = Items.ARMOR, power = 2, defense = 2 } Durability.ensure(game.player.armor) local durabilityBefore = game.player.armor.durability Abilities.tryAct(game, shaman) eq(game.player.armor.durability, durabilityBefore - 1, "the curse wears equipped armor exactly like a landed melee or breath hit does") end function tests.curseWithheldWhenAdjacent() local game = stagedGame(1, arena(21, 11), 3, 6) local shaman = Monsters.create(Monsters.byKey.shaman, 1, 4, 6, 4) game.level.entities = { shaman } local acted = Abilities.tryAct(game, shaman) eq(acted, false, "a shaman standing next to the player withholds its curse") end function tests.shriekAtRangeBlindsThePlayer() local game = stagedGame(1, arena(21, 11), 3, 6) local banshee = Monsters.create(Monsters.byKey.banshee, 1, 7, 6, 5) game.level.entities = { banshee } eq(Status.has(game.player, Status.BLIND), false, "the player starts able to see") local acted = Abilities.tryAct(game, banshee) eq(acted, true, "a banshee with a clear shot shrieks instead of moving") eq(Status.has(game.player, Status.BLIND), true, "the shriek blinds the player") eq(banshee.cooldown, 6, "shrieking starts its cooldown") ok(contains(game.log:last(), "blinds you"), "the shriek is logged") end function tests.shriekWithheldWhenAdjacent() local game = stagedGame(1, arena(21, 11), 3, 6) local banshee = Monsters.create(Monsters.byKey.banshee, 1, 4, 6, 5) game.level.entities = { banshee } local acted = Abilities.tryAct(game, banshee) eq(acted, false, "a banshee standing next to the player withholds its shriek") eq(Status.has(game.player, Status.BLIND), false, "no shriek means no blindness") end function tests.shriekIsUnavailableOutOfRange() local game = stagedGame(1, arena(21, 11), 3, 6) local banshee = Monsters.create(Monsters.byKey.banshee, 1, 20, 6, 5) game.level.entities = { banshee } local acted = Abilities.tryAct(game, banshee) eq(acted, false, "a banshee out of shriek range falls through to its base ai") eq(Status.has(game.player, Status.BLIND), false, "no shriek means no blindness") end function tests.shriekBlindnessReducesTheVictimsEffectiveSightRadius() local game = stagedGame(1, arena(21, 11), 3, 6) local banshee = Monsters.create(Monsters.byKey.banshee, 1, 7, 6, 5) game.level.entities = { banshee } Abilities.tryAct(game, banshee) eq(game:effectiveRadius(), 1, "blind (also inflicted by a banshee) still narrows sight to game.lua's own radius") end function tests.abilityCooldownBlocksImmediateReuse() local game = stagedGame(1, arena(21, 11), 3, 6) local shaman = Monsters.create(Monsters.byKey.shaman, 1, 7, 6, 4) game.level.entities = { shaman } Abilities.tryAct(game, shaman) local hpAfterFirst = game.player.hp local acted = Abilities.tryAct(game, shaman) eq(acted, false, "an ability on cooldown does not fire again") eq(game.player.hp, hpAfterFirst, "no further damage is dealt while on cooldown") for _ = 1, 4 do Abilities.tickCooldown(shaman) end eq(shaman.cooldown, 0, "four ticks fully drains a four-turn cooldown") local actedAgain = Abilities.tryAct(game, shaman) eq(actedAgain, true, "the ability is usable again once its cooldown has ticked down") end function tests.healerMendsNeediestAlly() local game = stagedGame(1, arena(21, 11), 3, 6) local cleric = Monsters.create(Monsters.byKey.cleric, 1, 6, 6, 5) local hurt = Monsters.create(Monsters.byKey.rat, 2, 8, 6, 5) hurt.hp = 1 game.level.entities = { cleric, hurt } local acted = Abilities.tryAct(game, cleric) eq(acted, true, "a cleric with a hurt ally in sight heals instead of moving") eq(hurt.hp, 7, "healing restores the fixed amount, capped at max hp") eq(cleric.cooldown, 5, "healing starts its cooldown") end function tests.healerSkipsWhenAlliesAreHealthy() local game = stagedGame(1, arena(21, 11), 3, 6) local cleric = Monsters.create(Monsters.byKey.cleric, 1, 6, 6, 5) local ally = Monsters.create(Monsters.byKey.rat, 2, 8, 6, 5) game.level.entities = { cleric, ally } local acted = Abilities.tryAct(game, cleric) eq(acted, false, "a cleric with no hurt allies falls through to its base ai") end function tests.rallyBuffsAdjacentAllies() local game = stagedGame(1, arena(21, 11), 3, 6) local chieftain = Monsters.create(Monsters.byKey.chieftain, 1, 10, 6, 6) local grunt = Monsters.create(Monsters.byKey.goblin, 2, 11, 6, 6) game.level.entities = { chieftain, grunt } eq(Status.has(grunt, Status.STRENGTH), false, "the grunt starts unbuffed") local acted = Abilities.tryAct(game, chieftain) eq(acted, true, "a chieftain next to an unbuffed ally rallies instead of moving") eq(Status.has(grunt, Status.STRENGTH), true, "rallying buffs the adjacent ally") eq(Status.has(chieftain, Status.STRENGTH), true, "rallying also buffs the chieftain itself") eq(chieftain.cooldown, 8, "rallying starts its cooldown") end function tests.rallySkipsWhenNoUnbuffedAlliesAdjacent() local game = stagedGame(1, arena(21, 11), 3, 6) local chieftain = Monsters.create(Monsters.byKey.chieftain, 1, 10, 6, 6) game.level.entities = { chieftain } local acted = Abilities.tryAct(game, chieftain) eq(acted, false, "a chieftain with no adjacent allies falls through to its base ai") end function tests.healerNeverMendsThePlayersOwnCompanionHirelingOrThrall() local game = stagedGame(1, arena(21, 11), 3, 6) local cleric = Monsters.create(Monsters.byKey.cleric, 1, 6, 6, 5) local companion = Companions.summon(game, "hound") companion.x, companion.y = 8, 6 companion.hp = 1 game.level.entities = { cleric, companion } local acted = Abilities.tryAct(game, cleric) eq(acted, false, "a hurt companion is not a valid heal target, so the cleric falls through instead") eq(companion.hp, 1, "the player's own companion is left exactly as hurt as it was") end function tests.rallyNeverBuffsThePlayersOwnCompanionHirelingOrThrall() local game = stagedGame(1, arena(21, 11), 3, 6) local chieftain = Monsters.create(Monsters.byKey.chieftain, 1, 10, 6, 6) local companion = Companions.summon(game, "hound") companion.x, companion.y = 11, 6 game.level.entities = { chieftain, companion } local acted = Abilities.tryAct(game, chieftain) eq(acted, false, "an adjacent companion is not a valid rally target, so the chieftain falls through instead") eq(Status.has(companion, Status.STRENGTH), false, "the player's own companion is never buffed by a hostile chieftain") eq(Status.has(chieftain, Status.STRENGTH), false, "the chieftain does not even buff itself when it has no monster ally to rally") end function tests.footpadStealsThenFlees() local game = stagedGame(1, arena(21, 11), 3, 6) local footpad = Monsters.create(Monsters.byKey.footpad, 1, 4, 6, 4) game.level.entities = { footpad } local before = #game.player.inventory local acted = Abilities.tryAct(game, footpad) eq(acted, true, "an adjacent footpad steals instead of attacking") eq(#game.player.inventory, before - 1, "the stolen item leaves the player's inventory") eq(footpad.fleeing, true, "the footpad starts fleeing after the theft") ok(contains(game.log:last(), "steals"), "the theft is logged") local xBefore = footpad.x local actedAgain = Abilities.tryAct(game, footpad) eq(actedAgain, true, "a fleeing footpad keeps acting through its ability") eq(#game.player.inventory, before - 1, "a fleeing footpad does not steal again") ok(footpad.x ~= xBefore, "a fleeing footpad moves away from the player") end function tests.footpadWithNothingToStealStillFlees() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.inventory = {} local footpad = Monsters.create(Monsters.byKey.footpad, 1, 4, 6, 4) game.level.entities = { footpad } local acted = Abilities.tryAct(game, footpad) eq(acted, true, "a footpad with nothing to steal still consumes its turn") eq(footpad.fleeing, true, "it flees even without a prize") ok(contains(game.log:last(), "nothing worth stealing"), "the empty-handed theft attempt is logged") end function tests.slimeSplitsOnSurvivedHit() local game = stagedGame(1, arena(21, 11), 3, 6) local slime = Monsters.create(Monsters.byKey.slime, 1, 4, 6, 4) game.level.entities = { slime } eq(slime.hp, 13, "a depth-4 slime spawns with its bonus hp") game:playerAttack(slime) eq(slime:isAlive(), true, "the slime survives a single hit") eq(slime.maxHp, 6, "a surviving split halves the slime's max hp") eq(slime.hp, 6, "the split slime is left at its new max hp") eq(#game.level.entities, 2, "the split spawns a clone next to the original") local clone = game.level.entities[2] eq(clone.species, "slime", "the clone is the same species as the original") eq(clone.maxHp, 6, "the clone shares the halved max hp") eq(clone.splitGen, 1, "the clone is tagged as a first-generation split") ok(contains(game.log:last(), "splits in two"), "the split is logged") end function tests.splitCapsAtTwoGenerations() local game = stagedGame(1, arena(21, 11), 3, 6) local slime = Monsters.create(Monsters.byKey.slime, 1, 4, 6, 4) slime.splitGen = 2 game.level.entities = { slime } local before = #game.level.entities local line = Abilities.onHit(game, slime) eq(line, nil, "a slime that already split twice does not split again") eq(#game.level.entities, before, "no clone is added once the split cap is reached") end function tests.takeMonsterTurnPrefersAbilityOverMovement() local game = stagedGame(1, arena(21, 11), 3, 6) local necro = Monsters.create(Monsters.byKey.necromancer, 1, 6, 6, 4) game.level.entities = { necro } game:takeMonsterTurn(necro) eq(necro.x, 6, "summoning consumes the turn instead of the necromancer's flee movement") eq(necro.y, 6, "summoning leaves the necromancer's y unchanged too") eq(#game.level.entities, 2, "the full turn pipeline invokes the summon ability") end function tests.takeMonsterTurnFallsBackToAiWhenOnCooldown() local game = stagedGame(1, arena(21, 11), 3, 6) local necro = Monsters.create(Monsters.byKey.necromancer, 1, 6, 6, 4) necro.cooldown = 3 game.level.entities = { necro } game:takeMonsterTurn(necro) eq(#game.level.entities, 1, "no minion is summoned while the ability is on cooldown") eq(necro.cooldown, 2, "the cooldown ticks down even on a turn spent fleeing") ok(necro.x ~= 6 or necro.y ~= 6, "the necromancer falls back to its flee ai instead") end function tests.abilityFieldsSurviveSerialization() local necro = Monsters.create(Monsters.byKey.necromancer, 5, 6, 6, 4) necro.cooldown = 3 necro.fleeing = true necro.splitGen = 1 necro.summonedBy = 2 necro.stolen = Items.potion(10) local restored = Entity.fromData(necro:toData()) eq(restored.ability, "summon", "the ability tag survives a round trip") eq(restored.summonSpecies, "zombie", "the summon species survives a round trip") eq(restored.cooldown, 3, "cooldown survives a round trip") eq(restored.fleeing, true, "the fleeing flag survives a round trip") eq(restored.splitGen, 1, "the split generation survives a round trip") eq(restored.summonedBy, 2, "the summoner id survives a round trip") eq(restored.stolen.name, "potion of healing", "a stolen item's data survives a round trip") end function tests.statusEffectKillGrantsXpAndLogsDeath() local game = stagedGame(1, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 15, 9, 1) Status.apply(rat, Status.POISON, 1, 50) game.level.entities = { rat } local xpBefore = game.player.xp game:takeMonsterTurn(rat) eq(#game.level.entities, 0, "a monster that dies to its own status tick is swept from the level") eq(game.player.xp, xpBefore + Monsters.xpFor(rat), "dying to a status effect still grants its xp") ok(contains(game.log:recent(4)[1] or "", "dies") or contains(game.log:recent(4)[2] or "", "dies") or contains(game.log:recent(4)[3] or "", "dies") or contains(game.log:recent(4)[4] or "", "dies"), "the status-effect kill is logged as a death") end function tests.hazardKillDuringMonsterTurnGrantsXp() local game = stagedGame(1, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 15, 9, 1) rat.hp = 1 Hazards.ignite(game.level, rat.x, rat.y, 3, 50) game.level.entities = { rat } local xpBefore = game.player.xp game:takeMonsterTurn(rat) eq(#game.level.entities, 0, "a monster that dies standing in fire is swept from the level") eq(game.player.xp, xpBefore + Monsters.xpFor(rat), "dying to a hazard still grants its xp") end function tests.hazardStepRunsOncePerPlayerRound() local game = stagedGame(1, arena(21, 11), 3, 6) local px, py = game.player.x, game.player.y Hazards.ignite(game.level, px, py, 1, 3) local hpBefore = game.player.hp game:autoStep() eq(game.player.hp, hpBefore - 3, "the player takes hazard damage while standing in fire on their turn") eq(Hazards.at(game.level, px, py, Hazards.FIRE), nil, "the hazard's duration is spent by the environmental step at the end of the round") end function tests.saveLoadRoundTrip() local game = Game.new(2024) for _ = 1, 80 do if not game.player:isAlive() then break end game:autoStep() end local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(Serialize.encode(restored:toData()), Serialize.encode(game:toData()), "state is byte-identical after round trip") eq(restored.player.hp, game.player.hp, "player hp survives round trip") eq(restored.depth, game.depth, "depth survives round trip") eq(restored.turn, game.turn, "turn survives round trip") eq(table.concat(restored.level.map.tiles), table.concat(game.level.map.tiles), "current map survives round trip") eq(#restored.level.entities, #game.level.entities, "monster count survives round trip") eq(restored:render(), game:render(), "render is identical after round trip") end function tests.deterministicPlaythrough() local a = Game.new(1337) local b = Game.new(1337) for _ = 1, 120 do if a.player:isAlive() then a:autoStep() end if b.player:isAlive() then b:autoStep() end end eq(a:render(), b:render(), "same seed yields the same playthrough") eq(a.player.hp, b.player.hp, "same seed yields the same player hp") eq(a.depth, b.depth, "same seed yields the same depth") local c = Game.new(4242) for _ = 1, 120 do if c.player:isAlive() then c:autoStep() end end ok(a:render() ~= c:render(), "a different seed diverges") end function tests.autoplayDescends() local function playout(seed) local game = Game.new(seed) for _ = 1, 600 do if not game.player:isAlive() then break end game:autoStep() end return game end -- Seed re-picked when the blind potion/trap effect joined the shared loot -- and trap-effect tables (builder.lua): appending a new outcome changes the -- denominator of every rng:range(1, #effects) draw those tables feed, which -- reshuffles the whole downstream procedural sequence for any fixed seed - -- 2024 itself did not get more dangerous, its particular draw just moved. local run = playout(2025) ok(run.depth >= 3, "the autopilot descends several levels") local again = playout(2025) eq(again.depth, run.depth, "the autopilot reaches the same depth on the same seed") eq(again.turn, run.turn, "the autopilot takes the same number of turns") end function tests.amuletSpawnsOnFinalFloorOnly() local finalLevel = Builder.build(Builder.MAX_DEPTH, Rng.new(999), {}) local finalCount = 0 for _, ground in ipairs(finalLevel.floorItems) do if ground.item.kind == Items.AMULET then finalCount = finalCount + 1 end end eq(finalCount, 1, "exactly one amulet is placed on the final floor") local shallowLevel = Builder.build(Builder.MAX_DEPTH - 1, Rng.new(999), {}) local shallowCount = 0 for _, ground in ipairs(shallowLevel.floorItems) do if ground.item.kind == Items.AMULET then shallowCount = shallowCount + 1 end end eq(shallowCount, 0, "no amulet appears above the final floor") end function tests.descendIsBlockedAtTheFinalFloor() local game = Game.new(555) game.depth = game.maxDepth game.level = game.dungeon:get(game.maxDepth) game.player.x, game.player.y = game.level.downX, game.level.downY local result = game:descend() eq(result, false, "descend fails once at the final floor") eq(game.depth, game.maxDepth, "depth is unchanged after a blocked descend") ok(contains(game.log:last(), "rock"), "a message explains why the descent is blocked") end function tests.ascendWithoutAmuletDoesNotWin() local game = Game.new(7) game.player.x, game.player.y = game.level.upX, game.level.upY local result = game:ascend() eq(result, true, "climbing out at depth 1 is still a valid move") eq(game.won, false, "climbing out without the amulet does not win the game") end function tests.ascendWithAmuletWinsTheGame() local game = Game.new(7) game.player.inventory[#game.player.inventory + 1] = Items.amulet() game.player.x, game.player.y = game.level.upX, game.level.upY local result = game:ascend() eq(result, true, "climbing out at depth 1 with the amulet succeeds") eq(game.won, true, "carrying the amulet to the surface wins the game") ok(contains(game.log:last(), "win"), "the victory is logged") end function tests.wonGameHaltsTheAutopilot() local game = Game.new(7) game.won = true local turnBefore = game.turn game:autoStep() eq(game.turn, turnBefore, "autoStep is a no-op once the game is already won") end function tests.autopilotFetchesTheAmuletAndWins() local function playout(seed) local game = Game.new(seed, { maxDepth = 3 }) for _ = 1, 900 do if not game.player:isAlive() or game.won then break end game:autoStep() end return game end local run = playout(67) ok(run.player:isAlive(), "the autopilot survives the round trip on this seed") eq(run.won, true, "the autopilot retrieves the amulet and escapes") eq(run.depth, 1, "winning leaves the player back at depth 1") ok(run:hasAmulet(), "the amulet is still in the winning player's inventory") local again = playout(67) eq(again.turn, run.turn, "the winning playthrough takes the same number of turns on the same seed") end function tests.shopPriceCatalogScalesWithPower() local base = Shop.priceOf(Items.weapon("dagger", 2)) local strong = Shop.priceOf(Items.weapon("battle axe", 5)) ok(strong > base, "a more powerful weapon costs more at the shop") eq(Shop.priceOf(Items.potionOf("heal")), 15, "a catalogued potion has its fixed price") eq(Shop.sellValue(Items.potionOf("heal")), 7, "selling nets half the catalog price, rounded down") end function tests.shopStockRollsAffordableWares() local wares = Shop.stock(Rng.new(7)) ok(#wares >= 3 and #wares <= 5, "a shop rolls three to five wares") for _, item in ipairs(wares) do ok(Shop.priceOf(item) > 0, "every ware has a positive price") end end function tests.goldPickupAddsToWalletNotInventory() local game = stagedGame(1, arena(21, 11), 3, 3) local before = #game.player.inventory local goldBefore = game.player.gold game.level.floorItems = { { x = 4, y = 3, item = Items.gold(35) } } game:movePlayer(1, 0) eq(game.player.gold, goldBefore + 35, "walking onto gold adds it to the wallet") eq(#game.player.inventory, before, "gold never occupies an inventory slot") eq(#game.level.floorItems, 0, "the gold pile is removed from the floor") end function tests.affordableWareIsBoughtAutomaticallyOnPickup() local game = stagedGame(1, arena(21, 11), 3, 3) local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) game.level.entities = { keeper } game.player.gold = 50 local before = #game.player.inventory game.level.floorItems = { { x = 4, y = 3, item = Items.potionOf("heal"), price = 15, shopId = keeper.id } } game:movePlayer(1, 0) eq(game.player.gold, 35, "the ware's stamped price is deducted from the wallet") eq(#game.player.inventory, before + 1, "the bought ware joins the inventory") eq(#game.level.floorItems, 0, "the purchased ware leaves the ground") end function tests.unaffordableWareStaysOnTheGround() local game = stagedGame(1, arena(21, 11), 3, 3) local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) game.level.entities = { keeper } game.player.gold = 5 local before = #game.player.inventory game.level.floorItems = { { x = 4, y = 3, item = Items.potionOf("heal"), price = 15, shopId = keeper.id } } game:movePlayer(1, 0) eq(game.player.gold, 5, "an unaffordable ware is never bought, so no gold changes hands") eq(#game.player.inventory, before, "the ware never reaches the inventory") eq(#game.level.floorItems, 1, "the unbought ware is left in place") end function tests.deadKeeperMakesWaresFreeToLoot() local game = stagedGame(1, arena(21, 11), 3, 3) local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) keeper.hp = 0 game.level.entities = { keeper } game.player.gold = 0 local before = #game.player.inventory game.level.floorItems = { { x = 4, y = 3, item = Items.potionOf("heal"), price = 15, shopId = keeper.id } } game:movePlayer(1, 0) eq(game.player.gold, 0, "looting a dead keeper's stock costs nothing") eq(#game.player.inventory, before + 1, "the ware is picked up normally once its keeper is dead") end function tests.stealingFromShopTurnsTheKeeperHostile() local game = stagedGame(1, arena(21, 11), 3, 3) local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) game.level.entities = { keeper } game.player.gold = 0 local before = #game.player.inventory game.level.floorItems = { { x = 4, y = 3, item = Items.scroll("identify"), price = 18, shopId = keeper.id } } local stolen = game:stealFromShop(1) eq(stolen, true, "stealing succeeds against a live keeper's ware") eq(game.player.gold, 0, "theft costs no gold") eq(#game.player.inventory, before + 1, "the stolen item joins the inventory") eq(keeper.hostile, true, "the keeper turns hostile after the theft") ok(contains(game.log:last(), "steal"), "the theft is logged") end function tests.hostileShopkeeperChasesLikeAnyMonster() local game = stagedGame(1, arena(21, 11), 3, 6) local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 8, 6, 4) keeper.hostile = true game.level.entities = { keeper } Ai.act(game, keeper) eq(keeper.x, 7, "a hostile shopkeeper closes in on the player like a chaser") end function tests.passiveShopkeeperIgnoresTheAdjacentPlayer() local game = stagedGame(1, arena(21, 11), 3, 6) local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 4, 6, 4) game.level.entities = { keeper } local hpBefore = game.player.hp Ai.act(game, keeper) eq(keeper.x, 4, "an un-robbed shopkeeper does not move") eq(game.player.hp, hpBefore, "an un-robbed shopkeeper does not attack even when adjacent") end function tests.sellItemGrantsHalfPriceAndRemovesIt() local game = stagedGame(1, arena(21, 11), 3, 3) local index = #game.player.inventory local item = game.player.inventory[index] local expected = Shop.sellValue(item) local goldBefore = game.player.gold local value = game:sellItem(index) eq(value, expected, "selling returns the catalog half-price") eq(game.player.gold, goldBefore + expected, "the sale proceeds land in the wallet") eq(#game.player.inventory, index - 1, "the sold item leaves the inventory") end function tests.builderOccasionallySeedsAShopWithMatchingWares() local level = Builder.build(4, Rng.new(1), {}) local keeper for _, e in ipairs(level.entities) do if e.species == "shopkeeper" then keeper = e end end ok(keeper ~= nil, "this seed's depth-4 floor spawns a shopkeeper") local waresFound = 0 for _, ground in ipairs(level.floorItems) do if ground.price then waresFound = waresFound + 1 eq(ground.shopId, keeper.id, "every priced ware is tagged with its shopkeeper's id") ok(ground.price > 0, "every priced ware has a positive price") end end ok(waresFound >= 3, "the shop stocks at least three wares") end function tests.shopAndLooseGoldStayOffTheShallowFloors() for depth = 1, 3 do local level = Builder.build(depth, Rng.new(1), {}) for _, ground in ipairs(level.floorItems) do ok(ground.item.kind ~= Items.GOLD, "no loose gold appears above depth 4") ok(not ground.price, "no shop ware appears above depth 4") end for _, e in ipairs(level.entities) do ok(e.species ~= "shopkeeper", "no shopkeeper appears above depth 4") end end end local function arenaDark(w, h) local map = Map.new(w, h) for y = 2, h - 1 do for x = 2, w - 1 do map:set(x, y, Map.FLOOR) end end return Level.new({ depth = 1, generator = "arena", map = map, entities = {}, floorItems = {}, traps = {}, lights = {}, dark = true, explored = {}, upX = 2, upY = 2, downX = w - 1, downY = h - 1, }) end function tests.classApplyGrantsFighterStatBonuses() local base = Game.new(9) local fighter = Game.new(9, { class = "fighter" }) eq(fighter.player.class, "fighter", "the requested class is stamped onto the player") eq(fighter.player.maxHp, base.player.maxHp + 8, "fighter grants a flat max hp bonus") eq(fighter.player.hp, base.player.hp + 8, "the hp bonus also tops up current hp") eq(fighter.player.attack, base.player.attack + 2, "fighter grants a flat attack bonus") end function tests.classApplyGrantsRogueGoldAndAttack() local base = Game.new(9) local rogue = Game.new(9, { class = "rogue" }) eq(rogue.player.gold, base.player.gold + 15, "rogue starts with extra gold") eq(rogue.player.attack, base.player.attack + 1, "rogue grants a small attack bonus") end function tests.classApplyGrantsMageManaAndAttack() local base = Game.new(9) local mage = Game.new(9, { class = "mage" }) eq(mage.player.maxMana, base.player.maxMana + 8, "mage grants a flat mana bonus") eq(mage.player.mana, base.player.mana + 8, "the mana bonus also tops up current mana") eq(mage.player.attack, base.player.attack - 1, "mage trades some attack for its mana pool") end function tests.classApplyGrantsRangerBowAndAmmo() local ranger = Game.new(9, { class = "ranger" }) ok(ranger.player.bow ~= nil, "ranger starts with a bow equipped") local hasAmmo = false for _, item in ipairs(ranger.player.inventory) do if item.kind == Items.AMMO then hasAmmo = true end end ok(hasAmmo, "ranger starts with a stack of arrows") end function tests.invalidClassIsIgnored() local base = Game.new(9) local weird = Game.new(9, { class = "wizard" }) eq(weird.player.class, nil, "an unrecognised class name is not applied") eq(weird.player.maxHp, base.player.maxHp, "stats are unaffected by an invalid class") end function tests.skillLearnRespectsClassLevelAndPoints() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.class = "fighter" game.player.skillPoints = 0 eq(game:learnSkill("cleave"), false, "learning fails without a banked skill point") game.player.skillPoints = 1 eq(game:learnSkill("backstab"), false, "learning a skill from another class fails") eq(game.player.skillPoints, 1, "a failed learn spends no point") eq(game:learnSkill("berserk"), false, "learning fails below the skill's level requirement") eq(game:learnSkill("cleave"), true, "a same-class, level-eligible skill with a point banked learns") eq(game.player.skillPoints, 0, "learning spends the point") eq(Skills.has(game.player, "cleave"), true, "the skill is now known") game.player.skillPoints = 1 eq(game:learnSkill("cleave"), false, "an already-known skill cannot be learned twice") eq(game.player.skillPoints, 1, "re-learning a known skill spends no point") end function tests.skillCooldownBlocksImmediateReuseAndTicksDown() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.class = "fighter" game.player.skills.secondWind = true game.player.hp = game.player.maxHp - 10 eq(game:useSkill("secondWind"), true, "second wind activates while off cooldown") eq(game:useSkill("secondWind"), false, "an immediate reuse fails while on cooldown") for _ = 1, Skills.active.secondWind.cooldown do game:endPlayerTurn() end eq(game.player.skillCooldowns.secondWind, 0, "the cooldown ticks down to zero over that many player turns") game.player.hp = game.player.maxHp - 10 eq(game:useSkill("secondWind"), true, "second wind is usable again once its cooldown fully elapses") end function tests.fighterCleaveHitsPrimaryAndAdjacentMonsters() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.class = "fighter" game.player.skills.cleave = true local primary = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) local splash = Monsters.create(Monsters.byKey.orc, 2, 3, 5, 1) game.level.entities = { primary, splash } local success = game:useSkill("cleave", primary) eq(success, true, "cleave lands on an adjacent primary target") ok(primary.hp < primary.maxHp, "the primary target takes damage") ok(splash.hp < splash.maxHp, "a second monster adjacent to the player also takes splash damage") ok((splash.maxHp - splash.hp) < (primary.maxHp - primary.hp), "the splash hit deals less than the primary hit") eq(game.player.skillCooldowns.cleave, Skills.active.cleave.cooldown, "cleave starts its own cooldown") end -- Cleave's splash targets must be mitigated by the wielder's own weapon -- damage type exactly like the primary target already is through -- Combat.damage/Combat.attack (see combat.lua's damageTypeOf and affixes.lua's -- own comment that a flaming sword "can do nothing at all against something -- fire-immune") - before this test the splash loop subtracted a raw -- attack-minus-defense number straight off hp with no Resistances.apply call -- at all, so a flaming weapon's cleave splash burned through fire immunity -- that the very same swing's primary hit already respected. function tests.fighterCleaveSplashRespectsAFireImmuneTargetLikeThePrimaryHitAlreadyDoes() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.class = "fighter" game.player.skills.cleave = true game.player.weapon = Affixes.decorate(Items.weapon("dagger", 2), Affixes.weapon[3]) eq(game.player.weapon.damageType, "fire", "the flaming affix retags the dagger's swing as fire") local primary = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) local immune = Monsters.create(Monsters.byKey.dragon, 2, 3, 5, 6) game.level.entities = { primary, immune } game:useSkill("cleave", primary) ok(primary.hp < primary.maxHp, "the primary target still burns for full mitigated damage") eq(immune.hp, immune.maxHp, "a fire-immune splash target takes zero cleave splash from a flaming weapon") end function tests.fighterSecondWindRestoresPortionOfMaxHp() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.class = "fighter" game.player.skills.secondWind = true game.player.hp = 5 game:useSkill("secondWind") eq(game.player.hp, 5 + math.floor(game.player.maxHp * 0.3), "second wind restores three tenths of max hp") game.player.skillCooldowns.secondWind = 0 game.player.hp = game.player.maxHp - 1 game:useSkill("secondWind") eq(game.player.hp, game.player.maxHp, "second wind never heals past max hp") end function tests.fighterBerserkBoostsAttackAtLowHp() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.class = "fighter" game.player.skills.berserk = true local full = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) game.level.entities = { full } game.player.hp = game.player.maxHp game:playerAttack(full) local dmgFull = full.maxHp - full.hp local game2 = stagedGame(1, arena(21, 11), 3, 6) game2.player.class = "fighter" game2.player.skills.berserk = true local low = Monsters.create(Monsters.byKey.orc, 2, 4, 6, 1) game2.level.entities = { low } game2.player.hp = math.floor(game2.player.maxHp * 0.3) game2:playerAttack(low) local dmgLow = low.maxHp - low.hp ok(dmgLow > dmgFull, "berserk raises attack once hp drops to a third of max") end function tests.rogueBackstabDoublesDamageOnFullHpTarget() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.class = "rogue" game.player.skills.backstab = true local fresh = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) game.level.entities = { fresh } game:playerAttack(fresh) local dmgFresh = fresh.maxHp - fresh.hp local game2 = stagedGame(1, arena(21, 11), 3, 6) game2.player.class = "rogue" game2.player.skills.backstab = true local hurt = Monsters.create(Monsters.byKey.rat, 2, 4, 6, 1) hurt.hp = hurt.hp - 1 game2.level.entities = { hurt } game2:playerAttack(hurt) local dmgHurt = (hurt.maxHp - 1) - hurt.hp eq(dmgFresh, dmgHurt * 2, "backstab doubles damage against a target still at full hp") end function tests.rogueEvasionCanFullyNegateMeleeHit() local defender = { skills = { evasion = true } } local sawDodge, sawHit = false, false local rng = Rng.new(1) for _ = 1, 60 do if Skills.evades({ rng = rng }, defender) then sawDodge = true else sawHit = true end end ok(sawDodge, "evasion dodges at least once over enough attempts") ok(sawHit, "evasion is a chance, not a guarantee, over enough attempts") eq(Skills.evades({ rng = Rng.new(1) }, {}), false, "a defender without the skill never evades") end function tests.poisonBladeAppliesPoisonOnNextMeleeHitOnly() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.class = "rogue" game.player.skills.poisonBlade = true local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) orc.hp, orc.maxHp = 100, 100 game.level.entities = { orc } eq(game:useSkill("poisonBlade"), true, "poison blade readies without needing a target") game:playerAttack(orc) ok(Status.has(orc, Status.POISON), "the readied venom lands on the very next melee hit") orc.statuses = {} game:playerAttack(orc) eq(Status.has(orc, Status.POISON), false, "poison blade is spent after one hit, not reapplied on the next") end -- pendingPoisonBlade lives on the player between "ready the venom" and "land -- the next swing" - a window a save/quit can happen inside, exactly like -- reputation, sanity or the skill tree already have to survive Game:toData. function tests.pendingPoisonBladeReadinessSurvivesAGameSaveLoadRoundTrip() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.class = "rogue" game.player.skills.poisonBlade = true eq(game:useSkill("poisonBlade"), true, "poison blade readies without needing a target") eq(game.player.pendingPoisonBlade, true, "readying the skill sets the pending flag before any save/load happens") local restored = Game.fromData(game:toData()) eq(restored.player.pendingPoisonBlade, true, "a poison blade readied before a save is still primed after loading it back") ok(Skills.consumePoisonBlade(restored.player), "the restored pending venom is still consumable on the very next swing") end function tests.mageOverloadBoostsMissileDamage() local game = stagedGame(5, arena(21, 11), 3, 6) local base = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) game.level.entities = { base } game:castSpell("missile", base) local dmgBase = base.maxHp - base.hp local game2 = stagedGame(5, arena(21, 11), 3, 6) game2.player.class = "mage" game2.player.skills.overload = true local boosted = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) game2.level.entities = { boosted } game2:castSpell("missile", boosted) local dmgBoosted = boosted.maxHp - boosted.hp ok(dmgBoosted > dmgBase, "overload raises missile spell damage") end function tests.mageMeditateRestoresManaWithinCap() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.class = "mage" game.player.skills.meditate = true game.player.mana = 2 game:useSkill("meditate") eq(game.player.mana, 8, "meditate restores a flat amount of mana") game.player.mana = game.player.maxMana - 1 game:useSkill("meditate") eq(game.player.mana, game.player.maxMana, "meditate never overfills the mana pool") end function tests.mageManaShieldRaisesDefenseWhileManaHeld() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.class = "mage" game.player.skills.manaShield = true local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) local before = game.player.hp game.player.mana = 10 game:monsterMelee(orc) local dmgWithShield = before - game.player.hp game.player.hp = before game.player.mana = 0 game:monsterMelee(orc) local dmgWithoutShield = before - game.player.hp ok(dmgWithoutShield > dmgWithShield, "mana shield reduces damage taken while mana holds") end function tests.rangerMultishotHitsTwoTargetsForOneArrow() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.class = "ranger" game.player.skills.multishot = true game.player.bow = Items.bow("shortbow", 3, 6) game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 5) local first = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) local second = Monsters.create(Monsters.byKey.orc, 2, 3, 8, 1) game.level.entities = { first, second } local success = game:useSkill("multishot", first, second) eq(success, true, "multishot succeeds with two reachable targets") ok(first.hp < first.maxHp, "the primary target takes damage") ok(second.hp < second.maxHp, "the secondary target takes damage") local stack for _, item in ipairs(game.player.inventory) do if item.kind == Items.AMMO then stack = item end end eq(stack.count, 4, "multishot consumes only a single arrow for both targets") end -- Multishot's split arrows must run through Resistances.apply exactly like a -- single Ranged.fire shot does (both always tag physical - arrows never -- carry an elemental damageType) - before this test the loop subtracted a -- raw attack-minus-defense number straight off hp for both targets with no -- mitigation call at all, so a physically resistant monster took a full, -- unresisted hit from a skill that is supposed to be "an arrow that splits", -- not a different damage model than the bow it comes out of. function tests.rangerMultishotRespectsPhysicalResistanceOnBothSplitArrows() local function primaryDamageDealt(resistant) local game = stagedGame(1, arena(21, 11), 3, 6) game.player.class = "ranger" game.player.skills.multishot = true game.player.bow = Items.bow("shortbow", 3, 6) game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 5) local first = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) first.hp, first.maxHp = 100, 100 if resistant then first.resist = { physical = Resistances.RESIST } end local second = Monsters.create(Monsters.byKey.orc, 2, 3, 8, 1) second.hp, second.maxHp = 100, 100 game.level.entities = { first, second } game:useSkill("multishot", first, second) return 100 - first.hp end local plain = primaryDamageDealt(false) local resisted = primaryDamageDealt(true) ok(resisted < plain, "a physically resistant target takes measurably less multishot damage than an unresisted one") eq(resisted, math.floor(plain / 2), "physical resistance halves the split-arrow hit, the same mitigation Resistances.apply gives a single Ranged.fire shot") end function tests.rangerCamouflageShrinksDetectionOnDarkFloors() local game = stagedGame(1, arenaDark(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 11, 6, 1) eq(game:canSeePlayer(orc, 10), true, "without camouflage a dark floor does not shrink detection") game.player.class = "ranger" game.player.skills.camouflage = true eq(game:canSeePlayer(orc, 10), false, "camouflage shrinks a monster's detection range on a dark floor") game.level.dark = false eq(game:canSeePlayer(orc, 10), true, "camouflage does nothing once the floor is lit") end -- Blindness overrides Game:effectiveRadius outright rather than stacking as -- another penalty term (contrast Atmosphere.sightPenalty/Events.sightPenalty/ -- Ascension.sightPenalty, which all just shave a fixed amount off) - a fully -- lit, unpenalized floor still clamps to a radius of one the instant the -- player is blinded, and returns to normal the instant the status is gone. function tests.effectiveRadiusBlindStatusOverridesLightAndWeatherToAMinimumOfOne() local game = stagedGame(1, arena(21, 11), 3, 6) local clear = game:effectiveRadius() ok(clear > 1, "an unblinded, unpenalized floor has more than a radius of one to begin with") Status.apply(game.player, Status.BLIND, 6, 1) eq(game:effectiveRadius(), 1, "a blinded player's sight radius clamps to one regardless of light or weather") game.player.statuses = {} eq(game:effectiveRadius(), clear, "sight radius returns to normal once blindness is gone") end -- A blinded monster keeps its ordinary listed detection range on paper, but -- Game:canSeePlayer clamps every one of ai.lua's behaviours (chase/pack/ -- ranged/flee/erratic/tunnel all route through this same helper) down to -- melee-only awareness while it lasts. function tests.canSeePlayerBlindedMonsterOnlyDetectsThePlayerAtMeleeRange() local game = stagedGame(1, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 8, 6, 1) eq(game:canSeePlayer(orc, 10), true, "an unblinded monster within its listed range sees the player") Status.apply(orc, Status.BLIND, 6, 1) eq(game:canSeePlayer(orc, 10), false, "a blinded monster loses a five-cell-away sighting despite its listed range") local adjacentOrc = Monsters.create(Monsters.byKey.orc, 2, 4, 6, 1) Status.apply(adjacentOrc, Status.BLIND, 6, 1) eq(game:canSeePlayer(adjacentOrc, 10), true, "a blinded monster still senses the player once adjacent") end function tests.rangerTrueshotAddsFlatRangedDamage() local game = stagedGame(4, arena(21, 11), 3, 6) local base = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) game.level.entities = { base } game.player.bow = Items.bow("short bow", 2, 6) game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 2) game:fireAt(base) local dmgBase = base.maxHp - base.hp local game2 = stagedGame(4, arena(21, 11), 3, 6) game2.player.class = "ranger" game2.player.skills.trueshot = true local boosted = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) game2.level.entities = { boosted } game2.player.bow = Items.bow("short bow", 2, 6) game2.player.inventory[#game2.player.inventory + 1] = Items.ammo("arrow", 2) game2:fireAt(boosted) local dmgTrueshot = boosted.maxHp - boosted.hp eq(dmgTrueshot, dmgBase + 3, "trueshot adds a flat +3 to every ranged hit") end function tests.autopilotLearnsSkillsAutomaticallyAsPointsAllow() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.class = "fighter" game.player.skillPoints = 1 game:choosePlayerAction() ok(Skills.has(game.player, "cleave"), "the autopilot spends a banked point on the first level-eligible skill in class order") eq(game.player.skillPoints, 0, "learning the skill spends the banked point") end function tests.skillFieldsSurviveSerialization() local game = Game.new(5, { class = "mage" }) game.player.skills.overload = true game.player.skillCooldowns.meditate = 2 game.player.skillPoints = 3 local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(restored.player.class, "mage", "class survives a save/load round trip") eq(restored.player.skillPoints, 3, "skill points survive a save/load round trip") eq(Skills.has(restored.player, "overload"), true, "learned skills survive a save/load round trip") eq(restored.player.skillCooldowns.meditate, 2, "skill cooldowns survive a save/load round trip") end function tests.craftingForgeSpendsEssenceForATierOneGem() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.essence = Crafting.FORGE_COST - 1 local shortSuccess, shortReason = Crafting.forge(game.player, Crafting.FIRE) eq(shortSuccess, false, "forging refuses when essence is short of the cost") eq(shortReason, "not enough essence", "the refusal names the reason") game.player.essence = Crafting.FORGE_COST local success, gem = Crafting.forge(game.player, Crafting.FIRE) ok(success, "forging succeeds with exactly the cost in essence") eq(gem.tier, 1, "a forged gem starts at tier one") eq(gem.element, Crafting.FIRE, "the gem carries the requested element") eq(game.player.essence, 0, "the forge cost is spent") local badSuccess, badReason = Crafting.forge(game.player, "shadow") eq(badSuccess, false, "an unknown element is refused") eq(badReason, "no such element", "the refusal names the reason") end function tests.craftingMergeGemsCombinesMatchingPairsIntoTheNextTier() local success, _, merged = Crafting.mergeGems(Crafting.gem(Crafting.FIRE, 1), Crafting.gem(Crafting.FIRE, 1)) ok(success, "two matching chip gems merge") eq(merged.tier, 2, "the merged gem is one tier higher") eq(merged.element, Crafting.FIRE, "the merged gem keeps the shared element") eq(merged.power, 2, "a gem's power always matches its tier") local mismatchElement, elementReason = Crafting.mergeGems(Crafting.gem(Crafting.FIRE, 1), Crafting.gem(Crafting.FROST, 1)) eq(mismatchElement, false, "gems of different elements refuse to merge") eq(elementReason, "elements do not match", "the refusal names the reason") local mismatchTier, tierReason = Crafting.mergeGems(Crafting.gem(Crafting.FIRE, 1), Crafting.gem(Crafting.FIRE, 2)) eq(mismatchTier, false, "gems of different tiers refuse to merge") eq(tierReason, "tiers do not match", "the refusal names the reason") local atCap, capReason = Crafting.mergeGems( Crafting.gem(Crafting.FIRE, Crafting.MAX_TIER), Crafting.gem(Crafting.FIRE, Crafting.MAX_TIER)) eq(atCap, false, "a gem already at the maximum tier cannot merge further") eq(capReason, "already at maximum tier", "the refusal names the reason") end function tests.craftingSocketAddsPowerAndSwapsRiderOnResocket() local game = stagedGame(1, arena(21, 11), 3, 6) local weapon = Items.weapon("dagger", 2) Items.equipWeapon(weapon, game.player) local attackBaseline = game.player.attack ok(Crafting.socket(game.player, weapon, Crafting.gem(Crafting.FIRE, 1)), "a gem sockets into an equipped weapon") eq(weapon.power, 3, "the gem's power lands on the item") eq(game.player.attack, attackBaseline + 1, "socketing an equipped weapon updates attack immediately") eq(weapon.socket.element, Crafting.FIRE, "the socket records its element") ok(Crafting.socket(game.player, weapon, Crafting.gem(Crafting.FROST, 2)), "resocketing replaces the previous gem") eq(weapon.power, 4, "power reflects only the new gem, not a stack of both") eq(game.player.attack, attackBaseline + 2, "attack reflects the swap, not an accumulation") eq(weapon.socket.element, Crafting.FROST, "the new gem's element replaces the old one") local success, reason = Crafting.socket(game.player, weapon, { kind = Items.WEAPON }) eq(success, false, "a non-gem refuses to socket") eq(reason, "not a gem", "the refusal names the reason") end function tests.craftingTemperRaisesPowerAtRisingCostUpToACap() local game = stagedGame(1, arena(21, 11), 3, 6) local weapon = Items.weapon("dagger", 2) Items.equipWeapon(weapon, game.player) local attackBaseline = game.player.attack local totalCost = 0 for level = 0, Crafting.TEMPER_MAX - 1 do totalCost = totalCost + (level + 1) * 4 end game.player.essence = totalCost for level = 1, Crafting.TEMPER_MAX do ok(Crafting.temper(game.player, weapon), "temper step " .. level .. " succeeds with enough essence") end eq(weapon.power, 2 + Crafting.TEMPER_MAX, "each temper step adds exactly one flat power") eq(game.player.attack, attackBaseline + Crafting.TEMPER_MAX, "tempering an equipped weapon updates attack immediately") eq(game.player.essence, 0, "the full ladder of rising costs is spent exactly") local success, reason = Crafting.temper(game.player, weapon) eq(success, false, "a fully tempered item refuses a further step") eq(reason, "fully tempered", "the refusal names the reason") end function tests.craftingTemperRefusesWithoutEnoughEssence() local game = stagedGame(1, arena(21, 11), 3, 6) local weapon = Items.weapon("mace", 3) Items.equipWeapon(weapon, game.player) game.player.essence = 3 local success, reason = Crafting.temper(game.player, weapon) eq(success, false, "tempering fails when essence is short of the cost") eq(reason, "not enough essence", "the refusal names the reason") eq(weapon.power, 3, "a failed temper leaves power untouched") eq(game.player.essence, 3, "a failed temper spends nothing") end function tests.craftingEnchantGrantsAChosenAffixOnce() local game = stagedGame(1, arena(21, 11), 3, 6) local weapon = Items.weapon("dagger", 2) Items.equipWeapon(weapon, game.player) local attackBaseline = game.player.attack game.player.essence = Crafting.ENCHANT_COST ok(Crafting.enchant(game.player, weapon, "flaming"), "enchanting an unaffixed weapon succeeds") eq(weapon.affix.key, "flaming", "the chosen affix is attached, not a random roll") eq(weapon.power, 3, "the affix's power lands on the item") eq(weapon.name, "flaming dagger", "the affix prefix is applied to the name") eq(game.player.attack, attackBaseline + 1, "enchanting an equipped weapon updates attack immediately") eq(game.player.essence, 0, "the enchant cost is spent") local repeatSuccess, repeatReason = Crafting.enchant(game.player, weapon, "vicious") eq(repeatSuccess, false, "an already-affixed weapon refuses a second enchant") eq(repeatReason, "already bears an affix", "the refusal names the reason") local badSuccess, badReason = Crafting.enchant(game.player, Items.armor("leather armor", 1), "not-a-real-affix") eq(badSuccess, false, "an unknown affix key is refused") eq(badReason, "unknown affix", "the refusal names the reason") local ringSuccess, ringReason = Crafting.enchant(game.player, Items.ring("ring of power", 1, 1), "sharp") eq(ringSuccess, false, "gear without an affix pool cannot be enchanted") eq(ringReason, "cannot be enchanted", "the refusal names the reason") end function tests.craftingDisenchantStripsAffixAndRefundsEssence() local game = stagedGame(1, arena(21, 11), 3, 6) local armor = Affixes.decorate(Items.armor("chain mail", 2), Affixes.armor[2]) Items.equipArmor(armor, game.player) local defenseBaseline = game.player.defense eq(armor.name, "reinforced chain mail", "sanity check: the drop rolled the expected affix") local success, refund = Crafting.disenchant(game.player, armor) ok(success, "disenchanting an affixed armor succeeds") eq(refund, Crafting.FORGE_COST + 2 * 3, "the refund scales with the stripped affix's power") eq(armor.power, 2, "power drops back to its base value") eq(armor.name, "chain mail", "the affix prefix is removed from the name") eq(armor.affix, nil, "the affix is gone") eq(game.player.defense, defenseBaseline - 2, "disenchanting an equipped armor updates defense immediately") eq(game.player.essence, refund, "the refund lands in the player's essence") local again, reason = Crafting.disenchant(game.player, armor) eq(again, false, "an already-plain item has nothing left to disenchant") eq(reason, "nothing to disenchant", "the refusal names the reason") end function tests.craftingSocketedGemRidersStackWithARolledAffixOnHit() local game = stagedGame(1, arena(21, 11), 3, 6) local weapon = Affixes.decorate(Items.weapon("dagger", 2), Affixes.weapon[4]) Items.equipWeapon(weapon, game.player) ok(Crafting.socket(game.player, weapon, Crafting.gem(Crafting.SHOCK, 1)), "a shock gem sockets alongside the rolled affix") local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) orc.hp, orc.maxHp = 30, 30 game.level.entities = { orc } game:playerAttack(orc) eq(Status.has(orc, Status.POISON), true, "the rolled venomed affix still lands its rider") eq(Status.has(orc, Status.STUN), true, "the socketed shock gem lands its own rider on the same hit") end -- Regression: a shock rider applied during the PLAYER's turn is ticked for -- the first time on the very same round, inside the victim's own upcoming -- Game:takeMonsterTurn (Status.tick runs there before Status.skipsTurn is -- ever checked) - so a duration of 1 would already read 0 and be gone -- before skipsTurn saw it, and the "stun" would silently never do anything. -- This confirms the fix (duration 2, see crafting.lua) actually skips the -- victim's very next turn, not just that the status gets attached. function tests.craftingShockRiderActuallyStunsTheVictimOnItsNextTurnNotJustOnPaper() local game = stagedGame(1, arena(21, 11), 3, 6) local weapon = Items.weapon("dagger", 2) Items.equipWeapon(weapon, game.player) ok(Crafting.socket(game.player, weapon, Crafting.gem(Crafting.SHOCK, 1)), "a bare shock gem sockets onto an unaffixed weapon") local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) orc.hp, orc.maxHp = 30, 30 game.level.entities = { orc } game:playerAttack(orc) eq(Status.has(orc, Status.STUN), true, "the shock rider lands immediately on the hit") local hpBefore, xBefore, yBefore = game.player.hp, orc.x, orc.y game:takeMonsterTurn(orc) eq(game.player.hp, hpBefore, "a stunned orc's very next turn deals no melee damage") eq(orc.x, xBefore, "a stunned orc's very next turn does not move") eq(orc.y, yBefore, "a stunned orc's very next turn does not move") game:takeMonsterTurn(orc) eq(Status.has(orc, Status.STUN), false, "the stun has fully expired by the turn after that") ok(game.player.hp < hpBefore, "once unstunned the orc resumes attacking normally") end function tests.gameCraftingWrappersDriveTheFullPipeline() local game = stagedGame(1, arena(21, 11), 3, 6) local weapon = Items.weapon("dagger", 2) Items.equipWeapon(weapon, game.player) local attackBaseline = game.player.attack game.player.essence = Crafting.FORGE_COST * 2 ok(game:forgeGem(Crafting.FIRE), "forging a gem through the game wrapper succeeds") ok(game:forgeGem(Crafting.FIRE), "forging a second matching gem succeeds") local invBefore = #game.player.inventory local firstGemIndex, secondGemIndex for i, item in ipairs(game.player.inventory) do if item.kind == Items.GEM then if not firstGemIndex then firstGemIndex = i else secondGemIndex = i end end end ok(game:mergeGemsAt(firstGemIndex, secondGemIndex), "merging through the game wrapper succeeds") eq(#game.player.inventory, invBefore - 1, "merging removes both source gems and adds one merged gem") local mergedIndex for i, item in ipairs(game.player.inventory) do if item.kind == Items.GEM then mergedIndex = i end end ok(game:socketItem("weapon", mergedIndex), "socketing the equipped weapon through the game wrapper succeeds") eq(weapon.socket.element, Crafting.FIRE, "the socketed gem's element lands on the weapon") eq(game.player.attack, attackBaseline + 2, "socketing a tier-2 gem adds its power to attack") ok(not game:socketItem("weapon", mergedIndex), "the gem slot is consumed and cannot be socketed twice") game.player.essence = Crafting.temperCost(weapon) ok(game:temperItem("weapon"), "tempering the equipped weapon through the game wrapper succeeds") eq(weapon.power, 2 + 2 + 1, "temper stacks on top of the socket's power") eq(game.player.attack, attackBaseline + 3, "tempering the equipped weapon updates attack immediately") game.player.essence = Crafting.ENCHANT_COST ok(game:enchantItem("weapon", "vicious"), "enchanting the equipped weapon through the game wrapper succeeds") eq(weapon.affix.key, "vicious", "the chosen affix lands on the weapon") local beforeDisenchant = game.player.attack local expectedRefund = Crafting.FORGE_COST + 2 * 3 ok(game:disenchantItem("weapon"), "disenchanting the equipped weapon through the game wrapper succeeds") eq(game.player.attack, beforeDisenchant - 2, "disenchanting removes the affix's power from attack") eq(game.player.essence, expectedRefund, "the disenchant refund lands in essence") ok(contains(game.log:recent(1)[1] or "", "disenchant"), "the disenchant is logged") end function tests.monsterKillsGrantEssenceScaledToTheirXpReward() local game = stagedGame(1, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) rat.hp = 1 game.level.entities = { rat } local essenceBefore = game.player.essence game:playerAttack(rat) eq(game.player.essence, essenceBefore + Crafting.essenceFor(Monsters.xpFor(rat)), "a kill grants essence scaled to the monster's xp reward") end function tests.craftingFieldsSurviveSerialization() local game = Game.new(5) local weapon = Items.weapon("dagger", 2) Items.equipWeapon(weapon, game.player) Crafting.socket(game.player, weapon, Crafting.gem(Crafting.VENOM, 2)) game.player.essence = 37 game.player.inventory[#game.player.inventory + 1] = Crafting.gem(Crafting.SHOCK, 1) local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(restored.player.essence, 37, "essence survives a save/load round trip") eq(restored.player.weapon.socket.element, Crafting.VENOM, "a socketed gem's element survives a save/load round trip") eq(restored.player.weapon.socket.tier, 2, "a socketed gem's tier survives a save/load round trip") local restoredGem for _, item in ipairs(restored.player.inventory) do if item.kind == Items.GEM then restoredGem = item end end ok(restoredGem ~= nil, "a loose gem in the inventory survives a save/load round trip") eq(restoredGem.element, Crafting.SHOCK, "the loose gem's element survives a save/load round trip") end function tests.alchemyBrewResolvesAKnownPairToItsFixedPotion() local potion = Alchemy.brew(Alchemy.SUNPETAL, Alchemy.SUNPETAL) eq(potion.effect, "heal", "a matched pair of the same herb brews that herb's aspect") eq(potion.power, 18, "a doubled herb brews a stronger potion than a mixed pair") end function tests.alchemyBrewIsOrderIndependent() local forward = Alchemy.brew(Alchemy.BLOODROOT, Alchemy.NIGHTCAP) local backward = Alchemy.brew(Alchemy.NIGHTCAP, Alchemy.BLOODROOT) eq(forward.effect, backward.effect, "brewing a pair does not depend on which herb is named first") eq(forward.power, backward.power, "a brewed potion's power does not depend on herb order either") end function tests.alchemyBrewCurdlesOnAnOpposedPairing() local potion, reason = Alchemy.brew(Alchemy.SUNPETAL, Alchemy.NIGHTCAP) eq(potion, nil, "light and shade herbs curdle rather than brewing a potion") eq(reason, "curdle", "a curdled brew names its failure") end function tests.alchemyLookupRejectsAnUnknownHerb() eq(Alchemy.lookup("mud", Alchemy.SUNPETAL), nil, "a nonexistent herb key has no recipe") end function tests.gameBrewAtCombinesTwoHerbsIntoAPotionAtACauldron() local game = stagedGame(1, arena(21, 11), 5, 5) game.level.cauldrons = { { x = 5, y = 5 } } game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL), Alchemy.herb(Alchemy.BLOODROOT) } ok(game:brewAt(1, 2), "brewing two herbs at a cauldron underfoot succeeds") eq(#game.player.inventory, 1, "both source herbs are spent and one potion is added") local brewed = game.player.inventory[1] eq(brewed.kind, Items.POTION, "brewing yields an ordinary catalog potion") eq(brewed.effect, "heal", "the brewed potion matches the herb pair's fixed recipe") eq(brewed.power, 10, "the brewed potion's power matches the recipe, not the catalog default") ok(Identify.isKnown(game.identify, brewed), "brewing a potion yourself identifies its type for free") end function tests.gameBrewAtFailsWithoutACauldronUnderfoot() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL), Alchemy.herb(Alchemy.BLOODROOT) } ok(not game:brewAt(1, 2), "brewing away from any cauldron fails") eq(#game.player.inventory, 2, "a failed brew for lack of a cauldron spends nothing") end function tests.gameBrewAtRequiresTwoHerbItems() local game = stagedGame(1, arena(21, 11), 5, 5) game.level.cauldrons = { { x = 5, y = 5 } } game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL) } ok(not game:brewAt(1, 1), "brewing a herb against itself by index is rejected") game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL), Items.potion(10) } ok(not game:brewAt(1, 2), "a non-herb item cannot stand in for the second half of a brew") eq(#game.player.inventory, 2, "a rejected brew attempt spends nothing") end function tests.gameBrewAtCurdlesIntoGasInsteadOfAPotion() local game = stagedGame(1, arena(21, 11), 5, 5) game.level.cauldrons = { { x = 5, y = 5 } } game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL), Alchemy.herb(Alchemy.NIGHTCAP) } ok(not game:brewAt(1, 2), "a curdled brew reports failure") eq(#game.player.inventory, 0, "a curdled brew still spends both herbs") ok(Hazards.at(game.level, 5, 5, Hazards.GAS) ~= nil, "a curdled brew vents a gas hazard at the brewer's feet") ok(contains(game.log:recent(1)[1] or "", "curdle"), "the curdle is logged") end function tests.buildersScatterHerbsFromTheFirstFloor() local level = Builder.build(1, Rng.new(1), {}) local herbs = 0 for _, ground in ipairs(level.floorItems) do if ground.item.kind == Items.HERB then herbs = herbs + 1 end end ok(herbs >= 2, "the entrance floor already scatters foraging herbs") end function tests.cauldronsStayOffTheEntranceFloor() for seed = 1, 5 do local level = Builder.build(1, Rng.new(seed), {}) eq(#level.cauldrons, 0, "no cauldron appears on the entrance floor") end end function tests.buildersOccasionallyPlaceACauldronFromDepthTwo() local level = Builder.build(2, Rng.new(14), {}) eq(#level.cauldrons, 1, "this seed's depth-2 floor places a cauldron") end function tests.cauldronFieldsSurviveSerialization() local level = arena(21, 11) level.cauldrons = { { x = 4, y = 4 } } local restored = Level.fromData(level:toData()) eq(#restored.cauldrons, 1, "the cauldron list survives a round trip") eq(restored.cauldrons[1].x, 4, "a cauldron's position survives a round trip") end function tests.cauldronRendersItsGlyphWhenVisible() local rendered = stagedGame(1, arena(21, 11), 3, 6) rendered.level.cauldrons = { { x = 5, y = 6 } } rendered:computeVisible() ok(contains(rendered:render(), "U"), "a cauldron on a visible cell renders as its glyph") end local function nodePlayer() return { essence = 0, inventory = {} } end local function nodeGame(seed) local game = { player = nodePlayer(), rng = Rng.new(seed), level = { event = nil }, ascension = {} } function game:essenceMultiplier() return Events.essenceMultiplier(self.level.event) * Ascension.essenceMultiplier(self.ascension) end return game end function tests.nodesHarvestingAnOreVeinPaysEssenceAndSpendsACharge() local node = Nodes.new(Nodes.ORE) local game = nodeGame(3) local success, line = Nodes.harvest(game, node) ok(success, "harvesting a fresh ore vein succeeds") eq(node.charges, Nodes.MAX_CHARGES - 1, "a harvest spends exactly one charge") ok(game.player.essence >= Nodes.ORE_YIELD_MIN and game.player.essence <= Nodes.ORE_YIELD_MAX, "the essence paid out falls within the vein's yield range") ok(contains(line, "essence"), "the harvest is logged") end function tests.nodesHarvestingAnOreVeinDoublesItsYieldDuringRichVeins() local node = Nodes.new(Nodes.ORE) local game = nodeGame(3) game.level.event = { key = Events.RICHVEINS, turnsLeft = 5 } Nodes.harvest(game, node) ok(game.player.essence >= Nodes.ORE_YIELD_MIN * 2 and game.player.essence <= Nodes.ORE_YIELD_MAX * 2, "rich veins doubles the vein's ordinary yield range") end function tests.nodesHarvestingAGemSeamYieldsARawTierOneGemOfItsElement() local node = Nodes.new(Nodes.GEM, Crafting.FIRE) local game = nodeGame(3) local success = Nodes.harvest(game, node) ok(success, "harvesting a fresh gem seam succeeds") eq(#game.player.inventory, 1, "a gem lands in the inventory") local gem = game.player.inventory[1] eq(gem.kind, Items.GEM, "the yield is a gem item") eq(gem.element, Crafting.FIRE, "the gem matches the seam's own element") eq(gem.tier, 1, "a freshly pried gem is raw, tier one") end function tests.nodesGoingFallowRefusesFurtherHarvestsUntilItReopens() local node = Nodes.new(Nodes.ORE) node.charges = 1 local game = nodeGame(9) ok(Nodes.harvest(game, node), "the last charge still pays out") eq(node.charges, 0, "spending the last charge empties the vein") eq(node.cooldown, Nodes.REGEN_TURNS, "emptying a vein starts its regen cooldown") local success, line = Nodes.harvest(game, node) eq(success, false, "a fallow vein refuses to be harvested again") ok(contains(line, "played out"), "the refusal is logged") end function tests.nodesStepCountsDownAndReopensAFallowNode() local level = { nodes = { Nodes.new(Nodes.ORE) } } local node = level.nodes[1] node.charges = 0 node.cooldown = 2 Nodes.step(level) eq(node.cooldown, 1, "each step counts the cooldown down by one") eq(node.charges, 0, "the vein stays fallow until its cooldown reaches zero") Nodes.step(level) eq(node.cooldown, 0, "the cooldown reaches zero on schedule") eq(node.charges, Nodes.MAX_CHARGES, "reaching zero cooldown fully reopens the vein") end function tests.nodesFieldsSurviveSerialization() local level = arena(21, 11) local node = Nodes.new(Nodes.GEM, Crafting.FROST) node.x, node.y = 4, 4 node.charges = 1 node.cooldown = 5 level.nodes = { node } local restored = Level.fromData(level:toData()) eq(#restored.nodes, 1, "the node list survives a round trip") eq(restored.nodes[1].kind, Nodes.GEM, "a node's kind survives a round trip") eq(restored.nodes[1].element, Crafting.FROST, "a node's element survives a round trip") eq(restored.nodes[1].charges, 1, "a node's charges survive a round trip") eq(restored.nodes[1].cooldown, 5, "a node's cooldown survives a round trip") end function tests.nodeRendersItsGlyphWhenVisible() local rendered = stagedGame(1, arena(21, 11), 3, 6) local ore = Nodes.new(Nodes.ORE) ore.x, ore.y = 5, 6 rendered.level.nodes = { ore } rendered:computeVisible() ok(contains(rendered:render(), "O"), "an ore vein on a visible cell renders as its glyph") end function tests.gameHarvestNodeAtAnOreVeinAddsEssenceAndSpendsACharge() local game = stagedGame(1, arena(21, 11), 5, 5) local ore = Nodes.new(Nodes.ORE) ore.x, ore.y = 5, 5 game.level.nodes = { ore } local before = game.player.essence ok(game:harvestNode(), "harvesting the ore vein underfoot succeeds") ok(game.player.essence > before, "harvesting an ore vein raises the player's essence") eq(ore.charges, Nodes.MAX_CHARGES - 1, "harvesting spends one of the vein's charges") end function tests.gameHarvestNodeFailsWithoutANodeUnderfoot() local game = stagedGame(1, arena(21, 11), 5, 5) ok(not game:harvestNode(), "harvesting empty ground fails") end function tests.gameHarvestNodeOnAFallowVeinFails() local game = stagedGame(1, arena(21, 11), 5, 5) local ore = Nodes.new(Nodes.ORE) ore.x, ore.y, ore.charges = 5, 5, 0 game.level.nodes = { ore } ok(not game:harvestNode(), "a fallow vein cannot be harvested") end function tests.autopilotHarvestsAnOreVeinWhenIdleAndStandingOnIt() local game = stagedGame(1, arena(21, 11), 5, 5) local ore = Nodes.new(Nodes.ORE) ore.x, ore.y = 5, 5 game.level.nodes = { ore } local before = game.player.essence game:choosePlayerAction() eq(ore.charges, Nodes.MAX_CHARGES - 1, "the autopilot harvests an unspent vein it stands on") ok(game.player.essence > before, "the autopilot's harvest actually pays out essence") end function tests.autopilotSkipsAFallowNode() local game = stagedGame(1, arena(21, 11), 5, 5) local ore = Nodes.new(Nodes.ORE) ore.x, ore.y, ore.charges = 5, 5, 0 game.level.nodes = { ore } game:choosePlayerAction() eq(ore.charges, 0, "the autopilot never touches a fallow node") end function tests.buildersNeverPlaceANodeOnTheEntranceFloor() for seed = 1, 5 do local level = Builder.build(1, Rng.new(seed), {}) eq(#level.nodes, 0, "no resource node appears on the entrance floor") end end function tests.buildersAlwaysPlaceAtLeastOneNodeFromDepthTwoOn() for seed = 1, 5 do for depth = 2, 5 do local level = Builder.build(depth, Rng.new(seed), {}) ok(#level.nodes >= 1, "every depth-2-and-deeper floor places at least one resource node") ok(#level.nodes <= 2, "no floor places more than two resource nodes") for _, node in ipairs(level.nodes) do ok(node.kind == Nodes.ORE or node.kind == Nodes.GEM, "every placed node is a known kind") end end end end local function corpsePlayer() return { inventory = {}, statuses = {} } end function tests.corpsesSpawnsForAWildMonsterButNotForUndeadMimicOrBoss() local level = { corpses = {} } local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) Corpses.spawn(level, rat) eq(#level.corpses, 1, "a wild monster leaves a corpse behind") local zombie = Monsters.create(Monsters.byKey.zombie, 2, 6, 6, 4) Corpses.spawn(level, zombie) eq(#level.corpses, 1, "an undead monster crumbles to dust, leaving no corpse") local mimic = Mimics.reveal(3, 7, 7, 4) Corpses.spawn(level, mimic) eq(#level.corpses, 1, "a mimic was never alive, so it leaves no corpse") local warlord = Bosses.create(Bosses.byKey.warlord, 4, 8, 8) Corpses.spawn(level, warlord) eq(#level.corpses, 1, "a boss's own scripted loot drop replaces a corpse") end function tests.corpsesNutritionScalesWithTheFelledSpeciesMaxHpClampedToAFixedBand() local bat = Monsters.create(Monsters.byKey.bat, 1, 5, 5, 1) local batCorpse = Corpses.new(bat) eq(batCorpse.nutrition, Corpses.NUTRITION_MIN, "a frail species clamps to the nutrition floor") local troll = Monsters.create(Monsters.byKey.troll, 2, 5, 5, 5) local trollCorpse = Corpses.new(troll) eq(trollCorpse.nutrition, troll.maxHp * Corpses.NUTRITION_PER_HP, "a tougher species scales nutrition with maxHp") local dragon = Monsters.create(Monsters.byKey.dragon, 3, 5, 5, 6) local dragonCorpse = Corpses.new(dragon) eq(dragonCorpse.nutrition, Corpses.NUTRITION_MAX, "an especially tough species clamps to the nutrition ceiling") end function tests.corpsesStepCountsDownAndRotsAwayEntirely() local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) local level = { corpses = {} } Corpses.spawn(level, rat) local corpse = level.corpses[1] for _ = 1, Corpses.DECAY_TURNS - 1 do Corpses.step(level) end eq(#level.corpses, 1, "a corpse survives right up to its final round") eq(corpse.decay, 1, "decay counts down by exactly one each step") Corpses.step(level) eq(#level.corpses, 0, "a corpse vanishes outright once its decay reaches zero") end function tests.corpsesGoesRottenPastItsThresholdButStaysButcherableUntilThen() local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) local corpse = Corpses.new(rat) ok(not Corpses.isRotten(corpse), "a freshly spawned corpse is not yet rotten") corpse.decay = Corpses.ROT_TURNS ok(Corpses.isRotten(corpse), "a corpse right at its rot threshold counts as rotten") corpse.decay = Corpses.ROT_TURNS + 1 ok(not Corpses.isRotten(corpse), "a corpse one round above the threshold is still good") end function tests.corpsesButcheringFreshMeatAddsFoodToInventoryAndConsumesTheCorpse() local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) local level = { corpses = {} } Corpses.spawn(level, rat) local corpse = level.corpses[1] local game = { player = corpsePlayer(), level = level } local line = Corpses.butcher(game, corpse) eq(#level.corpses, 0, "butchering removes the corpse from the level") eq(#game.player.inventory, 1, "butchering fresh meat adds a food item to the inventory") eq(game.player.inventory[1].kind, Items.FOOD, "the yield is a food item") eq(game.player.inventory[1].nutrition, corpse.nutrition, "the food item carries the corpse's own nutrition value") ok(contains(line, "carve"), "a fresh butchering is logged as carving meat") ok(#game.player.statuses == 0, "fresh meat never poisons the player") end function tests.corpsesButcheringRottenMeatPoisonsThePlayerInsteadAndStillConsumesTheCorpse() local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) local level = { corpses = {} } Corpses.spawn(level, rat) local corpse = level.corpses[1] corpse.decay = Corpses.ROT_TURNS local game = { player = corpsePlayer(), level = level } local line = Corpses.butcher(game, corpse) eq(#level.corpses, 0, "butchering removes a rotten corpse too") eq(#game.player.inventory, 0, "rotten meat yields no food item") ok(Status.has(game.player, Status.POISON), "eating rotten meat poisons the player") ok(contains(line, "rotted"), "a rotten butchering is logged as such") end function tests.corpseFieldsSurviveSerialization() local level = arena(21, 11) local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) Corpses.spawn(level, rat) level.corpses[1].x, level.corpses[1].y = 4, 4 local restored = Level.fromData(level:toData()) eq(#restored.corpses, 1, "the corpse list survives a round trip") eq(restored.corpses[1].species, "rat", "a corpse's species survives a round trip") eq(restored.corpses[1].nutrition, level.corpses[1].nutrition, "a corpse's nutrition survives a round trip") eq(restored.corpses[1].decay, level.corpses[1].decay, "a corpse's decay survives a round trip") end function tests.corpseRendersItsGlyphWhenVisible() local rendered = stagedGame(1, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) Corpses.spawn(rendered.level, rat) rendered:computeVisible() ok(contains(rendered:render(), "%"), "a corpse on a visible cell renders as its glyph") end function tests.gameAwardKillLeavesACorpseForAWildMonsterButNotForUndeadOrMimic() local game = stagedGame(1, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) rat.hp = 0 game.level.entities = { rat } game:awardKill(rat) eq(#game.level.corpses, 1, "killing a wild monster through awardKill leaves a corpse") eq(game.level.corpses[1].x, 4, "the corpse sits exactly where the monster fell") local zombie = Monsters.create(Monsters.byKey.zombie, 2, 4, 7, 4) zombie.hp = 0 game.level.entities = { zombie } game:awardKill(zombie) eq(#game.level.corpses, 1, "killing an undead monster through awardKill leaves no new corpse") end function tests.gameButcherCorpseAtFreshMeatFeedsThePlayerAndClearsTheCorpse() local game = stagedGame(1, arena(21, 11), 5, 5) local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) Corpses.spawn(game.level, rat) local before = #game.player.inventory ok(game:butcherCorpse(), "butchering the corpse underfoot succeeds") eq(#game.level.corpses, 0, "the corpse is gone after butchering") eq(#game.player.inventory, before + 1, "the player's inventory gains the carved meat") end function tests.gameButcherCorpseFailsWithoutACorpseUnderfoot() local game = stagedGame(1, arena(21, 11), 5, 5) ok(not game:butcherCorpse(), "butchering empty ground fails") end function tests.autopilotButchersAFreshCorpseWhenIdleAndStandingOnIt() local game = stagedGame(1, arena(21, 11), 5, 5) local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) Corpses.spawn(game.level, rat) local before = #game.player.inventory game:choosePlayerAction() eq(#game.level.corpses, 0, "the autopilot butchers a fresh corpse it stands on") eq(#game.player.inventory, before + 1, "the autopilot's butchering actually yields food") end function tests.autopilotLeavesARottenCorpseAloneRatherThanRiskingPoison() local game = stagedGame(1, arena(21, 11), 5, 5) local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) Corpses.spawn(game.level, rat) game.level.corpses[1].decay = Corpses.ROT_TURNS game:choosePlayerAction() eq(#game.level.corpses, 1, "the autopilot never touches a rotten corpse") ok(not Status.has(game.player, Status.POISON), "the autopilot never risks poisoning itself on rotten meat") end function tests.hungerCurrentDefaultsToCapForAFreshEntity() eq(Hunger.current({}), Hunger.CAP, "an entity with no satiation field reads as fully fed") end function tests.hungerTierOfClassifiesEachBandAtItsBoundaries() eq(Hunger.tierOf({ satiation = Hunger.CAP }), Hunger.FED, "a full meter is fed") eq(Hunger.tierOf({ satiation = Hunger.HUNGRY_MAX + 1 }), Hunger.FED, "just above the hungry ceiling is still fed") eq(Hunger.tierOf({ satiation = Hunger.HUNGRY_MAX }), Hunger.HUNGRY, "the hungry ceiling itself reads as hungry") eq(Hunger.tierOf({ satiation = Hunger.WEAK_MAX + 1 }), Hunger.HUNGRY, "just above the weak ceiling is still hungry") eq(Hunger.tierOf({ satiation = Hunger.WEAK_MAX }), Hunger.WEAK, "the weak ceiling itself reads as weak") eq(Hunger.tierOf({ satiation = 1 }), Hunger.WEAK, "one point above zero is still weak, never starving") eq(Hunger.tierOf({ satiation = 0 }), Hunger.STARVING, "zero itself is starving") end function tests.hungerAttackAndDefensePenaltiesEscalateByTier() eq(Hunger.attackPenalty({ satiation = Hunger.CAP }), 0, "a fed body carries no attack penalty") eq(Hunger.attackPenalty({ satiation = Hunger.HUNGRY_MAX }), 0, "merely hungry still carries no attack penalty") eq(Hunger.attackPenalty({ satiation = Hunger.WEAK_MAX }), -2, "weak costs two points of attack") eq(Hunger.attackPenalty({ satiation = 0 }), -4, "starving costs four points of attack") eq(Hunger.defensePenalty({ satiation = Hunger.CAP }), 0, "a fed body carries no defense penalty") eq(Hunger.defensePenalty({ satiation = Hunger.HUNGRY_MAX }), 0, "merely hungry still carries no defense penalty") eq(Hunger.defensePenalty({ satiation = Hunger.WEAK_MAX }), -1, "weak costs one point of defense") eq(Hunger.defensePenalty({ satiation = 0 }), -3, "starving costs three points of defense") end function tests.entityEffectiveAttackAndDefenseFoldInTheHungerPenalty() local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 4 }) eq(entity:effectiveAttack(), 6, "a fresh entity with no satiation field carries no penalty") entity.satiation = 0 eq(entity:effectiveAttack(), 2, "a starving body's attack penalty is live in effectiveAttack") eq(entity:effectiveDefense(), 1, "a starving body's defense penalty is live in effectiveDefense") end function tests.hungerSuppressesRegenOnlyAtWeakOrWorse() ok(not Hunger.suppressesRegen({ satiation = Hunger.HUNGRY_MAX }), "merely hungry never blocks passive regen") ok(Hunger.suppressesRegen({ satiation = Hunger.WEAK_MAX }), "weak blocks passive regen") ok(Hunger.suppressesRegen({ satiation = 0 }), "starving blocks passive regen") end function tests.hungerTickDrainsByOneAndLogsOnlyOnATierCrossing() local entity = { satiation = Hunger.HUNGRY_MAX + 2, hp = 20 } eq(Hunger.tick(entity), nil, "a drain turn that stays within its tier reports no line") eq(entity.satiation, Hunger.HUNGRY_MAX + 1, "the drain was still deducted even without a tier crossing") eq(Hunger.tick(entity), "you feel hungry", "crossing down into hungry is announced") eq(entity.satiation, Hunger.HUNGRY_MAX, "the meter keeps draining turn over turn") eq(entity.hp, 20, "hunger alone never costs hp above starving") end function tests.hungerTickBitesForOneHpEveryRoundWhileStarving() local entity = { satiation = 1, hp = 10 } eq(Hunger.tick(entity), "you are starving", "crossing down into starving is announced") eq(entity.satiation, 0, "starving is a floor, not a negative value") eq(entity.hp, 9, "reaching starving costs a point of hp immediately") eq(Hunger.tick(entity), "you are starving", "starving keeps announcing and biting every round it holds") eq(entity.hp, 8, "a second starving round costs a second point of hp") end function tests.hungerEatCapsAtTheCeilingAndWastesAnOverfullMealIntoBloat() local entity = { satiation = Hunger.CAP, statuses = {} } local bloated = Hunger.eat(entity, 400) ok(bloated, "eating while already at the cap reports the meal as wasted") eq(entity.satiation, Hunger.CAP, "an overfull meal never lifts satiation past the cap") ok(Status.has(entity, Status.SLOW), "an overfull meal leaves the diner slowed") end function tests.hungerEatBelowTheCeilingBanksNutritionWithNoWaste() local entity = { satiation = Hunger.HUNGRY_MAX, statuses = {} } local bloated = Hunger.eat(entity, 100) ok(not bloated, "an ordinary meal below the cap is never wasted") eq(entity.satiation, Hunger.HUNGRY_MAX + 100, "the full nutrition value is banked") ok(not Status.has(entity, Status.SLOW), "an ordinary meal never bloats the diner") end function tests.hungerEatClampsAMealThatWouldOverflowPastTheCapWithoutBloating() local entity = { satiation = Hunger.CAP - 50, statuses = {} } local bloated = Hunger.eat(entity, 400) ok(not bloated, "a meal eaten while still short of the cap is never wasted, even if it overflows") eq(entity.satiation, Hunger.CAP, "the surplus past the cap is simply clamped away") ok(not Status.has(entity, Status.SLOW), "clamping alone never bloats the diner") end function tests.gameHungerTorchTickSuppressesRingRegenOnceThePlayerIsWeak() local game = Game.new(1) Items.equipRing(Artifacts.create("ring_of_the_tide"), game.player) game.player.hp = game.player.maxHp - 10 game.player.satiation = Hunger.WEAK_MAX local before = game.player.hp game:hungerTorchTick() eq(game.player.hp, before, "a weak player's ring of the tide grants no regen this turn") end function tests.gameUseItemEatingFoodRestoresSatiationAndLogsTheMeal() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.satiation = 200 game.player.inventory = { Items.food("ration", 400) } ok(game:useItem(1), "eating a carried ration succeeds") eq(game.player.satiation, 600, "the ration's full nutrition is banked") eq(#game.player.inventory, 0, "the eaten ration leaves the inventory") ok(contains(game.log:last(), "eat"), "the meal is logged") end function tests.gameUseItemEatingFoodWhileAlreadyFullAppliesSlowAndLogsBloat() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.satiation = Hunger.CAP game.player.inventory = { Items.food("ration", 400) } ok(game:useItem(1), "eating while already full still succeeds") eq(game.player.satiation, Hunger.CAP, "an overfull meal never lifts satiation past the cap") ok(Status.has(game.player, Status.SLOW), "eating past the cap leaves the player bloated and slow") ok(contains(game.log:last(), "bloated"), "the wasted meal is logged as bloating") end function tests.reanimationRaiseCreatesAThrallAdjacentToThePlayerAndSpendsTheCorpseAndSanity() local game = stagedGame(1, arena(21, 11), 5, 5) local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) Corpses.spawn(game.level, rat) local corpse = game.level.corpses[1] local sanityBefore = game.player.sanity local thrall = Reanimation.raise(game, corpse) ok(thrall ~= nil, "raising a fresh corpse succeeds") eq(thrall.kind, "thrall", "a raised thrall is tagged with the thrall kind") eq(math.max(math.abs(thrall.x - 5), math.abs(thrall.y - 5)), 1, "the thrall rises adjacent to the player") eq(#game.level.corpses, 0, "raising the corpse consumes it") eq(game.player.sanity, sanityBefore - Reanimation.SANITY_COST, "raising a thrall costs a fixed bite of sanity") end function tests.reanimationRaiseRefusesARottenCorpseWithoutSpendingAnything() local game = stagedGame(1, arena(21, 11), 5, 5) local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) Corpses.spawn(game.level, rat) game.level.corpses[1].decay = Corpses.ROT_TURNS local sanityBefore = game.player.sanity local thrall, line = Reanimation.raise(game, game.level.corpses[1]) eq(thrall, nil, "a rotten corpse cannot be raised") eq(#game.level.corpses, 1, "a refused raise leaves the corpse in place") eq(game.player.sanity, sanityBefore, "a refused raise spends no sanity") ok(contains(line, "rotted"), "the refusal explains the corpse has rotted too far") end function tests.reanimationRaiseRefusesWithoutEnoughSanityAndLeavesTheCorpseAlone() local game = stagedGame(1, arena(21, 11), 5, 5) local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) Corpses.spawn(game.level, rat) game.player.sanity = Reanimation.SANITY_COST - 1 local thrall, line = Reanimation.raise(game, game.level.corpses[1]) eq(thrall, nil, "raising fails when sanity is too low to pay the cost") eq(#game.level.corpses, 1, "a refused raise for lack of sanity leaves the corpse untouched") ok(contains(line, "mind"), "the refusal blames the player's own frail grip on their mind") end function tests.reanimationRaiseScalesThrallStatsDownFromTheOriginSpecies() local game = stagedGame(1, arena(21, 11), 5, 5) local troll = Monsters.create(Monsters.byKey.troll, 1, 5, 5, 5) Corpses.spawn(game.level, troll) local thrall = Reanimation.raise(game, game.level.corpses[1]) eq(thrall.hp, math.floor(Monsters.byKey.troll.hp * Reanimation.HP_FRACTION), "a thrall's hp is a fixed fraction of the living species' own hp") eq(thrall.attack, Monsters.byKey.troll.attack - Reanimation.ATTACK_PENALTY, "a thrall's attack is blunted by a fixed amount from the living species") eq(thrall.defense, Monsters.byKey.troll.defense, "a thrall keeps the living species' own defense") end function tests.reanimationRaiseFallsBackToAPlainSkeletonForAnUnrecognisedSpecies() local game = stagedGame(1, arena(21, 11), 5, 5) game.level.corpses[1] = { x = 5, y = 5, species = "unknown-thing", name = "unknown-thing", decay = Corpses.DECAY_TURNS } local thrall = Reanimation.raise(game, game.level.corpses[1]) ok(thrall ~= nil, "a corpse of an unrecognised species can still be raised") eq(thrall.hp, 8, "an unrecognised species falls back to a flat skeleton hp") eq(thrall.attack, 2, "an unrecognised species falls back to a flat skeleton attack") end function tests.reanimationRaiseRetiresAnEarlierThrallWhenRaisingAnother() local game = stagedGame(1, arena(21, 11), 5, 5) local rat1 = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) Corpses.spawn(game.level, rat1) local first = Reanimation.raise(game, game.level.corpses[1]) local rat2 = Monsters.create(Monsters.byKey.rat, 2, 6, 5, 1) Corpses.spawn(game.level, rat2) local second = Reanimation.raise(game, game.level.corpses[1]) ok(not first:isAlive(), "raising a new thrall retires the earlier one") ok(second:isAlive(), "the freshly raised thrall is alive") local alive = 0 for _, e in ipairs(game.level.entities) do if e.kind == "thrall" and e:isAlive() then alive = alive + 1 end end eq(alive, 1, "only one thrall is ever alive at a time") end function tests.reanimationActAttacksAnAdjacentHostileMonsterAndCanKillIt() local game = stagedGame(1, arena(21, 11), 5, 5) local chieftain = Monsters.create(Monsters.byKey.chieftain, 1, 5, 5, 6) Corpses.spawn(game.level, chieftain) local thrall = Reanimation.raise(game, game.level.corpses[1]) local rat = Monsters.create(Monsters.byKey.rat, 50, thrall.x + 1, thrall.y, 1) game.level.entities[#game.level.entities + 1] = rat local xpBefore = game.player.xp Reanimation.act(game, thrall) eq(rat.hp, 0, "a thrall's hit kills the weak rat standing next to it") ok(game.player.xp > xpBefore, "the player is credited xp for its thrall's kill") end function tests.reanimationActAdvancesOnAVisibleMonsterItCannotYetReach() local game = stagedGame(1, arena(21, 11), 5, 5) local rat0 = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) Corpses.spawn(game.level, rat0) local thrall = Reanimation.raise(game, game.level.corpses[1]) local orc = Monsters.create(Monsters.byKey.orc, 50, thrall.x + 5, thrall.y, 1) game.level.entities[#game.level.entities + 1] = orc local startX = thrall.x Reanimation.act(game, thrall) ok(thrall.x > startX, "the thrall closes the distance to a monster it can see but not reach") end function tests.reanimationActIgnoresAMonsterBeyondItsSightRadius() local game = stagedGame(1, arena(21, 11), 3, 3) local rat0 = Monsters.create(Monsters.byKey.rat, 1, 3, 3, 1) Corpses.spawn(game.level, rat0) local thrall = Reanimation.raise(game, game.level.corpses[1]) local orc = Monsters.create(Monsters.byKey.orc, 50, 19, 9, 1) game.level.entities[#game.level.entities + 1] = orc local startX, startY = thrall.x, thrall.y Reanimation.act(game, thrall) eq(thrall.x, startX, "a monster far outside sight range does not pull the thrall off course (x)") eq(thrall.y, startY, "a monster far outside sight range does not pull the thrall off course (y)") end -- Same gap as companionActBlindedCompanionOnlyDetectsAnAdjacentHostileNot -- OneWithinItsSightRadius, but for Reanimation.act's own nearestHostile. function tests.reanimationActBlindedThrallOnlyDetectsAnAdjacentHostileNotOneWithinItsSightRadius() local game = stagedGame(1, arena(21, 11), 5, 5) local rat0 = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) Corpses.spawn(game.level, rat0) local thrall = Reanimation.raise(game, game.level.corpses[1]) local farOrc = Monsters.create(Monsters.byKey.orc, 50, thrall.x + 5, thrall.y, 1) game.level.entities[#game.level.entities + 1] = farOrc Status.apply(thrall, Status.BLIND, 6, 1) local startX = thrall.x Reanimation.act(game, thrall) eq(thrall.x, startX, "a blinded thrall loses a sighting well within its listed sight radius") local rat = Monsters.create(Monsters.byKey.rat, 51, thrall.x + 1, thrall.y, 1) game.level.entities[#game.level.entities + 1] = rat local ratHpBefore = rat.hp Reanimation.act(game, thrall) ok(rat.hp < ratHpBefore, "a blinded thrall still fights a hostile once it stands adjacent") end -- Same gap as companionActAttackingAnAdjacentMonsterEmitsNoise..., mirrored -- onto the thrall side. Fresh game per seed rather than a shared one both to -- match that sibling test's shape and because Reanimation.act decrements -- thrall.decay on every call, so a single thrall could not absorb 200 calls -- regardless. function tests.reanimationActAttackingAnAdjacentMonsterEmitsNoiseThatCanStirADormantBystander() local stirred = false for seed = 1, 200 do local game = stagedGame(seed, arena(21, 11), 3, 6) local corpseSource = Monsters.create(Monsters.byKey.rat, 40, game.player.x + 1, game.player.y, 1) Corpses.spawn(game.level, corpseSource) local thrall = Reanimation.raise(game, game.level.corpses[1]) local target = Monsters.create(Monsters.byKey.rat, 50, thrall.x + 1, thrall.y, 1) local bystander = Monsters.create(Monsters.byKey.orc, 51, thrall.x + 4, thrall.y, 1) bystander.awareness = Stealth.DORMANT game.level.entities[#game.level.entities + 1] = target game.level.entities[#game.level.entities + 1] = bystander Reanimation.act(game, thrall) if bystander.awareness ~= Stealth.DORMANT then stirred = true; break end end ok(stirred, "a thrall's own melee is exactly as loud as any other ally's or the player's") end function tests.reanimationActFollowsThePlayerWhenNoMonsterIsVisible() local game = stagedGame(1, arena(21, 11), 10, 5) local rat0 = Monsters.create(Monsters.byKey.rat, 1, 10, 5, 1) Corpses.spawn(game.level, rat0) local thrall = Reanimation.raise(game, game.level.corpses[1]) thrall.x, thrall.y = 3, 5 local startDist = math.abs(thrall.x - game.player.x) Reanimation.act(game, thrall) ok(math.abs(thrall.x - game.player.x) < startDist, "a thrall with no monster in sight closes in on the player") end -- Same wiring proof as the companion/hireling versions above, but for -- Reanimation.act - placed after its own decay tick/death check (upkeep -- the confusion roll must never skip), just before the behaviour it can -- override. function tests.reanimationActConfusedThrallSometimesStumblesInsteadOfFightingAnAdjacentHostile() local sawAttack, sawStumble = false, false for seed = 1, 200 do local game = stagedGame(seed, arena(21, 11), 3, 6) local rat0 = Monsters.create(Monsters.byKey.rat, 1, 3, 6, 1) Corpses.spawn(game.level, rat0) local thrall = Reanimation.raise(game, game.level.corpses[1]) local rat = Monsters.create(Monsters.byKey.rat, 50, thrall.x + 1, thrall.y, 1) game.level.entities[#game.level.entities + 1] = rat Status.apply(thrall, Status.CONFUSE, 5, 1) local hpBefore = rat.hp Reanimation.act(game, thrall) if rat.hp < hpBefore then sawAttack = true else sawStumble = true end if sawAttack and sawStumble then break end end ok(sawAttack, "a confused thrall's roll sometimes lets it fight an adjacent hostile as normal") ok(sawStumble, "a confused thrall's roll sometimes overrides even a point-blank attack") end function tests.reanimationActCollapsesOnceItsBorrowedAnimationRunsOut() local game = stagedGame(1, arena(21, 11), 5, 5) local rat0 = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) Corpses.spawn(game.level, rat0) local thrall = Reanimation.raise(game, game.level.corpses[1]) thrall.decay = 1 game:takeMonsterTurn(thrall) eq(#game.level.entities, 0, "a thrall whose animation runs out is swept from the level") ok(contains(game.log:last(), "crumbles"), "a collapsing thrall's fate is logged") end function tests.takeMonsterTurnRoutesAThrallThroughReanimationAct() local game = stagedGame(1, arena(21, 11), 5, 5) local chieftain = Monsters.create(Monsters.byKey.chieftain, 1, 5, 5, 6) Corpses.spawn(game.level, chieftain) local thrall = Reanimation.raise(game, game.level.corpses[1]) local rat = Monsters.create(Monsters.byKey.rat, 50, thrall.x + 1, thrall.y, 1) game.level.entities[#game.level.entities + 1] = rat game:takeMonsterTurn(thrall) eq(rat.hp, 0, "the full monster-turn pipeline dispatches a thrall's turn to reanimation.act") end function tests.gameRaiseCorpseSucceedsAndLogsTheCallAnswered() local game = stagedGame(1, arena(21, 11), 5, 5) local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) Corpses.spawn(game.level, rat) ok(game:raiseCorpse(), "raising the corpse underfoot succeeds") eq(#game.level.corpses, 0, "the corpse is gone after raising") local hasThrall = false for _, e in ipairs(game.level.entities) do if e.kind == "thrall" then hasThrall = true end end ok(hasThrall, "a thrall joins the level once raised through the game action") ok(contains(game.log:last(), "answers the call"), "a successful raise is logged") end function tests.gameRaiseCorpseFailsWithoutACorpseUnderfoot() local game = stagedGame(1, arena(21, 11), 5, 5) ok(not game:raiseCorpse(), "raising empty ground fails") ok(contains(game.log:last(), "nothing here"), "the failure is logged") end function tests.scavengingHerbForMapsEachWildFactionToItsOwnFixedHerb() eq(Scavenging.herbFor({ species = "rat" }), Alchemy.NIGHTCAP, "vermin always yields nightcap") eq(Scavenging.herbFor({ species = "goblin" }), Alchemy.BLOODROOT, "goblinoids always yield bloodroot") eq(Scavenging.herbFor({ species = "troll" }), Alchemy.EMBERLEAF, "beasts always yield emberleaf") eq(Scavenging.herbFor({ species = "dragon" }), Alchemy.EMBERLEAF, "drakes yield the same herb as any other beast-aspected foe") end function tests.scavengingHerbForReturnsNilForACorpseWithNoBestiaryEntry() eq(Scavenging.herbFor({ species = "hound" }), nil, "a bonded companion's own remains carry no bestiary-mapped herb") end function tests.gameScavengeCorpseCarvesTheMatchingHerbAndClearsTheCorpse() local game = stagedGame(1, arena(21, 11), 5, 5) local goblin = Monsters.create(Monsters.byKey.goblin, 1, 5, 5, 2) Corpses.spawn(game.level, goblin) local before = #game.player.inventory ok(game:scavengeCorpse(), "scavenging the corpse underfoot succeeds") eq(#game.level.corpses, 0, "the corpse is gone after scavenging") eq(#game.player.inventory, before + 1, "the player's inventory gains the carved herb") local herb = game.player.inventory[#game.player.inventory] eq(herb.herb, Alchemy.BLOODROOT, "the carved herb matches the goblin's own goblinoid aspect") ok(contains(game.log:last(), "carve"), "a successful scavenge is logged") end function tests.gameScavengeCorpseFailsWithoutACorpseUnderfoot() local game = stagedGame(1, arena(21, 11), 5, 5) ok(not game:scavengeCorpse(), "scavenging empty ground fails") ok(contains(game.log:last(), "nothing here"), "the failure is logged") end function tests.gameScavengeCorpseFailsOnARottenCorpse() local game = stagedGame(1, arena(21, 11), 5, 5) local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) Corpses.spawn(game.level, rat) game.level.corpses[1].decay = Corpses.ROT_TURNS local before = #game.player.inventory ok(not game:scavengeCorpse(), "a rotten corpse yields no herb") eq(#game.level.corpses, 1, "a failed scavenge leaves the corpse in place rather than spending it") eq(#game.player.inventory, before, "no herb is added on a failed scavenge") end function tests.gameScavengeCorpseFailsForASpeciesWithNoKnownHerb() local game = stagedGame(1, arena(21, 11), 5, 5) game.level.corpses = { { x = 5, y = 5, species = "hound", name = "hound", nutrition = 40, decay = Corpses.DECAY_TURNS } } ok(not game:scavengeCorpse(), "a companion's own remains carry nothing worth carving") eq(#game.level.corpses, 1, "a failed scavenge leaves the corpse in place") end function tests.reanimationFieldsSurviveSerialization() local game = stagedGame(1, arena(21, 11), 5, 5) local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) Corpses.spawn(game.level, rat) local thrall = Reanimation.raise(game, game.level.corpses[1]) thrall.decay = 17 local restoredLevel = Level.fromData(game.level:toData()) eq(#restoredLevel.entities, 1, "the thrall survives a level round trip") eq(restoredLevel.entities[1].kind, "thrall", "the thrall's kind survives a round trip") eq(restoredLevel.entities[1].decay, 17, "the thrall's remaining animation survives a round trip") end function tests.herbItemsSurviveSerialization() local game = Game.new(5) game.player.inventory[#game.player.inventory + 1] = Alchemy.herb(Alchemy.NIGHTCAP) local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) local restoredHerb for _, item in ipairs(restored.player.inventory) do if item.kind == Items.HERB then restoredHerb = item end end ok(restoredHerb ~= nil, "a foraged herb survives a save/load round trip") eq(restoredHerb.herb, Alchemy.NIGHTCAP, "a herb's species survives a save/load round trip") end function tests.potionOfFortificationAppliesAShield() local game = Game.new(31) Effects.apply("fortify", game, game.player, {}) ok(Status.has(game.player, Status.SHIELD), "a fortify effect wards the drinker") end function tests.usingAFortifyPotionAppliesShieldAndConsumesIt() local game = stagedGame(1, arena(21, 11), 5, 5) local potion = Items.potionOf("fortify") game.player.inventory = { potion } ok(game:useItem(1), "drinking a fortify potion succeeds") eq(#game.player.inventory, 0, "drinking the potion consumes it") ok(Status.has(game.player, Status.SHIELD), "the fortify potion wards the player") end function tests.potionOfLevitationAppliesTheLevitateStatus() local game = Game.new(31) Effects.apply("levitate", game, game.player, {}) ok(Status.has(game.player, Status.LEVITATE), "a levitate effect lifts the drinker off the ground") end function tests.usingAPotionOfLevitationAppliesLevitateAndConsumesIt() local game = stagedGame(1, arena(21, 11), 5, 5) local potion = Items.potionOf("levitate") game.player.inventory = { potion } ok(game:useItem(1), "drinking a levitation potion succeeds") eq(#game.player.inventory, 0, "drinking the potion consumes it") ok(Status.has(game.player, Status.LEVITATE), "the levitation potion lifts the player off the ground") end function tests.companionSummonPlacesItAdjacentToThePlayer() local game = stagedGame(1, arena(21, 11), 5, 5) local hound = Companions.summon(game, "hound") ok(hound ~= nil, "summoning a valid species succeeds next to the player") eq(hound.kind, "companion", "a summoned companion is tagged with the companion kind") eq(math.max(math.abs(hound.x - 5), math.abs(hound.y - 5)), 1, "the companion lands adjacent to the player") eq(#game.level.entities, 1, "the companion joins the level's entity list") end function tests.companionSummonRejectsAnUnknownSpecies() local game = stagedGame(1, arena(21, 11), 5, 5) eq(Companions.summon(game, "wyrm"), nil, "an unknown species key summons nothing") eq(#game.level.entities, 0, "no entity is added for an unknown species") end function tests.companionSummonReplacesAnEarlierBond() local game = stagedGame(1, arena(21, 11), 5, 5) local first = Companions.summon(game, "hound") local second = Companions.summon(game, "golem") ok(not first:isAlive(), "bonding a new companion retires the earlier one") ok(second:isAlive(), "the freshly bonded companion is alive") local alive = 0 for _, e in ipairs(game.level.entities) do if e.kind == "companion" and e:isAlive() then alive = alive + 1 end end eq(alive, 1, "only one companion is ever alive at a time") end function tests.companionAttacksAdjacentHostileMonsterAndCanKillIt() local game = stagedGame(1, arena(21, 11), 5, 5) local hound = Companions.summon(game, "hound") local rat = Monsters.create(Monsters.byKey.rat, 50, hound.x + 1, hound.y, 1) game.level.entities[#game.level.entities + 1] = rat local xpBefore = game.player.xp Companions.act(game, hound) eq(rat.hp, 0, "a hound's hit kills the weak rat standing next to it") ok(game.player.xp > xpBefore, "the player is credited xp for its companion's kill") end -- Before this fix, companions.lua's own "adjacent" helper was Manhattan-1 -- (cardinal only) while mercenaries.lua's and reanimation.lua's were -- Chebyshev-1 (8-directional) despite both explicitly claiming to mirror -- Companions.act's "same three-branch shape" - a hound diagonally next to a -- monster fell through to stepMonsterToward instead of attacking, unlike a -- hireling or thrall caught in the identical geometry. function tests.companionAttacksADiagonallyAdjacentHostileMonsterJustLikeACardinalOne() local game = stagedGame(1, arena(21, 11), 5, 5) local hound = Companions.summon(game, "hound") local rat = Monsters.create(Monsters.byKey.rat, 50, hound.x + 1, hound.y + 1, 1) game.level.entities[#game.level.entities + 1] = rat Companions.act(game, hound) eq(rat.hp, 0, "a hound's hit kills a weak rat standing diagonally next to it, not just cardinally") end function tests.companionAdvancesOnAVisibleMonsterItCannotYetReach() local game = stagedGame(1, arena(21, 11), 5, 5) local hound = Companions.summon(game, "hound") local orc = Monsters.create(Monsters.byKey.orc, 50, hound.x + 5, hound.y, 1) game.level.entities[#game.level.entities + 1] = orc local startX = hound.x Companions.act(game, hound) ok(hound.x > startX, "the companion closes the distance to a monster it can see but not reach") end function tests.companionIgnoresAMonsterBeyondItsSightRadius() local game = stagedGame(1, arena(21, 11), 3, 3) local hound = Companions.summon(game, "hound") local orc = Monsters.create(Monsters.byKey.orc, 50, 19, 9, 1) game.level.entities[#game.level.entities + 1] = orc local startX, startY = hound.x, hound.y Companions.act(game, hound) eq(hound.x, startX, "a monster far outside sight range does not pull the companion off course (x)") eq(hound.y, startY, "a monster far outside sight range does not pull the companion off course (y)") end -- Before Companions.act's own nearestHostile clamped its sight to melee -- range under Status.BLIND, a blinded companion kept its full listed sight -- radius - the same gap Game:canSeePlayer's own clamp already closed for a -- blinded hostile monster (canSeePlayerBlindedMonsterOnlyDetectsThePlayer -- AtMeleeRange), just never mirrored onto the ally side. function tests.companionActBlindedCompanionOnlyDetectsAnAdjacentHostileNotOneWithinItsSightRadius() local game = stagedGame(1, arena(21, 11), 5, 5) local hound = Companions.summon(game, "hound") local farOrc = Monsters.create(Monsters.byKey.orc, 50, hound.x + 5, hound.y, 1) game.level.entities[#game.level.entities + 1] = farOrc Status.apply(hound, Status.BLIND, 6, 1) local startX = hound.x Companions.act(game, hound) eq(hound.x, startX, "a blinded companion loses a sighting well within its listed sight radius") local rat = Monsters.create(Monsters.byKey.rat, 51, hound.x + 1, hound.y, 1) game.level.entities[#game.level.entities + 1] = rat local ratHpBefore = rat.hp Companions.act(game, hound) ok(rat.hp < ratHpBefore, "a blinded companion still fights a hostile once it stands adjacent") end -- Companions.act called Combat.attack directly without ever routing through -- Game:emitNoise, unlike Game:playerAttack/Game:monsterMelee which both do -- (see gamePlayerAttackCanStirADormantBystanderThatIsNotEvenTheTarget and -- gameMonsterMeleeCanStirADormantBystanderTooNotJustPlayerActions) - a -- companion's own melee was silent to every dormant monster nearby, no -- matter how loud its species' attack would be for the player or a hostile. -- Fresh game per seed (unlike those two tests' single reused game) since a -- companion attack, unlike a repeatable playerAttack/monsterMelee call, can -- kill its one-hp-pool rat target outright. function tests.companionActAttackingAnAdjacentMonsterEmitsNoiseThatCanStirADormantBystander() local stirred = false for seed = 1, 200 do local game = stagedGame(seed, arena(21, 11), 3, 6) local hound = Companions.summon(game, "hound") local target = Monsters.create(Monsters.byKey.rat, 50, hound.x + 1, hound.y, 1) local bystander = Monsters.create(Monsters.byKey.orc, 51, hound.x + 4, hound.y, 1) bystander.awareness = Stealth.DORMANT game.level.entities[#game.level.entities + 1] = target game.level.entities[#game.level.entities + 1] = bystander Companions.act(game, hound) if bystander.awareness ~= Stealth.DORMANT then stirred = true; break end end ok(stirred, "a companion's own melee is exactly as loud as the player's or a hostile monster's") end function tests.companionFollowsThePlayerWhenNoMonsterIsVisible() local game = stagedGame(1, arena(21, 11), 10, 5) local golem = Companions.summon(game, "golem") golem.x, golem.y = 3, 5 local startDist = math.abs(golem.x - game.player.x) Companions.act(game, golem) ok(math.abs(golem.x - game.player.x) < startDist, "a companion with no monster in sight closes in on the player") end -- Companions.act never dispatches through ai.lua, so without Game:allyActs -- Confused wired in first it would leave Status.CONFUSE completely inert on -- a companion despite Game:springTrap already applying it to "any victim". -- Mirrors tests.aiActConfusedMonsterSometimesStumblesInsteadOfChasing's own -- loop-over-seeds shape, but for a point-blank melee decision instead of a -- chase step. function tests.companionActConfusedCompanionSometimesStumblesInsteadOfFightingAnAdjacentHostile() local sawAttack, sawStumble = false, false for seed = 1, 200 do local game = stagedGame(seed, arena(21, 11), 3, 6) local golem = Companions.summon(game, "golem") local rat = Monsters.create(Monsters.byKey.rat, 50, golem.x + 1, golem.y, 1) game.level.entities[#game.level.entities + 1] = rat Status.apply(golem, Status.CONFUSE, 5, 1) local hpBefore = rat.hp Companions.act(game, golem) if rat.hp < hpBefore then sawAttack = true else sawStumble = true end if sawAttack and sawStumble then break end end ok(sawAttack, "a confused companion's roll sometimes lets it fight an adjacent hostile as normal") ok(sawStumble, "a confused companion's roll sometimes overrides even a point-blank attack") end -- Unlike a confused monster's stumble (Ai.act -> Game:moveMonster, which -- treats a step onto the player's cell as an attack), a confused companion -- must never land a hit on the very player it is bonded to defend - the -- companion spawns due east of the player (Companions.summon's own -- ADJACENT order), so a westward stumble roll aims exactly at the player's -- cell across this seed range, and the player's hp must stay untouched -- every single time regardless of which of the 4 directions actually rolls. function tests.companionActConfusedCompanionNeverAttacksThePlayerEvenIfTheStumbleAimsAtItsCell() local sawMoved = false for seed = 1, 300 do local game = stagedGame(seed, arena(21, 11), 5, 5) local golem = Companions.summon(game, "golem") Status.apply(golem, Status.CONFUSE, 5, 1) local hpBefore = game.player.hp Companions.act(game, golem) eq(game.player.hp, hpBefore, "a confused companion never attacks the player even when its stumble aims at their cell") ok(not (golem.x == game.player.x and golem.y == game.player.y), "a confused companion's stumble is blocked, not allowed to share the player's own cell") if golem.x ~= 6 or golem.y ~= 5 then sawMoved = true end end ok(sawMoved, "the confused companion's stumble roll actually moves it somewhere across many seeds") end function tests.hearthSpriteMendsThePlayerWhenIdle() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.hp = game.player.maxHp - 5 local sprite = Companions.summon(game, "sprite") local before = game.player.hp Companions.act(game, sprite) eq(game.player.hp, before + Companions.byKey.sprite.heal, "a hearth sprite mends its bonded player each idle turn") end function tests.useItemOnACharmBondsACompanionAndConsumesIt() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.inventory = { Items.charm("hound") } ok(game:useItem(1), "using a charm succeeds") eq(#game.player.inventory, 0, "the charm leaves the inventory once bonded") eq(#game.level.entities, 1, "the bonded hound is added to the level") eq(game.level.entities[1].species, "hound", "the bonded companion matches the charm's species") end function tests.takeMonsterTurnRoutesACompanionThroughCompanionsAct() local game = stagedGame(1, arena(21, 11), 5, 5) local hound = Companions.summon(game, "hound") local rat = Monsters.create(Monsters.byKey.rat, 50, hound.x + 1, hound.y, 1) game.level.entities[#game.level.entities + 1] = rat game:takeMonsterTurn(hound) eq(rat.hp, 0, "the full monster-turn pipeline dispatches a companion's turn to companions.act") end -- Game:hungerTorchTick already ticks Disease.tick(self.player) every player -- turn; Game:takeMonsterTurn never called the same generic, entity-agnostic -- function for anyone else, so an ally infected through the new -- infighting.lua bite chance above would sit permanently incubating - -- never erupting, never escalating, never curable by anything that reads -- disease state - since nothing ever advanced its clock. function tests.gameTakeMonsterTurnTicksAnInfectedCompanionsDiseaseExactlyLikeThePlayersOwnEndOfTurnDoes() local game = stagedGame(1, arena(21, 11), 5, 5) local hound = Companions.summon(game, "hound") hound.hp, hound.maxHp = 1000, 1000 Disease.infect(hound, Disease.PARASITES) local incubation = Disease.defs[Disease.PARASITES].incubation for _ = 1, incubation - 1 do game:takeMonsterTurn(hound) eq(hound.disease.stage, 0, "the companion's infection keeps quietly incubating turn by turn") end game:takeMonsterTurn(hound) eq(hound.disease.stage, 1, "the companion's infection erupts on schedule, exactly like the player's own would") ok(contains(game.log:last(), "breaks out in gut parasites"), "the eruption is logged with the companion's own name") end function tests.aFallenCompanionIsLoggedAndSweptWithoutAwardingXp() local game = stagedGame(1, arena(21, 11), 5, 5) local hound = Companions.summon(game, "hound") hound.hp = 1 Status.apply(hound, Status.POISON, 1, 5) local xpBefore = game.player.xp game:takeMonsterTurn(hound) eq(#game.level.entities, 0, "a companion that dies to its own status tick is swept from the level") eq(game.player.xp, xpBefore, "a fallen companion grants the player no xp") ok(contains(game.log:last(), "falls"), "a fallen companion's death is logged") end function tests.builderOccasionallyPlacesACharmFromDepthThree() local level = Builder.build(3, Rng.new(1), {}) local found for _, ground in ipairs(level.floorItems) do if ground.item.kind == Items.CHARM then found = ground.item end end ok(found ~= nil, "this seed's depth-3 floor places a companion's charm") ok(Companions.byKey[found.companion] ~= nil, "the charm names a real companion species") end function tests.charmsStayOffTheShallowestFloors() for seed = 1, 5 do for depth = 1, 2 do local level = Builder.build(depth, Rng.new(seed), {}) for _, ground in ipairs(level.floorItems) do ok(ground.item.kind ~= Items.CHARM, "no charm appears above depth 3") end end end end function tests.companionFieldsSurviveSerialization() local game = stagedGame(1, arena(21, 11), 5, 5) Companions.summon(game, "sprite") local restoredLevel = Level.fromData(game.level:toData()) eq(#restoredLevel.entities, 1, "the companion survives a level round trip") eq(restoredLevel.entities[1].kind, "companion", "the companion's kind survives a round trip") eq(restoredLevel.entities[1].species, "sprite", "the companion's species survives a round trip") end function tests.companionGainXpBelowFirstThresholdBanksXpWithoutEvolving() local game = stagedGame(1, arena(21, 11), 5, 5) local hound = Companions.summon(game, "hound") Companions.gainXp(game, hound, 5) eq(hound.companionXp, 5, "xp below the first threshold still accumulates") eq(hound.companionLevel, 1, "a companion under its first threshold stays at level 1") eq(hound.name, "hunting hound", "an unevolved companion keeps its base name") eq(hound.attack, 4, "an unevolved companion keeps its base attack") end function tests.companionGainXpAtFirstThresholdEvolvesRenamesAndGrowsStats() local game = stagedGame(1, arena(21, 11), 5, 5) local hound = Companions.summon(game, "hound") Companions.gainXp(game, hound, 12) eq(hound.companionLevel, 2, "reaching the first threshold advances the companion to level 2") eq(hound.name, "warhound", "the first evolution renames the companion") eq(hound.maxHp, 20, "the first evolution's hp bonus raises max hp") eq(hound.hp, 20, "the first evolution's hp bonus also heals the same amount") eq(hound.attack, 6, "the first evolution's attack bonus is applied") eq(hound.defense, 1, "the first evolution's defense bonus is applied") ok(contains(game.log:last(), "evolves"), "an evolution is logged") end function tests.companionGainXpCrossingBothThresholdsAtOnceAppliesBothStagesCumulatively() local game = stagedGame(1, arena(21, 11), 5, 5) local hound = Companions.summon(game, "hound") Companions.gainXp(game, hound, 40) eq(hound.companionLevel, 3, "a single large xp grant can vault past an intermediate threshold") eq(hound.name, "dire hound", "the companion carries the highest stage's name, not the first") eq(hound.maxHp, 30, "both stages' hp bonuses stack (14 base + 6 + 10)") eq(hound.attack, 9, "both stages' attack bonuses stack (4 base + 2 + 3)") eq(hound.defense, 2, "both stages' defense bonuses stack (0 base + 1 + 1)") end function tests.companionGainXpNeverReappliesAnAlreadyCrossedStage() local game = stagedGame(1, arena(21, 11), 5, 5) local hound = Companions.summon(game, "hound") Companions.gainXp(game, hound, 12) Companions.gainXp(game, hound, 1) eq(hound.companionXp, 13, "further xp below the next threshold keeps accumulating") eq(hound.companionLevel, 2, "a companion does not advance again before its next threshold") eq(hound.attack, 6, "an already-crossed stage's bonus is never applied a second time") end function tests.companionGainXpAtMaxLevelJustBanksFurtherXpWithNoFurtherEvolution() local game = stagedGame(1, arena(21, 11), 5, 5) local hound = Companions.summon(game, "hound") Companions.gainXp(game, hound, 40) Companions.gainXp(game, hound, 100) eq(hound.companionXp, 140, "xp keeps accumulating past the last stage") eq(hound.companionLevel, 3, "a fully evolved companion caps out at its last stage") eq(hound.attack, 9, "stats hold steady once every stage has already been crossed") end function tests.companionGainXpIgnoresAZeroOrNegativeAmount() local game = stagedGame(1, arena(21, 11), 5, 5) local hound = Companions.summon(game, "hound") Companions.gainXp(game, hound, 0) Companions.gainXp(game, hound, -5) eq(hound.companionXp, 0, "a zero or negative grant leaves companion xp untouched") end function tests.companionActCreditsItsOwnKillWithXpOnTopOfThePlayers() local game = stagedGame(1, arena(21, 11), 5, 5) local hound = Companions.summon(game, "hound") local rat = Monsters.create(Monsters.byKey.rat, 50, hound.x + 1, hound.y, 1) game.level.entities[#game.level.entities + 1] = rat local xpBefore = game.player.xp Companions.act(game, hound) eq(rat.hp, 0, "the hound's hit still kills the rat") eq(hound.companionXp, Monsters.xpFor(rat), "the companion banks the felled monster's own xp value") ok(game.player.xp > xpBefore, "the player is still separately credited xp for the companion's kill") end function tests.companionEvolutionBoostsAHearthSpriteHealingOnTopOfItsStaticRate() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.hp = game.player.maxHp - 10 local sprite = Companions.summon(game, "sprite") Companions.gainXp(game, sprite, 10) eq(sprite.name, "kindled sprite", "the sprite's first evolution renames it") local before = game.player.hp Companions.act(game, sprite) eq(game.player.hp, before + Companions.byKey.sprite.heal + 1, "an evolved sprite heals its species rate plus its own evolution bonus") end function tests.companionLevelXpAndHealBonusSurviveSerialization() local game = stagedGame(1, arena(21, 11), 5, 5) local sprite = Companions.summon(game, "sprite") Companions.gainXp(game, sprite, 10) local restoredLevel = Level.fromData(game.level:toData()) local restored = restoredLevel.entities[1] eq(restored.companionLevel, 2, "the companion's level survives a round trip") eq(restored.companionXp, 10, "the companion's banked xp survives a round trip") eq(restored.companionHealBonus, 1, "the companion's evolved heal bonus survives a round trip") eq(restored.name, "kindled sprite", "the companion's evolved name survives a round trip") end function tests.mercenaryHireTakesOnAKnownRosterEntryAdjacentToThePlayer() local game = stagedGame(1, arena(21, 11), 5, 5) local sellsword = Mercenaries.hire(game, "sellsword") ok(sellsword ~= nil, "hiring a valid roster key succeeds next to the player") eq(sellsword.kind, "hireling", "a hired mercenary is tagged with the hireling kind") eq(math.max(math.abs(sellsword.x - 5), math.abs(sellsword.y - 5)), 1, "the hireling lands adjacent to the player") eq(sellsword.loyalty, Mercenaries.START_LOYALTY, "a freshly hired mercenary starts at full loyalty") eq(sellsword.wage, Mercenaries.byKey.sellsword.wage, "the hireling carries its roster wage") end function tests.mercenaryHireRejectsAnUnknownRosterKey() local game = stagedGame(1, arena(21, 11), 5, 5) eq(Mercenaries.hire(game, "dragon"), nil, "an unknown roster key hires nobody") eq(#game.level.entities, 0, "no entity is added for an unknown roster key") end function tests.mercenaryHireReplacesAnEarlierHireling() local game = stagedGame(1, arena(21, 11), 5, 5) local first = Mercenaries.hire(game, "sellsword") local second = Mercenaries.hire(game, "medic") ok(not first:isAlive(), "hiring a new mercenary retires the earlier one") ok(second:isAlive(), "the freshly hired mercenary is alive") local alive = 0 for _, e in ipairs(game.level.entities) do if e.kind == "hireling" and e:isAlive() then alive = alive + 1 end end eq(alive, 1, "only one hireling is ever alive at a time") end function tests.mercenaryAttacksAdjacentHostileMonsterAndCanKillIt() local game = stagedGame(1, arena(21, 11), 5, 5) local sellsword = Mercenaries.hire(game, "sellsword") local rat = Monsters.create(Monsters.byKey.rat, 50, sellsword.x + 1, sellsword.y, 1) game.level.entities[#game.level.entities + 1] = rat local xpBefore = game.player.xp Mercenaries.act(game, sellsword) ok(rat.hp <= 0, "a sellsword's hit kills the weak rat standing next to it") ok(game.player.xp > xpBefore, "the player is credited xp for its hireling's kill") end function tests.mercenaryAdvancesOnAVisibleMonsterItCannotYetReach() local game = stagedGame(1, arena(21, 11), 5, 5) local sellsword = Mercenaries.hire(game, "sellsword") local orc = Monsters.create(Monsters.byKey.orc, 50, sellsword.x + 5, sellsword.y, 1) game.level.entities[#game.level.entities + 1] = orc local startX = sellsword.x Mercenaries.act(game, sellsword) ok(sellsword.x > startX, "the hireling closes the distance to a monster it can see but not reach") end function tests.mercenaryIgnoresAMonsterBeyondItsSightRadius() local game = stagedGame(1, arena(21, 11), 3, 3) local medic = Mercenaries.hire(game, "medic") local orc = Monsters.create(Monsters.byKey.orc, 50, 19, 9, 1) game.level.entities[#game.level.entities + 1] = orc local startX, startY = medic.x, medic.y Mercenaries.act(game, medic) eq(medic.x, startX, "a monster far outside sight range does not pull the hireling off course (x)") eq(medic.y, startY, "a monster far outside sight range does not pull the hireling off course (y)") end -- Same gap as companionActBlindedCompanionOnlyDetectsAnAdjacentHostileNot -- OneWithinItsSightRadius, but for Mercenaries.act's own nearestHostile. function tests.mercenaryActBlindedHirelingOnlyDetectsAnAdjacentHostileNotOneWithinItsSightRadius() local game = stagedGame(1, arena(21, 11), 5, 5) local sellsword = Mercenaries.hire(game, "sellsword") local farOrc = Monsters.create(Monsters.byKey.orc, 50, sellsword.x + 5, sellsword.y, 1) game.level.entities[#game.level.entities + 1] = farOrc Status.apply(sellsword, Status.BLIND, 6, 1) local startX = sellsword.x Mercenaries.act(game, sellsword) eq(sellsword.x, startX, "a blinded hireling loses a sighting well within its listed sight radius") local rat = Monsters.create(Monsters.byKey.rat, 51, sellsword.x + 1, sellsword.y, 1) game.level.entities[#game.level.entities + 1] = rat local ratHpBefore = rat.hp Mercenaries.act(game, sellsword) ok(rat.hp < ratHpBefore, "a blinded hireling still fights a hostile once it stands adjacent") end -- Same gap as companionActAttackingAnAdjacentMonsterEmitsNoise..., mirrored -- onto the hireling side: Mercenaries.act's own Combat.attack call never -- routed through Game:emitNoise either. function tests.mercenaryActAttackingAnAdjacentMonsterEmitsNoiseThatCanStirADormantBystander() local stirred = false for seed = 1, 200 do local game = stagedGame(seed, arena(21, 11), 3, 6) local sellsword = Mercenaries.hire(game, "sellsword") local target = Monsters.create(Monsters.byKey.rat, 50, sellsword.x + 1, sellsword.y, 1) local bystander = Monsters.create(Monsters.byKey.orc, 51, sellsword.x + 4, sellsword.y, 1) bystander.awareness = Stealth.DORMANT game.level.entities[#game.level.entities + 1] = target game.level.entities[#game.level.entities + 1] = bystander Mercenaries.act(game, sellsword) if bystander.awareness ~= Stealth.DORMANT then stirred = true; break end end ok(stirred, "a hireling's own melee is exactly as loud as a companion's or the player's") end function tests.mercenaryFollowsThePlayerWhenNoMonsterIsVisible() local game = stagedGame(1, arena(21, 11), 10, 5) local sellsword = Mercenaries.hire(game, "sellsword") sellsword.x, sellsword.y = 3, 5 local startDist = math.abs(sellsword.x - game.player.x) Mercenaries.act(game, sellsword) ok(math.abs(sellsword.x - game.player.x) < startDist, "a hireling with no monster in sight closes in on the player") end -- Same wiring proof as tests.companionActConfusedCompanionSometimesStumbles -- InsteadOfFightingAnAdjacentHostile, but for Mercenaries.act - a distinct -- call site that could independently have been left off Game:allyActsConfused. function tests.mercenaryActConfusedHirelingSometimesStumblesInsteadOfFightingAnAdjacentHostile() local sawAttack, sawStumble = false, false for seed = 1, 200 do local game = stagedGame(seed, arena(21, 11), 3, 6) local sellsword = Mercenaries.hire(game, "sellsword") local rat = Monsters.create(Monsters.byKey.rat, 50, sellsword.x + 1, sellsword.y, 1) game.level.entities[#game.level.entities + 1] = rat Status.apply(sellsword, Status.CONFUSE, 5, 1) local hpBefore = rat.hp Mercenaries.act(game, sellsword) if rat.hp < hpBefore then sawAttack = true else sawStumble = true end if sawAttack and sawStumble then break end end ok(sawAttack, "a confused hireling's roll sometimes lets it fight an adjacent hostile as normal") ok(sawStumble, "a confused hireling's roll sometimes overrides even a point-blank attack") end function tests.fieldMedicMendsThePlayerWhenIdle() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.hp = game.player.maxHp - 5 local medic = Mercenaries.hire(game, "medic") local before = game.player.hp Mercenaries.act(game, medic) eq(game.player.hp, before + Mercenaries.byKey.medic.heal, "a field medic mends its employer each idle turn") end function tests.mercenaryStopsChasingAndFollowingOnceSulking() local game = stagedGame(1, arena(21, 11), 10, 5) local sellsword = Mercenaries.hire(game, "sellsword") sellsword.loyalty = Mercenaries.SULKING_THRESHOLD sellsword.x, sellsword.y = 3, 5 local startX, startY = sellsword.x, sellsword.y Mercenaries.act(game, sellsword) eq(sellsword.x, startX, "a sulking hireling does not close in on the player it cannot reach (x)") eq(sellsword.y, startY, "a sulking hireling does not close in on the player it cannot reach (y)") end function tests.mercenaryStillFightsAnAdjacentHostileEvenWhileSulking() local game = stagedGame(1, arena(21, 11), 5, 5) local sellsword = Mercenaries.hire(game, "sellsword") sellsword.loyalty = 0 local rat = Monsters.create(Monsters.byKey.rat, 50, sellsword.x + 1, sellsword.y, 1) game.level.entities[#game.level.entities + 1] = rat Mercenaries.act(game, sellsword) ok(rat.hp <= 0, "a sulking hireling still fights whatever already stands next to it") end function tests.mercenaryPayWagePaysWhenAffordableAndRestoresLoyalty() local game = stagedGame(1, arena(21, 11), 5, 5) local sellsword = Mercenaries.hire(game, "sellsword") sellsword.loyalty = 50 game.player.gold = 100 local line = Mercenaries.payWage(game) eq(game.player.gold, 100 - sellsword.wage, "an affordable wage is deducted from the wallet") eq(sellsword.loyalty, 50 + Mercenaries.LOYALTY_GAIN_PAID, "paying the wage restores some loyalty") ok(contains(line, "pay"), "a paid wage is logged as a payment") end function tests.mercenaryPayWageShortsAndDropsLoyaltyWhenGoldIsShort() local game = stagedGame(1, arena(21, 11), 5, 5) local sellsword = Mercenaries.hire(game, "sellsword") game.player.gold = 0 local line = Mercenaries.payWage(game) eq(game.player.gold, 0, "an unaffordable wage never touches the wallet") eq(sellsword.loyalty, Mercenaries.START_LOYALTY - Mercenaries.LOYALTY_LOSS_UNPAID, "missing a wage knocks the hireling's loyalty down") ok(contains(line, "grumbles"), "a missed wage that survives is logged as a grumble") end function tests.mercenaryPayWageDesertsOnceLoyaltyBottomsOut() local game = stagedGame(1, arena(21, 11), 5, 5) local sellsword = Mercenaries.hire(game, "sellsword") sellsword.loyalty = 20 game.player.gold = 0 local line = Mercenaries.payWage(game) ok(not sellsword:isAlive(), "a hireling whose loyalty bottoms out deserts") eq(#game.level.entities, 0, "a deserted hireling is swept from the level") ok(contains(line, "deserts"), "desertion is logged distinctly from a mere grumble") end function tests.mercenaryPayWageIsANoOpWithNoHirelingOnTheLevel() local game = stagedGame(1, arena(21, 11), 5, 5) eq(Mercenaries.payWage(game), nil, "paying a wage with no hireling present does nothing and reports nothing") end function tests.mercenaryDismissReleasesTheHirelingWithNoPenalty() local game = stagedGame(1, arena(21, 11), 5, 5) Mercenaries.hire(game, "medic") local name = Mercenaries.dismiss(game) eq(name, "field medic", "dismissing returns the released hireling's name") eq(#game.level.entities, 0, "the dismissed hireling leaves the level") end function tests.mercenaryDismissFailsWithNoOneUnderContract() local game = stagedGame(1, arena(21, 11), 5, 5) eq(Mercenaries.dismiss(game), nil, "dismissing with nobody hired reports nothing") end function tests.useItemOnAContractHiresTheNamedMercenaryAndConsumesIt() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.inventory = { Items.contract("skirmisher") } ok(game:useItem(1), "using a contract succeeds") eq(#game.player.inventory, 0, "the contract leaves the inventory once the hireling joins") eq(#game.level.entities, 1, "the hired mercenary is added to the level") eq(game.level.entities[1].species, "skirmisher", "the hired mercenary matches the contract's roster key") end function tests.useItemOnAContractWithAnUnknownRosterKeyFailsWithoutConsumingIt() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.inventory = { Items.contract("dragon") } eq(game:useItem(1), false, "using a contract for an unknown roster key fails") eq(#game.player.inventory, 1, "a failed hire never consumes the contract") end function tests.takeMonsterTurnRoutesAHirelingThroughMercenariesAct() local game = stagedGame(1, arena(21, 11), 5, 5) local sellsword = Mercenaries.hire(game, "sellsword") local rat = Monsters.create(Monsters.byKey.rat, 50, sellsword.x + 1, sellsword.y, 1) game.level.entities[#game.level.entities + 1] = rat game:takeMonsterTurn(sellsword) ok(rat.hp <= 0, "the full monster-turn pipeline dispatches a hireling's turn to mercenaries.act") end function tests.aFallenHirelingIsLoggedAndSweptWithoutAwardingXp() local game = stagedGame(1, arena(21, 11), 5, 5) local sellsword = Mercenaries.hire(game, "sellsword") sellsword.hp = 1 Status.apply(sellsword, Status.POISON, 1, 5) local xpBefore = game.player.xp game:takeMonsterTurn(sellsword) eq(#game.level.entities, 0, "a hireling that dies to its own status tick is swept from the level") eq(game.player.xp, xpBefore, "a fallen hireling grants the player no xp") ok(contains(game.log:last(), "falls"), "a fallen hireling's death is logged") end function tests.gameEndPlayerTurnPaysTheHirelingWageOnceTheIntervalElapses() local game = Game.new(5) local sellsword = Mercenaries.hire(game, "sellsword") ok(sellsword ~= nil, "the starting room around a fresh game has a free cell to hire into") game.player.gold = 1000 game.turn = Mercenaries.WAGE_INTERVAL - 1 local goldBefore = game.player.gold game:endPlayerTurn() eq(game.player.gold, goldBefore - sellsword.wage, "the wage interval turn bills the live hireling") end function tests.mercenaryFieldsSurviveSerialization() local game = stagedGame(1, arena(21, 11), 5, 5) local medic = Mercenaries.hire(game, "medic") medic.loyalty = 55 local restoredLevel = Level.fromData(game.level:toData()) eq(#restoredLevel.entities, 1, "the hireling survives a level round trip") eq(restoredLevel.entities[1].kind, "hireling", "the hireling's kind survives a round trip") eq(restoredLevel.entities[1].species, "medic", "the hireling's roster key survives a round trip") eq(restoredLevel.entities[1].wage, Mercenaries.byKey.medic.wage, "the hireling's wage survives a round trip") eq(restoredLevel.entities[1].loyalty, 55, "the hireling's loyalty survives a round trip") end function tests.shopPriceOfAContractMatchesItsRosterHireCost() eq(Shop.priceOf(Items.contract("sellsword")), Mercenaries.byKey.sellsword.hireCost, "a contract's catalog price matches its roster hire cost") end function tests.shopStockCanRollAContractWithAPositiveCatalogPrice() local sawContract = false for seed = 1, 40 do for _, item in ipairs(Shop.stock(Rng.new(seed))) do if item.kind == Items.CONTRACT then sawContract = true ok(Shop.priceOf(item) > 0, "a rolled contract carries a positive catalog price") ok(Mercenaries.byKey[item.hireling] ~= nil, "a rolled contract names a real roster key") end end end ok(sawContract, "shop stock can roll a mercenary contract across a handful of seeds") end function tests.autopilotHiresACarriedContractWhenNoHirelingIsYetUnderContract() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.entities = {} game.player.inventory = { Items.contract("skirmisher") } game:choosePlayerAction() eq(#game.player.inventory, 0, "the autopilot uses a carried contract on sight when no hireling is yet under contract") local hired = false for _, e in ipairs(game.level.entities) do if e.kind == "hireling" then hired = true end end ok(hired, "the autopilot's contract use actually hires the mercenary") end function tests.artifactsCreateBuildsTheCatalogedItemShape() local blade = Artifacts.create("bloodfang") eq(blade.kind, Items.WEAPON, "bloodfang is a weapon-slot artifact") eq(blade.unique, Artifacts.LIFESTEAL, "bloodfang carries the lifesteal tag") eq(blade.artifact, "bloodfang", "the item remembers its catalog key") local ring = Artifacts.create("ring_of_the_tide") eq(ring.kind, Items.RING, "ring of the tide is a ring-slot artifact") eq(ring.unique, Artifacts.REGEN, "ring of the tide carries the regen tag") eq(Artifacts.create("nonexistent"), nil, "an unknown catalog key builds nothing") end function tests.lifestealWeaponHealsThePlayerOnAHit() local game = stagedGame(1, arena(21, 11), 3, 6) Items.equipWeapon(Artifacts.create("bloodfang"), game.player) game.player.hp = game.player.maxHp - 10 local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) orc.hp, orc.maxHp = 30, 30 game.level.entities = { orc } local before = game.player.hp game:playerAttack(orc) ok(game.player.hp > before, "bloodfang heals the player for a share of the damage it deals") end function tests.thornedArmorReflectsDamageBackAtAMeleeAttacker() local game = stagedGame(1, arena(21, 11), 3, 6) Items.equipArmor(Artifacts.create("aegis_of_embers"), game.player) local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) game.level.entities = { orc } local before = orc.hp game:monsterMelee(orc) ok(orc.hp < before, "aegis of embers burns the monster that hits its wearer") end function tests.thornedArmorCanKillAWeakAttacker() local game = stagedGame(1, arena(21, 11), 3, 6) Items.equipArmor(Artifacts.create("aegis_of_embers"), game.player) local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) rat.hp, rat.maxHp = 1, 1 game.level.entities = { rat } local xpBefore = game.player.xp game:monsterMelee(rat) ok(not rat:isAlive(), "a thorn burn can finish off a weak attacker") ok(game.player.xp > xpBefore, "the player is credited xp for a thorn kill") end -- Aegis of Embers is fire damage by name ("Embers") and by its own log line -- ("your armor sears") - before this fix the reflect subtracted a raw amount -- straight off the attacker's hp with no Resistances.apply call at all, so a -- fire-immune dragon (bestiary-real, reachable from depth 6) burned through -- its own immunity attacking a thorned wearer. Same shape of bug -- fighterCleaveSplashRespectsAFireImmuneTargetLikeThePrimaryHitAlreadyDoes -- caught in skills.lua earlier the same day, this time in the thorns path. function tests.thornedArmorRespectsAFireImmuneAttacker() local game = stagedGame(1, arena(21, 11), 3, 6) Items.equipArmor(Artifacts.create("aegis_of_embers"), game.player) local dragon = Monsters.create(Monsters.byKey.dragon, 1, 4, 6, 1) game.level.entities = { dragon } local before = dragon.hp game:monsterMelee(dragon) eq(dragon.hp, before, "a fire-immune dragon takes zero thorns reflect from a fire-flavoured artifact") end function tests.thornedArmorIsHalvedAgainstAFireResistantAttacker() local function reflectDealt(resistant) local game = stagedGame(1, arena(21, 11), 3, 6) Items.equipArmor(Artifacts.create("aegis_of_embers"), game.player) local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) orc.hp, orc.maxHp = 100, 100 if resistant then orc.resist = { fire = Resistances.RESIST } end game.level.entities = { orc } game:monsterMelee(orc) return 100 - orc.hp end local plain = reflectDealt(false) local resisted = reflectDealt(true) ok(resisted < plain, "a fire-resistant attacker takes measurably less thorns reflect than an unresisted one") eq(resisted, math.floor(plain / 2), "fire resistance halves the thorns reflect, the same mitigation Resistances.apply gives every other fire source") end function tests.ringOfTheTideRegeneratesHpEachTurn() local game = Game.new(1) Items.equipRing(Artifacts.create("ring_of_the_tide"), game.player) game.player.hp = game.player.maxHp - 10 local before = game.player.hp game:hungerTorchTick() eq(game.player.hp, before + 2, "ring of the tide regenerates its fixed amount each turn") end function tests.piercingBowIgnoresPartOfTargetDefense() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.baseAttack = 5 Items.equipBow(Artifacts.create("gale_bow"), game.player) game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 5) local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) orc.defense, orc.baseDefense, orc.hp, orc.maxHp = 6, 6, 40, 40 game.level.entities = { orc } game:fireAt(orc) eq(orc.hp, 35, "a piercing bow's shot ignores a flat share of the target's defense") end function tests.builderOccasionallyPlacesAnArtifactFromDepthFive() local level = Builder.build(5, Rng.new(4), {}) local found for _, ground in ipairs(level.floorItems) do if ground.item.artifact then found = ground.item end end ok(found ~= nil, "this seed's depth-5 floor places a named artifact") ok(Artifacts.byKey[found.artifact] ~= nil, "the artifact names a real catalog entry") end function tests.artifactsStayOffFloorsAboveDepthFive() for seed = 1, 4 do for depth = 1, 4 do local level = Builder.build(depth, Rng.new(seed), {}) for _, ground in ipairs(level.floorItems) do ok(not ground.item.artifact, "no artifact appears above depth 5") end end end end function tests.artifactFieldsSurviveSerialization() local game = Game.new(3) Items.equipWeapon(Artifacts.create("bloodfang"), game.player) local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(restored.player.weapon.artifact, "bloodfang", "an equipped artifact's catalog key survives a save/load round trip") eq(restored.player.weapon.unique, Artifacts.LIFESTEAL, "an equipped artifact's unique tag survives a save/load round trip") eq(restored.player.weapon.value, 0.5, "an equipped artifact's tuning value survives a save/load round trip") end function tests.useItemOnAKitPlantsAHiddenTrapOnThePlayersTile() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.inventory = { Items.kit("dart") } ok(game:useItem(1), "using a trap kit succeeds") eq(#game.player.inventory, 0, "the kit leaves the inventory once planted") local trap = game.level:trapAt(5, 5) ok(trap ~= nil, "a trap is planted on the player's tile") eq(trap.effect, "dart", "the planted trap matches the kit's effect") eq(trap.revealed, false, "a freshly planted trap starts hidden") eq(game.level.map:get(5, 5), Tiles.FLOOR, "a hidden trap does not change the tile's glyph") end function tests.plantingATrapWhereOneAlreadyExistsFails() local game = stagedGame(1, arena(21, 11), 5, 5) game.level.traps = { { x = 5, y = 5, effect = "poison", revealed = false } } game.player.inventory = { Items.kit("dart") } ok(not game:useItem(1), "planting a trap on an occupied tile fails") eq(#game.player.inventory, 1, "a failed planting leaves the kit in the inventory") end function tests.aMonsterSteppingOntoAPlayerSetTrapSpringsIt() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.traps = { { x = 6, y = 6, effect = "dart", revealed = false, placedByPlayer = true } } local orc = Monsters.create(Monsters.byKey.orc, 1, 7, 6, 1) game.level.entities = { orc } local hpBefore = orc.hp game:stepMonsterToward(orc, game.player.x, game.player.y) eq(orc.x, 6, "the orc steps onto the trapped cell while closing on the player") ok(orc.hp < hpBefore, "stepping onto a player-set dart trap wounds the monster") eq(game.level.traps[1].revealed, true, "the sprung trap becomes revealed") end -- Status.LEVITATE (see status.lua) makes Game:checkTrapFor and movePlayer's -- own inline trap check both bail out before springTrap ever runs - and, -- unlike the lava/chasm skip in checkTerrainFor, deliberately leaves the -- trap unrevealed: floating clear of a pressure plate buys safety, not free -- detection of what was under it. function tests.checkTrapForSkipsAndLeavesUndetectedForALevitatingMonster() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.traps = { { x = 6, y = 6, effect = "dart", revealed = false } } local orc = Monsters.create(Monsters.byKey.orc, 1, 7, 6, 1) Status.apply(orc, Status.LEVITATE, 20, 1) game.level.entities = { orc } local hpBefore = orc.hp game:stepMonsterToward(orc, game.player.x, game.player.y) eq(orc.x, 6, "the levitating orc still steps onto the trapped cell while closing on the player") eq(orc.hp, hpBefore, "a levitating monster drifting over a trap takes no damage from it") eq(game.level.traps[1].revealed, false, "the trap it floated over is never revealed") end function tests.movePlayerSkipsTheInlineTrapTriggerForALevitatingPlayer() local game = stagedGame(1, arena(21, 11), 5, 5) game.level.traps = { { x = 6, y = 5, effect = "dart", revealed = false } } Status.apply(game.player, Status.LEVITATE, 20, 1) local hpBefore = game.player.hp game:movePlayer(1, 0) eq(game.player.x, 6, "the levitating player still moves onto the trapped cell") eq(game.player.hp, hpBefore, "a levitating player takes no damage from the trap") eq(game.level.traps[1].revealed, false, "the trap the player floated over stays hidden") end function tests.aLethalTrapKillsAWeakMonsterAndCreditsThePlayer() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.traps = { { x = 6, y = 6, effect = "dart", revealed = false } } local rat = Monsters.create(Monsters.byKey.rat, 1, 7, 6, 1) rat.hp, rat.maxHp = 1, 1 game.level.entities = { rat } local xpBefore = game.player.xp game:stepMonsterToward(rat, game.player.x, game.player.y) ok(not rat:isAlive(), "a dart trap can finish off a weak monster") ok(game.player.xp > xpBefore, "the player is credited xp for a trap kill") eq(#game.level.entities, 0, "the fallen monster is swept from the level") end function tests.aTeleportTrapRelocatesTheMonsterThatSpringsIt() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.traps = { { x = 6, y = 6, effect = "teleport", revealed = false } } local orc = Monsters.create(Monsters.byKey.orc, 1, 7, 6, 1) game.level.entities = { orc } game:stepMonsterToward(orc, game.player.x, game.player.y) ok(not (orc.x == 6 and orc.y == 6), "a teleport trap relocates the monster off the trapped cell") end function tests.builderOccasionallyPlacesATrapKitFromDepthTwo() local level = Builder.build(3, Rng.new(3), {}) local found for _, ground in ipairs(level.floorItems) do if ground.item.kind == Items.KIT then found = ground.item end end ok(found ~= nil, "this seed's depth-3 floor places a trap kit") local isKnownEffect = false for _, effect in ipairs(Builder.TRAP_EFFECTS) do if found.effect == effect then isKnownEffect = true end end ok(isKnownEffect, "the kit's effect is one of Builder.TRAP_EFFECTS") end function tests.kitsStayOffTheEntranceFloor() for seed = 1, 5 do local level = Builder.build(1, Rng.new(seed), {}) for _, ground in ipairs(level.floorItems) do ok(ground.item.kind ~= Items.KIT, "no trap kit appears on the entrance floor") end end end function tests.plantedTrapFieldsSurviveSerialization() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.inventory = { Items.kit("poison") } game:useItem(1) local restoredLevel = Level.fromData(game.level:toData()) eq(#restoredLevel.traps, 1, "the planted trap survives a level round trip") eq(restoredLevel.traps[1].effect, "poison", "the planted trap's effect survives a round trip") eq(restoredLevel.traps[1].placedByPlayer, true, "the planted trap's origin flag survives a round trip") end function tests.bossCreateBuildsAFreshUnenragedBoss() local warlord = Bosses.create(Bosses.byKey.warlord, 9, 4, 6) eq(warlord.isBoss, true, "a created boss is flagged isBoss") eq(warlord.bossKey, "warlord", "a created boss remembers its catalog key") eq(warlord.enraged, false, "a freshly created boss has not yet enraged") eq(warlord.hp, warlord.maxHp, "a created boss starts at full hp") eq(warlord.species, "boss_warlord", "a created boss's species is namespaced away from Monsters.byKey") end function tests.bossAtFullHealthDoesNotEnrage() local warlord = Bosses.create(Bosses.byKey.warlord, 1, 1, 1) local before = warlord.attack local line = Bosses.tryEnrage(warlord) eq(line, nil, "a boss above its enrage threshold does not enrage") eq(warlord.enraged, false, "the boss is still marked as not enraged") eq(warlord.attack, before, "an unenraged boss keeps its base attack") end function tests.bossEnragesOncePastItsHalfHpThresholdAndOnlyOnce() local warlord = Bosses.create(Bosses.byKey.warlord, 1, 1, 1) local spec = Bosses.byKey.warlord warlord.hp = math.floor(warlord.maxHp * spec.enrageAt) local attackBefore, speedBefore = warlord.attack, warlord.speed local line = Bosses.tryEnrage(warlord) ok(line ~= nil, "a bloodied boss enrages and logs a line") eq(warlord.enraged, true, "the boss is now flagged enraged") eq(warlord.attack, attackBefore + spec.enrageAttack, "enraging adds the catalog's flat attack bonus") eq(warlord.baseAttack, attackBefore + spec.enrageAttack, "enraging also lifts the boss's base attack") eq(warlord.speed, speedBefore + spec.enrageSpeed, "enraging adds the catalog's flat speed bonus") local hpAfterFirst = warlord.hp local second = Bosses.tryEnrage(warlord) eq(second, nil, "an already-enraged boss does not enrage a second time") eq(warlord.attack, attackBefore + spec.enrageAttack, "a second call never stacks the attack bonus again") eq(warlord.hp, hpAfterFirst, "a second call grants no further heal") end function tests.bossLootMatchesItsCatalogEntry() local lich = Bosses.create(Bosses.byKey.lich, 1, 1, 1) local loot = Bosses.lootFor(lich) ok(loot ~= nil, "a boss with a valid bossKey drops its catalog loot") eq(loot.artifact, "lich_phylactery", "the dropped item is the boss's exact catalog artifact") eq(loot.unique, Artifacts.REGEN, "the boss loot carries its unique mechanic tag") local unknown = Bosses.create({ key = "ghost" }, 1, 1, 1) unknown.bossKey = "ghost" eq(Bosses.lootFor(unknown), nil, "a boss with no catalog entry drops nothing") end function tests.bossOnlyArtifactsAreExcludedFromTheRandomBuilderRoll() local bossOnlyKeys = { "warlords_cleaver", "lich_phylactery", "bone_queen_carapace" } for _, key in ipairs(bossOnlyKeys) do ok(Artifacts.byKey[key] ~= nil, key .. " is a real catalog entry") local inOrder = false for _, orderKey in ipairs(Artifacts.order) do if orderKey == key then inOrder = true end end ok(not inOrder, key .. " never appears in the builder's random artifact roll") end end function tests.builderOccasionallyPlacesABossFromDepthSix() local level = Builder.build(6, Rng.new(8), {}) local found for _, entity in ipairs(level.entities) do if entity.isBoss then found = entity end end ok(found ~= nil, "this seed's depth-6 floor places a named boss") eq(found.bossKey, "warlord", "the only boss eligible at depth 6 is the warlord") end function tests.bossesStayOffFloorsAboveDepthSix() for seed = 1, 5 do for depth = 1, 5 do local level = Builder.build(depth, Rng.new(seed), {}) for _, entity in ipairs(level.entities) do ok(not entity.isBoss, "no boss appears above depth 6") end end end end function tests.takeMonsterTurnEnragesABloodiedBossBeforeItsAiActs() local game = stagedGame(1, arena(21, 11), 3, 6) local warlord = Bosses.create(Bosses.byKey.warlord, 1, 4, 6) warlord.hp = math.floor(warlord.maxHp * Bosses.byKey.warlord.enrageAt) game.level.entities = { warlord } game:takeMonsterTurn(warlord) eq(warlord.enraged, true, "a bloodied boss enrages the moment its turn comes up") local found = false for _, line in ipairs(game.log.lines) do if line:find("rage", 1, true) then found = true end end ok(found, "the enrage line is written to the message log") end function tests.defeatingABossGrantsBossXpAndDropsItsGuaranteedArtifact() local game = stagedGame(1, arena(21, 11), 3, 6) local lich = Bosses.create(Bosses.byKey.lich, 1, 4, 6) lich.hp = 1 game.level.entities = { lich } local levelBefore = game.player.level local essenceBefore = game.player.essence game:playerAttack(lich) -- A boss's xp reward is large enough to roll the player's level past its -- current threshold, so plain "xp increased by exactly N" arithmetic -- (which the leveling loop resets on every level-up) does not apply here - -- see progression.lua's levelUp. Essence, unlike xp, is a plain running -- total, so it still checks the exact catalog reward directly. ok(game.player.level > levelBefore, "a boss's xp reward is enough to level the player up") eq(game.player.essence, essenceBefore + Crafting.essenceFor(Bosses.xpFor(lich)), "felling a boss grants essence scaled to its catalog xp, not a plain monster's") local dropped for _, ground in ipairs(game.level.floorItems) do if ground.item.artifact == "lich_phylactery" then dropped = ground.item end end ok(dropped ~= nil, "the boss's guaranteed artifact lands on the floor where it died") end function tests.bossFieldsSurviveSerialization() local game = stagedGame(3, arena(21, 11), 5, 5) local warlord = Bosses.create(Bosses.byKey.warlord, 1, 6, 5) warlord.hp = math.floor(warlord.maxHp * Bosses.byKey.warlord.enrageAt) Bosses.tryEnrage(warlord) game.level.entities = { warlord } local restoredLevel = Level.fromData(game.level:toData()) eq(#restoredLevel.entities, 1, "the boss survives a level round trip") eq(restoredLevel.entities[1].isBoss, true, "the boss's isBoss flag survives a round trip") eq(restoredLevel.entities[1].bossKey, "warlord", "the boss's catalog key survives a round trip") eq(restoredLevel.entities[1].enraged, true, "the boss's enraged flag survives a round trip") end function tests.phasesTickIsANoOpForAPlainMonsterOrABossStillAboveItsDesperateThreshold() local game = stagedGame(1, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 10, 6, 1) local line, consumed = Phases.tick(game, orc) eq(line, nil, "a plain monster never enters a desperation phase") eq(consumed, false, "a plain monster's turn is never consumed by Phases") local warlord = Bosses.create(Bosses.byKey.warlord, 2, 5, 6) line, consumed = Phases.tick(game, warlord) eq(line, nil, "a boss above its desperateAt threshold does not brace") eq(consumed, false, "an untouched boss's turn is never consumed by Phases") end function tests.phasesTickBracesOnceABloodiedBossCrossesDesperateAtSpikingDefenseAndConsumingTheTurn() local game = stagedGame(1, arena(21, 11), 3, 6) local spec = Bosses.byKey.warlord local warlord = Bosses.create(spec, 1, 5, 6) warlord.enraged = true -- already past enrage so its heal cannot mask the desperate check below warlord.hp = math.floor(warlord.maxHp * spec.desperateAt) local defenseBefore = warlord.defense local line, consumed = Phases.tick(game, warlord) ok(line ~= nil and line:find("channel", 1, true) ~= nil, "a bloodied boss logs that it begins channeling") eq(consumed, true, "bracing consumes the boss's entire turn") eq(warlord.desperate, true, "the boss is now flagged desperate") eq(warlord.channeling, true, "the boss is now flagged channeling, awaiting its next turn to unleash") eq(warlord.defense, defenseBefore + spec.desperateDefenseBonus, "bracing spikes defense by the catalog's flat bonus") eq(warlord.baseDefense, spec.defense + spec.desperateDefenseBonus, "bracing also lifts the boss's base defense") end function tests.phasesTickUnleashesOnTheTurnAfterBracingHittingThePlayerAndRevertingDefense() local game = stagedGame(1, arena(21, 11), 4, 6) local spec = Bosses.byKey.lich local lich = Bosses.create(spec, 1, 5, 6) lich.enraged = true lich.hp = math.floor(lich.maxHp * spec.desperateAt) Phases.tick(game, lich) -- brace local defenseBraced = lich.defense local hpBefore = game.player.hp local line, consumed = Phases.tick(game, lich) eq(consumed, true, "unleashing also consumes the boss's entire turn") eq(lich.channeling, false, "unleashing clears the channeling flag") eq(lich.novaSpent, true, "unleashing latches novaSpent permanently") eq(lich.defense, defenseBraced - spec.desperateDefenseBonus, "unleashing reverts the braced defense spike") eq(lich.defense, spec.defense, "the reverted defense lands back on the boss's unmodified catalog value") eq(game.player.hp, hpBefore - spec.novaDamage, "the cataclysm deals the catalog's flat nova damage to the player") ok(line:find("cataclysm", 1, true) ~= nil, "the unleash line names the cataclysm") end function tests.phasesUnleashHitsCompanionsAndHirelingsInRadiusButLeavesAWildMonsterUntouched() local game = stagedGame(1, arena(21, 11), 3, 3) local spec = Bosses.byKey.bonequeen local queen = Bosses.create(spec, 1, 10, 6) queen.enraged = true queen.hp = math.floor(queen.maxHp * spec.desperateAt) local ally = Entity.new({ id = 2, x = 11, y = 6, glyph = "d", name = "wolf", kind = "companion", hp = 10, maxHp = 10, attack = 2, defense = 0, speed = 100, }) local hireling = Entity.new({ id = 3, x = 9, y = 7, glyph = "@", name = "sellsword", kind = "hireling", hp = 10, maxHp = 10, attack = 2, defense = 0, speed = 100, }) local bystander = Monsters.create(Monsters.byKey.rat, 4, 10, 5, 1) game.level.entities = { queen, ally, hireling, bystander } game.player.x, game.player.y = 20, 10 -- far outside novaRadius Phases.tick(game, queen) -- brace Phases.tick(game, queen) -- unleash eq(ally.hp, 10 - spec.novaDamage, "a companion within radius takes the cataclysm's nova damage") eq(hireling.hp, 10 - spec.novaDamage, "a hireling within radius takes the cataclysm's nova damage") eq(bystander.hp, bystander.maxHp, "a wild monster caught nearby is never a cataclysm target") end function tests.phasesUnleashHitsAReanimatedThrallInRadiusJustLikeACompanionOrHireling() local game = stagedGame(1, arena(21, 11), 3, 3) local spec = Bosses.byKey.bonequeen local queen = Bosses.create(spec, 1, 10, 6) queen.enraged = true queen.hp = math.floor(queen.maxHp * spec.desperateAt) local thrall = Entity.new({ id = 2, x = 11, y = 6, glyph = "Z", name = "rat thrall", kind = "thrall", hp = 10, maxHp = 10, attack = 2, defense = 0, speed = 100, }) game.level.entities = { queen, thrall } game.player.x, game.player.y = 20, 10 -- far outside novaRadius, only the thrall is in range Phases.tick(game, queen) -- brace Phases.tick(game, queen) -- unleash eq(thrall.hp, 10 - spec.novaDamage, "a player-raised thrall is just as much the boss's cornered party as a companion or hireling") end function tests.phasesNovaFiresAtMostOncePerBossThenFallsBackToItsOrdinaryAbility() local game = stagedGame(1, arena(21, 11), 3, 6) local spec = Bosses.byKey.warlord local warlord = Bosses.create(spec, 1, 5, 6) warlord.enraged = true warlord.hp = math.floor(warlord.maxHp * spec.desperateAt) Phases.tick(game, warlord) -- brace Phases.tick(game, warlord) -- unleash, latches novaSpent local line, consumed = Phases.tick(game, warlord) eq(line, nil, "a boss that already spent its cataclysm never braces again") eq(consumed, false, "Phases stops consuming turns once the cataclysm is spent") end function tests.takeMonsterTurnSkipsAbilityAndAiWhileABossBracesOrUnleashesItsCataclysm() local game = stagedGame(1, arena(21, 11), 3, 6) local spec = Bosses.byKey.warlord local warlord = Bosses.create(spec, 1, 4, 6) warlord.enraged = true warlord.hp = math.floor(warlord.maxHp * spec.desperateAt) game.level.entities = { warlord } game:takeMonsterTurn(warlord) eq(warlord.x, 4, "a bracing boss never steps toward the player through its ordinary ai") local hpAfterBrace = game.player.hp eq(hpAfterBrace, game.player.maxHp, "bracing alone never damages the player") game:takeMonsterTurn(warlord) ok(game.player.hp < hpAfterBrace, "unleashing the cataclysm damages the adjacent player") local hpBeforeThird = game.player.hp game:takeMonsterTurn(warlord) ok(warlord.ability == nil or true, "a spent boss falls through to its plain rally ability or ai on its next turn") ok(game.player.hp <= hpBeforeThird, "the boss keeps acting normally once its cataclysm is spent") end function tests.bossPhaseFieldsSurviveALevelSerializationRoundTrip() local game = stagedGame(3, arena(21, 11), 5, 5) local spec = Bosses.byKey.warlord local warlord = Bosses.create(spec, 1, 6, 5) warlord.enraged = true warlord.hp = math.floor(warlord.maxHp * spec.desperateAt) Phases.tick(game, warlord) -- brace: desperate + channeling game.level.entities = { warlord } local restoredLevel = Level.fromData(game.level:toData()) local restored = restoredLevel.entities[1] eq(restored.desperate, true, "the boss's desperate flag survives a round trip") eq(restored.channeling, true, "the boss's channeling flag survives a round trip") eq(restored.defense, warlord.defense, "the boss's braced defense value survives a round trip") Phases.tick(game, restored) -- unleash on the restored copy eq(restored.novaSpent, true, "a restored boss can still unleash and latch novaSpent") end function tests.questGenerateProducesACullBounty() local bounty = Quests.generate(Rng.new(2), 4, 10) eq(bounty.kind, Quests.CULL, "this seed at depth 4 rolls a cull bounty") eq(bounty.species, "necromancer", "the cull bounty's target species is fixed by the seed") eq(bounty.need, 4, "the cull bounty's tally is fixed by the seed") eq(bounty.have, 0, "a freshly generated cull bounty starts at zero progress") eq(bounty.rewardGold, 47, "reward gold scales with the generating depth") eq(bounty.rewardXp, 26, "reward xp scales with the generating depth") eq(Quests.describe(bounty), "cull 4 necromancer (0/4)", "describe renders the cull bounty's live progress") end function tests.questGenerateProducesAHoardBounty() local bounty = Quests.generate(Rng.new(1), 4, 10) eq(bounty.kind, Quests.HOARD, "this seed at depth 4 rolls a hoard bounty") eq(bounty.need, 120, "the hoard bounty's absolute gold target scales with depth") eq(Quests.describe(bounty), "amass 120 gold", "describe renders the hoard bounty's target") end function tests.questGenerateProducesADepthBountyClampedToMaxDepth() local shallow = Quests.generate(Rng.new(5), 4, 10) eq(shallow.kind, Quests.DEPTH, "this seed at depth 4 rolls a depth bounty") eq(shallow.need, 6, "an unclamped depth bounty targets a few floors further down") local nearBottom = Quests.generate(Rng.new(5), 9, 10) eq(nearBottom.kind, Quests.DEPTH, "the same seed at depth 9 also rolls a depth bounty") eq(nearBottom.need, 10, "a depth bounty that would exceed the dungeon's floor is clamped to it") end function tests.questAcceptBindsThePlayerAndClaimsTheBoard() local player = { quest = nil } local board = { x = 5, y = 5, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } local success, line = Quests.accept(player, board) eq(success, true, "accepting an unclaimed bounty while unbound succeeds") eq(player.quest, board.quest, "the player is bound to the board's exact bounty") eq(board.claimed, true, "the board is claimed the instant its bounty is taken") ok(line:find("necromancer") ~= nil, "the acceptance line names the bounty struck") end function tests.questAcceptFailsWhenAlreadyHoldingOne() local existing = Quests.generate(Rng.new(1), 4, 10) local player = { quest = existing } local board = { x = 5, y = 5, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } local success = Quests.accept(player, board) eq(success, false, "a player already bound to a bounty cannot bind to a second") eq(player.quest, existing, "the player's original bounty is untouched") eq(board.claimed, false, "the untaken board stays unclaimed") end function tests.questAcceptFailsOnAnAlreadyClaimedBoard() local player = { quest = nil } local board = { x = 5, y = 5, claimed = true, quest = Quests.generate(Rng.new(2), 4, 10) } local success = Quests.accept(player, board) eq(success, false, "a board already claimed grants no second bounty") eq(player.quest, nil, "the player stays unbound") end function tests.questRegisterKillIncrementsOnlyMatchingSpeciesWhileActive() local player = { quest = { kind = Quests.CULL, species = "rat", need = 3, have = 0 } } Quests.registerKill(player, "goblin") eq(player.quest.have, 0, "a kill of an unrelated species does not advance the tally") Quests.registerKill(player, "rat") Quests.registerKill(player, "rat") eq(player.quest.have, 2, "kills of the target species advance the tally") Quests.registerKill(player, "rat") Quests.registerKill(player, "rat") eq(player.quest.have, 3, "the tally never climbs past the bounty's required count") end function tests.questResolvedDetectsEachKindsCompletion() local culler = { quest = { kind = Quests.CULL, need = 3, have = 2 }, gold = 0 } eq(Quests.resolved(culler, 1), false, "a cull short of its tally is not resolved") culler.quest.have = 3 eq(Quests.resolved(culler, 1), true, "a cull at its tally is resolved") local hoarder = { quest = { kind = Quests.HOARD, need = 100 }, gold = 40 } eq(Quests.resolved(hoarder, 1), false, "a hoard short of its gold target is not resolved") hoarder.gold = 100 eq(Quests.resolved(hoarder, 1), true, "a hoard at its gold target is resolved") local pactbound = { quest = { kind = Quests.DEPTH, need = 5 }, gold = 0 } eq(Quests.resolved(pactbound, 4), false, "a depth pact short of its floor is not resolved") eq(Quests.resolved(pactbound, 5), true, "a depth pact met at its floor is resolved") eq(Quests.resolved({ quest = nil, gold = 0 }, 5), false, "a player with no bounty is never resolved") end function tests.gameResolveQuestPaysOutAndClearsAFulfilledBounty() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.quest = { kind = Quests.CULL, species = "rat", need = 1, have = 1, rewardGold = 30, rewardXp = 15 } local goldBefore, essenceBefore = game.player.gold, game.player.essence local resolved = game:resolveQuest() eq(resolved, true, "resolveQuest reports the payout it made") eq(game.player.gold, goldBefore + 30, "the bounty's gold reward is paid") eq(game.player.essence, essenceBefore + Crafting.essenceFor(15), "the bounty's xp reward also converts to essence") eq(game.player.quest, nil, "a resolved bounty frees the player to bind another") end function tests.gameResolveQuestIsANoOpOnAnUnmetBounty() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.quest = { kind = Quests.CULL, species = "rat", need = 3, have = 1, rewardGold = 30, rewardXp = 15 } local goldBefore = game.player.gold local resolved = game:resolveQuest() eq(resolved, false, "resolveQuest reports nothing was paid out") eq(game.player.gold, goldBefore, "no gold changes hands on an unmet bounty") ok(game.player.quest ~= nil, "the unmet bounty stays bound to the player") end function tests.gameTryAcceptQuestBindsAndLogsWhenStandingOnAnUnclaimedBoard() local game = stagedGame(1, arena(21, 11), 5, 5) local board = { x = 5, y = 5, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } local success = game:tryAcceptQuest(board) eq(success, true, "tryAcceptQuest binds the player to the board it stands on") eq(game.player.quest.kind, Quests.CULL, "the bound bounty matches the board's own") eq(board.claimed, true, "the board is spent on acceptance") end function tests.autopilotAcceptsAnUnclaimedBountyForFreeWithoutSkippingItsTurn() local game = stagedGame(1, arena(21, 11), 5, 5) game.level.questBoards = { { x = 5, y = 5, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } } local orc = Monsters.create(Monsters.byKey.orc, 1, 6, 5, 1) game.level.entities = { orc } local hpBefore = orc.hp game:choosePlayerAction() ok(game.player.quest ~= nil, "the autopilot binds itself to a bounty it stands on") eq(game.level.questBoards[1].claimed, true, "the board is claimed once the autopilot steps onto it") ok(orc.hp < hpBefore, "accepting the bounty costs no action, so the same turn still lands an attack") end function tests.autopilotSkipsAnAlreadyClaimedBoard() local game = stagedGame(1, arena(21, 11), 5, 5) game.level.questBoards = { { x = 5, y = 5, claimed = true, quest = Quests.generate(Rng.new(2), 4, 10) } } game:choosePlayerAction() eq(game.player.quest, nil, "the autopilot never binds to an already claimed board") end function tests.autopilotDoesNotStackASecondBountyWhileOneIsActive() local game = stagedGame(1, arena(21, 11), 5, 5) local held = Quests.generate(Rng.new(1), 4, 10) game.player.quest = held game.level.questBoards = { { x = 5, y = 5, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } } game:choosePlayerAction() eq(game.player.quest, held, "the autopilot keeps its held bounty rather than swapping to a new one") eq(game.level.questBoards[1].claimed, false, "a board it cannot bind to stays unclaimed for later") end function tests.questBoardRendersItsGlyphAndFlipsOnceClaimed() local game = stagedGame(1, arena(21, 11), 3, 6) local board = { x = 5, y = 6, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } game.level.questBoards = { board } game:computeVisible() ok(game:render():find("&", 1, true) ~= nil, "an unclaimed bounty board renders its glyph") board.claimed = true ok(game:render():find("=", 1, true) ~= nil, "a claimed bounty board renders a different glyph") end function tests.questFieldsSurviveSerialization() local level = arena(21, 11) level.questBoards = { { x = 4, y = 4, claimed = true, quest = Quests.generate(Rng.new(2), 4, 10) } } local restored = Level.fromData(level:toData()) eq(#restored.questBoards, 1, "the quest board list survives a level round trip") eq(restored.questBoards[1].claimed, true, "a board's claimed flag survives a round trip") eq(restored.questBoards[1].quest.kind, Quests.CULL, "a board's bounty kind survives a round trip") eq(restored.questBoards[1].quest.need, 4, "a board's bounty tally survives a round trip") local game = Game.new(6) game.player.quest = { kind = Quests.HOARD, need = 200, rewardGold = 50, rewardXp = 20 } local path = os.tmpname() game:save(path) local restoredGame = Game.load(path) os.remove(path) eq(restoredGame.player.quest.kind, Quests.HOARD, "the player's own bound bounty survives a save/load round trip") eq(restoredGame.player.quest.need, 200, "the bound bounty's target survives a save/load round trip") end function tests.builderOccasionallyPlacesAQuestBoardFromDepthTwo() local level = Builder.build(2, Rng.new(11), {}) eq(#level.questBoards, 1, "this seed's depth-2 floor places a bounty board") eq(level.questBoards[1].claimed, false, "a freshly built board starts unclaimed") ok(level.questBoards[1].quest ~= nil, "a freshly built board already carries its one bounty") end function tests.questBoardsStayOffTheEntranceFloor() for seed = 1, 5 do local level = Builder.build(1, Rng.new(seed), {}) eq(#level.questBoards, 0, "no bounty board appears on the entrance floor") end end function tests.metaFreshStateHasNoRenownRunsOrAchievements() local meta = Meta.new() eq(meta.renown, 0, "a fresh meta state banks no renown") eq(meta.totalRuns, 0, "a fresh meta state has recorded no runs") eq(meta.wins, 0, "a fresh meta state has recorded no wins") eq(meta.bestDepth, 0, "a fresh meta state has no best depth yet") eq(next(meta.achievements), nil, "a fresh meta state has unlocked nothing") eq(next(meta.perks), nil, "a fresh meta state owns no perks") end function tests.metaRenownForScalesWithDepthBossesAndQuestsAndAddsAWinBonus() local base = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 0, won = false }) eq(base, 12, "renown scales with deepest depth alone") local withBosses = Meta.renownFor({ deepestDepth = 3, bossKills = 2, questsCompleted = 0, won = false }) eq(withBosses, base + 30, "each felled boss adds a fixed renown bonus") local withQuests = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 4, won = false }) eq(withQuests, base + 32, "each resolved bounty adds a fixed renown bonus") local won = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 0, won = true }) eq(won, base + 100, "escaping with the amulet adds a large flat bonus") end function tests.metaRecordRunBanksRenownAndAdvancesTallies() local meta = Meta.new() Meta.recordRun(meta, { deepestDepth = 4, kills = 2, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = false }) eq(meta.totalRuns, 1, "recordRun advances the run tally") eq(meta.wins, 0, "an unwon run does not advance the win tally") eq(meta.bestDepth, 4, "recordRun raises the best depth to this run's own") eq(meta.renown, Meta.renownFor({ deepestDepth = 4, bossKills = 0, questsCompleted = 0, won = false }), "recordRun banks exactly the renown this run's tally is worth") Meta.recordRun(meta, { deepestDepth = 2, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = true }) eq(meta.totalRuns, 2, "a second run advances the run tally again") eq(meta.wins, 1, "a won run advances the win tally") eq(meta.bestDepth, 4, "a shallower later run does not lower the best depth") end function tests.metaRecordRunUnlocksMatchingAchievementsOnlyOnce() local meta = Meta.new() local stats = { deepestDepth = 5, kills = 1, bossKills = 1, questsCompleted = 3, artifactsFound = 1, won = true } local unlocked = Meta.recordRun(meta, stats) table.sort(unlocked) local expected = { "Amulet Bearer", "Artificer", "Boss Slayer", "Bounty Hunter", "Deep Delver", "First Blood" } table.sort(expected) eq(table.concat(unlocked, ","), table.concat(expected, ","), "a run meeting every threshold unlocks every achievement at once") for _, key in ipairs({ "first_blood", "boss_slayer", "deep_delver", "bounty_hunter", "artificer", "amulet_bearer" }) do eq(meta.achievements[key], true, key .. " is marked unlocked") end local again = Meta.recordRun(meta, stats) eq(#again, 0, "an already-unlocked achievement is not re-announced on a later run") end function tests.metaPurchaseSpendsRenownAndMarksThePerkOwned() local meta = Meta.new() meta.renown = 30 local success, message = Meta.purchase(meta, "vigor") eq(success, true, "purchase succeeds with enough banked renown") eq(message, "unlocked Vigor", "purchase reports the perk it unlocked") eq(meta.renown, 0, "purchase spends exactly the perk's cost") eq(Meta.isOwned(meta, "vigor"), true, "the purchased perk is marked owned") end function tests.metaPurchaseFailsWithoutEnoughRenown() local meta = Meta.new() meta.renown = 5 local success, message = Meta.purchase(meta, "vigor") eq(success, false, "purchase fails without enough banked renown") eq(message, "not enough renown", "purchase explains why it failed") eq(meta.renown, 5, "a failed purchase spends nothing") end function tests.metaPurchaseFailsForAnAlreadyOwnedPerk() local meta = Meta.new() meta.renown = 100 Meta.purchase(meta, "vigor") local renownAfterFirst = meta.renown local success, message = Meta.purchase(meta, "vigor") eq(success, false, "purchase refuses to sell the same perk twice") eq(message, "already owned", "purchase explains why it failed") eq(meta.renown, renownAfterFirst, "a refused repurchase spends nothing further") end function tests.metaPurchaseFailsForAnUnknownPerk() local meta = Meta.new() meta.renown = 100 local success, message = Meta.purchase(meta, "not_a_real_perk") eq(success, false, "purchase fails for a key outside the catalog") eq(message, "no such perk", "purchase explains why it failed") eq(meta.renown, 100, "a failed purchase spends nothing") end function tests.metaApplyAllAppliesEveryOwnedPerkToAFreshPlayer() local meta = Meta.new() meta.renown = 200 Meta.purchase(meta, "vigor") Meta.purchase(meta, "purse") local game = Game.new(1, { meta = meta }) eq(game.player.maxHp, 30 + 5, "the owned vigor perk raises max hp before class deltas") eq(game.player.hp, 30 + 5, "the owned vigor perk raises current hp to match") eq(game.player.gold, 20 + 25, "the owned deep pockets perk raises starting gold") local plain = Game.new(1) eq(plain.player.maxHp, 30, "a game started without a meta table gets no perk bonus") end function tests.metaAppliedPerksLandBeneathAClassDelta() local meta = Meta.new() meta.renown = 200 Meta.purchase(meta, "vigor") local game = Game.new(1, { meta = meta, class = "fighter" }) eq(game.player.maxHp, 30 + 5 + 8, "a fighter's class bonus stacks on top of an owned perk's bonus") end function tests.metaLoadReturnsFreshStateForAMissingFile() local meta = Meta.load("/tmp/warren_meta_does_not_exist_12345.sav") eq(meta.renown, 0, "loading a missing meta file yields a fresh state") eq(meta.totalRuns, 0, "a freshly defaulted meta state has recorded no runs") end function tests.metaSaveLoadRoundTrip() local meta = Meta.new() meta.renown = 42 Meta.recordRun(meta, { deepestDepth = 6, kills = 5, bossKills = 1, questsCompleted = 2, artifactsFound = 1, won = false }) Meta.purchase(meta, "focus") local path = os.tmpname() Meta.save(meta, path) local restored = Meta.load(path) os.remove(path) eq(restored.renown, meta.renown, "renown survives a save/load round trip") eq(restored.totalRuns, meta.totalRuns, "run tally survives a save/load round trip") eq(restored.bestDepth, meta.bestDepth, "best depth survives a save/load round trip") eq(restored.achievements.boss_slayer, true, "achievements survive a save/load round trip") eq(restored.perks.focus, true, "owned perks survive a save/load round trip") end function tests.gameAwardKillTracksKillsAndBossKillsForMetaStats() local game = stagedGame(1, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) rat.hp = 1 game.level.entities = { rat } game:playerAttack(rat) eq(game.stats.kills, 1, "felling a plain monster counts toward the kill tally") eq(game.stats.bossKills, 0, "a plain monster does not count as a boss kill") local lich = Bosses.create(Bosses.byKey.lich, 2, 4, 6) lich.hp = 1 game.level.entities = { lich } game:playerAttack(lich) eq(game.stats.kills, 2, "felling a boss also counts toward the plain kill tally") eq(game.stats.bossKills, 1, "felling a boss counts toward the boss-kill tally") end function tests.gameResolveQuestTracksQuestsCompletedForMetaStats() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.quest = { kind = Quests.CULL, species = "rat", need = 1, have = 0, rewardGold = 30, rewardXp = 15 } game:resolveQuest() eq(game.stats.questsCompleted, 0, "an unmet bounty does not count toward quests completed") game.player.quest = { kind = Quests.CULL, species = "rat", need = 1, have = 1, rewardGold = 30, rewardXp = 15 } game:resolveQuest() eq(game.stats.questsCompleted, 1, "resolving a bounty counts toward quests completed") end function tests.gamePickupTracksArtifactsFoundForMetaStats() local game = stagedGame(1, arena(21, 11), 5, 5) game.level.floorItems = { { x = 5, y = 5, item = Artifacts.create("bloodfang") } } game:pickupHere() eq(game.stats.artifactsFound, 1, "picking up an artifact counts toward artifacts found") ok(game.player.weapon == nil, "picking up an artifact only stashes it in inventory, it is not auto-equipped") end function tests.gameDescendTracksDeepestDepthEvenAfterAscending() local game = Game.new(9, { maxDepth = 4 }) eq(game.deepestDepth, 1, "a fresh game's deepest depth is its starting depth") game.player.x, game.player.y = game.level.downX, game.level.downY game:descend() game.player.x, game.player.y = game.level.downX, game.level.downY game:descend() eq(game.deepestDepth, 3, "descending twice raises the deepest depth to the floor just reached") game.player.x, game.player.y = game.level.upX, game.level.upY game:ascend() eq(game.depth, 2, "ascending drops the live depth") eq(game.deepestDepth, 3, "ascending back up does not erase the deepest depth already reached") end function tests.gameMetaStatsFieldsSurviveSerialization() local game = stagedGame(1, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) rat.hp = 1 game.level.entities = { rat } game:playerAttack(rat) game.deepestDepth = 3 local restored = Game.fromData(game:toData()) eq(restored.stats.kills, game.stats.kills, "the kill tally survives a save/load round trip") eq(restored.deepestDepth, game.deepestDepth, "the deepest depth survives a save/load round trip") local snapshot = restored:metaStats() eq(snapshot.kills, 1, "metaStats reads the restored kill tally") eq(snapshot.deepestDepth, 3, "metaStats reads the restored deepest depth") end -- Elemental resistance (resistances.lua): a damage-type layer that sits -- underneath every existing damage pipeline. Default behaviour (no resist -- table anywhere) must stay perfectly transparent - that is what every test -- above this point already relies on - so these tests build entities and -- gear with resist tables explicitly rather than reaching for bestiary data. function tests.resistancesDefaultTierPassesDamageThroughUnchanged() local plain = Entity.new({ x = 1, y = 1, glyph = "r", name = "rat", kind = "monster", hp = 10, maxHp = 10, attack = 2, defense = 0 }) eq(Resistances.tierOf(plain, Resistances.FIRE), Resistances.NORMAL, "an entity with no resist table is normal to every type") eq(Resistances.apply(plain, 7, Resistances.FIRE), 7, "normal tier leaves the amount untouched") eq(Resistances.apply(plain, 0, Resistances.FIRE), 0, "zero damage stays zero regardless of tier") end function tests.resistancesResistHalvesAndFloors() local warded = Entity.new({ x = 1, y = 1, glyph = "@", name = "warded one", kind = "monster", hp = 20, maxHp = 20, attack = 2, defense = 0 }) warded.resist = { fire = Resistances.RESIST } eq(Resistances.apply(warded, 9, Resistances.FIRE), 4, "resist halves and floors an odd amount") eq(Resistances.apply(warded, 1, Resistances.FIRE), 0, "resist can floor a small hit all the way to zero") eq(Resistances.apply(warded, 9, Resistances.COLD), 9, "a resist entry for one type does not bleed into another") end function tests.resistancesWeakAmplifies() local frail = Entity.new({ x = 1, y = 1, glyph = "@", name = "frail one", kind = "monster", hp = 20, maxHp = 20, attack = 2, defense = 0 }) frail.resist = { cold = Resistances.WEAK } eq(Resistances.apply(frail, 4, Resistances.COLD), 6, "weak deals 1.5x, floored") eq(Resistances.apply(frail, 4, Resistances.FIRE), 4, "an unrelated type is unaffected by a cold weakness") end function tests.resistancesImmuneZeroesEvenAHugeHit() local warded = Entity.new({ x = 1, y = 1, glyph = "@", name = "warded one", kind = "monster", hp = 20, maxHp = 20, attack = 2, defense = 0 }) warded.resist = { poison = Resistances.IMMUNE } eq(Resistances.apply(warded, 500, Resistances.POISON), 0, "immune drops even a massive hit to nothing") end function tests.resistancesEquipmentGrantsATierOnTopOfABaseProfile() local wearer = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, maxHp = 20, attack = 5, defense = 1 }) eq(Resistances.tierOf(wearer, Resistances.COLD), Resistances.NORMAL, "an unequipped entity has no resist from thin air") wearer.ring = Items.ring("frost band", 0, 0) wearer.ring.resist = { cold = Resistances.RESIST } eq(Resistances.tierOf(wearer, Resistances.COLD), Resistances.RESIST, "a worn ring grants its resist tier") eq(Resistances.apply(wearer, 10, Resistances.COLD), 5, "the granted tier scales incoming damage of that type") wearer.armor = Items.armor("plate", 2) wearer.armor.resist = { cold = Resistances.IMMUNE } eq(Resistances.tierOf(wearer, Resistances.COLD), Resistances.IMMUNE, "the best tier across every equipped slot wins") end function tests.resistancesInnateWeaknessIsMaskedByAResistantItem() local dragon = Entity.new({ x = 1, y = 1, glyph = "D", name = "young dragon", kind = "monster", hp = 30, maxHp = 30, attack = 8, defense = 3 }) dragon.resist = { cold = Resistances.WEAK } eq(Resistances.tierOf(dragon, Resistances.COLD), Resistances.WEAK, "the innate weakness applies with nothing worn") dragon.ring = Items.ring("frost band", 0, 0) dragon.ring.resist = { cold = Resistances.RESIST } eq(Resistances.tierOf(dragon, Resistances.COLD), Resistances.RESIST, "a resistant ring outranks and masks an innate weakness") end function tests.combatDefenderResistanceHalvesTheFinalBlow() local attacker = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, maxHp = 20, attack = 10, defense = 1 }) local defender = Entity.new({ x = 2, y = 1, glyph = "o", name = "orc", kind = "monster", hp = 30, maxHp = 30, attack = 4, defense = 2 }) local baseline = Combat.damage(attacker, defender) defender.resist = { physical = Resistances.RESIST } local resisted = Combat.damage(attacker, defender) eq(resisted, math.floor(baseline / 2), "a physical resist halves combat.damage's own output for the same matchup") end function tests.combatWeaponElementOverridesPhysicalAndCanBeFullyImmune() local attacker = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, maxHp = 20, attack = 10, defense = 1 }) attacker.weapon = Affixes.decorate(Items.weapon("dagger", 2), Affixes.weapon[3]) eq(attacker.weapon.damageType, "fire", "a flaming affix retags the weapon's own damage type") local mundane = Entity.new({ x = 2, y = 1, glyph = "o", name = "orc", kind = "monster", hp = 30, maxHp = 30, attack = 4, defense = 2 }) ok(Combat.damage(attacker, mundane) > 0, "a flaming weapon still hurts an ordinary target") local salamander = Entity.new({ x = 2, y = 1, glyph = "s", name = "salamander", kind = "monster", hp = 30, maxHp = 30, attack = 4, defense = 2 }) salamander.resist = { fire = Resistances.IMMUNE } eq(Combat.damage(attacker, salamander), 0, "the same flaming weapon does nothing at all against a fire-immune target") end function tests.statusPoisonAndBurningTicksRespectResistance() local immunePoison = Entity.new({ x = 1, y = 1, glyph = "z", name = "zombie", kind = "monster", hp = 10, maxHp = 10, attack = 2, defense = 0 }) immunePoison.resist = { poison = Resistances.IMMUNE } Status.apply(immunePoison, Status.POISON, 3, 4) local lines = Status.tick(immunePoison) eq(immunePoison.hp, 10, "a poison-immune entity takes no damage from a poison tick") ok(contains(lines[1], "shrugs off"), "the immune tick logs a distinct shrug-off line") local frailToFire = Entity.new({ x = 1, y = 1, glyph = "T", name = "troll", kind = "monster", hp = 20, maxHp = 20, attack = 6, defense = 2 }) frailToFire.resist = { fire = Resistances.WEAK } Status.apply(frailToFire, Status.BURNING, 3, 2) Status.tick(frailToFire) eq(frailToFire.hp, 17, "a fire-weak entity takes 1.5x the tick's potency, floored") end function tests.hazardsAfflictAppliesTargetResistance() local level = arena(21, 11) Hazards.ignite(level, 5, 5, 3, 4) local salamander = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) salamander.resist = { fire = Resistances.RESIST } local hpBefore = salamander.hp Hazards.afflict(level, salamander) eq(salamander.hp, hpBefore - 2, "standing in fire as fire-resistant halves the usual potency") end function tests.spellMissileAndFireballRespectTargetResistance() local game = stagedGame(1, arena(21, 11), 3, 6) local wisp = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) wisp.hp, wisp.maxHp = 40, 40 wisp.resist = { arcane = Resistances.IMMUNE } game.level.entities = { wisp } game:castSpell("missile", wisp) eq(wisp.hp, 40, "an arcane-immune target takes no damage from magic missile") local blastGame = stagedGame(2, arena(21, 11), 5, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) orc.hp, orc.maxHp = 20, 20 orc.resist = { fire = Resistances.RESIST } blastGame.level.entities = { orc } Effects.apply("fireball", blastGame, blastGame.player, {}) eq(orc.hp, 16, "a fire-resistant target only takes half of fireball's flat burn") end function tests.throwingAndRangedDamageRespectResistance() local game = stagedGame(1, arena(21, 11), 3, 6) local flamingDagger = Affixes.decorate(Items.weapon("dagger", 3), Affixes.weapon[3]) game.player.inventory[#game.player.inventory + 1] = flamingDagger local salamander = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) salamander.hp, salamander.maxHp = 40, 40 salamander.resist = { fire = Resistances.IMMUNE } game.level.entities = { salamander } local success = game:throwAt(#game.player.inventory, salamander) ok(success, "the throw itself still leaves the thrower's hand") eq(salamander.hp, 40, "a fire-immune target takes no damage from a thrown flaming weapon") local rangedGame = stagedGame(1, arena(21, 11), 3, 6) rangedGame.player.bow = Items.bow("longbow", 3, 6) rangedGame.player.inventory[#rangedGame.player.inventory + 1] = Items.ammo("arrow", 5) local armored = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) armored.hp, armored.maxHp = 40, 40 armored.resist = { physical = Resistances.RESIST } rangedGame.level.entities = { armored } rangedGame:fireAt(armored) eq(armored.hp, 37, "physical resistance halves the arrow's already-defense-reduced damage") end function tests.equippingCinderplateAndFrostbandGrantsTheirResist() local wearer = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, maxHp = 20, attack = 5, defense = 1 }) Items.equipArmor(Artifacts.create("cinderplate"), wearer) eq(Resistances.tierOf(wearer, Resistances.FIRE), Resistances.RESIST, "cinderplate grants fire resist while worn") Items.equipRing(Artifacts.create("frostband"), wearer) eq(Resistances.tierOf(wearer, Resistances.COLD), Resistances.RESIST, "frostband grants cold resist while worn") eq(Resistances.tierOf(wearer, Resistances.POISON), Resistances.NORMAL, "neither artifact grants a resist it was not built for") end function tests.disenchantingAnElementalAffixClearsItsDamageType() local game = stagedGame(1, arena(21, 11), 3, 6) local weapon = Affixes.decorate(Items.weapon("dagger", 2), Affixes.weapon[4]) Items.equipWeapon(weapon, game.player) eq(weapon.damageType, "poison", "venomed tags the weapon as poison-typed") Crafting.disenchant(game.player, weapon) eq(weapon.damageType, nil, "disenchanting clears the elemental typing along with the affix") end function tests.bossBreathDamageTypeMatchesTheMonsterAndCanBeShruggedOff() local game = stagedGame(1, arena(21, 11), 3, 6) local dragon = Monsters.create(Monsters.byKey.dragon, 1, 6, 6, 1) game.level.entities = { dragon } eq(Resistances.tierOf(dragon, Resistances.FIRE), Resistances.IMMUNE, "the bestiary dragon is immune to its own breath element") eq(Resistances.tierOf(dragon, Resistances.COLD), Resistances.WEAK, "the bestiary dragon is innately weak to cold") game.player.resist = { fire = Resistances.IMMUNE } local hpBefore = game.player.hp game:monsterShoot(dragon) eq(game.player.hp, hpBefore, "a fire-immune player takes no damage from dragon breath") eq(Status.has(game.player, Status.BURNING), false, "a fire-immune player never catches alight from the breath") end function tests.resistFieldsSurviveEntitySerialization() local dragon = Monsters.create(Monsters.byKey.dragon, 1, 5, 5, 6) local restored = Entity.fromData(dragon:toData()) eq(Resistances.tierOf(restored, Resistances.FIRE), Resistances.IMMUNE, "a restored entity keeps its innate resist profile") end function tests.factionsTierBoundariesReflectReputationScore() eq(Factions.tier(0), "neutral", "a fresh reputation reads neutral") eq(Factions.tier(-10), "unfriendly", "the unfriendly ceiling is inclusive") eq(Factions.tier(-9), "neutral", "one point above the unfriendly ceiling is still neutral") eq(Factions.tier(-30), "hostile", "the hostile ceiling is inclusive") eq(Factions.tier(25), "friendly", "the friendly floor is inclusive") eq(Factions.tier(60), "honored", "the honored floor is inclusive") end function tests.factionsAdjustAccumulatesAndClampsToTheHundredBounds() local player = {} Factions.adjust(player, Factions.GOBLINOID, -40) eq(Factions.reputationFor(player, Factions.GOBLINOID), -40, "a first adjustment lands from the implicit zero baseline") Factions.adjust(player, Factions.GOBLINOID, -80) eq(Factions.reputationFor(player, Factions.GOBLINOID), -100, "reputation floors at -100") Factions.adjust(player, Factions.GOBLINOID, 500) eq(Factions.reputationFor(player, Factions.GOBLINOID), 100, "reputation caps at 100") eq(Factions.reputationFor(player, Factions.MERCHANT), 0, "an untouched faction still reads neutral zero") end function tests.factionsRegisterKillSoursOnlyTheFallensFaction() local player = {} local goblin = Monsters.create(Monsters.byKey.goblin, 1, 1, 1, 2) Factions.registerKill(player, goblin) eq(Factions.reputationFor(player, Factions.GOBLINOID), -5, "felling a goblin sours goblinoid standing") eq(Factions.reputationFor(player, Factions.VERMIN), 0, "an unrelated faction is left untouched") end function tests.factionsRegisterKillPenalizesABossFarMoreThanARegularKill() local player = {} local dragon = Monsters.create(Monsters.byKey.dragon, 1, 1, 1, 6) dragon.isBoss = true Factions.registerKill(player, dragon) eq(Factions.reputationFor(player, Factions.DRAKE), -15, "a boss kill costs three times a rank-and-file kill") end function tests.factionsRegisterKillIgnoresAFactionlessTarget() local player = {} Factions.registerKill(player, { faction = nil }) eq(player.reputation, nil, "a factionless kill never even creates a reputation table") end function tests.factionsPriceMultiplierScalesFromHostileToHonored() local player = {} eq(Factions.priceMultiplier(player, Factions.MERCHANT), 1.0, "a fresh, neutral standing charges catalog price") Factions.adjust(player, Factions.MERCHANT, -40) eq(Factions.priceMultiplier(player, Factions.MERCHANT), 1.5, "a hostile guild marks its prices up") Factions.adjust(player, Factions.MERCHANT, 100) eq(Factions.priceMultiplier(player, Factions.MERCHANT), 0.65, "an honored guild discounts its prices") end function tests.factionsIsWaryTrueForAFreshGoblinoidAndFalseOnceProvoked() local player = {} local kobold = Monsters.create(Monsters.byKey.kobold, 1, 1, 1, 1) eq(Factions.isWary(player, kobold), true, "an unprovoked goblinoid starts wary of a stranger") kobold.provoked = true eq(Factions.isWary(player, kobold), false, "a provoked goblinoid is no longer wary") end function tests.factionsIsWaryIsFalseForNonTribalFactionsAndOnceTheTribeGoesHostile() local player = {} local rat = Monsters.create(Monsters.byKey.rat, 1, 1, 1, 1) eq(Factions.isWary(player, rat), false, "vermin were never a wary tribe to begin with") local kobold = Monsters.create(Monsters.byKey.kobold, 2, 1, 1, 1) Factions.adjust(player, Factions.GOBLINOID, -40) eq(Factions.isWary(player, kobold), false, "a tribe hated into hostility stops holding back even for a fresh member") end function tests.factionsAppeaseSpendsGoldAndRaisesReputationOrFailsWithoutFunds() local player = { gold = 10 } local success, cost = Factions.appease(player, Factions.GOBLINOID) eq(success, false, "an empty purse cannot buy any goodwill") eq(cost, 20, "the failure reports the base tribute cost at neutral standing") eq(player.gold, 10, "a failed tribute spends nothing") player.gold = 100 local success2, cost2 = Factions.appease(player, Factions.GOBLINOID) eq(success2, true, "a well-stocked purse can pay the tribute") eq(cost2, 20, "the neutral tribute costs its base amount") eq(player.gold, 80, "the tribute's cost is deducted") eq(Factions.reputationFor(player, Factions.GOBLINOID), 15, "the tribute raises standing by its fixed amount") end function tests.factionsAppeaseCostsMoreTheDeeperTheGrudge() local player = { gold = 1000 } Factions.adjust(player, Factions.BANDIT, -30) local _, cost = Factions.appease(player, Factions.BANDIT) eq(cost, 80, "the tribute cost climbs with how negative the standing already is") end function tests.factionsAppeaseFailsOnceAFactionIsAlreadyMaxedOut() local player = { gold = 1000, reputation = { [Factions.GOBLINOID] = 100 } } local success, cost = Factions.appease(player, Factions.GOBLINOID) eq(success, false, "an already-maxed relationship has nothing left to buy") eq(cost, 0, "the failure reports there was no cost, not an unaffordable one") eq(player.gold, 1000, "nothing is spent when there is nothing to buy") end function tests.factionAndProvokedFieldsSurviveEntitySerialization() local kobold = Monsters.create(Monsters.byKey.kobold, 1, 1, 1, 1) kobold.provoked = true local restored = Entity.fromData(kobold:toData()) eq(restored.faction, Factions.GOBLINOID, "a monster's faction tag survives a round trip") eq(restored.provoked, true, "a monster's provoked flag survives a round trip") end function tests.gameTakeMonsterTurnLeavesAWaryGoblinoidAloneUntilProvoked() local game = stagedGame(1, arena(21, 11), 3, 6) local kobold = Monsters.create(Monsters.byKey.kobold, 1, 4, 6, 1) game.level.entities = { kobold } local hpBefore = game.player.hp game:takeMonsterTurn(kobold) eq(kobold.x, 4, "an unprovoked kobold does not close in") eq(game.player.hp, hpBefore, "an unprovoked kobold does not attack even when adjacent") end function tests.gamePlayerAttackProvokesAWaryGoblinoidSoItFightsBackNextTurn() local game = stagedGame(1, arena(21, 11), 3, 6) local kobold = Monsters.create(Monsters.byKey.kobold, 1, 4, 6, 1) kobold.hp = 100 game.level.entities = { kobold } game:playerAttack(kobold) eq(kobold.provoked, true, "aiming a melee attack at a wary goblinoid provokes it") local hpBefore = game.player.hp game:takeMonsterTurn(kobold) ok(game.player.hp < hpBefore, "a provoked, adjacent kobold now fights back") end function tests.gameAwardKillSoursFactionReputationWithTheFallensFaction() local game = stagedGame(1, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) orc.hp = 1 game.level.entities = { orc } game:playerAttack(orc) eq(orc:isAlive(), false, "the orc dies to the hit") eq(Factions.reputationFor(game.player, Factions.ORCISH), -5, "the kill sours standing with the orc's faction") end function tests.shopBuyIsMarkedUpWhenMerchantsAreHostile() local game = stagedGame(1, arena(21, 11), 3, 3) local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) game.level.entities = { keeper } game.player.gold = 100 Factions.adjust(game.player, Factions.MERCHANT, -40) game.level.floorItems = { { x = 4, y = 3, item = Items.potionOf("heal"), price = 15, shopId = keeper.id } } game:movePlayer(1, 0) eq(game.player.gold, 78, "a hostile guild marks the stamped price up by half, floored") end function tests.shopBuyIsDiscountedWhenMerchantsAreHonored() local game = stagedGame(1, arena(21, 11), 3, 3) local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) game.level.entities = { keeper } game.player.gold = 100 Factions.adjust(game.player, Factions.MERCHANT, 60) game.level.floorItems = { { x = 4, y = 3, item = Items.potionOf("heal"), price = 15, shopId = keeper.id } } game:movePlayer(1, 0) eq(game.player.gold, 91, "an honored guild discounts the stamped price, floored") end function tests.shopSellValueRisesWithMerchantStanding() local game = stagedGame(1, arena(21, 11), 3, 3) game.player.inventory = { Items.potionOf("heal") } local goldBefore = game.player.gold Factions.adjust(game.player, Factions.MERCHANT, 60) local value = game:sellItem(1) eq(value, 10, "an honored guild pays more than the neutral half-price for the same ware") eq(game.player.gold, goldBefore + 10, "the sale proceeds land in the wallet") end function tests.stealingFromShopAlsoSoursMerchantReputation() local game = stagedGame(1, arena(21, 11), 3, 3) local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) game.level.entities = { keeper } game.level.floorItems = { { x = 4, y = 3, item = Items.scroll("identify"), price = 18, shopId = keeper.id } } game:stealFromShop(1) eq(Factions.reputationFor(game.player, Factions.MERCHANT), -20, "a theft sours the merchant guild's opinion of the player") end function tests.resolveQuestNudgesMerchantReputationUpward() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.quest = { kind = Quests.CULL, species = "rat", need = 1, have = 1, rewardGold = 30, rewardXp = 15 } game:resolveQuest() eq(Factions.reputationFor(game.player, Factions.MERCHANT), 3, "turning in a bounty pads the merchant guild's opinion of the player") end function tests.gameAppeaseFactionSpendsGoldAndLogsTheOutcome() local game = stagedGame(1, arena(21, 11), 3, 3) game.player.gold = 100 local success = game:appeaseFaction(Factions.GOBLINOID) eq(success, true, "a well-funded tribute succeeds") eq(game.player.gold, 80, "the tribute's cost is deducted from the wallet") eq(Factions.reputationFor(game.player, Factions.GOBLINOID), 15, "the tribute raises standing") ok(contains(game.log:last(), "goblinoid"), "the successful tribute is logged") game.player.gold = 0 local failure = game:appeaseFaction(Factions.BANDIT) eq(failure, false, "an empty purse cannot pay any tribute") ok(contains(game.log:last(), "gold"), "the failed tribute explains the shortfall") end function tests.playerReputationSurvivesGameSaveLoadRoundTrip() local game = stagedGame(1, arena(21, 11), 3, 3) Factions.adjust(game.player, Factions.GOBLINOID, -40) Factions.adjust(game.player, Factions.MERCHANT, 10) local restored = Game.fromData(game:toData()) eq(Factions.reputationFor(restored.player, Factions.GOBLINOID), -40, "goblinoid standing survives a save/load round trip") eq(Factions.reputationFor(restored.player, Factions.MERCHANT), 10, "merchant standing survives a save/load round trip") end -- Carry weight (encumbrance.lua): a hoard heavy enough to cross a capacity -- threshold slows the carrier down, and a wide overage on top of that also -- costs defense. Weight is derived purely from inventory/equipped items and -- level, so nothing here needs its own save-format handling. function tests.encumbranceItemWeightScalesWithGearPower() eq(Encumbrance.itemWeight(Items.weapon("dagger", 2)), 6, "a weak weapon carries close to its flat base weight") eq(Encumbrance.itemWeight(Items.weapon("greatsword", 9)), 9, "a powerful weapon weighs more than a weak one of the same kind") eq(Encumbrance.itemWeight(Items.armor("padded vest", 0)), 9, "armor has its own, heavier, flat base") end function tests.encumbranceAmmoWeightScalesWithStackCount() eq(Encumbrance.itemWeight(Items.ammo("arrow", 1)), 1, "a single arrow weighs its unit weight") eq(Encumbrance.itemWeight(Items.ammo("arrow", 5)), 5, "a stack of arrows weighs the unit weight times the count") end function tests.encumbranceCapacityGrowsWithLevel() eq(Encumbrance.capacityFor({ level = 1 }), 16, "a level 1 carrier's capacity is the flat base plus one level's worth") eq(Encumbrance.capacityFor({ level = 5 }), 32, "capacity scales linearly with level") eq(Encumbrance.capacityFor({}), 16, "a missing level defaults to level 1's capacity") end function tests.encumbranceEquippedGearCountsTowardCarriedWeight() local carrier = { inventory = {}, weapon = Items.weapon("sword", 3), armor = Items.armor("mail", 6), ring = Items.ring("band", 1, 1), bow = Items.bow("bow", 2, 3), } eq(Encumbrance.carriedWeight(carrier), 26, "worn and wielded gear counts exactly like the same items in the pack") end function tests.encumbranceLightLoadHasNoPenalty() local game = Game.new(1) eq(game.player:encumbranceTier(), Encumbrance.LIGHT, "a fresh player's starting kit is well under capacity") eq(game.player:effectiveSpeed(), 100, "a light load never slows the carrier down") eq(game.player:effectiveDefense(), 1, "a light load never costs defense") end -- Each padding armor has zero power (so bestUpgrade never mistakes it for an -- upgrade) and so weighs exactly the base 9. local function padWith(entity, count) for _ = 1, count do entity.inventory[#entity.inventory + 1] = Items.armor("lockbox", 0) end end function tests.encumbranceBurdenedSlowsMovementButNotDefense() local game = Game.new(1) game.player.inventory = {} padWith(game.player, 2) ok(Encumbrance.carriedWeight(game.player) > Encumbrance.capacityFor(game.player), "two heavy armors alone clear the fresh player's capacity") eq(game.player:encumbranceTier(), Encumbrance.BURDENED, "a moderate overage is merely burdened") eq(game.player:effectiveSpeed(), 80, "a burdened carrier loses a flat chunk of speed") eq(game.player:effectiveDefense(), 1, "a burdened carrier's defense is untouched") end function tests.encumbranceOverloadedSlowsMovementAndWeakensDefense() local game = Game.new(1) game.player.inventory = {} padWith(game.player, 3) eq(game.player:encumbranceTier(), Encumbrance.OVERLOADED, "a wide overage is overloaded") eq(game.player:effectiveSpeed(), 55, "an overloaded carrier loses far more speed than a merely burdened one") eq(game.player:effectiveDefense(), -1, "an overloaded carrier also fumbles, losing defense") end function tests.gameDropItemReturnsItemToTheFloorAndFreesCapacity() local game = stagedGame(1, arena(21, 11), 3, 3) game.player.inventory = { Items.potionOf("heal") } local before = Encumbrance.carriedWeight(game.player) local dropped = game:dropItem(1) eq(dropped.effect, "heal", "dropItem returns the item that was shed") eq(#game.player.inventory, 0, "the item leaves the inventory") eq(#game.level.floorItems, 1, "the item lands on the floor") eq(game.level.floorItems[1].x, game.player.x, "the dropped item lands exactly at the player's feet") eq(game.level.floorItems[1].y, game.player.y, "the dropped item lands exactly at the player's feet") ok(Encumbrance.carriedWeight(game.player) < before, "dropping an item frees up carried weight") ok(contains(game.log:last(), "drop"), "dropping an item is logged") end function tests.gamePickupWarnsWhenNewWeightTipsIntoOverloaded() local game = stagedGame(1, arena(21, 11), 3, 3) game.player.inventory = {} padWith(game.player, 1) eq(game.player:encumbranceTier(), Encumbrance.LIGHT, "one heavy armor alone still fits under capacity") game.level.floorItems = {} for _ = 1, 2 do game.level.floorItems[#game.level.floorItems + 1] = { x = 3, y = 3, item = Items.armor("lockbox", 0) } end game:pickupHere() eq(game.player:encumbranceTier(), Encumbrance.OVERLOADED, "the pickup tips the total weight past the overloaded threshold") ok(contains(game.log:last(), "overloaded"), "tipping straight into overloaded is called out by name") end function tests.gamePickupWarnsWithALighterMessageWhenOnlyTippingIntoBurdened() local game = stagedGame(1, arena(21, 11), 3, 3) game.player.inventory = {} game.level.floorItems = {} for _ = 1, 2 do game.level.floorItems[#game.level.floorItems + 1] = { x = 3, y = 3, item = Items.armor("lockbox", 0) } end game:pickupHere() eq(game.player:encumbranceTier(), Encumbrance.BURDENED, "two heavy armors in one pickup lands in the burdened band") ok(contains(game.log:last(), "heavy"), "tipping only into burdened gets the softer warning") end function tests.autopilotDropsCheapestJunkWhenOverloadedAndSafe() local game = stagedGame(1, arena(21, 11), 3, 3) game.level.entities = {} game.player.inventory = {} padWith(game.player, 3) local amulet, key = Items.amulet(), Items.key() game.player.inventory[#game.player.inventory + 1] = amulet game.player.inventory[#game.player.inventory + 1] = key local countBefore = #game.player.inventory eq(game:encumbranceTier(), Encumbrance.OVERLOADED, "the staged inventory is overloaded before the autopilot acts") game:choosePlayerAction() eq(#game.player.inventory, countBefore - 1, "the autopilot sheds exactly one item while overloaded and safe") ok(contains(game.log:last(), "drop"), "the shed item is logged as a drop") ok(game:findInInventory(function(item) return item == amulet end) ~= nil, "the amulet is never sacrificed to encumbrance") ok(game:findInInventory(function(item) return item == key end) ~= nil, "a key is never sacrificed to encumbrance") end function tests.encumbranceTierIsDerivedAcrossASaveLoadRoundTrip() local game = stagedGame(1, arena(21, 11), 3, 3) game.player.inventory = {} padWith(game.player, 2) local tierBefore = game:encumbranceTier() local restored = Game.fromData(game:toData()) eq(restored:encumbranceTier(), tierBefore, "the same inventory yields the same tier after a save/load round trip") end function tests.renderPanelShowsCarriedLoad() local game = Game.new(1) local panel = Render.panel(game) ok(contains(panel, "load light ("), "the status panel reports the carrier's current load tier and weight") end function tests.tilesClassifyVolatileTerrain() eq(Tiles.isLava(Tiles.LAVA), true, "isLava recognizes the lava glyph") eq(Tiles.isLava(Tiles.FLOOR), false, "isLava rejects plain floor") eq(Tiles.isChasm(Tiles.CHASM), true, "isChasm recognizes the chasm glyph") eq(Tiles.isChasm(Tiles.FLOOR), false, "isChasm rejects plain floor") eq(Tiles.isIce(Tiles.ICE), true, "isIce recognizes the ice glyph") eq(Tiles.isIce(Tiles.FLOOR), false, "isIce rejects plain floor") eq(Tiles.isWalkable(Tiles.LAVA), true, "lava is walkable at the player's own risk") eq(Tiles.isWalkable(Tiles.CHASM), true, "a chasm is walkable right up until it swallows you") eq(Tiles.isWalkable(Tiles.ICE), true, "ice is walkable, if slippery") end function tests.terrainAfflictLavaDoesNothingOffLava() local level = arena(21, 11) local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) eq(Terrain.afflictLava(level, rat), nil, "afflictLava does nothing to an entity off any lava cell") end function tests.terrainLavaRespectsFireResistanceAndImmunity() local level = arena(21, 11) level.map:set(5, 5, Tiles.LAVA) local resistant = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) resistant.resist = { fire = Resistances.RESIST } local hpBefore = resistant.hp local line = Terrain.afflictLava(level, resistant) eq(resistant.hp, hpBefore - math.floor(Terrain.LAVA_DAMAGE / 2), "lava damage is halved for a fire-resistant target") ok(contains(line, "sears"), "a damaging lava tick describes itself as searing") eq(Status.has(resistant, Status.BURNING), true, "a scorched target is also set alight") local immune = Monsters.create(Monsters.byKey.rat, 2, 5, 5, 1) immune.resist = { fire = Resistances.IMMUNE } local hpBefore2 = immune.hp local immuneLine = Terrain.afflictLava(level, immune) eq(immune.hp, hpBefore2, "a fire-immune target takes no damage from lava") ok(contains(immuneLine, "unharmed"), "a harmless lava tick says so") eq(Status.has(immune, Status.BURNING), false, "a fire-immune target is not set alight by lava") end function tests.terrainLavaBurnsAndIgnitesAPlayerWhoStepsOnIt() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.map:set(4, 6, Tiles.LAVA) local hpBefore = game.player.hp game:movePlayer(1, 0) eq(game.player.hp, hpBefore - Terrain.LAVA_DAMAGE, "stepping onto lava deals its full damage to an unresistant player") eq(Status.has(game.player, Status.BURNING), true, "lava also sets the player alight") ok(contains(game.log:last(), "sears"), "the burn is logged") end function tests.terrainLavaCanKillAWeakMonsterAndCreditsThePlayer() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.map:set(5, 6, Tiles.LAVA) local bat = Monsters.create(Monsters.byKey.bat, 1, 4, 6, 1) bat.hp, bat.maxHp = 3, 3 game.level.entities = { bat } local killsBefore = game.stats.kills game:moveMonster(bat, 1, 0) eq(#game.level.entities, 0, "a monster killed by lava is removed from the level") eq(game.stats.kills, killsBefore + 1, "a lava death still credits the player with a kill") end function tests.terrainChasmDropsThePlayerToTheFloorBelowWithFallDamage() local game = stagedGame(3, arena(21, 11), 5, 6) game.level.map:set(6, 6, Tiles.CHASM) local hpBefore = game.player.hp local depthBefore = game.depth game:movePlayer(1, 0) ok(game.player.hp < hpBefore, "falling into a chasm deals fall damage") eq(game.depth, depthBefore + 1, "falling into a chasm advances to the floor below") eq(game.deepestDepth, depthBefore + 1, "falling into a chasm counts toward the deepest depth reached") ok(contains(game.log:last(), "plunge"), "the fall is logged") end function tests.terrainChasmSwallowsAMonsterAndCreditsTheKill() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.map:set(5, 6, Tiles.CHASM) local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) game.level.entities = { rat } local killsBefore = game.stats.kills game:moveMonster(rat, 1, 0) eq(#game.level.entities, 0, "a monster that falls into a chasm is removed from the level") eq(game.stats.kills, killsBefore + 1, "a chasm death still credits the player with a kill, like a lethal trap") end function tests.terrainIceSlidesATravellerUntilSomethingStopsIt() local game = stagedGame(1, arena(21, 11), 2, 6) for x = 3, 6 do game.level.map:set(x, 6, Tiles.ICE) end game:movePlayer(1, 0) eq(game.player.x, 7, "stepping onto ice slides the player through every consecutive ice tile") 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 -- 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") eq(game.stats.vaultsOpened, 1, "the vault tally advances exactly once, on the pull that completes the puzzle") end function tests.pullingAnAlreadyThrownLeverAgainDoesNotInflateTheVaultTally() 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 = "v5", pulled = false } game.level.vaultLevers = { lever } game.level.vaultDoors = { { x = 10, y = 6, id = "v5", opened = false } } game:pullLever(lever) eq(game.stats.vaultsOpened, 1, "the first pull opens the vault") local pulledAgain = game:pullLever(lever) eq(pulledAgain, false, "re-pulling an already-thrown lever reports failure") eq(game.stats.vaultsOpened, 1, "re-pulling an already-thrown lever does not inflate the vault tally") end function tests.movePlayerAutoPullsALeverItStepsOnto() local game = stagedGame(1, arena(21, 11), 5, 5) game.level.map:set(6, 5, Tiles.LEVER) local lever = { x = 6, y = 5, id = "v6", pulled = false } game.level.vaultLevers = { lever } game.level.vaultDoors = { { x = 15, y = 5, id = "v6", opened = false } } game:movePlayer(1, 0) eq(game.player.x, 6, "the player actually steps onto the lever's cell") eq(lever.pulled, true, "stepping onto a lever automatically throws it") end function tests.vaultDoorBlocksMovementUntilItsLeversOpenIt() local game = stagedGame(1, arena(21, 11), 9, 5) game.level.map:set(10, 5, Tiles.VAULT_DOOR) local moved = game:movePlayer(1, 0) eq(moved, false, "an unopened vault door blocks the step onto it") eq(game.player.x, 9, "the player does not pass through a sealed vault door") game.level.map:set(10, 5, Tiles.DOOR_OPEN) local movedAfter = game:movePlayer(1, 0) eq(movedAfter, true, "once opened, the door's tile behaves like any plain open door") eq(game.player.x, 10, "the player steps onto the formerly sealed cell") end function tests.builderOccasionallyPlacesAVaultFromDepthFive() -- Seed 41, not 1: adding the banshee species (minDepth 5) to Monsters.species -- shifts Monsters.forDepth's eligible count for every depth-5+ monster spawn -- roll, which desyncs Rng.new(1)'s stream from the very first monster onward - -- seed 1 no longer rolls a vault onto this floor at all. Recomputed by -- brute-force search (seeds 1-50) for one that still places exactly one -- vault door with its treasure, same as every other assertion below expects. local level = Builder.build(5, Rng.new(41), {}) eq(#level.vaultDoors, 1, "this seed's depth-5 floor places exactly one vault door") ok(#level.vaultLevers >= 2, "the vault's puzzle has at least two levers") local door = level.vaultDoors[1] eq(level.map:get(door.x, door.y), Tiles.VAULT_DOOR, "the vault door's own tile matches its sealed glyph") for _, lever in ipairs(level.vaultLevers) do eq(lever.id, door.id, "every placed lever shares the door's own vault id") eq(level.map:get(lever.x, lever.y), Tiles.LEVER, "each lever's tile matches its unpulled glyph") end local found for _, ground in ipairs(level.floorItems) do if ground.item.name and ground.item.name:find("vault", 1, true) then found = ground.item end end ok(found ~= nil, "the vault's guaranteed treasure is placed somewhere on the floor") end function tests.vaultsStayOffFloorsAboveDepthFive() for seed = 1, 4 do for depth = 1, 4 do local level = Builder.build(depth, Rng.new(seed), {}) eq(#level.vaultDoors, 0, "no vault door appears above depth five") eq(#level.vaultLevers, 0, "no vault lever appears above depth five") end end end -- Every vault the builder actually places must be genuinely solvable: each -- of its levers sits somewhere already reachable from the entrance without -- crossing a locked door or the vault door itself (see builder.lua's -- fallback to a plain closed door whenever that cannot be guaranteed). function tests.builderOnlyEverPlacesASolvableVaultPuzzle() local checked = 0 for seed = 1, 200 do local level = Builder.build(5, Rng.new(seed), {}) if #level.vaultDoors == 1 then checked = checked + 1 for _, lever in ipairs(level.vaultLevers) do local path = Astar.find(level.map, level.upX, level.upY, lever.x, lever.y, function(x, y) if x == lever.x and y == lever.y then return false end return not level.map:isPassable(x, y, false) end) ok(path ~= nil, "seed " .. seed .. ": every vault lever is reachable without a key or the vault door") end end end ok(checked > 0, "at least one of the sampled seeds actually placed a vault to check") end function tests.vaultFieldsSurviveSerialization() local level = arena(21, 11) level.map:set(6, 6, Tiles.LEVER_PULLED) level.map:set(10, 6, Tiles.DOOR_OPEN) level.vaultLevers = { { x = 6, y = 6, id = "v7", pulled = true } } level.vaultDoors = { { x = 10, y = 6, id = "v7", opened = true } } local restored = Level.fromData(level:toData()) eq(#restored.vaultLevers, 1, "the vault lever list survives a round trip") eq(restored.vaultLevers[1].pulled, true, "a lever's pulled state survives a round trip") eq(#restored.vaultDoors, 1, "the vault door list survives a round trip") eq(restored.vaultDoors[1].opened, true, "a door's opened state survives a round trip") end function tests.gameVaultsOpenedStatSurvivesSaveLoad() local game = Game.new(1) local lx, ly = game.player.x + 1, game.player.y game.level.map:set(lx, ly, Tiles.LEVER) local lever = { x = lx, y = ly, id = "v8", pulled = false } game.level.vaultLevers = { lever } game.level.vaultDoors = { { x = game.player.x + 2, y = game.player.y, id = "v8", opened = false } } game:pullLever(lever) eq(game.stats.vaultsOpened, 1, "pulling the lever opens the vault") local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(restored.stats.vaultsOpened, 1, "the vault-opened tally survives a save/load round trip") eq(#restored.level.vaultLevers, 1, "the level's vault lever list survives a save/load round trip") eq(restored.level.vaultLevers[1].pulled, true, "a pulled lever's state survives a save/load round trip") end function tests.metaRenownForScalesWithVaultsOpened() local base = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 0, vaultsOpened = 0, won = false }) local withVault = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 0, vaultsOpened = 2, won = false }) eq(withVault, base + 20, "each opened vault adds a fixed renown bonus") end function tests.metaRecordRunUnlocksVaultBreakerOnceAVaultOpens() local meta = Meta.new() local stats = { deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, vaultsOpened = 1, won = false, } local unlocked = Meta.recordRun(meta, stats) eq(unlocked[1], "Vault Breaker", "opening a vault for the first time unlocks Vault Breaker") eq(meta.achievements.vault_breaker, true, "vault_breaker is marked unlocked") local again = Meta.recordRun(meta, stats) eq(#again, 0, "an already-unlocked Vault Breaker is not re-announced on a later run") end -- Runewords (runewords.lua): rune stones socket into a weapon or armor's -- rune chain in order; an exact match against the catalog grants a fixed -- attack/defense bonus read live by combat.lua, exactly like a skill bonus. function tests.runewordsSocketAppendsToTheChainInOrderAndCapsAtMaxRunes() local sword = Items.weapon("sword", 4) ok(Runewords.socket(sword, Runewords.rune("sol")), "socketing a rune into a bare weapon succeeds") ok(Runewords.socket(sword, Runewords.rune("ith")), "a second rune joins the same chain") eq(table.concat(sword.runes, "-"), "sol-ith", "the chain records runes in the order they were socketed") ok(Runewords.socket(sword, Runewords.rune("tal")), "a third rune still fits under the cap") local success, reason = Runewords.socket(sword, Runewords.rune("ral")) eq(success, false, "a fourth rune overflows the chain") eq(reason, "the rune chain is full", "socket explains why a full chain refuses another rune") end function tests.runewordsSocketRefusesAnythingThatIsNotWeaponOrArmor() local potion = Items.potion(10) local success, reason = Runewords.socket(potion, Runewords.rune("sol")) eq(success, false, "a potion cannot hold a rune chain") eq(reason, "cannot hold runes", "socket explains why a non-gear item refuses a rune") end function tests.runewordsActiveWordMatchesOnlyAnExactChain() local sword = Items.weapon("sword", 4) eq(Runewords.activeWord(sword), nil, "a bare weapon forms no runeword") Runewords.socket(sword, Runewords.rune("sol")) eq(Runewords.activeWord(sword), nil, "a single rune alone matches no cataloged word") Runewords.socket(sword, Runewords.rune("ith")) local word = Runewords.activeWord(sword) ok(word ~= nil, "sol then ith completes the Zenith runeword") eq(word.name, "Zenith", "the matched word is the cataloged Zenith") Runewords.socket(sword, Runewords.rune("tal")) eq(Runewords.activeWord(sword), nil, "a third rune growing past Zenith's length breaks its match") end function tests.runewordsAttackAndDefenseBonusReadTheWielderSGearLive() local player = { weapon = Items.weapon("sword", 4), armor = Items.armor("chain mail", 2) } eq(Runewords.attackBonus(player), 0, "no bonus applies before any runeword completes") eq(Runewords.defenseBonus(player), 0, "no defense bonus applies before any runeword completes") Runewords.socket(player.weapon, Runewords.rune("sol")) Runewords.socket(player.weapon, Runewords.rune("ith")) eq(Runewords.attackBonus(player), 3, "a completed Zenith on the weapon grants its flat attack bonus") Runewords.socket(player.armor, Runewords.rune("tal")) Runewords.socket(player.armor, Runewords.rune("ral")) eq(Runewords.defenseBonus(player), 3, "a completed Bastion on the armor grants its flat defense bonus") eq(Runewords.attackBonus(player), 3, "the armor's runeword does not leak an attack bonus") end function tests.gameSocketRuneAppliesItsRunewordBonusInActualCombat() local game = stagedGame(1, arena(21, 11), 3, 6) Items.equipWeapon(Items.weapon("sword", 4), game.player) local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) orc.defense, orc.baseDefense, orc.hp, orc.maxHp = 0, 0, 40, 40 game.level.entities = { orc } local before = orc.hp game:playerAttack(orc) local plainDamage = before - orc.hp local game2 = stagedGame(1, arena(21, 11), 3, 6) Items.equipWeapon(Items.weapon("sword", 4), game2.player) local orc2 = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) orc2.defense, orc2.baseDefense, orc2.hp, orc2.maxHp = 0, 0, 40, 40 game2.level.entities = { orc2 } game2.player.inventory = { Runewords.rune("sol"), Runewords.rune("ith") } ok(game2:socketRune("weapon", 1), "socketing the first rune into the equipped weapon succeeds") ok(game2:socketRune("weapon", 1), "socketing the second rune completes the runeword") eq(#game2.player.inventory, 0, "both runes leave the inventory once socketed") ok(contains(game2.log:last(), "Zenith"), "completing a runeword is announced by name") local before2 = orc2.hp game2:playerAttack(orc2) local runewordDamage = before2 - orc2.hp ok(runewordDamage > plainDamage, "a completed Zenith runeword deals more damage than the bare weapon") eq(runewordDamage - plainDamage, 3, "the extra damage matches Zenith's exact flat attack bonus") end function tests.gameSocketRuneFailsOnAnUnknownTargetOrANonRuneItem() local game = stagedGame(1, arena(21, 11), 3, 6) Items.equipWeapon(Items.weapon("sword", 4), game.player) game.player.inventory = { Items.potion(10) } eq(game:socketRune("weapon", 1), false, "socketing a non-rune item fails") game.player.inventory = { Runewords.rune("sol") } eq(game:socketRune("armor", 1), false, "socketing into gear the player has not equipped fails") eq(#game.player.inventory, 1, "a failed socket attempt leaves the rune in inventory") end function tests.builderOccasionallyPlacesARuneFromDepthThree() local level = Builder.build(3, Rng.new(1), {}) local found for _, ground in ipairs(level.floorItems) do if ground.item.kind == Items.RUNE then found = ground.item end end ok(found ~= nil, "this seed's depth-3 floor places a rune stone") ok(found.rune ~= nil, "the placed rune names a real catalog key") end function tests.runesStayOffTheShallowestTwoFloors() for seed = 1, 6 do for depth = 1, 2 do local level = Builder.build(depth, Rng.new(seed), {}) for _, ground in ipairs(level.floorItems) do ok(ground.item.kind ~= Items.RUNE, "no rune appears on the shallowest two floors") end end end end function tests.runeChainFieldsSurviveSerialization() local game = Game.new(3) local sword = Items.weapon("sword", 4) Items.equipWeapon(sword, game.player) Runewords.socket(sword, Runewords.rune("sol")) Runewords.socket(sword, Runewords.rune("ith")) local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(table.concat(restored.player.weapon.runes, "-"), "sol-ith", "a weapon's rune chain survives a save/load round trip") ok(Runewords.activeWord(restored.player.weapon) ~= nil, "the restored chain still forms its runeword") end -- Atmosphere (atmosphere.lua): a single floor-wide weather condition drawn -- once at generation, distinct from terrain.lua's per-cell hazards - every -- hook here is a flat modifier applying to the whole floor for its lifetime. function tests.atmosphereHooksAreZeroForAnyConditionTheyDoNotGovern() eq(Atmosphere.sightPenalty(Atmosphere.CONSECRATED), 0, "consecrated ground carries no sight penalty") eq(Atmosphere.regenBonus(Atmosphere.FOGGY), 0, "fog carries no regen bonus") eq(Atmosphere.monsterAttackBonus(Atmosphere.FOGGY), 0, "fog carries no monster attack bonus") eq(Atmosphere.sightPenalty(nil), 0, "a clear floor (no weather) carries no sight penalty") end function tests.atmosphereFoggyShrinksSightRadiusButNeverBelowOne() eq(Atmosphere.sightPenalty(Atmosphere.FOGGY), 2, "fog carries a fixed sight penalty") local game = stagedGame(1, arena(21, 11), 3, 6) local clear = game:effectiveRadius() game.level.weather = Atmosphere.FOGGY eq(game:effectiveRadius(), clear - 2, "fog shaves its fixed penalty off the floor's own sight radius") local darkGame = stagedGame(1, arenaDark(21, 11), 3, 6) darkGame.player.light.fuel = 0 darkGame.level.weather = Atmosphere.FOGGY eq(darkGame:effectiveRadius(), 1, "fog on an already-dark, torchless floor floors out at a minimum radius of one") end function tests.atmosphereConsecratedRegeneratesThePlayerEachTurn() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.weather = Atmosphere.CONSECRATED game.player.hp = game.player.maxHp - 10 local before = game.player.hp game:hungerTorchTick() eq(game.player.hp, before + 1, "consecrated ground regenerates its fixed amount each turn") end function tests.atmosphereConsecratedNeverRegeneratesPastMaxHp() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.weather = Atmosphere.CONSECRATED game.player.hp = game.player.maxHp game:hungerTorchTick() eq(game.player.hp, game.player.maxHp, "consecrated regen never lifts hp past its maximum") end function tests.builderOccasionallyDrawsEachWeatherConditionFromDepthTwo() local infested = Builder.build(2, Rng.new(1), {}) eq(infested.weather, Atmosphere.INFESTED, "this seed's depth-2 floor draws an infested reading") local rat for _, entity in ipairs(infested.entities) do if entity.species == "rat" then rat = entity end end ok(rat ~= nil, "an infested floor still places its usual monsters") eq(rat.attack, Monsters.byKey.rat.attack + Atmosphere.monsterAttackBonus(Atmosphere.INFESTED), "an infested floor bakes its attack bonus into every monster placed on it") local consecrated = Builder.build(2, Rng.new(3), {}) eq(consecrated.weather, Atmosphere.CONSECRATED, "this seed's depth-2 floor draws a consecrated reading") local foggy = Builder.build(2, Rng.new(6), {}) eq(foggy.weather, Atmosphere.FOGGY, "this seed's depth-2 floor draws a foggy reading") end function tests.weatherNeverAppearsOnTheEntranceFloor() for seed = 1, 20 do local level = Builder.build(1, Rng.new(seed), {}) eq(level.weather, nil, "no weather condition is ever drawn for the entrance floor") end end function tests.weatherFieldSurvivesSerialization() local level = arena(21, 11) level.weather = Atmosphere.FOGGY local restored = Level.fromData(level:toData()) eq(restored.weather, Atmosphere.FOGGY, "a floor's weather survives a round trip") end function tests.renderPanelReportsTheFloorSWeatherWhenPresent() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.weather = Atmosphere.INFESTED ok(contains(Render.panel(game), "infested"), "the status panel names an active weather condition") 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() local game = stagedGame(1, wallSplitArena(), 15, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 6, 1) orc.awareness = Stealth.HUNTING orc.lastKnownX, orc.lastKnownY = 8, 6 game.level.entities = { orc } for _ = 1, Stealth.LOST_TURNS_LIMIT do game:takeMonsterTurn(orc) end eq(orc.awareness, Stealth.SEARCHING, "losing sight of the player for several turns in a row switches a hunter to searching") eq(orc.x, 6, "the newly searching monster immediately takes its first step toward the last known position") eq(orc.y, 6, "the search movement heads straight down the open corridor") end function tests.stealthSearchingMonsterReacquiresThePlayerAndReturnsToHunting() local game = stagedGame(1, wallSplitArena(), 15, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 6, 1) orc.awareness = Stealth.HUNTING orc.lastKnownX, orc.lastKnownY = 8, 6 game.level.entities = { orc } for _ = 1, Stealth.LOST_TURNS_LIMIT do game:takeMonsterTurn(orc) end game.player.x, game.player.y = 7, 6 game:takeMonsterTurn(orc) eq(orc.awareness, Stealth.HUNTING, "regaining line of sight during a search snaps the monster straight back to hunting") eq(orc.lastKnownX, 7, "hunting again refreshes the last-known position to where the player actually is") end function tests.stealthSearchingMonsterGivesUpAndReturnsToDormant() local game = stagedGame(1, wallSplitArena(), 15, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 5, 6, 1) orc.awareness = Stealth.HUNTING orc.lastKnownX, orc.lastKnownY = 8, 6 game.level.entities = { orc } for _ = 1, 15 do game:takeMonsterTurn(orc) end eq(orc.awareness, Stealth.DORMANT, "abandoning a fruitless search puts the monster back to sleep") eq(orc.lastKnownX, nil, "a monster back asleep no longer remembers where it last saw the player") end function tests.stealthAwarenessFieldsSurviveASaveLoadRoundTrip() local orc = Monsters.create(Monsters.byKey.orc, 5, 6, 6, 1) orc.awareness = Stealth.SEARCHING orc.lastKnownX, orc.lastKnownY = 12, 9 orc.lostTurns = 2 orc.searchTurns = 1 local restored = Entity.fromData(orc:toData()) eq(restored.awareness, Stealth.SEARCHING, "the awareness state survives a round trip") eq(restored.lastKnownX, 12, "the last-known x coordinate survives a round trip") eq(restored.lastKnownY, 9, "the last-known y coordinate survives a round trip") eq(restored.lostTurns, 2, "the lost-turn counter survives a round trip") eq(restored.searchTurns, 1, "the search-turn counter survives a round trip") end function tests.renderPanelFlagsSleepingFoesSeparatelyFromAwakeOnes() local game = stagedGame(1, arena(21, 11), 3, 6) local sleeper = Monsters.create(Monsters.byKey.orc, 1, 10, 6, 1) sleeper.awareness = Stealth.DORMANT game.level.entities = { sleeper } ok(contains(Render.panel(game), "1 asleep"), "the status panel tallies how many visible foes are still asleep") local awake = Monsters.create(Monsters.byKey.orc, 2, 11, 6, 1) game.level.entities = { awake } ok(not contains(Render.panel(game), "asleep"), "the panel omits the asleep tally once every foe is awake") end -- Compendium: cross-run bestiary of sightings and kills. function tests.compendiumNoteSightingReturnsTrueOnlyOnTheFirstSighting() local compendium = Compendium.new() eq(Compendium.noteSighting(compendium, "rat"), true, "the very first sighting of a species reports true") eq(Compendium.noteSighting(compendium, "rat"), false, "a repeat sighting of the same species reports false") ok(Compendium.hasSeen(compendium, "rat"), "the species is recorded as seen") ok(not Compendium.hasSeen(compendium, "bat"), "an entirely different species is unaffected") end function tests.compendiumNoteKillAccumulatesAcrossMultipleKills() local compendium = Compendium.new() eq(Compendium.killCount(compendium, "goblin"), 0, "a species starts with zero recorded kills") Compendium.noteKill(compendium, "goblin") Compendium.noteKill(compendium, "goblin") Compendium.noteKill(compendium, "goblin") eq(Compendium.killCount(compendium, "goblin"), 3, "kills accumulate one at a time") end function tests.compendiumEntriesListsEveryWildSpeciesAndEveryBoss() local entries = Compendium.entries(Compendium.new()) eq(#entries, #Monsters.species + #Bosses.catalog, "the compendium lists every wild species and every named boss") local byKey = {} for _, entry in ipairs(entries) do byKey[entry.key] = entry end ok(byKey.orc ~= nil, "a wild species keys its entry by its plain species key") ok(byKey.boss_warlord ~= nil, "a boss keys its entry with the same boss_ prefix game.lua stamps onto its entity") eq(byKey.orc.seen, false, "a freshly created compendium has not seen anything yet") ok(byKey.orc.flavor ~= nil, "every catalogued species carries a flavor line") end function tests.gameRecordsASightingTheFirstTimeAMonsterBecomesVisible() local game = stagedGame(1, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) game.level.entities = { orc } game:computeVisible() ok(Compendium.hasSeen(game.compendium, "orc"), "a monster within sight is recorded into the compendium the moment it is seen") local rat = Monsters.create(Monsters.byKey.rat, 2, 19, 10, 1) game.level.entities = { rat } game:computeVisible() ok(not Compendium.hasSeen(game.compendium, "rat"), "a species standing far outside sight range is never recorded just because it exists on the level") end function tests.gameRecordsAKillIntoItsCompendiumOnAwardKill() local game = stagedGame(1, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) game.level.entities = { orc } eq(Compendium.killCount(game.compendium, "orc"), 0, "no kills recorded before the fight starts") game:awardKill(orc) eq(Compendium.killCount(game.compendium, "orc"), 1, "felling a monster records exactly one kill for its species") game:awardKill(Monsters.create(Monsters.byKey.orc, 3, 4, 6, 1)) eq(Compendium.killCount(game.compendium, "orc"), 2, "kill counts accumulate across separate kills of the same species") end function tests.compendiumFieldsSurviveAGameSaveLoadRoundTrip() local game = stagedGame(1, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) game.level.entities = { orc } game:computeVisible() game:awardKill(orc) local restored = Game.fromData(game:toData()) eq(Compendium.killCount(restored.compendium, "orc"), 1, "a recorded kill survives a save/load round trip") ok(Compendium.hasSeen(restored.compendium, "orc"), "a recorded sighting survives a save/load round trip too") end function tests.gameReusesThePassedMetaSCompendiumByReference() local meta = Meta.new() local game = Game.new(1, { meta = meta }) local orc = Monsters.create(Monsters.byKey.orc, 1, game.player.x + 1, game.player.y, 1) game.level.entities = { orc } game:awardKill(orc) eq(Compendium.killCount(meta.compendium, "orc"), 1, "a kill recorded mid-run is already sitting in the exact meta table warren.lua will save back out") end function tests.setsCreateBuildsTheCatalogedItemShape() local blade = Sets.create("ironclad", "weapon") eq(blade.kind, Items.WEAPON, "the ironclad weapon piece is a weapon") eq(blade.set, "ironclad", "the item remembers its set key") eq(blade.name, "Ironclad Greatsword", "the item takes the catalog's exact name") local ring = Sets.create("duskrunner", "ring") eq(ring.kind, Items.RING, "the duskrunner ring piece is a ring") eq(ring.set, "duskrunner", "a second set tags its own pieces with its own key") eq(Sets.create("nonexistent", "weapon"), nil, "an unknown set key builds nothing") eq(Sets.create("ironclad", "bow"), nil, "a slot ironclad does not define builds nothing") end function tests.setsAttackAndDefenseBonusEscalateAsMorePiecesAreEquipped() local player = { weapon = Sets.create("ironclad", "weapon") } eq(Sets.attackBonus(player), 0, "a single piece grants no bonus below the 2-piece threshold") eq(Sets.defenseBonus(player), 0, "a single piece grants no defense bonus either") player.armor = Sets.create("ironclad", "armor") eq(Sets.attackBonus(player), 1, "two matching pieces grant the 2-piece attack bonus") eq(Sets.defenseBonus(player), 1, "two matching pieces grant the 2-piece defense bonus") eq(Sets.regenAmount(player), 0, "the 2-piece tier grants no regen") player.ring = Sets.create("ironclad", "ring") eq(Sets.attackBonus(player), 2, "the full 3-piece bonus replaces the 2-piece total rather than adding to it") eq(Sets.defenseBonus(player), 3, "the full set's defense bonus also replaces rather than stacks") eq(Sets.regenAmount(player), 1, "only the full set grants its regen bonus") end function tests.setsBonusDropsBackWhenAPieceIsUnequipped() local player = { weapon = Sets.create("ironclad", "weapon"), armor = Sets.create("ironclad", "armor"), ring = Sets.create("ironclad", "ring"), } eq(Sets.attackBonus(player), 2, "the full set starts active") player.ring = Items.ring("plain band", 0, 0) eq(Sets.attackBonus(player), 1, "losing a piece drops the bonus back to the 2-piece tier") player.armor = Items.armor("plain mail", 2) eq(Sets.attackBonus(player), 0, "a single remaining piece grants no bonus at all") end function tests.setsDoNotCrossTalkBetweenDifferentSets() local player = { weapon = Sets.create("ironclad", "weapon"), bow = Sets.create("duskrunner", "bow") } eq(Sets.attackBonus(player), 0, "one piece from each of two different sets completes neither") end function tests.gameEquippingASetPieceAppliesItsBonusInActualCombat() local game = stagedGame(1, arena(21, 11), 3, 6) Items.equipWeapon(Items.weapon("sword", 4), game.player) local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) orc.defense, orc.baseDefense, orc.hp, orc.maxHp = 0, 0, 40, 40 game.level.entities = { orc } local before = orc.hp game:playerAttack(orc) local plainDamage = before - orc.hp local game2 = stagedGame(1, arena(21, 11), 3, 6) Items.equipWeapon(Sets.create("ironclad", "weapon"), game2.player) Items.equipArmor(Sets.create("ironclad", "armor"), game2.player) local orc2 = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) orc2.defense, orc2.baseDefense, orc2.hp, orc2.maxHp = 0, 0, 40, 40 game2.level.entities = { orc2 } local before2 = orc2.hp game2:playerAttack(orc2) local setDamage = before2 - orc2.hp -- The ironclad weapon's own power (3) is one below the plain sword's (4), -- but the completed 2-piece bonus adds back exactly +1 attack, landing on -- the same total damage - proof the bonus reads live in actual combat. eq(setDamage, plainDamage, "a 2-piece Ironclad bonus makes up for the set piece's own weaker power") end function tests.gameEquippingTheSecondSetPieceLogsTheSetAwakening() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.inventory = { Sets.create("ironclad", "weapon"), Sets.create("ironclad", "armor") } ok(game:useItem(1), "equipping the first ironclad piece succeeds") ok(not contains(game.log:last(), "awakens"), "a lone set piece does not announce a bonus") ok(game:useItem(1), "equipping the second ironclad piece succeeds") ok(contains(game.log:last(), "Ironclad") and contains(game.log:last(), "awakens"), "completing the 2-piece threshold announces the set by name") end function tests.setsRegenerationAppliesEachTurnAtTheFullIroncladSet() local game = Game.new(1) Items.equipWeapon(Sets.create("ironclad", "weapon"), game.player) Items.equipArmor(Sets.create("ironclad", "armor"), game.player) Items.equipRing(Sets.create("ironclad", "ring"), game.player) game.player.hp = game.player.maxHp - 10 local before = game.player.hp game:hungerTorchTick() eq(game.player.hp, before + 1, "the full ironclad set regenerates its fixed amount each turn") end function tests.builderOccasionallyPlacesASetPieceFromDepthThree() local found for seed = 1, 20 do local level = Builder.build(3, Rng.new(seed), {}) for _, ground in ipairs(level.floorItems) do if ground.item.set then found = ground.item end end if found then break end end ok(found ~= nil, "a depth-3 floor eventually places a set piece across a handful of seeds") ok(Sets.byKey[found.set] ~= nil, "the placed piece names a real catalog set") end function tests.setPiecesStayOffTheShallowestTwoFloors() for seed = 1, 6 do for depth = 1, 2 do local level = Builder.build(depth, Rng.new(seed), {}) for _, ground in ipairs(level.floorItems) do ok(not ground.item.set, "no set piece appears on depth 1 or 2") end end end end function tests.setFieldsSurviveSerialization() local game = Game.new(3) Items.equipWeapon(Sets.create("ironclad", "weapon"), game.player) local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(restored.player.weapon.set, "ironclad", "an equipped set piece's set key survives a save/load round trip") end function tests.gamePickupTracksSetPiecesFoundForMetaStats() local game = stagedGame(1, arena(21, 11), 5, 5) game.level.floorItems = { { x = 5, y = 5, item = Sets.create("ironclad", "ring") } } game:pickupHere() eq(game.stats.setPiecesFound, 1, "picking up a set piece counts toward set pieces found") ok(game.player.ring == nil, "picking up a set piece only stashes it in inventory, it is not auto-equipped") end function tests.metaRenownForScalesWithSetPiecesFound() local base = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 0, setPiecesFound = 0, won = false }) local withPieces = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 0, setPiecesFound = 2, won = false }) eq(withPieces, base + 6, "each set piece found adds a fixed renown bonus") end function tests.metaRecordRunUnlocksSetCollectorOnceThreePiecesFound() local meta = Meta.new() local stats = { deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, vaultsOpened = 0, setPiecesFound = 3, won = false, } local unlocked = Meta.recordRun(meta, stats) eq(unlocked[1], "Set Collector", "finding three set pieces in a run unlocks Set Collector") eq(meta.achievements.set_collector, true, "set_collector is marked unlocked") local again = Meta.recordRun(meta, stats) eq(#again, 0, "an already-unlocked Set Collector is not re-announced on a later run") end function tests.renderPanelShowsTheActiveSetBonusWhenOneApplies() local game = stagedGame(1, arena(21, 11), 3, 6) ok(not contains(Render.panel(game), "set "), "the panel omits the set line without an active bonus") Items.equipWeapon(Sets.create("ironclad", "weapon"), game.player) Items.equipArmor(Sets.create("ironclad", "armor"), game.player) ok(contains(Render.panel(game), "Ironclad 2pc"), "the panel names the active set and its piece count") end function tests.mimicsFloorItemBuildsADisguisedLootShape() local item = Mimics.floorItem(Rng.new(1)) eq(item.kind, "mimic", "a mimic's floor item is its own kind, not a real item kind") eq(item.mimic, true, "a mimic's floor item is tagged so pickupHere knows to unmask it") local matched for _, disguise in ipairs(Mimics.DISGUISES) do if disguise.name == item.name and disguise.glyph == item.glyph then matched = true end end ok(matched, "the rolled disguise's name and glyph both come from the catalog") end function tests.mimicsRevealBuildsAHostileMonsterScaledByDepth() local shallow = Mimics.reveal(1, 4, 6, 1) eq(shallow.kind, "monster", "a revealed mimic is a genuine monster entity") eq(shallow.glyph, "M", "a revealed mimic shows its own glyph, not a disguise") eq(shallow.mimic, true, "a revealed mimic stays tagged as a mimic") eq(shallow.hp, 12, "a depth-1 mimic has its base hp with no bonus") local deep = Mimics.reveal(2, 4, 6, 5) eq(deep.hp, 16, "a mimic's hp scales with depth the same way Monsters.create scales a wild species") end function tests.gamePickupHereRevealsAMimicAndAmbushesThePlayer() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.defense, game.player.baseDefense = 0, 0 game.level.floorItems = { { x = 5, y = 5, item = Mimics.floorItem(Rng.new(1)) } } local hpBefore = game.player.hp game:pickupHere() eq(#game.level.floorItems, 0, "the disguised item leaves the floor the instant it is disturbed") eq(#game.level.entities, 1, "a revealed mimic joins the level as a real hostile monster") eq(game.level.entities[1].mimic, true, "the spawned monster is tagged as a mimic") eq(game.player.hp, hpBefore - 5, "the mimic lands one free ambush hit for its full attack") ok(contains(game.log:last(), "damage"), "the ambush hit is logged") end function tests.gameKillingARevealedMimicGrantsItsFixedEssenceReward() local game = stagedGame(1, arena(21, 11), 3, 6) local mimic = Mimics.reveal(1, 4, 6, 1) mimic.hp = 1 game.level.entities = { mimic } local essenceBefore = game.player.essence game:playerAttack(mimic) eq(game.player.essence, essenceBefore + Crafting.essenceFor(Mimics.xpFor()), "felling a mimic grants essence scaled to its own fixed xp reward, not a plain monster's") end function tests.builderOccasionallyPlacesAMimicFromDepthTwo() local found for seed = 1, 20 do local level = Builder.build(2, Rng.new(seed), {}) for _, ground in ipairs(level.floorItems) do if ground.item.mimic then found = ground.item end end if found then break end end ok(found ~= nil, "a depth-2 floor eventually places a disguised mimic across a handful of seeds") end function tests.mimicsNeverAppearOnTheEntranceFloor() for seed = 1, 6 do local level = Builder.build(1, Rng.new(seed), {}) for _, ground in ipairs(level.floorItems) do ok(not ground.item.mimic, "no mimic appears on the entrance floor") end end end function tests.mimicFieldSurvivesSerialization() local mimic = Mimics.reveal(1, 4, 6, 1) local restored = Entity.fromData(mimic:toData()) eq(restored.mimic, true, "a revealed mimic's tag survives a save/load round trip") eq(restored.glyph, "M", "a revealed mimic's glyph survives a save/load round trip too") end function tests.cursesRollRespectsItsOneInEightChanceAcrossSeedsAndKnownSlots() local hit, miss = false, false for seed = 1, 60 do local curse = Curses.roll(Rng.new(seed), "weapon") if curse then hit = true else miss = true end if hit and miss then break end end ok(hit, "a weapon curse fires on at least one seed in the first 60") ok(miss, "a weapon curse also misses on at least one seed in the first 60") eq(Curses.roll(Rng.new(1), "amulet"), nil, "an unsupported slot never rolls a curse") end function tests.cursesAfflictFoldsThePenaltyIntoTheItemAndKeepsItHidden() local weapon = Items.weapon("dagger", 5) Curses.afflict(weapon, Curses.pool.weapon[1]) eq(weapon.power, 3, "a weakness curse folds its penalty directly into the weapon's power") eq(weapon.cursed, true, "the item is marked cursed") eq(weapon.curseRevealed, false, "the curse starts hidden") eq(weapon.name, "dagger", "an unrevealed curse never touches the item's displayed name") local bow = Items.bow("short bow", 2, 5) Curses.afflict(bow, Curses.pool.bow[1]) eq(bow.power, 1, "a faulty curse also taxes a bow's power") eq(bow.range, 3, "a faulty curse shortens a bow's range") local ring = Items.ring("ring of power", 1, 1) Curses.afflict(ring, Curses.pool.ring[1]) eq(ring.attack, 1, "an affliction curse leaves a ring's own stats untouched") eq(ring.curseDrain, 1, "an affliction curse instead sets a per-turn drain") end function tests.cursesAfflictNeverDropsPowerOrRangeBelowItsFloor() local dagger = Items.weapon("dagger", 1) Curses.afflict(dagger, { key = "test", powerPenalty = 5 }) eq(dagger.power, 0, "power never drops below zero even with an oversized penalty") local bow = Items.bow("short bow", 2, 1) Curses.afflict(bow, { key = "test", rangePenalty = 5 }) eq(bow.range, 1, "range never drops below one even with an oversized penalty") end function tests.cursesIsCursedAndIsRevealedReadEmptyOrCleanGearSafely() eq(Curses.isCursed(nil), false, "a nil slot is never cursed") local clean = Items.weapon("sword", 4) eq(Curses.isCursed(clean), false, "a plain item is never cursed") eq(Curses.isRevealed(clean), false, "a plain item has nothing to reveal") Curses.afflict(clean, Curses.pool.weapon[1]) eq(Curses.isCursed(clean), true, "afflict marks the item cursed") eq(Curses.isRevealed(clean), false, "afflict alone does not reveal the curse") Curses.reveal(clean) eq(Curses.isRevealed(clean), true, "reveal flips the item to known") Curses.reveal(nil) ok(true, "revealing a nil item is a safe no-op") end function tests.cursesIsBoundInChecksASpecificEquippedSlot() local entity = { weapon = Items.weapon("sword", 4), armor = nil } eq(Curses.isBoundIn(entity, "weapon"), false, "a clean equipped weapon is not bound") eq(Curses.isBoundIn(entity, "armor"), false, "an empty slot is never bound") Curses.afflict(entity.weapon, Curses.pool.weapon[1]) eq(Curses.isBoundIn(entity, "weapon"), true, "a cursed equipped weapon reads as bound") end function tests.cursesTryReleaseRevealsAndRefusesABoundSlotButPassesACleanOne() local entity = { weapon = Items.weapon("sword", 4) } eq(Curses.tryRelease(entity, "weapon"), true, "a clean weapon releases without resistance") eq(Curses.tryRelease(entity, "armor"), true, "an empty slot always releases") Curses.afflict(entity.weapon, Curses.pool.weapon[1]) eq(Curses.tryRelease(entity, "weapon"), false, "a bound weapon refuses to release") eq(Curses.isRevealed(entity.weapon), true, "the failed release reveals the curse") end function tests.cursesCleanseUndoesThePenaltyAndClearsEveryField() local weapon = Items.weapon("dagger", 5) Curses.afflict(weapon, Curses.pool.weapon[1]) eq(Curses.cleanse(weapon), true, "cleansing a cursed weapon reports success") eq(weapon.power, 5, "cleansing restores the weapon's original power") eq(weapon.cursed, nil, "cleansing clears the cursed flag") eq(weapon.curseKind, nil, "cleansing clears the curse kind") eq(weapon.curseRevealed, nil, "cleansing clears the revealed flag") eq(weapon.cursePenalty, nil, "cleansing clears the stored penalty") eq(Curses.cleanse(weapon), false, "cleansing an already-clean item is a no-op") local ring = Items.ring("ring of power", 0, 0) Curses.afflict(ring, Curses.pool.ring[1]) Curses.cleanse(ring) eq(ring.curseDrain, nil, "cleansing a ring also drops its drain") end function tests.cursesCleanseWornLiftsEveryCursedSlotAndCountsThem() local entity = { weapon = Curses.afflict(Items.weapon("dagger", 5), Curses.pool.weapon[1]), armor = Items.armor("leather armor", 1), ring = Curses.afflict(Items.ring("ring of power", 0, 0), Curses.pool.ring[1]), } eq(Curses.cleanseWorn(entity), 2, "cleanseWorn reports exactly the cursed pieces it lifted") eq(Curses.isCursed(entity.weapon), false, "the cursed weapon is clean afterward") eq(Curses.isCursed(entity.ring), false, "the cursed ring is clean afterward") eq(Curses.cleanseWorn(entity), 0, "a second pass finds nothing left to cleanse") end function tests.cursesRevealFirstUnknownFindsEarliestSlotAndAdvances() local entity = { weapon = Curses.afflict(Items.weapon("dagger", 5), Curses.pool.weapon[1]), armor = Curses.afflict(Items.armor("leather armor", 1), Curses.pool.armor[1]), } local first = Curses.revealFirstUnknown(entity) eq(first, entity.weapon, "weapon slot order comes before armor") eq(Curses.isRevealed(entity.weapon), true, "the found curse is now revealed") local second = Curses.revealFirstUnknown(entity) eq(second, entity.armor, "the next call advances to the still-hidden armor curse") eq(Curses.revealFirstUnknown(entity), nil, "nothing left hidden once both are revealed") end function tests.cursesDrainAmountOnlyReadsACursedRing() local entity = {} eq(Curses.drainAmount(entity), 0, "no ring means no drain") entity.weapon = Curses.afflict(Items.weapon("dagger", 5), Curses.pool.weapon[1]) eq(Curses.drainAmount(entity), 0, "a cursed weapon never drains hp") entity.ring = Items.ring("ring of power", 0, 0) eq(Curses.drainAmount(entity), 0, "a clean ring drains nothing") Curses.afflict(entity.ring, Curses.pool.ring[1]) eq(Curses.drainAmount(entity), 1, "a cursed ring's affliction drains its fixed amount") end function tests.cursesLabelHidesUntilRevealedThenPrefixes() local weapon = Items.weapon("dagger", 5) eq(Curses.label(weapon, "dagger"), "dagger", "a clean item's label is untouched") Curses.afflict(weapon, Curses.pool.weapon[1]) eq(Curses.label(weapon, "dagger"), "dagger", "an unrevealed curse still hides behind the plain label") Curses.reveal(weapon) eq(Curses.label(weapon, "dagger"), "cursed dagger", "a revealed curse prefixes the label") end function tests.gameEquippingOverABoundCursedWeaponFailsAndRevealsTheCurse() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.weapon = Curses.afflict(Items.weapon("dagger", 2), Curses.pool.weapon[1]) game.player.inventory = { Items.weapon("sword", 4) } local before = game.player.weapon eq(game:useItem(1), false, "equipping a new weapon over a bound one fails") ok(game.player.weapon == before, "the bound weapon stays equipped") eq(Curses.isRevealed(before), true, "the failed swap reveals the curse") eq(#game.player.inventory, 1, "the replacement weapon stays in the pack, never spent") ok(contains(game.log:last(), "will not come off"), "the log explains the weapon is stuck") end function tests.gameEquippingAFreshCurseHidesItUntilYouTryToTakeItOff() local game = stagedGame(1, arena(21, 11), 3, 6) local cursedRing = Curses.afflict(Items.ring("ring of power", 1, 1), Curses.pool.ring[1]) game.player.inventory = { cursedRing } ok(game:useItem(1), "equipping an empty slot with a cursed item still succeeds") ok(game.player.ring == cursedRing, "the cursed ring is now equipped") ok(not contains(game.log:last(), "cursed"), "the equip message never spoils an unrevealed curse") end function tests.gameRemoveCurseScrollLiftsEveryCurseCurrentlyWorn() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.ring = Curses.afflict(Items.ring("ring of power", 0, 0), Curses.pool.ring[1]) game.player.inventory = { Items.scroll("removecurse") } ok(game:useItem(1), "reading the scroll succeeds") eq(Curses.isCursed(game.player.ring), false, "the worn ring's curse is lifted") ok(contains(game.log:last(), "lifts"), "the log reports the cleansing") end function tests.gameHungerTorchTickBleedsHpFromACursedRingEachTurn() local game = Game.new(1) game.player.ring = Curses.afflict(Items.ring("ring of power", 0, 0), Curses.pool.ring[1]) local before = game.player.hp game:hungerTorchTick() eq(game.player.hp, before - 1, "a cursed ring's affliction bleeds its fixed drain each turn") ok(contains(game.log:last(), "saps"), "the drain is announced in the log") end function tests.shrinePrayFortuneBlessingAlsoLiftsAnyCurseCurrentlyWorn() local player = shrinePlayer(50) player.ring = Curses.afflict(Items.ring("ring of power", 0, 0), Curses.pool.ring[1]) local game = { player = player, rng = Rng.new(1) } local shrine = { x = 1, y = 1, kind = Shrines.FORTUNE, uses = 0 } local success, lines = Shrines.pray(game, shrine) eq(success, true, "the prayer resolves") eq(Curses.isCursed(player.ring), false, "fortune's blessing lifts the worn ring's curse too") ok(contains(lines[2], "cleanses"), "a lifted curse counts toward the cleansing message") end function tests.renderPanelPrefixesCursedGearOnceRevealedButNotBefore() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.weapon = Curses.afflict(Items.weapon("dagger", 5), Curses.pool.weapon[1]) ok(not contains(Render.panel(game), "cursed"), "an unrevealed curse never shows in the status panel") Curses.reveal(game.player.weapon) ok(contains(Render.panel(game), "cursed dagger"), "a revealed curse is flagged in the status panel") end function tests.builderOccasionallyPlacesACursedPieceOfGear() local found for seed = 1, 80 do local level = Builder.build(2, Rng.new(seed), {}) for _, ground in ipairs(level.floorItems) do if ground.item.cursed then found = ground.item end end if found then break end end ok(found ~= nil, "a cursed piece of gear eventually turns up across a handful of seeds") ok(found.cursePenalty ~= nil or found.curseDrain ~= nil, "a cursed drop always carries a concrete penalty") end function tests.builderNeverCursesAnItemThatAlreadyHasABeneficialAffix() for seed = 1, 30 do for depth = 1, 5 do local level = Builder.build(depth, Rng.new(seed), {}) for _, ground in ipairs(level.floorItems) do if ground.item.affix then ok(not ground.item.cursed, "an item with a beneficial affix is never also cursed") end end end end end function tests.cursedFieldsSurviveSerialization() local game = Game.new(3) game.player.ring = Curses.afflict(Items.ring("ring of power", 1, 1), Curses.pool.ring[1]) Curses.reveal(game.player.ring) local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(restored.player.ring.cursed, true, "a cursed ring's flag survives a save/load round trip") eq(restored.player.ring.curseDrain, 1, "a cursed ring's drain survives a save/load round trip") eq(restored.player.ring.curseRevealed, true, "a revealed curse stays revealed after reload") end function tests.momentumStacksReadsZeroForAFreshEntity() eq(Momentum.stacks({}), 0, "an entity with no combo field reads as zero stacks") end function tests.momentumOnMeleeHitBuildsAndCapsAtItsMaxStacks() local entity = {} for i = 1, Momentum.MAX_STACKS do Momentum.onMeleeHit(entity) eq(Momentum.stacks(entity), i, "stack " .. i .. " builds one at a time") end Momentum.onMeleeHit(entity) eq(Momentum.stacks(entity), Momentum.MAX_STACKS, "stacks never climb past the cap") end function tests.momentumOnDamagedSnapsTheStackBackToZero() local entity = { combo = 3 } Momentum.onDamaged(entity) eq(Momentum.stacks(entity), 0, "taking a hit clears the whole stack outright") end function tests.momentumAttackBonusScalesLinearlyWithStacks() local entity = {} eq(Momentum.attackBonus(entity), 0, "no stacks means no bonus") Momentum.onMeleeHit(entity) Momentum.onMeleeHit(entity) eq(Momentum.attackBonus(entity), 2 * Momentum.BONUS_PER_STACK, "the bonus scales with the current stack count") end function tests.entityEffectiveAttackFoldsInMomentumAlongsideStatusModifiers() local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 2 }) eq(entity:effectiveAttack(), 6, "a fresh entity's effective attack is just its base") Momentum.onMeleeHit(entity) Momentum.onMeleeHit(entity) eq(entity:effectiveAttack(), 8, "two momentum stacks add their flat bonus on top of base attack") end function tests.gamePlayerAttackBuildsMomentumAndItReadsLiveInEffectiveAttack() local game = stagedGame(1, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) orc.hp, orc.maxHp = 100, 100 game.level.entities = { orc } eq(game.player:effectiveAttack(), game.player.attack, "a fresh player carries no momentum bonus") game:playerAttack(orc) eq(game.player.combo, 1, "a landed melee hit builds one stack") eq(game.player:effectiveAttack(), game.player.attack + 1, "the very next read of effectiveAttack already reflects the new stack") end function tests.gameMonsterMeleeSnapsMomentumBackToZero() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.combo = 4 local orc = Monsters.create(Monsters.byKey.orc, 1, 2, 6, 1) game:monsterMelee(orc) eq(game.player.combo, 0, "getting hit by a monster's melee resets momentum to zero") end function tests.gameEvadedMonsterAttackNeverBreaksMomentum() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.skills = { evasion = true } local orc = Monsters.create(Monsters.byKey.orc, 1, 2, 6, 1) local sawEvasion = false for seed = 1, 40 do game.rng = Rng.new(seed) game.player.combo = 3 game:monsterMelee(orc) if game.player.combo == 3 then sawEvasion = true break end end ok(sawEvasion, "an evaded attack never reaches Combat.attack, so momentum survives it") end function tests.gameMimicAmbushAlsoBreaksMomentum() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.combo = 2 game.level.floorItems = { { x = 5, y = 5, item = Mimics.floorItem(Rng.new(1)) } } game:pickupHere() eq(game.player.combo, 0, "a mimic's ambush hit also breaks momentum") end function tests.renderPanelShowsComboOnlyOnceItIsBuilt() local game = stagedGame(1, arena(21, 11), 3, 6) ok(not contains(Render.panel(game), "combo"), "a fresh player with no momentum shows no combo line") game.player.combo = 3 ok(contains(Render.panel(game), "combo x3"), "a built-up combo is reported in the status panel") end function tests.momentumFieldSurvivesSerialization() local game = Game.new(3) game.player.combo = 4 local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(restored.player.combo, 4, "the player's momentum stack survives a save/load round trip") end function tests.conductivityIsWetReadsTheEntitysOwnWetField() eq(Conductivity.isWet({}), false, "an entity with no wet field at all reads as dry") eq(Conductivity.isWet({ wet = 0 }), false, "a wet counter already run down to zero reads as dry") eq(Conductivity.isWet({ wet = 1 }), true, "any positive wet counter reads as wet") end function tests.conductivityTickSoaksOnWaterAndDecaysOneStepAtATimeOffIt() local level = arena(21, 11) level.map:set(6, 6, Tiles.WATER) local entity = { x = 6, y = 6 } Conductivity.tick(level, entity) eq(entity.wet, Conductivity.SOAK_DURATION, "standing on water soaks an entity to the full duration") entity.x = 7 for i = Conductivity.SOAK_DURATION - 1, 0, -1 do Conductivity.tick(level, entity) eq(entity.wet, i, "the soak counter decays by exactly one per turn spent off water") end eq(Conductivity.isWet(entity), false, "the counter bottoms out at zero rather than going negative") end function tests.conductivityTickReSoaksImmediatelyOnSteppingBackOntoWater() local level = arena(21, 11) level.map:set(6, 6, Tiles.WATER) local entity = { x = 6, y = 6, wet = 1 } Conductivity.tick(level, entity) eq(entity.wet, Conductivity.SOAK_DURATION, "stepping back onto water refreshes the counter to full rather than adding to it") end function tests.conductivityApplyFireDampensDamageOnlyOnceSoaked() eq(Conductivity.applyFire({}, 10), 10, "fire damage against a dry entity is untouched") eq(Conductivity.applyFire({ wet = 2 }, 10), 5, "fire damage against a soaked entity is halved on top of its resistance tier") end function tests.conductivityApplyLightningBonusesDamageOnlyOnceSoaked() eq(Conductivity.applyLightning({}, 10), 10, "lightning damage against a dry entity is untouched") eq(Conductivity.applyLightning({ wet = 2 }, 10), 15, "lightning damage against a soaked entity gets a 50 percent conductivity bonus") end function tests.conductivityNeverConjuresDamageOutOfAnImmuneResistance() eq(Conductivity.applyFire({ wet = 2, resist = { fire = Resistances.IMMUNE } }, 10), 0, "fire immunity still floors damage at zero even while soaked") eq(Conductivity.applyLightning({ wet = 2, resist = { lightning = Resistances.IMMUNE } }, 10), 0, "lightning immunity still floors damage at zero even while soaked") end function tests.conductivityResistsIgnitionOnlyWhileWet() eq(Conductivity.resistsIgnition({}), false, "a dry entity does not resist ignition") eq(Conductivity.resistsIgnition({ wet = 3 }), true, "a soaked entity resists catching fire") end function tests.statusApplyRefusesBurningOnlyWhileSoakedButOtherEffectsStillApply() local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 2 }) entity.wet = 2 local result = Status.apply(entity, Status.BURNING, 3, 2) eq(result, nil, "applying burning to a soaked entity is refused outright") eq(Status.has(entity, Status.BURNING), false, "a refused burning application leaves no trace on the entity") Status.apply(entity, Status.POISON, 3, 2) eq(Status.has(entity, Status.POISON), true, "a soaked entity still catches every other status effect normally") entity.wet = 0 Status.apply(entity, Status.BURNING, 3, 2) eq(Status.has(entity, Status.BURNING), true, "once dry again, the same entity catches fire normally") end function tests.statusRemoveStripsANamedStatusRegardlessOfItsRemainingDuration() local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 2 }) Status.apply(entity, Status.POISON, 99, 2) eq(Status.remove(entity, Status.POISON), true, "removing a status that is present reports success") eq(Status.has(entity, Status.POISON), false, "the removed status no longer reads as active") eq(Status.remove(entity, Status.POISON), false, "removing an already-absent status reports nothing to do") eq(Status.remove(entity, Status.CONFUSE), false, "removing a status that was never applied at all also reports nothing to do") end function tests.statusCureHostileLiftsEveryDebuffButLeavesBuffsUntouched() local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 2 }) Status.apply(entity, Status.POISON, 5, 2) Status.apply(entity, Status.CONFUSE, 6, 1) Status.apply(entity, Status.STUN, 2, 1) Status.apply(entity, Status.STRENGTH, 30, 3) Status.apply(entity, Status.SHIELD, 15, 1) local lifted = Status.cureHostile(entity) eq(#lifted, 3, "cureHostile reports exactly the three debuffs it lifted") eq(Status.has(entity, Status.POISON), false, "poison is gone after cureHostile") eq(Status.has(entity, Status.CONFUSE), false, "confuse is gone after cureHostile") eq(Status.has(entity, Status.STUN), false, "stun is gone after cureHostile") ok(Status.has(entity, Status.STRENGTH), "a buff like strength survives cureHostile untouched") ok(Status.has(entity, Status.SHIELD), "a buff like shield survives cureHostile untouched") eq(#Status.cureHostile(entity), 0, "calling cureHostile again with nothing hostile left returns an empty list") end function tests.hazardsAfflictDampensFireAndBlocksIgnitionForASoakedVictim() local level = arena(21, 11) Hazards.ignite(level, 5, 5, 3, 4) local victim = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) victim.wet = 2 local hpBefore = victim.hp local line = Hazards.afflict(level, victim) eq(victim.hp, hpBefore - 2, "standing in fire while soaked only deals half the usual potency") ok(contains(line, "soaked"), "the afflict message calls out the victim's own soaked skin") eq(Status.has(victim, Status.BURNING), false, "a soaked victim standing in fire never actually catches alight") end function tests.gameConductivityTicksAMonsterEachTurnBasedOnTheTileUnderfoot() local level = arena(21, 11) level.map:set(6, 6, Tiles.WATER) local game = stagedGame(1, level, 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 6, 6, 1) game.level.entities = { rat } game:takeMonsterTurn(rat) eq(rat.wet, Conductivity.SOAK_DURATION, "a monster ending its turn on water is soaked to the full duration") rat.x, rat.y = 7, 6 game:takeMonsterTurn(rat) eq(rat.wet, Conductivity.SOAK_DURATION - 1, "moving off water the following turn lets the soak counter decay by one") end function tests.gameConductivityTicksThePlayerEachAutoStepBasedOnTheTileUnderfoot() local level = arena(21, 11) level.map:set(3, 6, Tiles.WATER) local game = stagedGame(1, level, 3, 6) game:autoStep() eq(game.player.wet, Conductivity.SOAK_DURATION, "the player ending a turn on water is soaked to the full duration") end function tests.spellShockBoltDealsMoreDamageToASoakedTargetThanADryOne() local dryGame = stagedGame(1, arena(21, 11), 3, 6) Spells.learn(dryGame.player, "shock") local dry = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) dry.hp, dry.maxHp = 100, 100 dryGame.level.entities = { dry } local castOk = dryGame:castSpell("shock", dry) ok(castOk, "casting a learned, in-range shock bolt succeeds") local dryDealt = 100 - dry.hp ok(dryDealt > 0, "shock bolt deals real lightning damage to a dry target") local wetGame = stagedGame(1, arena(21, 11), 3, 6) Spells.learn(wetGame.player, "shock") local soaked = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) soaked.hp, soaked.maxHp = 100, 100 soaked.wet = 2 wetGame.level.entities = { soaked } wetGame:castSpell("shock", soaked) local wetDealt = 100 - soaked.hp eq(wetDealt, dryDealt + dryDealt // 2, "shock bolt deals exactly its 50 percent conductivity bonus against a soaked target") ok(contains(wetGame.log:last(), "water clinging"), "casting shock bolt on a soaked target reports the conductivity through water") end function tests.renderPanelShowsWetOnlyWhileSoaked() local game = stagedGame(1, arena(21, 11), 3, 6) ok(not contains(Render.panel(game), "wet"), "a fresh dry player shows no wet line") game.player.wet = 2 ok(contains(Render.panel(game), "wet (2)"), "a soaked player's status panel reports the remaining soak turns") end function tests.builderCanRollATomeOfTheShockBoltSpell() -- 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 shock tome under the new list happens to sit at -- seed 174 - still well within "a reasonable seed range". local found 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 == "shock" then found = ground.item end end if found then break end end ok(found ~= nil, "the shock bolt tome is reachable dungeon loot within a reasonable seed range") eq(found.name, "tome of shock", "an unnamed tome defaults its label to the spell key") end function tests.entityWetFieldSurvivesSerialization() local game = Game.new(3) game.player.wet = 2 local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(restored.player.wet, 2, "the player's soak counter survives a save/load round trip") end function tests.diggingCanDigAllowsOnlyWallsAndRubble() local level = arena(21, 11) ok(Digging.canDig(level.map, 1, 5), "the arena's own border wall is diggable") ok(not Digging.canDig(level.map, 5, 5), "open floor has nothing left to dig") level.map:set(5, 5, Tiles.RUBBLE) ok(Digging.canDig(level.map, 5, 5), "rubble left by a prior swing is still diggable") level.map:set(5, 5, Tiles.VAULT_DOOR) ok(not Digging.canDig(level.map, 5, 5), "a vault's own boundary never yields to a pick") ok(not Digging.canDig(level.map, 0, 5), "a cell outside the map is never diggable") end function tests.diggingSwingClearsAWallToRubbleOnAnOrdinarySwing() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.map:set(5, 5, Tiles.WALL) game.rng = Rng.new(1) local line = Digging.swing(game, game.player, 5, 5) eq(game.level.map:get(5, 5), Tiles.RUBBLE, "an ordinary swing crumbles a wall to rubble, not clear") ok(contains(line, "carve"), "the log line describes carving through rock") end function tests.diggingSwingClearsRubbleToFloorOnASecondSwing() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.map:set(5, 5, Tiles.RUBBLE) game.rng = Rng.new(1) local line = Digging.swing(game, game.player, 5, 5) eq(game.level.map:get(5, 5), Tiles.FLOOR, "a second swing clears rubble the rest of the way to open floor") ok(contains(line, "clear"), "the log line describes clearing rubble away") end function tests.diggingSwingCaveInHurtsTheDiggerAndLeavesTheTileUntouched() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.map:set(5, 5, Tiles.WALL) game.rng = Rng.new(6) local hpBefore = game.player.hp local line = Digging.swing(game, game.player, 5, 5) eq(game.level.map:get(5, 5), Tiles.WALL, "a cave-in wastes the swing; the wall is untouched") ok(game.player.hp < hpBefore, "a cave-in deals real damage to the digger") ok(contains(line, "buries you"), "the log line names the player directly, not by their entity name") end function tests.diggingSwingOreStrikeClearsTheWallOutrightAndPaysEssenceToATrackingDigger() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.map:set(5, 5, Tiles.WALL) game.rng = Rng.new(2) local essenceBefore = game.player.essence local line = Digging.swing(game, game.player, 5, 5) eq(game.level.map:get(5, 5), Tiles.FLOOR, "a lucky ore strike clears the wall in a single stroke") ok(game.player.essence > essenceBefore, "striking a vein pays essence to a digger that tracks it") ok(contains(line, "essence"), "the log line reports the essence payout") end function tests.diggingSwingOreStrikeSkipsTheEssenceRewardForADiggerThatDoesNotTrackIt() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.map:set(5, 5, Tiles.WALL) local burrower = Entity.new({ id = 1, x = 4, y = 5, glyph = "b", name = "rock burrower", kind = "monster", hp = 12, attack = 4, defense = 2 }) game.rng = Rng.new(2) local line = Digging.swing(game, burrower, 5, 5) eq(game.level.map:get(5, 5), Tiles.FLOOR, "the same lucky strike still clears the wall for a monster") eq(burrower.essence, nil, "a monster never gains an essence field it never had") ok(contains(line, "vein"), "the log line still names the vein even without a payout") end function tests.diggingWearPickDepletesDurabilityAndBreaksAtZero() local entity = { pick = Items.pick("test pick", 2) } eq(Digging.wearPick(entity), nil, "the first swing just wears the edge down") eq(entity.pick.uses, 1, "one use is spent") local brokenLine = Digging.wearPick(entity) ok(contains(brokenLine, "crumbles"), "the swing that empties the pick reports it breaking") eq(entity.pick, nil, "a spent pick is discarded outright") end function tests.diggingWearPickIsANoOpWithoutAPickEquipped() local entity = {} eq(Digging.wearPick(entity), nil, "wearing down a pick that was never equipped changes nothing") end function tests.diggingDirectionsTowardPrefersTheLongerAxisFirst() local dirs = Digging.directionsToward(10, 10, 15, 12) eq(#dirs, 2, "a diagonal target yields both an x and a y candidate") eq(dirs[1][1], 1, "the longer x delta is offered first") eq(dirs[1][2], 0, "the first candidate is a pure x step") eq(dirs[2][1], 0, "the second candidate is a pure y step") eq(dirs[2][2], 1, "it steps toward the smaller but still positive y delta") local vertical = Digging.directionsToward(10, 10, 11, 5) eq(vertical[1][1], 0, "when y dominates, the y step comes first") eq(vertical[1][2], -1, "stepping toward a smaller y is a negative step") end function tests.diggingDirectionsTowardOmitsAZeroDelta() local dirs = Digging.directionsToward(10, 10, 15, 10) eq(#dirs, 1, "a purely horizontal target yields only one candidate") eq(dirs[1][1], 1, "the sole candidate steps toward positive x") eq(dirs[1][2], 0, "the sole candidate carries no y component") local same = Digging.directionsToward(10, 10, 10, 10) eq(#same, 0, "already standing on the target yields no candidate at all") end function tests.itemsEquipPickSwapsAnyPriorPickBackIntoInventory() local entity = { inventory = {}, pick = Items.pick("worn pick", 3) } local fresh = Items.pick("fresh pick", 20) Items.equipPick(fresh, entity) eq(entity.pick, fresh, "the freshly equipped pick becomes the active one") eq(#entity.inventory, 1, "the previously equipped pick drops back into the inventory") eq(entity.inventory[1].name, "worn pick", "the displaced pick is the one that was equipped before") end function tests.shopPicksHaveAPositiveCatalogPrice() ok(Shop.priceOf(Items.pick()) > 0, "a miner's pick has a positive catalog price") end function tests.gameDigFailsWithoutAPickEquipped() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.map:set(4, 6, Tiles.WALL) local moved = game:dig(1, 0) eq(moved, false, "digging without a pick equipped fails outright") eq(game.level.map:get(4, 6), Tiles.WALL, "the wall is untouched without a pick") ok(contains(game.log:last(), "nothing to dig with"), "the log explains why") end function tests.gameDigFailsWhenNothingAheadIsDiggable() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.pick = Items.pick() local moved = game:dig(1, 0) eq(moved, false, "there is nothing to dig into open floor") ok(contains(game.log:last(), "nothing here to dig"), "the log explains why") end function tests.gameDigClearsAWallAndWearsThePickDown() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.map:set(4, 6, Tiles.WALL) game.player.pick = Items.pick("miner's pick", 5) game.rng = Rng.new(1) local moved = game:dig(1, 0) eq(moved, true, "a swing against a wall with a pick readied succeeds") eq(game.level.map:get(4, 6), Tiles.RUBBLE, "the wall crumbles to rubble") eq(game.player.pick.uses, 4, "the swing spends one use of the pick's durability") end function tests.gameDigBreaksThePickOnItsLastUse() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.map:set(4, 6, Tiles.WALL) game.player.pick = Items.pick("worn pick", 1) game.rng = Rng.new(1) game:dig(1, 0) eq(game.player.pick, nil, "the pick's last use breaks it outright") ok(contains(game.log:last(), "crumbles"), "the log reports the pick breaking") end function tests.gameMonsterTunnelsThroughAWallToReachAndAttackThePlayer() local level = arena(21, 11) for y = 2, 10 do level.map:set(10, y, Tiles.WALL) end local game = stagedGame(1, level, 8, 6) local burrower = Entity.new({ id = 1, x = 12, y = 6, glyph = "b", name = "rock burrower", kind = "monster", ai = "tunnel", hp = 20, maxHp = 20, attack = 5, defense = 0, speed = 100, }) game.level.entities = { burrower } local hpBefore = game.player.hp local sawRubble = false for _ = 1, 10 do game.rng = Rng.new(1) Ai.act(game, burrower) if game.level.map:get(10, 6) ~= Tiles.WALL then sawRubble = true end if game.player.hp < hpBefore then break end end ok(sawRubble, "the burrower actually carves through the partition wall") ok(game.player.hp < hpBefore, "the burrower eventually reaches and melees the player through its own tunnel") end function tests.pickFieldSurvivesSerialization() local game = Game.new(3) game.player.pick = Items.pick("saved pick", 7) local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(restored.player.pick.name, "saved pick", "the equipped pick's name survives a save/load round trip") eq(restored.player.pick.uses, 7, "the equipped pick's remaining durability survives a save/load round trip") end function tests.builderNeverPlacesAPickBeforeDepthTwo() for seed = 1, 20 do local level = Builder.build(1, Rng.new(seed), {}) for _, ground in ipairs(level.floorItems) do ok(ground.item.kind ~= Items.PICK, "a pick never turns up on the entrance floor") end end end function tests.builderOccasionallyPlacesAPickFromDepthTwo() local level = Builder.build(2, Rng.new(6), {}) local found for _, ground in ipairs(level.floorItems) do if ground.item.kind == Items.PICK then found = ground.item end end ok(found ~= nil, "this seed's depth-2 floor places a miner's pick") ok(Shop.priceOf(found) > 0, "the placed pick still carries a positive catalog price") end function tests.sanityCurrentDefaultsToMaxForAFreshEntity() eq(Sanity.current({}), Sanity.MAX, "an entity with no sanity field reads as fully composed") end function tests.sanityTierOfClassifiesEachBandAtItsBoundaries() eq(Sanity.tierOf({ sanity = 100 }), Sanity.COMPOSED, "a full meter is composed") eq(Sanity.tierOf({ sanity = 61 }), Sanity.COMPOSED, "just above the shaken ceiling is still composed") eq(Sanity.tierOf({ sanity = 60 }), Sanity.SHAKEN, "the shaken ceiling itself reads as shaken") eq(Sanity.tierOf({ sanity = 31 }), Sanity.SHAKEN, "just above the terrified ceiling is still shaken") eq(Sanity.tierOf({ sanity = 30 }), Sanity.TERRIFIED, "the terrified ceiling itself reads as terrified") eq(Sanity.tierOf({ sanity = 1 }), Sanity.TERRIFIED, "one point above zero is still terrified, never broken") eq(Sanity.tierOf({ sanity = 0 }), Sanity.BROKEN, "zero itself is broken") end function tests.sanityAdjustClampsToTheZeroAndMaxBounds() local entity = { sanity = 95 } eq(Sanity.adjust(entity, 30), Sanity.MAX, "adjusting past the ceiling clamps to max") eq(Sanity.adjust(entity, -1000), 0, "adjusting far past the floor clamps to zero") eq(Sanity.adjust(entity, 5), 5, "an ordinary adjustment lands exactly where expected") end function tests.sanityAttackAndDefensePenaltiesEscalateByTier() eq(Sanity.attackPenalty({ sanity = 100 }), 0, "a composed mind carries no attack penalty") eq(Sanity.attackPenalty({ sanity = 60 }), -1, "shaken costs a point of attack") eq(Sanity.attackPenalty({ sanity = 30 }), -3, "terrified costs three points of attack") eq(Sanity.attackPenalty({ sanity = 0 }), -5, "broken costs five points of attack") eq(Sanity.defensePenalty({ sanity = 100 }), 0, "a composed mind carries no defense penalty") eq(Sanity.defensePenalty({ sanity = 60 }), -1, "shaken costs a point of defense") eq(Sanity.defensePenalty({ sanity = 30 }), -2, "terrified costs two points of defense") eq(Sanity.defensePenalty({ sanity = 0 }), -4, "broken costs four points of defense") end function tests.entityEffectiveAttackAndDefenseFoldInTheSanityPenalty() local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 4 }) eq(entity:effectiveAttack(), 6, "a fresh entity with no sanity field carries no penalty") entity.sanity = 0 eq(entity:effectiveAttack(), 1, "a broken mind's attack penalty is live in effectiveAttack") eq(entity:effectiveDefense(), 0, "a broken mind's defense penalty is live in effectiveDefense") end function tests.sanityDarknessDreadFiresOnlyOnADarkFloorWithASpentTorch() local dark = stagedGame(1, arenaDark(21, 11), 3, 6) dark.player.light.fuel = 0 eq(Sanity.darknessDread(dark), Sanity.DARKNESS_DREAD, "a dark floor with a spent torch dreads the mind") dark.player.light.fuel = 50 eq(Sanity.darknessDread(dark), 0, "a lit torch on a dark floor holds the dread off entirely") local lit = stagedGame(1, arena(21, 11), 3, 6) lit.player.light.fuel = 0 eq(Sanity.darknessDread(lit), 0, "an ordinary floor never dreads the mind regardless of torch fuel") end function tests.sanityCorpseDreadCountsOnlyCorpsesAdjacentToThePlayer() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.corpses = { { x = 2, y = 5 } } eq(Sanity.corpseDread(game), Sanity.CORPSE_DREAD_PER, "one adjacent corpse contributes its fixed share") game.level.corpses[#game.level.corpses + 1] = { x = 10, y = 6 } eq(Sanity.corpseDread(game), Sanity.CORPSE_DREAD_PER, "a corpse well outside arm's reach adds nothing") end function tests.sanityCorpseDreadCapsAtItsFixedCeiling() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.corpses = { { x = 2, y = 5 }, { x = 2, y = 6 }, { x = 2, y = 7 }, { x = 3, y = 5 }, { x = 3, y = 7 }, } eq(Sanity.corpseDread(game), Sanity.CORPSE_DREAD_CAP, "five adjacent corpses still cap at the fixed ceiling") end function tests.sanityUndeadDreadCountsOnlyVisibleUndeadAndCapsAtItsCeiling() local game = stagedGame(1, arena(21, 11), 3, 6) local zombie = Monsters.create(Monsters.byKey.zombie, 1, 4, 6, 1) game.level.entities = { zombie } eq(Sanity.undeadDread(game), Sanity.UNDEAD_DREAD_PER, "one visible undead contributes its fixed share") game.visible = {} eq(Sanity.undeadDread(game), 0, "an undead outside the player's sight adds nothing") game:computeVisible() local orc = Monsters.create(Monsters.byKey.orc, 2, 5, 6, 1) game.level.entities[#game.level.entities + 1] = orc eq(Sanity.undeadDread(game), Sanity.UNDEAD_DREAD_PER, "a visible living monster never counts as undead dread") for i = 3, 6 do game.level.entities[#game.level.entities + 1] = Monsters.create(Monsters.byKey.zombie, i, 6, 6, 1) end eq(Sanity.undeadDread(game), Sanity.UNDEAD_DREAD_CAP, "a crowd of visible undead still caps at the fixed ceiling") end function tests.sanityCurseDreadFiresOnlyForARevealedCurse() local player = { ring = Curses.afflict(Items.ring("ring of power", 0, 0), Curses.pool.ring[1]) } eq(Sanity.curseDread(player), 0, "a curse still hidden behind an ordinary appearance dreads nothing") Curses.reveal(player.ring) eq(Sanity.curseDread(player), Sanity.CURSE_DREAD, "a revealed curse gnaws at the mind") end function tests.sanityWeatherDreadFiresOnlyForInfestedWeather() eq(Sanity.weatherDread(Atmosphere.INFESTED), Sanity.WEATHER_DREAD, "an infested floor dreads the mind") eq(Sanity.weatherDread(Atmosphere.FOGGY), 0, "a merely foggy floor is not itself dreadful") eq(Sanity.weatherDread(nil), 0, "a clear floor carries no weather dread") end function tests.sanityTotalDreadSumsEverySourceAtOnce() local dark = stagedGame(1, arenaDark(21, 11), 3, 6) dark.player.light.fuel = 0 dark.level.weather = Atmosphere.INFESTED dark.level.corpses = { { x = 2, y = 5 } } eq(Sanity.totalDread(dark), Sanity.DARKNESS_DREAD + Sanity.WEATHER_DREAD + Sanity.CORPSE_DREAD_PER, "every currently-active dread source is summed at once") end function tests.sanityTickAppliesDreadAndLogsOnlyOnATierCrossing() local game = stagedGame(1, arenaDark(21, 11), 3, 6) game.player.light.fuel = 0 game.player.sanity = 63 eq(Sanity.tick(game), nil, "a dread turn that stays within its tier reports no line") eq(game.player.sanity, 61, "the dread was still deducted even without a tier crossing") eq(Sanity.tick(game), "your nerves begin to fray", "crossing down into shaken is announced") eq(game.player.sanity, 59, "the meter keeps draining turn over turn") end function tests.sanityTickAnnouncesReachingTerrifiedAndBroken() local game = stagedGame(1, arenaDark(21, 11), 3, 6) game.player.light.fuel = 0 game.player.sanity = 32 eq(Sanity.tick(game), "dread claws at the edges of your sanity", "crossing down into terrified is announced") game.player.sanity = 2 eq(Sanity.tick(game), "your mind gives way to raw terror", "crossing down into broken is announced") eq(game.player.sanity, 0, "broken is a floor, not a negative value") end function tests.sanityTickRecoversAndAnnouncesClimbingBackOutOfBroken() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.sanity = 0 eq(Sanity.tick(game), "your nerves start to steady", "recovering out of broken is announced as steadying, never as fresh dread") eq(game.player.sanity, 1, "a calm turn only ever recovers its small fixed amount") end function tests.sanityTickAnnouncesFullyRegainingComposure() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.sanity = 60 eq(Sanity.tick(game), "your mind is your own again", "climbing back above the shaken ceiling is announced") end function tests.sanityTickNeverClimbsPastTheMaxOrReportsAPhantomCrossing() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.sanity = 100 eq(Sanity.tick(game), nil, "an already-composed mind with nothing to recover reports no line") eq(game.player.sanity, 100, "recovery never climbs the meter past its own maximum") end function tests.sanityRollPanicNeverFiresAboveBroken() local entity = { sanity = 1 } for seed = 1, 50 do ok(not Sanity.rollPanic(Rng.new(seed), entity), "terrified never risks losing the turn to panic") end end function tests.sanityRollPanicRollsAgainstItsFixedChanceOnceBroken() local entity = { sanity = 0 } local sawPanic, sawComposure = false, false for seed = 1, 50 do if Sanity.rollPanic(Rng.new(seed), entity) then sawPanic = true else sawComposure = true end end ok(sawPanic, "broken panics on at least some rolls across a handful of seeds") ok(sawComposure, "broken does not panic on every single roll") end function tests.sanityRollScreamNeverFiresAboveTerrified() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.sanity = 31 for seed = 1, 50 do game.rng = Rng.new(seed) ok(not Sanity.rollScream(game.rng, game), "merely shaken never risks an involuntary scream") end end function tests.sanityRollScreamAlertsEveryDormantMonsterWithinItsRadius() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.sanity = 0 local zombie = Monsters.create(Monsters.byKey.zombie, 1, 5, 6, 1) zombie.awareness = Stealth.DORMANT game.level.entities = { zombie } local triggered = false for seed = 1, 80 do game.rng = Rng.new(seed) if Sanity.rollScream(game.rng, game) then triggered = true break end end ok(triggered, "a broken mind eventually screams across a handful of seeds") eq(zombie.awareness, Stealth.HUNTING, "the scream startles a nearby dormant monster fully awake") eq(zombie.lastKnownX, game.player.x, "the alerted monster now knows exactly where the scream came from") end function tests.sanityFieldSurvivesSerialization() local game = Game.new(3) game.player.sanity = 37 local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(restored.player.sanity, 37, "the player's sanity survives a save/load round trip") end function tests.gameEndPlayerTurnTicksSanityAndTheLogReportsATierCrossing() local game = stagedGame(1, arenaDark(21, 11), 3, 6) game.player.light.fuel = 0 game.player.sanity = 63 game:endPlayerTurn() eq(game.player.sanity, 61, "ending a turn on a dark, torchless floor drains the fixed darkness dread") game:endPlayerTurn() ok(contains(game.log:last(), "fray"), "crossing into shaken is reported in the message log") eq(game.player:effectiveAttack(), game.player.attack - 1, "the fresh shaken penalty is already live in effectiveAttack") end function tests.gameAutoStepSkipsThePlayerTurnWhenPanicFires() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.sanity = 0 local px, py = game.player.x, game.player.y local sawPanic = false for seed = 1, 80 do game.rng = Rng.new(seed) game.player.x, game.player.y = px, py game.player.sanity = 0 -- endPlayerTurn's own Sanity.tick line (recovery or otherwise) always -- lands after any panic line within the same turn, so the panic line -- itself has to be found among the turn's recent lines rather than -- assumed to be the very last one. game:autoStep() if contains(table.concat(game.log:recent(3), " "), "terror seizes you") then sawPanic = true break end end ok(sawPanic, "a broken mind eventually loses a whole turn to panic across a handful of seeds") eq(game.player.x, px, "a panicked turn never moves the player") eq(game.player.y, py, "a panicked turn never moves the player") end function tests.gameUseItemPotionOfClarityRestoresSanity() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.sanity = 40 game.player.inventory = { Items.potionOf("clarity", 30) } ok(game:useItem(1), "drinking the potion of clarity succeeds") eq(game.player.sanity, 70, "clarity restores its fixed power to the drinker's sanity") ok(contains(game.log:last(), "steadies"), "the log reports the mind steadying") end function tests.renderPanelShowsSanityOnlyOnceItDropsBelowComposed() local game = stagedGame(1, arena(21, 11), 3, 6) ok(not contains(Render.panel(game), "sanity"), "a fully composed player shows no sanity line at all") game.player.sanity = 30 ok(contains(Render.panel(game), "sanity 30/100 (terrified)"), "a shaken mind is reported in the status panel") end -- Disease used to be invisible outside the transient log lines Disease.tick -- emits (the eruption, a damage tick, an escalation) - once one of those -- scrolled off the log, a player mid-incubation or mid-plague had no -- persistent way to check their own infection at all, unlike every timed -- status statusTags already folds into this same line. function tests.renderPanelShowsTheDiseaseStageOnceThePlayerIsInfectedAndNothingBeforeInfection() local game = stagedGame(1, arena(21, 11), 3, 6) ok(not contains(Render.panel(game), "parasites"), "an uninfected player's status line mentions no disease") Disease.infect(game.player, Disease.PARASITES) ok(contains(Render.panel(game), "gut parasites(incubating)"), "an incubating infection shows up in the status panel even before it erupts") game.player.disease.stage = 2 ok(contains(Render.panel(game), "gut parasites(stage 2)"), "an erupted infection reports its current stage in the status panel") end function tests.builderOccasionallyRollsAPotionOfClarityAsLoot() local found for seed = 1, 200 do local level = Builder.build(2, Rng.new(seed), {}) for _, ground in ipairs(level.floorItems) do if ground.item.kind == Items.POTION and ground.item.effect == "clarity" then found = ground.item end end if found then break end end ok(found ~= nil, "a potion of clarity eventually turns up in the depth-2 loot table across a handful of seeds") ok(Shop.priceOf(found) > 0, "a found clarity potion still carries a positive catalog price") end function tests.builderOccasionallyRollsAPotionOfConfusionAsLoot() local found for seed = 1, 200 do local level = Builder.build(2, Rng.new(seed), {}) for _, ground in ipairs(level.floorItems) do if ground.item.kind == Items.POTION and ground.item.effect == "confuse" then found = ground.item end end if found then break end end ok(found ~= nil, "a potion of confusion eventually turns up in the depth-2 loot table across a handful of seeds") ok(Shop.priceOf(found) > 0, "a found confusion potion still carries a positive catalog price") end function tests.builderOccasionallyRollsAPotionOfBlindnessAsLoot() local found for seed = 1, 200 do local level = Builder.build(2, Rng.new(seed), {}) for _, ground in ipairs(level.floorItems) do if ground.item.kind == Items.POTION and ground.item.effect == "blind" then found = ground.item end end if found then break end end ok(found ~= nil, "a potion of blindness eventually turns up in the depth-2 loot table across a handful of seeds") ok(Shop.priceOf(found) > 0, "a found blindness potion still carries a positive catalog price") end function tests.builderOccasionallyRollsAPotionOfLevitationAsLoot() local found for seed = 1, 200 do local level = Builder.build(2, Rng.new(seed), {}) for _, ground in ipairs(level.floorItems) do if ground.item.kind == Items.POTION and ground.item.effect == "levitate" then found = ground.item end end if found then break end end ok(found ~= nil, "a potion of levitation eventually turns up in the depth-2 loot table across a handful of seeds") ok(Shop.priceOf(found) > 0, "a found levitation potion still carries a positive catalog price") end function tests.eventsCatalogEntriesEachHaveDistinctStartAndEndFlavorText() for _, key in ipairs(Events.order) do local start = Events.describe(key) local ending = Events.endDescribe(key) ok(start ~= nil, key .. " has a start line") ok(ending ~= nil, key .. " has an end line") ok(start ~= ending, key .. "'s start and end lines read differently") end end function tests.eventsMonsterDamageMultiplierOnlyFiresDuringBloodmoon() eq(Events.monsterDamageMultiplier(nil), 1, "no active event leaves monster damage unscaled") eq(Events.monsterDamageMultiplier({ key = Events.RICHVEINS }), 1, "a non-bloodmoon event leaves monster damage unscaled") eq(Events.monsterDamageMultiplier({ key = Events.BLOODMOON }), Events.BLOODMOON_MULTIPLIER, "bloodmoon scales monster damage by its fixed multiplier") end function tests.eventsEssenceMultiplierOnlyFiresDuringRichVeins() eq(Events.essenceMultiplier(nil), 1, "no active event leaves essence unscaled") eq(Events.essenceMultiplier({ key = Events.BLOODMOON }), 1, "a non-richveins event leaves essence unscaled") eq(Events.essenceMultiplier({ key = Events.RICHVEINS }), Events.RICHVEINS_MULTIPLIER, "rich veins scales essence by its fixed multiplier") end function tests.eventsSightPenaltyOnlyFiresDuringHush() eq(Events.sightPenalty(nil), 0, "no active event carries no sight penalty") eq(Events.sightPenalty({ key = Events.BLOODMOON }), 0, "a non-hush event carries no sight penalty") eq(Events.sightPenalty({ key = Events.HUSH }), Events.HUSH_SIGHT_PENALTY, "hush carries its fixed sight penalty") end function tests.eventsStepNeverStartsOnTheFirstFloorRegardlessOfTheRoll() for seed = 1, 100 do local level = { depth = 1 } eq(Events.step(level, Rng.new(seed)), nil, "a depth-one floor never rolls an event") eq(level.event, nil, "a depth-one floor's event stays nil across every seed") end end function tests.eventsStepEventuallyStartsFromDepthTwoAcrossAHandfulOfSeeds() local started = false for seed = 1, 400 do local level = { depth = 2 } if Events.step(level, Rng.new(seed)) then started = true break end end ok(started, "a depth-two floor eventually rolls a fresh event across a handful of seeds") end function tests.eventsStepCountsDownAndExpiresWithItsEndLine() local level = { depth = 2, event = { key = Events.BLOODMOON, turnsLeft = 2 } } eq(Events.step(level, Rng.new(1)), nil, "an ordinary tick of a running event reports no line") eq(level.event.turnsLeft, 1, "the running event's clock ticks down by one") eq(Events.step(level, Rng.new(1)), Events.endDescribe(Events.BLOODMOON), "the event's expiry is reported the turn its clock runs out") eq(level.event, nil, "an expired event clears itself from the level") end function tests.gameMonsterMeleeDamageScalesDuringBloodmoon() local game = stagedGame(1, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) local before = game.player.hp game:monsterMelee(orc) local plainDamage = before - game.player.hp game.player.hp = before game.level.event = { key = Events.BLOODMOON, turnsLeft = 5 } game:monsterMelee(orc) local bloodmoonDamage = before - game.player.hp eq(bloodmoonDamage, math.floor(plainDamage * Events.BLOODMOON_MULTIPLIER), "bloodmoon scales a monster's melee damage by its fixed multiplier") end function tests.gameMonsterShootDamageScalesDuringBloodmoon() local game = stagedGame(1, arena(21, 11), 3, 6) local dragon = Monsters.create(Monsters.byKey.dragon, 1, 6, 6, 1) local before = game.player.hp game:monsterShoot(dragon) local plainDamage = before - game.player.hp game.player.hp = before game.player.statuses = {} game.level.event = { key = Events.BLOODMOON, turnsLeft = 5 } game:monsterShoot(dragon) local bloodmoonDamage = before - game.player.hp eq(bloodmoonDamage, math.floor(plainDamage * Events.BLOODMOON_MULTIPLIER), "bloodmoon scales a monster's ranged damage by its fixed multiplier") end function tests.gameKillEssenceIsDoubledDuringRichVeins() local game = stagedGame(1, arena(21, 11), 3, 6) local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) rat.hp = 1 game.level.entities = { rat } game.level.event = { key = Events.RICHVEINS, turnsLeft = 5 } local essenceBefore = game.player.essence game:playerAttack(rat) eq(game.player.essence, essenceBefore + Crafting.essenceFor(Monsters.xpFor(rat)) * Events.RICHVEINS_MULTIPLIER, "rich veins doubles the essence earned from a kill") end function tests.gameEffectiveRadiusShrinksDuringHush() local game = stagedGame(1, arena(21, 11), 3, 6) local plain = game:effectiveRadius() game.level.event = { key = Events.HUSH, turnsLeft = 5 } eq(game:effectiveRadius(), plain - Events.HUSH_SIGHT_PENALTY, "hush dulls the player's effective sight radius") end function tests.gameEndPlayerTurnEventuallyRollsAFreshEventFromDepthTwo() local triggered = false for seed = 1, 400 do local game = stagedGame(1, arena(21, 11), 3, 6) game.level.depth = 2 game.rng = Rng.new(seed) game:endPlayerTurn() if game.level.event then triggered = true break end end ok(triggered, "ending a turn on a depth-two floor eventually rolls a fresh event across a handful of seeds") end function tests.gameEndPlayerTurnAnnouncesAnEventEndingInTheLog() local game = stagedGame(1, arena(21, 11), 3, 6) game.level.event = { key = Events.RICHVEINS, turnsLeft = 1 } game:endPlayerTurn() eq(game.level.event, nil, "the event's clock runs out and clears itself") ok(contains(game.log:last(), "quiet"), "the event's end is reported in the message log") end function tests.renderPanelShowsTheActiveEventAndItsRemainingTurns() local game = stagedGame(1, arena(21, 11), 3, 6) ok(not contains(Render.panel(game), "turns left"), "no active event shows no event line at all") game.level.event = { key = Events.BLOODMOON, turnsLeft = 7 } ok(contains(Render.panel(game), "event bloodmoon (7 turns left)"), "an active event reports its name and remaining turns") end function tests.eventFieldSurvivesSerialization() local game = Game.new(3) game.level.event = { key = Events.HUSH, turnsLeft = 9 } local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(restored.level.event.key, Events.HUSH, "the active event's key survives a save/load round trip") eq(restored.level.event.turnsLeft, 9, "the active event's remaining turns survive a save/load round trip") end -- graves.lua: cross-run bones. function tests.gravesRecordAppendsNewestFirstAndTrimsToMaxRecorded() local meta = Meta.new() for i = 1, Graves.MAX_RECORDED + 5 do Graves.record(meta, { className = "fighter", depth = i, gold = i, essence = 0, turn = i }) end eq(#meta.graves, Graves.MAX_RECORDED, "recording past the cap trims the oldest graves off") eq(meta.graves[1].depth, Graves.MAX_RECORDED + 5, "the most recently recorded grave sits at the front") end function tests.gravesEligibleForOnlyIncludesGravesAtOrDeeperThanTheQueriedDepth() local graves = { { className = "a", depth = 2 }, { className = "b", depth = 5 }, { className = "c", depth = 5 }, } local atFive = Graves.eligibleFor(graves, 5) eq(#atFive, 2, "only the two graves dug at depth 5 or deeper are eligible for a depth-5 floor") local atOne = Graves.eligibleFor(graves, 1) eq(#atOne, 3, "every recorded grave is eligible for the entrance floor, however deep it was dug") eq(#Graves.eligibleFor(nil, 3), 0, "a caller with no recorded graves at all gets an empty list, not an error") end function tests.gravesFloorItemBuildsAnInertGraveMarkerShape() local grave = { className = "ranger", depth = 4, gold = 50, essence = 6, turn = 20 } local item = Graves.floorItem(grave) eq(item.kind, "grave", "a grave's floor item is its own kind, not a real item kind") eq(item.grave, true, "a grave's floor item is tagged so pickupHere knows to wake it") eq(item.graveClass, "ranger", "the marker carries the fallen's own class") eq(item.graveGold, 50, "the marker carries the fallen's gold for Graves.reveal to read later") eq(item.graveEssence, 6, "the marker carries the fallen's essence for Graves.reveal to read later") end function tests.gravesRevealScalesHpAndAttackWithCarriedWealthAndCaps() local poor = Graves.reveal(1, 4, 6, { graveGold = 0, graveEssence = 0 }) eq(poor.hp, 10, "a penniless grave's shade has no hp bonus at all") eq(poor.attack, 4, "a penniless grave's shade has no attack bonus at all") eq(poor.faction, Factions.UNDEAD, "a shade is undead like every other restless dead in the bestiary") local rich = Graves.reveal(2, 4, 6, { graveGold = 400, graveEssence = 200 }) eq(rich.hp, 30, "a rich grave's shade hp bonus caps at 20 rather than scaling without bound") eq(rich.attack, 10, "a rich grave's shade attack bonus caps at 6 rather than scaling without bound") end function tests.gravesLootForReturnsHalfGoldAndAThirdEssenceRoundedDown() local shade = Graves.reveal(1, 4, 6, { graveGold = 55, graveEssence = 10 }) local gold, essence = Graves.lootFor(shade) eq(gold, 27, "grave loot pays out half the carried gold, rounded down") eq(essence, 3, "grave loot pays out a third of the carried essence, rounded down") end function tests.graveFieldsSurviveSerialization() local shade = Graves.reveal(1, 4, 6, { graveClass = "ranger", graveGold = 88, graveEssence = 15 }) local restored = Entity.fromData(shade:toData()) eq(restored.grave, true, "a revealed shade's grave tag survives a save/load round trip") eq(restored.graveClass, "ranger", "the fallen's class survives a save/load round trip") eq(restored.graveGold, 88, "the grave's gold survives a save/load round trip") eq(restored.graveEssence, 15, "the grave's essence survives a save/load round trip") end function tests.gamePickupHereWakesAGraveShadeAndAmbushesThePlayer() local game = stagedGame(1, arena(21, 11), 5, 5) game.player.defense, game.player.baseDefense = 0, 0 local grave = { className = "rogue", depth = 3, gold = 0, essence = 0, turn = 10 } game.level.floorItems = { { x = 5, y = 5, item = Graves.floorItem(grave) } } local hpBefore = game.player.hp game:pickupHere() eq(#game.level.floorItems, 0, "the grave marker leaves the floor the instant it is disturbed") eq(#game.level.entities, 1, "a disturbed grave joins the level as a real hostile monster") eq(game.level.entities[1].grave, true, "the spawned monster is tagged as a grave shade") eq(game.player.hp, hpBefore - 4, "the shade lands one free ambush hit for its full attack") ok(contains(game.log:last(), "damage"), "the ambush hit is logged") end function tests.gameKillingARevealedShadeGrantsItsFixedXpAndPaysOutGraveLoot() local game = stagedGame(1, arena(21, 11), 3, 6) local shade = Graves.reveal(1, 4, 6, { graveGold = 40, graveEssence = 9 }) shade.hp = 1 game.level.entities = { shade } local essenceBefore, goldBefore = game.player.essence, game.player.gold game:playerAttack(shade) eq(game.player.essence, essenceBefore + Crafting.essenceFor(Graves.xpFor()) + 3, "felling a shade grants essence scaled to its own fixed xp reward, plus a third of its grave essence") eq(game.player.gold, goldBefore + 20, "felling a shade also pays out half its grave gold") ok(contains(game.log:last(), "gold"), "the grave payout is logged") end function tests.gravesShadeLeavesNoCorpseLikeAnyOtherUndead() local game = stagedGame(1, arena(21, 11), 3, 6) local shade = Graves.reveal(1, 4, 6, { graveGold = 0, graveEssence = 0 }) shade.hp = 1 game.level.entities = { shade } game:playerAttack(shade) eq(#game.level.corpses, 0, "a felled shade leaves no corpse behind, same as any other undead") end function tests.gameGraveEntryCapturesDepthGoldEssenceAndClassAtTheMomentOfDeath() local game = Game.new(4, { class = "mage" }) game.deepestDepth = 6 game.player.gold = 77 game.player.essence = 14 game.turn = 120 local entry = game:graveEntry() eq(entry.className, "mage", "the grave records the class the run actually played") eq(entry.depth, 6, "the grave records the deepest depth reached, not just the current floor") eq(entry.gold, 77, "the grave records exactly what gold was still on hand at death") eq(entry.essence, 14, "the grave records exactly what essence was still on hand at death") eq(entry.turn, 120, "the grave records the turn count at death") end function tests.gameGraveEntryDefaultsToWandererForAClasslessRun() local game = Game.new(4) eq(game:graveEntry().className, "wanderer", "a run that never picked a class leaves a wanderer's grave") end function tests.builderNeverPlacesAGraveWhenOptionsCarriesNone() for seed = 1, 15 do local level = Builder.build(5, Rng.new(seed), {}) for _, ground in ipairs(level.floorItems) do ok(not ground.item.grave, "no grave appears without the caller supplying any") end end end function tests.buildersOnlyRollAGraveOntoAFloorAtOrShallowerThanWhereItWasDug() local shallowGrave = { className = "rogue", depth = 2, gold = 40, essence = 5, turn = 30 } local found = false for seed = 1, 40 do local level = Builder.build(5, Rng.new(seed), { graves = { shallowGrave } }) for _, ground in ipairs(level.floorItems) do if ground.item.grave then found = true end end end ok(not found, "a grave dug at depth 2 never surfaces on a floor as deep as 5") end function tests.buildersOccasionallyPlaceAnEligibleGraveFromSuppliedOptions() local deepGrave = { className = "mage", depth = 6, gold = 80, essence = 12, turn = 90 } local found for seed = 1, 40 do local level = Builder.build(3, Rng.new(seed), { graves = { deepGrave } }) for _, ground in ipairs(level.floorItems) do if ground.item.grave then found = ground.item end end if found then break end end ok(found ~= nil, "a floor eventually rolls an eligible grave across a handful of seeds") eq(found.graveClass, "mage", "the placed marker carries the fallen's own class") end function tests.buildersInsertAGraveWithoutDisturbingAnyOtherPlacement() local grave = { className = "fighter", depth = 8, gold = 60, essence = 9, turn = 50 } local perturbed = false local sawExtra = false for seed = 1, 40 do local plain = Builder.build(4, Rng.new(seed), {}) local withGrave = Builder.build(4, Rng.new(seed), { graves = { grave } }) if table.concat(plain.map.tiles) ~= table.concat(withGrave.map.tiles) then perturbed = true end if #withGrave.entities ~= #plain.entities then perturbed = true end if #withGrave.floorItems == #plain.floorItems + 1 then sawExtra = true local extra = withGrave.floorItems[#withGrave.floorItems] ok(extra.item.grave, "the one extra floor item a supplied grave adds is the grave marker itself") elseif #withGrave.floorItems ~= #plain.floorItems then perturbed = true end end ok(not perturbed, "supplying a grave never changes the map or any other placement, only adds at most its own marker") ok(sawExtra, "across a handful of seeds, at least one actually places the grave marker") end -- proficiency.lua: weapon mastery. function tests.proficiencyKeyForReadsFistsWhenUnarmedAndTheWeaponsNameOnceEquipped() eq(Proficiency.keyFor({}), Proficiency.UNARMED, "an entity with no weapon field masters bare fists") eq(Proficiency.keyFor({ weapon = { name = "sword" } }), "sword", "an equipped weapon is keyed by its own name") end function tests.proficiencyOnMeleeHitAccumulatesAndReturnsOnlyOnARankCrossing() local entity = {} for i = 1, Proficiency.RANKS[1].hits - 1 do local key = Proficiency.onMeleeHit(entity) eq(key, nil, "no rank crossed yet on hit " .. i) end local key, bonus = Proficiency.onMeleeHit(entity) eq(key, Proficiency.UNARMED, "the rank-crossing hit reports the mastered key") eq(bonus, Proficiency.RANKS[1].bonus, "the rank-crossing hit reports that rank's own bonus") local nextKey = Proficiency.onMeleeHit(entity) eq(nextKey, nil, "the very next hit past a rank reports no further milestone") end function tests.proficiencyAttackBonusReadsTheHighestRankCrossedForTheCurrentKeyAlone() local entity = { weapon = { name = "sword" } } for _ = 1, Proficiency.RANKS[1].hits do Proficiency.onMeleeHit(entity) end eq(Proficiency.attackBonus(entity), Proficiency.RANKS[1].bonus, "mastery with the equipped weapon applies") entity.weapon = { name = "mace" } eq(Proficiency.attackBonus(entity), 0, "switching to an unmastered weapon drops the bonus entirely") entity.weapon = { name = "sword" } eq(Proficiency.attackBonus(entity), Proficiency.RANKS[1].bonus, "re-equipping the mastered weapon picks its bonus straight back up") end function tests.entityEffectiveAttackFoldsInProficiencyAlongsideMomentumAndSanity() local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 2 }) eq(entity:effectiveAttack(), 6, "a fresh entity carries no mastery bonus") for _ = 1, Proficiency.RANKS[1].hits do Proficiency.onMeleeHit(entity) end eq(entity:effectiveAttack(), 6 + Proficiency.RANKS[1].bonus, "a mastered weapon's flat bonus lands on effective attack") end function tests.gamePlayerAttackAccumulatesProficiencyAndLogsOnlyOnARankCrossing() local game = stagedGame(1, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) orc.hp, orc.maxHp = 999, 999 game.level.entities = { orc } for _ = 1, Proficiency.RANKS[1].hits - 1 do game:playerAttack(orc) end ok(not contains(game.log:last(), "skilled"), "no milestone logged before the rank is actually crossed") game:playerAttack(orc) ok(contains(game.log:last(), "skilled"), "crossing a mastery rank is announced as the very last log line") eq(game.player.proficiency[Proficiency.UNARMED], Proficiency.RANKS[1].hits, "an unarmed player's landed hits accumulate under the fists key") end function tests.renderPanelShowsMasteryOnlyOnceARankIsCrossed() local game = stagedGame(1, arena(21, 11), 3, 6) ok(not contains(Render.panel(game), "mastery"), "a fresh player with no mastery shows no mastery line") for _ = 1, Proficiency.RANKS[1].hits do Proficiency.onMeleeHit(game.player) end ok(contains(Render.panel(game), string.format("mastery +%d with %s", Proficiency.RANKS[1].bonus, Proficiency.UNARMED)), "a crossed mastery rank is reported in the status panel") end function tests.proficiencyFieldSurvivesSerialization() local game = Game.new(3) for _ = 1, Proficiency.RANKS[1].hits do Proficiency.onMeleeHit(game.player) end local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(restored.player.proficiency[Proficiency.UNARMED], Proficiency.RANKS[1].hits, "the player's accumulated mastery survives a save/load round trip") end function tests.proficiencyOnHitWithAndBonusForTrackAnExplicitKeyIndependentOfKeyFor() local entity = { weapon = { name = "sword" } } for _ = 1, Proficiency.RANKS[1].hits do Proficiency.onHitWith(entity, "short bow") end eq(Proficiency.bonusFor(entity, "short bow"), Proficiency.RANKS[1].bonus, "an explicit key masters independently of whatever keyFor would report") eq(Proficiency.attackBonus(entity), 0, "the equipped melee weapon's own mastery is untouched by an explicit-key bow's own hits") end function tests.gameFireAtAccumulatesBowSpecificProficiencySeparateFromMelee() local game = stagedGame(4, arena(21, 11), 3, 6) local orc = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) orc.hp, orc.maxHp = 999, 999 game.level.entities = { orc } game.player.bow = Items.bow("short bow", 2, 6) game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", Proficiency.RANKS[1].hits + 1) for i = 1, Proficiency.RANKS[1].hits - 1 do game:fireAt(orc) ok(not contains(game.log:last(), "skilled"), "no milestone logged before the rank is actually crossed") end game:fireAt(orc) ok(contains(game.log:last(), "skilled"), "crossing a bow mastery rank is announced as the shot's own last line") eq(game.player.proficiency["short bow"], Proficiency.RANKS[1].hits, "bow hits accumulate under the bow's own name") eq(game.player.proficiency[Proficiency.UNARMED], nil, "firing a bow never touches the unarmed melee key") end function tests.metaGravesFieldDefaultsToEmptyAndSurvivesASaveLoadRoundTrip() local fresh = Meta.load("/tmp/warren_meta_does_not_exist_98765.sav") eq(#fresh.graves, 0, "a freshly defaulted meta state has no recorded graves") local meta = Meta.new() Graves.record(meta, { className = "rogue", depth = 4, gold = 30, essence = 2, turn = 15 }) local path = os.tmpname() Meta.save(meta, path) local restored = Meta.load(path) os.remove(path) eq(#restored.graves, 1, "a recorded grave survives a save/load round trip") eq(restored.graves[1].className, "rogue", "a restored grave keeps its own fallen class") eq(restored.graves[1].depth, 4, "a restored grave keeps its own depth") end -- ascension.lua: opt-in run mutators traded for extra renown. function tests.ascensionCatalogEntriesHaveUniqueKeysAndAtLeastOneEffectHook() local seen = {} for _, def in ipairs(Ascension.MUTATORS) do ok(not seen[def.key], def.key .. " appears only once in the catalog") seen[def.key] = true local hasHook = def.damageMultiplier or def.essenceMultiplier or def.sightPenalty or def.applyToPlayer ok(hasHook ~= nil, def.key .. " carries at least one actual gameplay effect") end end function tests.ascensionIsUnlockedRequiresMeetingBothDepthAndWinRequirements() local meta = Meta.new() meta.bestDepth = 2 ok(Ascension.isUnlocked(meta, "famine"), "famine unlocks once best depth reaches 2") ok(not Ascension.isUnlocked(meta, "bloodbound"), "bloodbound still needs depth 3") ok(not Ascension.isUnlocked(meta, "glasscannon"), "glasscannon still needs a first win") meta.wins = 1 ok(Ascension.isUnlocked(meta, "glasscannon"), "glasscannon unlocks once a win is banked") eq(Ascension.isUnlocked(meta, "madeupkey"), false, "an unknown key is never unlocked") end function tests.ascensionIsUnlockedWithNoMetaLocksEveryCatalogedMutator() for _, key in ipairs(Ascension.order) do ok(not Ascension.isUnlocked(nil, key), key .. " stays locked for a run started without a meta file") end end function tests.ascensionResolveDropsUnknownAndStillLockedKeys() local meta = Meta.new() meta.bestDepth = 2 local active = Ascension.resolve(meta, { "famine", "bloodbound", "madeupkey" }) eq(#active, 1, "only the one unlocked, known key survives resolution") eq(active[1], "famine", "famine is the survivor") end function tests.ascensionResolveDeduplicatesAndReordersToCatalogOrder() local meta = Meta.new() meta.bestDepth = 4 local active = Ascension.resolve(meta, { "nightblind", "famine", "nightblind", "famine" }) eq(#active, 2, "a key repeated in the request is not duplicated in the resolved list") eq(active[1], "famine", "the resolved order follows the catalog, not the request") eq(active[2], "nightblind", "the resolved order follows the catalog, not the request") end function tests.ascensionResolveWithNoRequestReturnsEmpty() eq(#Ascension.resolve(Meta.new(), nil), 0, "a nil request resolves to no active mutators") eq(#Ascension.resolve(Meta.new(), {}), 0, "an empty request resolves to no active mutators") end function tests.ascensionIsActiveTrueOnlyForKeysInTheActiveList() local active = { "famine", "frail" } ok(Ascension.isActive(active, "famine"), "famine is reported active") ok(not Ascension.isActive(active, "bloodbound"), "bloodbound is reported inactive") ok(not Ascension.isActive(nil, "famine"), "a nil active list reports every key inactive") end function tests.ascensionDamageMultiplierMultipliesAcrossActiveMutators() eq(Ascension.damageMultiplier(nil), 1, "no active mutators leaves damage unscaled") eq(Ascension.damageMultiplier({ "famine" }), 1, "a mutator with no damage hook leaves damage unscaled") eq(Ascension.damageMultiplier({ "bloodbound" }), 1.5, "bloodbound scales damage by its own fixed multiplier") end function tests.ascensionEssenceMultiplierAppliesFamineAlone() eq(Ascension.essenceMultiplier(nil), 1, "no active mutators leaves essence unscaled") eq(Ascension.essenceMultiplier({ "bloodbound" }), 1, "a mutator with no essence hook leaves essence unscaled") eq(Ascension.essenceMultiplier({ "famine" }), 0.5, "famine halves essence income") end function tests.ascensionSightPenaltySumsAcrossActiveMutators() eq(Ascension.sightPenalty(nil), 0, "no active mutators carries no sight penalty") eq(Ascension.sightPenalty({ "nightblind" }), 2, "nightblind carries its own fixed sight penalty") end function tests.ascensionRenownBonusPercentSumsRatherThanCompounds() eq(Ascension.renownBonusPercent(nil), 0, "no active mutators adds no renown bonus") eq(Ascension.renownBonusPercent({ "famine" }), 10, "famine alone adds its own fixed bonus") eq(Ascension.renownBonusPercent({ "famine", "nightblind" }), 20, "two mutators add their bonuses, not compound them") end function tests.ascensionApplyToPlayerHalvesMaxHpAndDoublesAttackForGlassCannon() local player = { maxHp = 30, hp = 30, baseAttack = 5, attack = 5, baseDefense = 1, defense = 1 } Ascension.applyToPlayer({ "glasscannon" }, player) eq(player.maxHp, 15, "glass cannon halves max hp") eq(player.hp, 15, "current hp is clamped down to the new max") eq(player.attack, 10, "glass cannon doubles attack") eq(player.baseAttack, 10, "glass cannon doubles base attack alongside the equipped total") eq(player.defense, 1, "glass cannon leaves defense untouched") end function tests.ascensionApplyToPlayerHalvesDefenseForFrail() local player = { maxHp = 30, hp = 30, baseAttack = 5, attack = 5, baseDefense = 3, defense = 3 } Ascension.applyToPlayer({ "frail" }, player) eq(player.defense, 1, "frail floors defense down by half") eq(player.baseDefense, 1, "frail floors base defense down by half") eq(player.attack, 5, "frail leaves attack untouched") end function tests.gameNewResolvesRequestedMutatorsAgainstMetaAndAppliesTheirDeltas() local meta = Meta.new() meta.bestDepth = 4 meta.wins = 1 local game = Game.new(5, { meta = meta, mutators = { "unknownkey", "glasscannon", "frail" } }) eq(#game.ascension, 2, "only the two unlocked, known mutators survive resolution") eq(game.ascension[1], "frail", "active mutators are ordered by the catalog, not the request") eq(game.ascension[2], "glasscannon", "active mutators are ordered by the catalog, not the request") eq(game.player.maxHp, 15, "glasscannon halves the fresh player's max hp") eq(game.player.defense, 0, "frail halves the fresh player's base defense down from 1") end function tests.gameMonsterMeleeDamageStacksBloodboundWithBloodmoonMultiplicatively() local meta = Meta.new() meta.bestDepth = 3 local game = Game.new(1, { meta = meta, mutators = { "bloodbound" } }) game.level = arena(21, 11) game.player.x, game.player.y = 3, 6 game:computeVisible() local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) local before = game.player.hp game.ascension = {} game:monsterMelee(orc) local plainDamage = before - game.player.hp game.player.hp = before game.ascension = { "bloodbound" } game:monsterMelee(orc) local bloodboundDamage = before - game.player.hp eq(bloodboundDamage, math.floor(plainDamage * 1.5), "bloodbound alone scales melee damage by its own multiplier") game.player.hp = before game.level.event = { key = Events.BLOODMOON, turnsLeft = 5 } game:monsterMelee(orc) local stacked = before - game.player.hp eq(stacked, math.floor(plainDamage * 1.5 * Events.BLOODMOON_MULTIPLIER), "bloodbound and bloodmoon stack multiplicatively on the same hit") end function tests.gameKillEssenceStacksFamineWithRichVeinsMultiplicatively() local meta = Meta.new() meta.bestDepth = 2 local game = Game.new(1, { meta = meta, mutators = { "famine" } }) game.level = arena(21, 11) game.player.x, game.player.y = 3, 6 game:computeVisible() local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) rat.hp = 1 game.level.entities = { rat } game.ascension = {} local before = game.player.essence game:playerAttack(rat) local plainEssence = game.player.essence - before local rat2 = Monsters.create(Monsters.byKey.rat, 2, 4, 6, 1) rat2.hp = 1 game.level.entities = { rat2 } game.player.essence = before game.ascension = { "famine" } game:playerAttack(rat2) local famineEssence = game.player.essence - before eq(famineEssence, math.floor(plainEssence * 0.5), "famine alone halves essence earned from a kill") local rat3 = Monsters.create(Monsters.byKey.rat, 3, 4, 6, 1) rat3.hp = 1 game.level.entities = { rat3 } game.player.essence = before game.level.event = { key = Events.RICHVEINS, turnsLeft = 5 } game:playerAttack(rat3) local stackedEssence = game.player.essence - before eq(stackedEssence, plainEssence, "famine's half and rich veins' double multiply to exactly cancel back to the plain amount") end function tests.gameEffectiveRadiusStacksNightblindWithHush() local meta = Meta.new() meta.bestDepth = 2 local game = Game.new(1, { meta = meta, mutators = { "nightblind" } }) game.ascension = {} local noAscension = game:effectiveRadius() game.ascension = { "nightblind" } eq(game:effectiveRadius(), noAscension - 2, "nightblind alone shrinks the effective sight radius by its own penalty") game.level.event = { key = Events.HUSH, turnsLeft = 5 } eq(game:effectiveRadius(), noAscension - 2 - Events.HUSH_SIGHT_PENALTY, "nightblind and hush both shrink the effective sight radius, summed together") end function tests.metaRenownForAddsTheAscensionBonusOnTopOfTheBaseScore() local stats = { deepestDepth = 5, bossKills = 0, questsCompleted = 0, won = false } local base = Meta.renownFor(stats) stats.mutators = { "famine" } eq(Meta.renownFor(stats), base + (base * 10) // 100, "famine's 10% renown bonus is added on top of the base score") stats.mutators = { "famine", "nightblind" } eq(Meta.renownFor(stats), base + (base * 20) // 100, "two mutators' renown bonuses sum before being applied") end function tests.ascensionFieldSurvivesASaveLoadRoundTrip() local meta = Meta.new() meta.bestDepth = 3 local game = Game.new(4, { meta = meta, mutators = { "bloodbound" } }) local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(#restored.ascension, 1, "the active ascension mutator list survives a save/load round trip") eq(restored.ascension[1], "bloodbound", "the restored mutator key matches what was active") end function tests.renderPanelShowsActiveAscensionMutatorsByKey() local game = stagedGame(1, arena(21, 11), 3, 6) ok(not contains(Render.panel(game), "ascension"), "no active mutators shows no ascension line at all") game.ascension = { "famine", "nightblind" } ok(contains(Render.panel(game), "ascension famine,nightblind"), "active mutators are listed by key in the panel") end -- durability.lua: weapon/armor/bow wear and repair. function tests.durabilityFreshWeaponStartsAtMaxAndIsNotBroken() local sword = Items.weapon("sword", 4) eq(sword.durability, Durability.MAX, "a freshly forged weapon starts at max durability") eq(sword.maxDurability, Durability.MAX, "a freshly forged weapon's max durability matches the catalog default") ok(not Durability.isBroken(sword), "a freshly forged weapon is not broken") end function tests.durabilityWearWeaponDecrementsAndBreaksAtZeroSubtractingItsPower() local hero = { attack = 5, inventory = {}, kind = "player", name = "you" } Items.equipWeapon(Items.weapon("sword", 4), hero) eq(hero.attack, 9, "the sword's power is baked into attack on equip") local broke for _ = 1, Durability.MAX - 1 do broke = Durability.wearWeapon(hero) ok(broke == nil, "the sword survives every hit before its last") end eq(hero.weapon.durability, 1, "one point of durability remains before the final hit") broke = Durability.wearWeapon(hero) ok(broke ~= nil, "the hit that empties durability reports a break") ok(contains(broke, "breaks apart"), "the break line names what happened") eq(hero.weapon.durability, 0, "durability floors at exactly zero") eq(hero.attack, 5, "the sword's power is pulled back out of attack the instant it breaks") end function tests.durabilityWearWeaponIsANoOpOnceAlreadyBroken() local item = Items.weapon("dagger", 2) item.durability = 0 local hero = { weapon = item, attack = 10, kind = "player", name = "you" } eq(Durability.wearWeapon(hero), nil, "wearing an already broken weapon reports no further break") eq(item.durability, 0, "durability never drops below zero") eq(hero.attack, 10, "wearing an already-broken weapon never touches attack again") end function tests.durabilityWearArmorBreaksAndSubtractsItsPowerFromDefense() local hero = { defense = 1, inventory = {}, kind = "player", name = "you" } Items.equipArmor(Items.armor("plate", 3), hero) eq(hero.defense, 4, "the plate's power is baked into defense on equip") hero.armor.durability = 1 local broke = Durability.wearArmor(hero) ok(contains(broke, "battered"), "the break line reports the armor giving out") eq(hero.defense, 1, "the armor's power is pulled back out of defense the instant it breaks") end function tests.durabilityWearBowBreaksWithoutTouchingAnyStatDirectly() local hero = { bow = Items.bow("shortbow", 3, 6), attack = 5, kind = "player", name = "you" } hero.bow.durability = 1 local broke = Durability.wearBow(hero) ok(contains(broke, "snaps"), "the break line reports the bow giving out") eq(hero.bow.durability, 0, "durability floors at zero") eq(hero.attack, 5, "a bow's power was never baked into attack, so breaking it leaves attack untouched") end function tests.durabilityEffectivePowerIsZeroForABrokenItemAndFullOtherwise() local bow = Items.bow("shortbow", 3, 6) eq(Durability.effectivePower(bow), 3, "a pristine bow contributes its full power") bow.durability = 0 eq(Durability.effectivePower(bow), 0, "a broken bow contributes nothing") eq(Durability.effectivePower(nil), 0, "no bow equipped contributes nothing") end function tests.durabilityRepairFailsWithoutEnoughGoldOrOnAPristineItem() local hero = { weapon = Items.weapon("sword", 4), gold = 0 } local success, reason = Durability.repair(hero, "weapon") eq(success, false, "a pristine weapon refuses repair") eq(reason, "already in perfect condition", "the refusal explains why") hero.weapon.durability = 10 success, reason = Durability.repair(hero, "weapon") eq(success, false, "an empty purse refuses to cover the repair bill") eq(reason, "not enough gold", "the refusal explains why") success, reason = Durability.repair(hero, "bow") eq(success, false, "repairing an empty slot fails") eq(reason, "nothing equipped there", "the refusal explains why") end function tests.durabilityRepairRestoresFullDurabilityAndFoldsPowerBackInAfterABreak() local hero = { attack = 5, gold = 100, inventory = {}, kind = "player", name = "you" } Items.equipWeapon(Items.weapon("sword", 4), hero) hero.weapon.durability = 0 hero.attack = hero.attack - 4 local success, cost = Durability.repair(hero, "weapon") ok(success, "a full purse and a broken weapon repair successfully") eq(hero.weapon.durability, Durability.MAX, "repairing restores full durability") eq(cost, Durability.MAX * Durability.REPAIR_COST_PER_POINT, "repairing a fully broken item costs its full bill") eq(hero.gold, 100 - cost, "the repair bill is deducted from gold") eq(hero.attack, 9, "the weapon's power is folded back into attack once repaired") end function tests.durabilityRepairOnANotYetBrokenItemJustTopsUpDurabilityWithoutTouchingStats() local hero = { attack = 9, gold = 100 } hero.weapon = Items.weapon("sword", 4) hero.weapon.durability = 20 local success, cost = Durability.repair(hero, "weapon") ok(success, "a merely dinged weapon still repairs") eq(hero.weapon.durability, Durability.MAX, "durability tops back up to max") eq(cost, (Durability.MAX - 20) * Durability.REPAIR_COST_PER_POINT, "the bill only covers the missing points") eq(hero.attack, 9, "attack is untouched since the weapon was never broken in the first place") end function tests.equipWeaponAddsOnlyEffectivePowerForAnAlreadyBrokenWeapon() local hero = { attack = 5, inventory = {} } local sword = Items.weapon("sword", 4) sword.durability = 0 Items.equipWeapon(sword, hero) eq(hero.attack, 5, "equipping an already-broken weapon adds none of its power") end function tests.rangedFireDealsNoBowBonusOnceTheBowIsBroken() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.bow = Items.bow("shortbow", 5, 6) game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 10) local orc = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) orc.hp, orc.maxHp = 999, 999 game:fireAt(orc) local pristineDamage = 999 - orc.hp orc.hp = 999 game.player.bow.durability = 0 game:fireAt(orc) local brokenDamage = 999 - orc.hp ok(brokenDamage < pristineDamage, "a broken bow deals strictly less damage than a working one") eq(brokenDamage, pristineDamage - 5, "the broken bow's missing power matches its catalog power exactly") end function tests.gamePlayerAttackWearsTheEquippedWeaponAndLogsWhenItBreaks() local game = stagedGame(1, arena(21, 11), 3, 6) Items.equipWeapon(Items.weapon("dagger", 2), game.player) game.player.weapon.durability = 1 local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) orc.hp, orc.maxHp = 999, 999 game:playerAttack(orc) eq(game.player.weapon.durability, 0, "the weapon's last durability point is spent on this hit") local broke = false for _, line in ipairs(game.log:recent(6)) do if contains(line, "breaks apart") then broke = true end end ok(broke, "the weapon breaking is reported in the message log") end function tests.gameMonsterMeleeWearsThePlayersArmorAndLogsWhenItBreaks() local game = stagedGame(1, arena(21, 11), 3, 6) Items.equipArmor(Items.armor("plate", 3), game.player) game.player.armor.durability = 1 local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) game:monsterMelee(orc) eq(game.player.armor.durability, 0, "the armor's last durability point is spent on this hit") local broke = false for _, line in ipairs(game.log:recent(6)) do if contains(line, "battered") then broke = true end end ok(broke, "the armor breaking is reported in the message log") end function tests.gameFireAtWearsTheEquippedBowAndLogsWhenItBreaks() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.bow = Items.bow("shortbow", 3, 6) game.player.bow.durability = 1 game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 5) local orc = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) orc.hp, orc.maxHp = 999, 999 game:fireAt(orc) eq(game.player.bow.durability, 0, "the bow's last durability point is spent on this shot") ok(contains(game.log:last(), "snaps"), "the bow snapping is the shot's own last log line") end function tests.gameBrokenEquippedSlotFindsTheFirstBrokenPieceInPriorityOrder() local game = stagedGame(1, arena(21, 11), 3, 6) eq(game:brokenEquippedSlot(), nil, "a fresh player with no gear has nothing broken") Items.equipArmor(Items.armor("plate", 3), game.player) game.player.armor.durability = 0 eq(game:brokenEquippedSlot(), "armor", "the only broken piece is found") Items.equipWeapon(Items.weapon("sword", 4), game.player) game.player.weapon.durability = 0 eq(game:brokenEquippedSlot(), "weapon", "weapon takes priority over armor once both are broken") end function tests.gameRepairEquippedSpendsGoldAndLogsSuccess() local game = stagedGame(1, arena(21, 11), 3, 6) Items.equipWeapon(Items.weapon("sword", 4), game.player) game.player.weapon.durability = 0 game.player.gold = 1000 local before = game.player.gold local success = game:repairEquipped("weapon") ok(success, "a full purse repairs the broken weapon") eq(game.player.weapon.durability, Durability.MAX, "the weapon comes back to full durability") ok(game.player.gold < before, "gold is spent on the repair") ok(contains(game.log:last(), "repair"), "the repair is logged") end function tests.gameRepairEquippedFailsAndLogsWhenGoldIsShort() local game = stagedGame(1, arena(21, 11), 3, 6) Items.equipWeapon(Items.weapon("sword", 4), game.player) game.player.weapon.durability = 0 game.player.gold = 0 local success = game:repairEquipped("weapon") eq(success, false, "an empty purse cannot repair") eq(game.player.weapon.durability, 0, "the weapon stays broken") ok(contains(game.log:last(), "cannot repair"), "the failure is logged") end function tests.autopilotRepairsBrokenGearAutomaticallyWhenAffordable() local game = stagedGame(1, arena(21, 11), 3, 6) Items.equipWeapon(Items.weapon("sword", 4), game.player) game.player.weapon.durability = 0 game.player.gold = 1000 game.level.entities = {} game:choosePlayerAction() eq(game.player.weapon.durability, Durability.MAX, "the autopilot repairs a broken weapon on sight once it can afford to") end function tests.durabilityFieldsSurviveSerialization() local game = Game.new(3) Items.equipWeapon(Items.weapon("sword", 4), game.player) game.player.weapon.durability = 7 local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(restored.player.weapon.durability, 7, "a weapon's own durability survives a save/load round trip") eq(restored.player.weapon.maxDurability, Durability.MAX, "a weapon's max durability survives a save/load round trip") end function tests.renderPanelShowsWornGearOnlyOnceItHasTakenDamage() local game = stagedGame(1, arena(21, 11), 3, 6) Items.equipWeapon(Items.weapon("sword", 4), game.player) ok(not contains(Render.panel(game), "sword ("), "a pristine weapon's label carries no wear indicator") game.player.weapon.durability = 10 ok(contains(Render.panel(game), "sword (10/30)"), "a dinged weapon's label reports its remaining durability") game.player.weapon.durability = 0 ok(contains(Render.panel(game), "sword (broken)"), "a broken weapon's label says so plainly") end -- disarm.lua: passive trap sense and deliberate disarming. function tests.disarmSweepDetectsAHiddenTrapWithinOneStepEventuallyAcrossAHandfulOfSeeds() local detected = false for seed = 1, 100 do local level = { traps = { { x = 5, y = 5, effect = "dart", revealed = false } } } if #Disarm.sweep(level, Rng.new(seed), 5, 6) > 0 then detected = true break end end ok(detected, "an adjacent hidden trap is eventually detected across a handful of seeds") end function tests.disarmSweepNeverDetectsATrapTwoStepsAway() for seed = 1, 200 do local level = { traps = { { x = 5, y = 5, effect = "dart", revealed = false } } } eq(#Disarm.sweep(level, Rng.new(seed), 7, 7), 0, "a trap two steps away is never detected regardless of the roll") end end function tests.disarmSweepNeverRedetectsAnAlreadyDetectedOrRevealedTrap() local level = { traps = { { x = 5, y = 5, effect = "dart", revealed = false, detected = true }, { x = 5, y = 6, effect = "dart", revealed = true }, } } for seed = 1, 50 do eq(#Disarm.sweep(level, Rng.new(seed), 5, 5), 0, "neither an already-detected nor an already-sprung trap is swept again") end end function tests.disarmOddsForClimbWithLevelButNeverReachTheDenominator() local num1, den1 = Disarm.oddsFor({ level = 1 }) eq(num1, Disarm.DISARM_BASE_NUMERATOR, "a fresh level-one player rolls the catalog's base odds") local num2, den2 = Disarm.oddsFor({ level = 100 }) eq(den2, den1, "the denominator never changes with level") ok(num2 < den2, "even an extremely high level never reaches a guaranteed disarm") eq(num2, Disarm.DISARM_BASE_DENOMINATOR - 1, "the numerator caps a single point shy of the denominator") end function tests.disarmAttemptOnSuccessRemovesTheTrapAndPaysSalvage() local succeeded = false for seed = 1, 300 do local game = stagedGame(seed, arena(21, 11), 3, 6) local trap = { x = 4, y = 6, effect = "dart", revealed = false, detected = true } game.level.traps = { trap } local essenceBefore = game.player.essence local success, line = Disarm.attempt(game, trap) if success then eq(#game.level.traps, 0, "a successfully disarmed trap is removed from the level") ok(game.player.essence > essenceBefore, "a successful disarm pays out salvage essence") ok(contains(line, "disarm"), "the success is reported") succeeded = true break end end ok(succeeded, "disarming eventually succeeds across a handful of seeds") end function tests.disarmAttemptOnFailureSpringsTheTrapOnThePlayer() local failed = false for seed = 1, 300 do local game = stagedGame(seed, arena(21, 11), 3, 6) local trap = { x = 4, y = 6, effect = "dart", revealed = false, detected = true } game.level.traps = { trap } local hpBefore = game.player.hp local success = Disarm.attempt(game, trap) if not success then eq(trap.revealed, true, "a failed disarm springs the trap, revealing it") ok(game.player.hp < hpBefore, "a failed disarm's spring wounds the player exactly like a footstep would") failed = true break end end ok(failed, "disarming eventually fails across a handful of seeds") end function tests.gameDisarmTrapRefusesAnUndetectedOrAlreadySprungTrap() local game = stagedGame(1, arena(21, 11), 3, 6) local undetected = { x = 4, y = 6, effect = "dart", revealed = false } game.level.traps = { undetected } eq(game:disarmTrap(undetected), false, "an undetected trap refuses disarming outright") eq(#game.level.traps, 1, "the untouched trap is still there") local sprung = { x = 5, y = 6, effect = "dart", revealed = true, detected = true } eq(game:disarmTrap(sprung), false, "an already-sprung trap refuses disarming outright") end function tests.gameDisarmTrapSucceedsOrSpringsExactlyLikeDisarmAttempt() local sawSuccess, sawFailure = false, false for seed = 1, 300 do if sawSuccess and sawFailure then break end local game = stagedGame(seed, arena(21, 11), 3, 6) local trap = { x = 4, y = 6, effect = "dart", revealed = false, detected = true } game.level.traps = { trap } local hpBefore = game.player.hp if game:disarmTrap(trap) then eq(#game.level.traps, 0, "a successful disarm through the game wrapper removes the trap") sawSuccess = true else eq(trap.revealed, true, "a failed disarm through the game wrapper springs the trap") ok(game.player.hp <= hpBefore, "a failed disarm through the game wrapper can wound the player") sawFailure = true end end ok(sawSuccess, "the game wrapper eventually succeeds across a handful of seeds") ok(sawFailure, "the game wrapper eventually fails across a handful of seeds") end function tests.gameEndPlayerTurnEventuallyDetectsAnAdjacentTrapAcrossAHandfulOfSeeds() local noticed = false for seed = 1, 100 do local game = stagedGame(1, arena(21, 11), 3, 6) game.rng = Rng.new(seed) game.level.traps = { { x = 4, y = 6, effect = "dart", revealed = false } } game:endPlayerTurn() if game.level.traps[1].detected then local reported = false for _, line in ipairs(game.log:recent(6)) do if contains(line, "notice") then reported = true end end ok(reported, "a freshly detected trap is reported somewhere in the turn's log lines") noticed = true break end end ok(noticed, "an adjacent hidden trap is eventually detected during a normal turn across a handful of seeds") end function tests.trapDetectedFieldSurvivesSerialization() local game = Game.new(3) game.level.traps = { { x = 1, y = 1, effect = "dart", revealed = false, detected = true } } local path = os.tmpname() game:save(path) local restored = Game.load(path) os.remove(path) eq(restored.level.traps[1].detected, true, "a trap's detected flag survives a save/load round trip") end end local order = { "rngDeterminism", "mapDeterminism", "tilesRegistry", "fovKnownScene", "losSightLine", "astarPathing", "combatResolution", "itemsAndEquipment", "affixes", "statusEffects", "scheduler", "generators", "connectivityRepair", "dungeonPersistence", "monsterAiChase", "monsterAiRanged", "monsterAiFlee", "monsterAiDeterministic", "doorsAndKeys", "traps", "lightAndDark", "identification", "effects", "messageLog", "weaponAffixOnHit", "saveLoadRoundTrip", "deterministicPlaythrough", "autoplayDescends", } for _, name in ipairs(order) do tests[name]() io.write(string.format(" %-26s ok\n", name)) end io.write(string.format("\n%d tests, %d asserts, %d failed\n", #order, passed + failed, failed)) if failed > 0 then io.write("FAILURES:\n") for _, name in ipairs(failures) do io.write(" - " .. name .. "\n") end os.exit(1) end os.exit(0)