From 7b06f9ef8713c19c26bc2efa9c6e9d4061d67b39 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 28 Aug 2026 15:50:00 +0200 Subject: [PATCH] test: add core, fov, pathfinding and combat tests --- tests/run.lua | 232 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 tests/run.lua diff --git a/tests/run.lua b/tests/run.lua new file mode 100644 index 0000000..a5ee930 --- /dev/null +++ b/tests/run.lua @@ -0,0 +1,232 @@ +-- 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)