From 61296a4dd31744decaffce95442e968182d8c61b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 10 Sep 2026 13:44:37 +0200 Subject: [PATCH] test: cover tests.bossPhaseFieldsSurviveALevelSerializationRoundTrip --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 9cbf0e5..46ecce5 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -4973,6 +4973,179 @@ function tests.takeMonsterTurnSkipsAbilityAndAiWhileABossBracesOrUnleashesItsCat warlord.enraged = true warlord.hp = math.floor(warlord.maxHp * spec.desperateAt) game.level.entities = { warlord } + + game:takeMonsterTurn(warlord) + eq(warlord.x, 4, "a bracing boss never steps toward the player through its ordinary ai") + local hpAfterBrace = game.player.hp + eq(hpAfterBrace, game.player.maxHp, "bracing alone never damages the player") + + game:takeMonsterTurn(warlord) + ok(game.player.hp < hpAfterBrace, "unleashing the cataclysm damages the adjacent player") + + local hpBeforeThird = game.player.hp + game:takeMonsterTurn(warlord) + ok(warlord.ability == nil or true, "a spent boss falls through to its plain rally ability or ai on its next turn") + ok(game.player.hp <= hpBeforeThird, "the boss keeps acting normally once its cataclysm is spent") +end + +function tests.bossPhaseFieldsSurviveALevelSerializationRoundTrip() + local game = stagedGame(3, arena(21, 11), 5, 5) + local spec = Bosses.byKey.warlord + local warlord = Bosses.create(spec, 1, 6, 5) + warlord.enraged = true + warlord.hp = math.floor(warlord.maxHp * spec.desperateAt) + Phases.tick(game, warlord) -- brace: desperate + channeling + game.level.entities = { warlord } + + local restoredLevel = Level.fromData(game.level:toData()) + local restored = restoredLevel.entities[1] + eq(restored.desperate, true, "the boss's desperate flag survives a round trip") + eq(restored.channeling, true, "the boss's channeling flag survives a round trip") + eq(restored.defense, warlord.defense, "the boss's braced defense value survives a round trip") + + Phases.tick(game, restored) -- unleash on the restored copy + eq(restored.novaSpent, true, "a restored boss can still unleash and latch novaSpent") +end + +function tests.questGenerateProducesACullBounty() + local bounty = Quests.generate(Rng.new(2), 4, 10) + eq(bounty.kind, Quests.CULL, "this seed at depth 4 rolls a cull bounty") + eq(bounty.species, "necromancer", "the cull bounty's target species is fixed by the seed") + eq(bounty.need, 4, "the cull bounty's tally is fixed by the seed") + eq(bounty.have, 0, "a freshly generated cull bounty starts at zero progress") + eq(bounty.rewardGold, 47, "reward gold scales with the generating depth") + eq(bounty.rewardXp, 26, "reward xp scales with the generating depth") + eq(Quests.describe(bounty), "cull 4 necromancer (0/4)", "describe renders the cull bounty's live progress") +end + +function tests.questGenerateProducesAHoardBounty() + local bounty = Quests.generate(Rng.new(1), 4, 10) + eq(bounty.kind, Quests.HOARD, "this seed at depth 4 rolls a hoard bounty") + eq(bounty.need, 120, "the hoard bounty's absolute gold target scales with depth") + eq(Quests.describe(bounty), "amass 120 gold", "describe renders the hoard bounty's target") +end + +function tests.questGenerateProducesADepthBountyClampedToMaxDepth() + local shallow = Quests.generate(Rng.new(5), 4, 10) + eq(shallow.kind, Quests.DEPTH, "this seed at depth 4 rolls a depth bounty") + eq(shallow.need, 6, "an unclamped depth bounty targets a few floors further down") + local nearBottom = Quests.generate(Rng.new(5), 9, 10) + eq(nearBottom.kind, Quests.DEPTH, "the same seed at depth 9 also rolls a depth bounty") + eq(nearBottom.need, 10, "a depth bounty that would exceed the dungeon's floor is clamped to it") +end + +function tests.questAcceptBindsThePlayerAndClaimsTheBoard() + local player = { quest = nil } + local board = { x = 5, y = 5, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } + local success, line = Quests.accept(player, board) + eq(success, true, "accepting an unclaimed bounty while unbound succeeds") + eq(player.quest, board.quest, "the player is bound to the board's exact bounty") + eq(board.claimed, true, "the board is claimed the instant its bounty is taken") + ok(line:find("necromancer") ~= nil, "the acceptance line names the bounty struck") +end + +function tests.questAcceptFailsWhenAlreadyHoldingOne() + local existing = Quests.generate(Rng.new(1), 4, 10) + local player = { quest = existing } + local board = { x = 5, y = 5, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } + local success = Quests.accept(player, board) + eq(success, false, "a player already bound to a bounty cannot bind to a second") + eq(player.quest, existing, "the player's original bounty is untouched") + eq(board.claimed, false, "the untaken board stays unclaimed") +end + +function tests.questAcceptFailsOnAnAlreadyClaimedBoard() + local player = { quest = nil } + local board = { x = 5, y = 5, claimed = true, quest = Quests.generate(Rng.new(2), 4, 10) } + local success = Quests.accept(player, board) + eq(success, false, "a board already claimed grants no second bounty") + eq(player.quest, nil, "the player stays unbound") +end + +function tests.questRegisterKillIncrementsOnlyMatchingSpeciesWhileActive() + local player = { quest = { kind = Quests.CULL, species = "rat", need = 3, have = 0 } } + Quests.registerKill(player, "goblin") + eq(player.quest.have, 0, "a kill of an unrelated species does not advance the tally") + Quests.registerKill(player, "rat") + Quests.registerKill(player, "rat") + eq(player.quest.have, 2, "kills of the target species advance the tally") + Quests.registerKill(player, "rat") + Quests.registerKill(player, "rat") + eq(player.quest.have, 3, "the tally never climbs past the bounty's required count") +end + +function tests.questResolvedDetectsEachKindsCompletion() + local culler = { quest = { kind = Quests.CULL, need = 3, have = 2 }, gold = 0 } + eq(Quests.resolved(culler, 1), false, "a cull short of its tally is not resolved") + culler.quest.have = 3 + eq(Quests.resolved(culler, 1), true, "a cull at its tally is resolved") + + local hoarder = { quest = { kind = Quests.HOARD, need = 100 }, gold = 40 } + eq(Quests.resolved(hoarder, 1), false, "a hoard short of its gold target is not resolved") + hoarder.gold = 100 + eq(Quests.resolved(hoarder, 1), true, "a hoard at its gold target is resolved") + + local pactbound = { quest = { kind = Quests.DEPTH, need = 5 }, gold = 0 } + eq(Quests.resolved(pactbound, 4), false, "a depth pact short of its floor is not resolved") + eq(Quests.resolved(pactbound, 5), true, "a depth pact met at its floor is resolved") + + eq(Quests.resolved({ quest = nil, gold = 0 }, 5), false, "a player with no bounty is never resolved") +end + +function tests.gameResolveQuestPaysOutAndClearsAFulfilledBounty() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.quest = { kind = Quests.CULL, species = "rat", need = 1, have = 1, rewardGold = 30, rewardXp = 15 } + local goldBefore, essenceBefore = game.player.gold, game.player.essence + local resolved = game:resolveQuest() + eq(resolved, true, "resolveQuest reports the payout it made") + eq(game.player.gold, goldBefore + 30, "the bounty's gold reward is paid") + eq(game.player.essence, essenceBefore + Crafting.essenceFor(15), "the bounty's xp reward also converts to essence") + eq(game.player.quest, nil, "a resolved bounty frees the player to bind another") +end + +function tests.gameResolveQuestIsANoOpOnAnUnmetBounty() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.quest = { kind = Quests.CULL, species = "rat", need = 3, have = 1, rewardGold = 30, rewardXp = 15 } + local goldBefore = game.player.gold + local resolved = game:resolveQuest() + eq(resolved, false, "resolveQuest reports nothing was paid out") + eq(game.player.gold, goldBefore, "no gold changes hands on an unmet bounty") + ok(game.player.quest ~= nil, "the unmet bounty stays bound to the player") +end + +function tests.gameTryAcceptQuestBindsAndLogsWhenStandingOnAnUnclaimedBoard() + local game = stagedGame(1, arena(21, 11), 5, 5) + local board = { x = 5, y = 5, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } + local success = game:tryAcceptQuest(board) + eq(success, true, "tryAcceptQuest binds the player to the board it stands on") + eq(game.player.quest.kind, Quests.CULL, "the bound bounty matches the board's own") + eq(board.claimed, true, "the board is spent on acceptance") +end + +function tests.autopilotAcceptsAnUnclaimedBountyForFreeWithoutSkippingItsTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.questBoards = { { x = 5, y = 5, claimed = false, quest = Quests.generate(Rng.new(2), 4, 10) } } + local orc = Monsters.create(Monsters.byKey.orc, 1, 6, 5, 1) + game.level.entities = { orc } + local hpBefore = orc.hp + game:choosePlayerAction() + ok(game.player.quest ~= nil, "the autopilot binds itself to a bounty it stands on") + eq(game.level.questBoards[1].claimed, true, "the board is claimed once the autopilot steps onto it") + ok(orc.hp < hpBefore, "accepting the bounty costs no action, so the same turn still lands an attack") +end + +function tests.autopilotSkipsAnAlreadyClaimedBoard() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.questBoards = { { x = 5, y = 5, claimed = true, quest = Quests.generate(Rng.new(2), 4, 10) } } + game:choosePlayerAction() + eq(game.player.quest, nil, "the autopilot never binds to an already claimed board") +end + +function tests.autopilotDoesNotStackASecondBountyWhileOneIsActive() + local game = stagedGame(1, arena(21, 11), 5, 5) + 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) } } end local order = {