diff --git a/tests/run.lua b/tests/run.lua index cbef589..7aec43c 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -7395,6 +7395,179 @@ function tests.gameEquippingOverABoundCursedWeaponFailsAndRevealsTheCurse() ok(game.player.weapon == before, "the bound weapon stays equipped") eq(Curses.isRevealed(before), true, "the failed swap reveals the curse") eq(#game.player.inventory, 1, "the replacement weapon stays in the pack, never spent") + ok(contains(game.log:last(), "will not come off"), "the log explains the weapon is stuck") +end + +function tests.gameEquippingAFreshCurseHidesItUntilYouTryToTakeItOff() + local game = stagedGame(1, arena(21, 11), 3, 6) + local cursedRing = Curses.afflict(Items.ring("ring of power", 1, 1), Curses.pool.ring[1]) + game.player.inventory = { cursedRing } + ok(game:useItem(1), "equipping an empty slot with a cursed item still succeeds") + ok(game.player.ring == cursedRing, "the cursed ring is now equipped") + ok(not contains(game.log:last(), "cursed"), "the equip message never spoils an unrevealed curse") +end + +function tests.gameRemoveCurseScrollLiftsEveryCurseCurrentlyWorn() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.ring = Curses.afflict(Items.ring("ring of power", 0, 0), Curses.pool.ring[1]) + game.player.inventory = { Items.scroll("removecurse") } + ok(game:useItem(1), "reading the scroll succeeds") + eq(Curses.isCursed(game.player.ring), false, "the worn ring's curse is lifted") + ok(contains(game.log:last(), "lifts"), "the log reports the cleansing") +end + +function tests.gameHungerTorchTickBleedsHpFromACursedRingEachTurn() + local game = Game.new(1) + game.player.ring = Curses.afflict(Items.ring("ring of power", 0, 0), Curses.pool.ring[1]) + local before = game.player.hp + game:hungerTorchTick() + eq(game.player.hp, before - 1, "a cursed ring's affliction bleeds its fixed drain each turn") + ok(contains(game.log:last(), "saps"), "the drain is announced in the log") +end + +function tests.shrinePrayFortuneBlessingAlsoLiftsAnyCurseCurrentlyWorn() + local player = shrinePlayer(50) + player.ring = Curses.afflict(Items.ring("ring of power", 0, 0), Curses.pool.ring[1]) + local game = { player = player, rng = Rng.new(1) } + local shrine = { x = 1, y = 1, kind = Shrines.FORTUNE, uses = 0 } + local success, lines = Shrines.pray(game, shrine) + eq(success, true, "the prayer resolves") + eq(Curses.isCursed(player.ring), false, "fortune's blessing lifts the worn ring's curse too") + ok(contains(lines[2], "cleanses"), "a lifted curse counts toward the cleansing message") +end + +function tests.renderPanelPrefixesCursedGearOnceRevealedButNotBefore() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.weapon = Curses.afflict(Items.weapon("dagger", 5), Curses.pool.weapon[1]) + ok(not contains(Render.panel(game), "cursed"), "an unrevealed curse never shows in the status panel") + Curses.reveal(game.player.weapon) + ok(contains(Render.panel(game), "cursed dagger"), "a revealed curse is flagged in the status panel") +end + +function tests.builderOccasionallyPlacesACursedPieceOfGear() + local found + for seed = 1, 80 do + local level = Builder.build(2, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.cursed then found = ground.item end + end + if found then break end + end + ok(found ~= nil, "a cursed piece of gear eventually turns up across a handful of seeds") + ok(found.cursePenalty ~= nil or found.curseDrain ~= nil, "a cursed drop always carries a concrete penalty") +end + +function tests.builderNeverCursesAnItemThatAlreadyHasABeneficialAffix() + for seed = 1, 30 do + for depth = 1, 5 do + local level = Builder.build(depth, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + if ground.item.affix then + ok(not ground.item.cursed, "an item with a beneficial affix is never also cursed") + end + end + end + end +end + +function tests.cursedFieldsSurviveSerialization() + local game = Game.new(3) + game.player.ring = Curses.afflict(Items.ring("ring of power", 1, 1), Curses.pool.ring[1]) + Curses.reveal(game.player.ring) + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.player.ring.cursed, true, "a cursed ring's flag survives a save/load round trip") + eq(restored.player.ring.curseDrain, 1, "a cursed ring's drain survives a save/load round trip") + eq(restored.player.ring.curseRevealed, true, "a revealed curse stays revealed after reload") +end + +function tests.momentumStacksReadsZeroForAFreshEntity() + eq(Momentum.stacks({}), 0, "an entity with no combo field reads as zero stacks") +end + +function tests.momentumOnMeleeHitBuildsAndCapsAtItsMaxStacks() + local entity = {} + for i = 1, Momentum.MAX_STACKS do + Momentum.onMeleeHit(entity) + eq(Momentum.stacks(entity), i, "stack " .. i .. " builds one at a time") + end + Momentum.onMeleeHit(entity) + eq(Momentum.stacks(entity), Momentum.MAX_STACKS, "stacks never climb past the cap") +end + +function tests.momentumOnDamagedSnapsTheStackBackToZero() + local entity = { combo = 3 } + Momentum.onDamaged(entity) + eq(Momentum.stacks(entity), 0, "taking a hit clears the whole stack outright") +end + +function tests.momentumAttackBonusScalesLinearlyWithStacks() + local entity = {} + eq(Momentum.attackBonus(entity), 0, "no stacks means no bonus") + Momentum.onMeleeHit(entity) + Momentum.onMeleeHit(entity) + eq(Momentum.attackBonus(entity), 2 * Momentum.BONUS_PER_STACK, "the bonus scales with the current stack count") +end + +function tests.entityEffectiveAttackFoldsInMomentumAlongsideStatusModifiers() + local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 2 }) + eq(entity:effectiveAttack(), 6, "a fresh entity's effective attack is just its base") + Momentum.onMeleeHit(entity) + Momentum.onMeleeHit(entity) + eq(entity:effectiveAttack(), 8, "two momentum stacks add their flat bonus on top of base attack") +end + +function tests.gamePlayerAttackBuildsMomentumAndItReadsLiveInEffectiveAttack() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.hp, orc.maxHp = 100, 100 + game.level.entities = { orc } + eq(game.player:effectiveAttack(), game.player.attack, "a fresh player carries no momentum bonus") + game:playerAttack(orc) + eq(game.player.combo, 1, "a landed melee hit builds one stack") + eq(game.player:effectiveAttack(), game.player.attack + 1, + "the very next read of effectiveAttack already reflects the new stack") +end + +function tests.gameMonsterMeleeSnapsMomentumBackToZero() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.combo = 4 + local orc = Monsters.create(Monsters.byKey.orc, 1, 2, 6, 1) + game:monsterMelee(orc) + eq(game.player.combo, 0, "getting hit by a monster's melee resets momentum to zero") +end + +function tests.gameEvadedMonsterAttackNeverBreaksMomentum() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.skills = { evasion = true } + local orc = Monsters.create(Monsters.byKey.orc, 1, 2, 6, 1) + local sawEvasion = false + for seed = 1, 40 do + game.rng = Rng.new(seed) + game.player.combo = 3 + game:monsterMelee(orc) + if game.player.combo == 3 then sawEvasion = true break end + end + ok(sawEvasion, "an evaded attack never reaches Combat.attack, so momentum survives it") +end + +function tests.gameMimicAmbushAlsoBreaksMomentum() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.combo = 2 + game.level.floorItems = { { x = 5, y = 5, item = Mimics.floorItem(Rng.new(1)) } } + game:pickupHere() + eq(game.player.combo, 0, "a mimic's ambush hit also breaks momentum") +end + +function tests.renderPanelShowsComboOnlyOnceItIsBuilt() + local game = stagedGame(1, arena(21, 11), 3, 6) + ok(not contains(Render.panel(game), "combo"), "a fresh player with no momentum shows no combo line") + game.player.combo = 3 + ok(contains(Render.panel(game), "combo x3"), "a built-up combo is reported in the status panel") +end + end local order = {