-- 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 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 Serialize = require("serialize") local Game = require("game") 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 local order = { "rngDeterminism", "mapDeterminism", "tilesRegistry", "fovKnownScene", "losSightLine", "astarPathing", "combatResolution", "itemsAndEquipment", "affixes", "statusEffects", } 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)