test: add generation, dungeon and monster ai tests

This commit is contained in:
dualfroz committed 2026-08-28 19:15:00 +02:00
1 parent 7b06f9ef87
commit 309c42dda7
1 file changed
+205
+205
View File
@@ -211,9 +211,214 @@ function tests.statusEffects()
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.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
local order = {
"rngDeterminism", "mapDeterminism", "tilesRegistry", "fovKnownScene", "losSightLine",
"astarPathing", "combatResolution", "itemsAndEquipment", "affixes", "statusEffects",
"scheduler", "generators", "connectivityRepair", "dungeonPersistence", "monsterAiChase",
"monsterAiRanged", "monsterAiFlee", "monsterAiDeterministic", "doorsAndKeys", "traps",
"lightAndDark", "identification",
}
for _, name in ipairs(order) do