-- 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") 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.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("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") 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.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 local run = playout(2024) ok(run.depth >= 3, "the autopilot descends several levels") local again = playout(2024) 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 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)