From 1a36a061ba8559830d02cd79abe2d234e944b0fe Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 21:32:23 +0200 Subject: [PATCH] test: cover tests.fighterBerserkBoostsAttackAtLowHp --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 35a7e33..fa1e410 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -2378,6 +2378,179 @@ function tests.fighterSecondWindRestoresPortionOfMaxHp() game.player.skillCooldowns.secondWind = 0 game.player.hp = game.player.maxHp - 1 + game:useSkill("secondWind") + eq(game.player.hp, game.player.maxHp, "second wind never heals past max hp") +end + +function tests.fighterBerserkBoostsAttackAtLowHp() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "fighter" + game.player.skills.berserk = true + local full = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + game.level.entities = { full } + game.player.hp = game.player.maxHp + game:playerAttack(full) + local dmgFull = full.maxHp - full.hp + + local game2 = stagedGame(1, arena(21, 11), 3, 6) + game2.player.class = "fighter" + game2.player.skills.berserk = true + local low = Monsters.create(Monsters.byKey.orc, 2, 4, 6, 1) + game2.level.entities = { low } + game2.player.hp = math.floor(game2.player.maxHp * 0.3) + game2:playerAttack(low) + local dmgLow = low.maxHp - low.hp + + ok(dmgLow > dmgFull, "berserk raises attack once hp drops to a third of max") +end + +function tests.rogueBackstabDoublesDamageOnFullHpTarget() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "rogue" + game.player.skills.backstab = true + local fresh = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + game.level.entities = { fresh } + game:playerAttack(fresh) + local dmgFresh = fresh.maxHp - fresh.hp + + local game2 = stagedGame(1, arena(21, 11), 3, 6) + game2.player.class = "rogue" + game2.player.skills.backstab = true + local hurt = Monsters.create(Monsters.byKey.rat, 2, 4, 6, 1) + hurt.hp = hurt.hp - 1 + game2.level.entities = { hurt } + game2:playerAttack(hurt) + local dmgHurt = (hurt.maxHp - 1) - hurt.hp + + eq(dmgFresh, dmgHurt * 2, "backstab doubles damage against a target still at full hp") +end + +function tests.rogueEvasionCanFullyNegateMeleeHit() + local defender = { skills = { evasion = true } } + local sawDodge, sawHit = false, false + local rng = Rng.new(1) + for _ = 1, 60 do + if Skills.evades({ rng = rng }, defender) then sawDodge = true else sawHit = true end + end + ok(sawDodge, "evasion dodges at least once over enough attempts") + ok(sawHit, "evasion is a chance, not a guarantee, over enough attempts") + eq(Skills.evades({ rng = Rng.new(1) }, {}), false, "a defender without the skill never evades") +end + +function tests.poisonBladeAppliesPoisonOnNextMeleeHitOnly() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "rogue" + game.player.skills.poisonBlade = true + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.hp, orc.maxHp = 100, 100 + game.level.entities = { orc } + eq(game:useSkill("poisonBlade"), true, "poison blade readies without needing a target") + game:playerAttack(orc) + ok(Status.has(orc, Status.POISON), "the readied venom lands on the very next melee hit") + orc.statuses = {} + game:playerAttack(orc) + eq(Status.has(orc, Status.POISON), false, "poison blade is spent after one hit, not reapplied on the next") +end + +-- pendingPoisonBlade lives on the player between "ready the venom" and "land +-- the next swing" - a window a save/quit can happen inside, exactly like +-- reputation, sanity or the skill tree already have to survive Game:toData. +function tests.pendingPoisonBladeReadinessSurvivesAGameSaveLoadRoundTrip() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "rogue" + game.player.skills.poisonBlade = true + eq(game:useSkill("poisonBlade"), true, "poison blade readies without needing a target") + eq(game.player.pendingPoisonBlade, true, "readying the skill sets the pending flag before any save/load happens") + local restored = Game.fromData(game:toData()) + eq(restored.player.pendingPoisonBlade, true, "a poison blade readied before a save is still primed after loading it back") + ok(Skills.consumePoisonBlade(restored.player), "the restored pending venom is still consumable on the very next swing") +end + +function tests.mageOverloadBoostsMissileDamage() + local game = stagedGame(5, arena(21, 11), 3, 6) + local base = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + game.level.entities = { base } + game:castSpell("missile", base) + local dmgBase = base.maxHp - base.hp + + local game2 = stagedGame(5, arena(21, 11), 3, 6) + game2.player.class = "mage" + game2.player.skills.overload = true + local boosted = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + game2.level.entities = { boosted } + game2:castSpell("missile", boosted) + local dmgBoosted = boosted.maxHp - boosted.hp + + ok(dmgBoosted > dmgBase, "overload raises missile spell damage") +end + +function tests.mageMeditateRestoresManaWithinCap() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "mage" + game.player.skills.meditate = true + game.player.mana = 2 + game:useSkill("meditate") + eq(game.player.mana, 8, "meditate restores a flat amount of mana") + + game.player.mana = game.player.maxMana - 1 + game:useSkill("meditate") + eq(game.player.mana, game.player.maxMana, "meditate never overfills the mana pool") +end + +function tests.mageManaShieldRaisesDefenseWhileManaHeld() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "mage" + game.player.skills.manaShield = true + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + local before = game.player.hp + game.player.mana = 10 + game:monsterMelee(orc) + local dmgWithShield = before - game.player.hp + + game.player.hp = before + game.player.mana = 0 + game:monsterMelee(orc) + local dmgWithoutShield = before - game.player.hp + + ok(dmgWithoutShield > dmgWithShield, "mana shield reduces damage taken while mana holds") +end + +function tests.rangerMultishotHitsTwoTargetsForOneArrow() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "ranger" + game.player.skills.multishot = true + game.player.bow = Items.bow("shortbow", 3, 6) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 5) + local first = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + local second = Monsters.create(Monsters.byKey.orc, 2, 3, 8, 1) + game.level.entities = { first, second } + local success = game:useSkill("multishot", first, second) + eq(success, true, "multishot succeeds with two reachable targets") + ok(first.hp < first.maxHp, "the primary target takes damage") + ok(second.hp < second.maxHp, "the secondary target takes damage") + local stack + for _, item in ipairs(game.player.inventory) do + if item.kind == Items.AMMO then stack = item end + end + eq(stack.count, 4, "multishot consumes only a single arrow for both targets") +end + +-- Multishot's split arrows must run through Resistances.apply exactly like a +-- single Ranged.fire shot does (both always tag physical - arrows never +-- carry an elemental damageType) - before this test the loop subtracted a +-- raw attack-minus-defense number straight off hp for both targets with no +-- mitigation call at all, so a physically resistant monster took a full, +-- unresisted hit from a skill that is supposed to be "an arrow that splits", +-- not a different damage model than the bow it comes out of. +function tests.rangerMultishotRespectsPhysicalResistanceOnBothSplitArrows() + local function primaryDamageDealt(resistant) + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "ranger" + game.player.skills.multishot = true + game.player.bow = Items.bow("shortbow", 3, 6) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 5) + local first = Monsters.create(Monsters.byKey.orc, 1, 6, 6, 1) + first.hp, first.maxHp = 100, 100 end local order = {