732 lines
31 KiB
Lua
732 lines
31 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 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
|
|
|
|
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)
|