diff --git a/tests/run.lua b/tests/run.lua index 46ecce5..404bbc9 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -5146,6 +5146,179 @@ function tests.autopilotDoesNotStackASecondBountyWhileOneIsActive() local held = Quests.generate(Rng.new(1), 4, 10) game.player.quest = held game.level.questBoards = { { x = 5, y = 5, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } } + game:choosePlayerAction() + eq(game.player.quest, held, "the autopilot keeps its held bounty rather than swapping to a new one") + eq(game.level.questBoards[1].claimed, false, "a board it cannot bind to stays unclaimed for later") +end + +function tests.questBoardRendersItsGlyphAndFlipsOnceClaimed() + local game = stagedGame(1, arena(21, 11), 3, 6) + local board = { x = 5, y = 6, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } + game.level.questBoards = { board } + game:computeVisible() + ok(game:render():find("&", 1, true) ~= nil, "an unclaimed bounty board renders its glyph") + board.claimed = true + ok(game:render():find("=", 1, true) ~= nil, "a claimed bounty board renders a different glyph") +end + +function tests.questFieldsSurviveSerialization() + local level = arena(21, 11) + level.questBoards = { { x = 4, y = 4, claimed = true, quest = Quests.generate(Rng.new(2), 4, 10) } } + local restored = Level.fromData(level:toData()) + eq(#restored.questBoards, 1, "the quest board list survives a level round trip") + eq(restored.questBoards[1].claimed, true, "a board's claimed flag survives a round trip") + eq(restored.questBoards[1].quest.kind, Quests.CULL, "a board's bounty kind survives a round trip") + eq(restored.questBoards[1].quest.need, 4, "a board's bounty tally survives a round trip") + + local game = Game.new(6) + game.player.quest = { kind = Quests.HOARD, need = 200, rewardGold = 50, rewardXp = 20 } + local path = os.tmpname() + game:save(path) + local restoredGame = Game.load(path) + os.remove(path) + eq(restoredGame.player.quest.kind, Quests.HOARD, "the player's own bound bounty survives a save/load round trip") + eq(restoredGame.player.quest.need, 200, "the bound bounty's target survives a save/load round trip") +end + +function tests.builderOccasionallyPlacesAQuestBoardFromDepthTwo() + local level = Builder.build(2, Rng.new(11), {}) + eq(#level.questBoards, 1, "this seed's depth-2 floor places a bounty board") + eq(level.questBoards[1].claimed, false, "a freshly built board starts unclaimed") + ok(level.questBoards[1].quest ~= nil, "a freshly built board already carries its one bounty") +end + +function tests.questBoardsStayOffTheEntranceFloor() + for seed = 1, 5 do + local level = Builder.build(1, Rng.new(seed), {}) + eq(#level.questBoards, 0, "no bounty board appears on the entrance floor") + end +end + +function tests.metaFreshStateHasNoRenownRunsOrAchievements() + local meta = Meta.new() + eq(meta.renown, 0, "a fresh meta state banks no renown") + eq(meta.totalRuns, 0, "a fresh meta state has recorded no runs") + eq(meta.wins, 0, "a fresh meta state has recorded no wins") + eq(meta.bestDepth, 0, "a fresh meta state has no best depth yet") + eq(next(meta.achievements), nil, "a fresh meta state has unlocked nothing") + eq(next(meta.perks), nil, "a fresh meta state owns no perks") +end + +function tests.metaRenownForScalesWithDepthBossesAndQuestsAndAddsAWinBonus() + local base = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 0, won = false }) + eq(base, 12, "renown scales with deepest depth alone") + local withBosses = Meta.renownFor({ deepestDepth = 3, bossKills = 2, questsCompleted = 0, won = false }) + eq(withBosses, base + 30, "each felled boss adds a fixed renown bonus") + local withQuests = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 4, won = false }) + eq(withQuests, base + 32, "each resolved bounty adds a fixed renown bonus") + local won = Meta.renownFor({ deepestDepth = 3, bossKills = 0, questsCompleted = 0, won = true }) + eq(won, base + 100, "escaping with the amulet adds a large flat bonus") +end + +function tests.metaRecordRunBanksRenownAndAdvancesTallies() + local meta = Meta.new() + Meta.recordRun(meta, { deepestDepth = 4, kills = 2, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = false }) + eq(meta.totalRuns, 1, "recordRun advances the run tally") + eq(meta.wins, 0, "an unwon run does not advance the win tally") + eq(meta.bestDepth, 4, "recordRun raises the best depth to this run's own") + eq(meta.renown, Meta.renownFor({ deepestDepth = 4, bossKills = 0, questsCompleted = 0, won = false }), + "recordRun banks exactly the renown this run's tally is worth") + Meta.recordRun(meta, { deepestDepth = 2, kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, won = true }) + eq(meta.totalRuns, 2, "a second run advances the run tally again") + eq(meta.wins, 1, "a won run advances the win tally") + eq(meta.bestDepth, 4, "a shallower later run does not lower the best depth") +end + +function tests.metaRecordRunUnlocksMatchingAchievementsOnlyOnce() + local meta = Meta.new() + local stats = { deepestDepth = 5, kills = 1, bossKills = 1, questsCompleted = 3, artifactsFound = 1, won = true } + local unlocked = Meta.recordRun(meta, stats) + table.sort(unlocked) + local expected = { "Amulet Bearer", "Artificer", "Boss Slayer", "Bounty Hunter", "Deep Delver", "First Blood" } + table.sort(expected) + eq(table.concat(unlocked, ","), table.concat(expected, ","), + "a run meeting every threshold unlocks every achievement at once") + for _, key in ipairs({ "first_blood", "boss_slayer", "deep_delver", "bounty_hunter", "artificer", "amulet_bearer" }) do + eq(meta.achievements[key], true, key .. " is marked unlocked") + end + local again = Meta.recordRun(meta, stats) + eq(#again, 0, "an already-unlocked achievement is not re-announced on a later run") +end + +function tests.metaPurchaseSpendsRenownAndMarksThePerkOwned() + local meta = Meta.new() + meta.renown = 30 + local success, message = Meta.purchase(meta, "vigor") + eq(success, true, "purchase succeeds with enough banked renown") + eq(message, "unlocked Vigor", "purchase reports the perk it unlocked") + eq(meta.renown, 0, "purchase spends exactly the perk's cost") + eq(Meta.isOwned(meta, "vigor"), true, "the purchased perk is marked owned") +end + +function tests.metaPurchaseFailsWithoutEnoughRenown() + local meta = Meta.new() + meta.renown = 5 + local success, message = Meta.purchase(meta, "vigor") + eq(success, false, "purchase fails without enough banked renown") + eq(message, "not enough renown", "purchase explains why it failed") + eq(meta.renown, 5, "a failed purchase spends nothing") +end + +function tests.metaPurchaseFailsForAnAlreadyOwnedPerk() + local meta = Meta.new() + meta.renown = 100 + Meta.purchase(meta, "vigor") + local renownAfterFirst = meta.renown + local success, message = Meta.purchase(meta, "vigor") + eq(success, false, "purchase refuses to sell the same perk twice") + eq(message, "already owned", "purchase explains why it failed") + eq(meta.renown, renownAfterFirst, "a refused repurchase spends nothing further") +end + +function tests.metaPurchaseFailsForAnUnknownPerk() + local meta = Meta.new() + meta.renown = 100 + local success, message = Meta.purchase(meta, "not_a_real_perk") + eq(success, false, "purchase fails for a key outside the catalog") + eq(message, "no such perk", "purchase explains why it failed") + eq(meta.renown, 100, "a failed purchase spends nothing") +end + +function tests.metaApplyAllAppliesEveryOwnedPerkToAFreshPlayer() + local meta = Meta.new() + meta.renown = 200 + Meta.purchase(meta, "vigor") + Meta.purchase(meta, "purse") + local game = Game.new(1, { meta = meta }) + eq(game.player.maxHp, 30 + 5, "the owned vigor perk raises max hp before class deltas") + eq(game.player.hp, 30 + 5, "the owned vigor perk raises current hp to match") + eq(game.player.gold, 20 + 25, "the owned deep pockets perk raises starting gold") + local plain = Game.new(1) + eq(plain.player.maxHp, 30, "a game started without a meta table gets no perk bonus") +end + +function tests.metaAppliedPerksLandBeneathAClassDelta() + local meta = Meta.new() + meta.renown = 200 + Meta.purchase(meta, "vigor") + local game = Game.new(1, { meta = meta, class = "fighter" }) + eq(game.player.maxHp, 30 + 5 + 8, "a fighter's class bonus stacks on top of an owned perk's bonus") +end + +function tests.metaLoadReturnsFreshStateForAMissingFile() + local meta = Meta.load("/tmp/warren_meta_does_not_exist_12345.sav") + eq(meta.renown, 0, "loading a missing meta file yields a fresh state") + eq(meta.totalRuns, 0, "a freshly defaulted meta state has recorded no runs") +end + +function tests.metaSaveLoadRoundTrip() + local meta = Meta.new() + meta.renown = 42 + Meta.recordRun(meta, { deepestDepth = 6, kills = 5, bossKills = 1, questsCompleted = 2, artifactsFound = 1, won = false }) + Meta.purchase(meta, "focus") + local path = os.tmpname() + Meta.save(meta, path) + local restored = Meta.load(path) end local order = {