From 309c42dda7daba62f08666070345d79360e44bf4 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 28 Aug 2026 19:15:00 +0200 Subject: [PATCH] test: add generation, dungeon and monster ai tests --- tests/run.lua | 205 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index a5ee930..5b3a3da 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -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