diff --git a/tests/run.lua b/tests/run.lua index ee63909..58356b5 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -6357,6 +6357,179 @@ function tests.gamePullLeverOnATwoLeverVaultWaitsForTheSecondPull() ok(contains(game.log:last(), "clunks into place"), "a partial pull is logged as a clunk, not an opening") game:pullLever(leverB) eq(game.level.map:get(10, 6), Tiles.DOOR_OPEN, "the door opens the instant the last lever is thrown") + eq(game.stats.vaultsOpened, 1, "the vault tally advances exactly once, on the pull that completes the puzzle") +end + +function tests.pullingAnAlreadyThrownLeverAgainDoesNotInflateTheVaultTally() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.level.map:set(6, 6, Tiles.LEVER) + game.level.map:set(10, 6, Tiles.VAULT_DOOR) + local lever = { x = 6, y = 6, id = "v5", pulled = false } + game.level.vaultLevers = { lever } + game.level.vaultDoors = { { x = 10, y = 6, id = "v5", opened = false } } + game:pullLever(lever) + eq(game.stats.vaultsOpened, 1, "the first pull opens the vault") + local pulledAgain = game:pullLever(lever) + eq(pulledAgain, false, "re-pulling an already-thrown lever reports failure") + eq(game.stats.vaultsOpened, 1, "re-pulling an already-thrown lever does not inflate the vault tally") +end + +function tests.movePlayerAutoPullsALeverItStepsOnto() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.map:set(6, 5, Tiles.LEVER) + local lever = { x = 6, y = 5, id = "v6", pulled = false } + game.level.vaultLevers = { lever } + game.level.vaultDoors = { { x = 15, y = 5, id = "v6", opened = false } } + game:movePlayer(1, 0) + eq(game.player.x, 6, "the player actually steps onto the lever's cell") + eq(lever.pulled, true, "stepping onto a lever automatically throws it") +end + +function tests.vaultDoorBlocksMovementUntilItsLeversOpenIt() + local game = stagedGame(1, arena(21, 11), 9, 5) + game.level.map:set(10, 5, Tiles.VAULT_DOOR) + local moved = game:movePlayer(1, 0) + eq(moved, false, "an unopened vault door blocks the step onto it") + eq(game.player.x, 9, "the player does not pass through a sealed vault door") + game.level.map:set(10, 5, Tiles.DOOR_OPEN) + local movedAfter = game:movePlayer(1, 0) + eq(movedAfter, true, "once opened, the door's tile behaves like any plain open door") + eq(game.player.x, 10, "the player steps onto the formerly sealed cell") +end + +function tests.builderOccasionallyPlacesAVaultFromDepthFive() + -- Seed 41, not 1: adding the banshee species (minDepth 5) to Monsters.species + -- shifts Monsters.forDepth's eligible count for every depth-5+ monster spawn + -- roll, which desyncs Rng.new(1)'s stream from the very first monster onward - + -- seed 1 no longer rolls a vault onto this floor at all. Recomputed by + -- brute-force search (seeds 1-50) for one that still places exactly one + -- vault door with its treasure, same as every other assertion below expects. + local level = Builder.build(5, Rng.new(41), {}) + eq(#level.vaultDoors, 1, "this seed's depth-5 floor places exactly one vault door") + ok(#level.vaultLevers >= 2, "the vault's puzzle has at least two levers") + local door = level.vaultDoors[1] + eq(level.map:get(door.x, door.y), Tiles.VAULT_DOOR, "the vault door's own tile matches its sealed glyph") + for _, lever in ipairs(level.vaultLevers) do + eq(lever.id, door.id, "every placed lever shares the door's own vault id") + eq(level.map:get(lever.x, lever.y), Tiles.LEVER, "each lever's tile matches its unpulled glyph") + end + local found + for _, ground in ipairs(level.floorItems) do + if ground.item.name and ground.item.name:find("vault", 1, true) then found = ground.item end + end + ok(found ~= nil, "the vault's guaranteed treasure is placed somewhere on the floor") +end + +function tests.vaultsStayOffFloorsAboveDepthFive() + for seed = 1, 4 do + for depth = 1, 4 do + local level = Builder.build(depth, Rng.new(seed), {}) + eq(#level.vaultDoors, 0, "no vault door appears above depth five") + eq(#level.vaultLevers, 0, "no vault lever appears above depth five") + end + end +end + +-- Every vault the builder actually places must be genuinely solvable: each +-- of its levers sits somewhere already reachable from the entrance without +-- crossing a locked door or the vault door itself (see builder.lua's +-- fallback to a plain closed door whenever that cannot be guaranteed). +function tests.builderOnlyEverPlacesASolvableVaultPuzzle() + local checked = 0 + for seed = 1, 200 do + local level = Builder.build(5, Rng.new(seed), {}) + if #level.vaultDoors == 1 then + checked = checked + 1 + for _, lever in ipairs(level.vaultLevers) do + local path = Astar.find(level.map, level.upX, level.upY, lever.x, lever.y, function(x, y) + if x == lever.x and y == lever.y then return false end + return not level.map:isPassable(x, y, false) + end) + ok(path ~= nil, "seed " .. seed .. ": every vault lever is reachable without a key or the vault door") + end + end + end + ok(checked > 0, "at least one of the sampled seeds actually placed a vault to check") +end + +function tests.vaultFieldsSurviveSerialization() + local level = arena(21, 11) + level.map:set(6, 6, Tiles.LEVER_PULLED) + level.map:set(10, 6, Tiles.DOOR_OPEN) + level.vaultLevers = { { x = 6, y = 6, id = "v7", pulled = true } } + level.vaultDoors = { { x = 10, y = 6, id = "v7", opened = true } } + local restored = Level.fromData(level:toData()) + eq(#restored.vaultLevers, 1, "the vault lever list survives a round trip") + eq(restored.vaultLevers[1].pulled, true, "a lever's pulled state survives a round trip") + eq(#restored.vaultDoors, 1, "the vault door list survives a round trip") + eq(restored.vaultDoors[1].opened, true, "a door's opened state survives a round trip") +end + +function tests.gameVaultsOpenedStatSurvivesSaveLoad() + local game = Game.new(1) + local lx, ly = game.player.x + 1, game.player.y + game.level.map:set(lx, ly, Tiles.LEVER) + local lever = { x = lx, y = ly, id = "v8", pulled = false } + game.level.vaultLevers = { lever } + game.level.vaultDoors = { { x = game.player.x + 2, y = game.player.y, id = "v8", opened = false } } + game:pullLever(lever) + eq(game.stats.vaultsOpened, 1, "pulling the lever opens the vault") + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.stats.vaultsOpened, 1, "the vault-opened tally survives a save/load round trip") + eq(#restored.level.vaultLevers, 1, "the level's vault lever list survives a save/load round trip") + eq(restored.level.vaultLevers[1].pulled, true, "a pulled lever's state survives a save/load round trip") +end + +function tests.metaRenownForScalesWithVaultsOpened() + local base = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 0, vaultsOpened = 0, won = false }) + local withVault = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 0, vaultsOpened = 2, won = false }) + eq(withVault, base + 20, "each opened vault adds a fixed renown bonus") +end + +function tests.metaRecordRunUnlocksVaultBreakerOnceAVaultOpens() + local meta = Meta.new() + local stats = { + deepestDepth = 1, kills = 0, bossKills = 0, questsCompleted = 0, + artifactsFound = 0, vaultsOpened = 1, won = false, + } + local unlocked = Meta.recordRun(meta, stats) + eq(unlocked[1], "Vault Breaker", "opening a vault for the first time unlocks Vault Breaker") + eq(meta.achievements.vault_breaker, true, "vault_breaker is marked unlocked") + local again = Meta.recordRun(meta, stats) + eq(#again, 0, "an already-unlocked Vault Breaker is not re-announced on a later run") +end + +-- Runewords (runewords.lua): rune stones socket into a weapon or armor's +-- rune chain in order; an exact match against the catalog grants a fixed +-- attack/defense bonus read live by combat.lua, exactly like a skill bonus. + +function tests.runewordsSocketAppendsToTheChainInOrderAndCapsAtMaxRunes() + local sword = Items.weapon("sword", 4) + ok(Runewords.socket(sword, Runewords.rune("sol")), "socketing a rune into a bare weapon succeeds") + ok(Runewords.socket(sword, Runewords.rune("ith")), "a second rune joins the same chain") + eq(table.concat(sword.runes, "-"), "sol-ith", "the chain records runes in the order they were socketed") + ok(Runewords.socket(sword, Runewords.rune("tal")), "a third rune still fits under the cap") + local success, reason = Runewords.socket(sword, Runewords.rune("ral")) + eq(success, false, "a fourth rune overflows the chain") + eq(reason, "the rune chain is full", "socket explains why a full chain refuses another rune") +end + +function tests.runewordsSocketRefusesAnythingThatIsNotWeaponOrArmor() + local potion = Items.potion(10) + local success, reason = Runewords.socket(potion, Runewords.rune("sol")) + eq(success, false, "a potion cannot hold a rune chain") + eq(reason, "cannot hold runes", "socket explains why a non-gear item refuses a rune") +end + +function tests.runewordsActiveWordMatchesOnlyAnExactChain() + local sword = Items.weapon("sword", 4) + eq(Runewords.activeWord(sword), nil, "a bare weapon forms no runeword") + Runewords.socket(sword, Runewords.rune("sol")) + eq(Runewords.activeWord(sword), nil, "a single rune alone matches no cataloged word") + Runewords.socket(sword, Runewords.rune("ith")) end local order = {