Files
warren/tests/run.lua
T

233 lines
9.5 KiB
Lua

-- 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)