233 lines
9.5 KiB
Lua
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)
|