From a9d2900e79b0f0bd749ff6f8d9575c6d2592ac12 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 18:36:20 +0200 Subject: [PATCH] test: cover tests.ascensionSightPenaltySumsAcrossActiveMutators --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index dc021d5..8ccd6d9 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -8779,6 +8779,179 @@ function tests.ascensionEssenceMultiplierAppliesFamineAlone() eq(Ascension.essenceMultiplier({ "famine" }), 0.5, "famine halves essence income") end +function tests.ascensionSightPenaltySumsAcrossActiveMutators() + eq(Ascension.sightPenalty(nil), 0, "no active mutators carries no sight penalty") + eq(Ascension.sightPenalty({ "nightblind" }), 2, "nightblind carries its own fixed sight penalty") +end + +function tests.ascensionRenownBonusPercentSumsRatherThanCompounds() + eq(Ascension.renownBonusPercent(nil), 0, "no active mutators adds no renown bonus") + eq(Ascension.renownBonusPercent({ "famine" }), 10, "famine alone adds its own fixed bonus") + eq(Ascension.renownBonusPercent({ "famine", "nightblind" }), 20, "two mutators add their bonuses, not compound them") +end + +function tests.ascensionApplyToPlayerHalvesMaxHpAndDoublesAttackForGlassCannon() + local player = { maxHp = 30, hp = 30, baseAttack = 5, attack = 5, baseDefense = 1, defense = 1 } + Ascension.applyToPlayer({ "glasscannon" }, player) + eq(player.maxHp, 15, "glass cannon halves max hp") + eq(player.hp, 15, "current hp is clamped down to the new max") + eq(player.attack, 10, "glass cannon doubles attack") + eq(player.baseAttack, 10, "glass cannon doubles base attack alongside the equipped total") + eq(player.defense, 1, "glass cannon leaves defense untouched") +end + +function tests.ascensionApplyToPlayerHalvesDefenseForFrail() + local player = { maxHp = 30, hp = 30, baseAttack = 5, attack = 5, baseDefense = 3, defense = 3 } + Ascension.applyToPlayer({ "frail" }, player) + eq(player.defense, 1, "frail floors defense down by half") + eq(player.baseDefense, 1, "frail floors base defense down by half") + eq(player.attack, 5, "frail leaves attack untouched") +end + +function tests.gameNewResolvesRequestedMutatorsAgainstMetaAndAppliesTheirDeltas() + local meta = Meta.new() + meta.bestDepth = 4 + meta.wins = 1 + local game = Game.new(5, { meta = meta, mutators = { "unknownkey", "glasscannon", "frail" } }) + eq(#game.ascension, 2, "only the two unlocked, known mutators survive resolution") + eq(game.ascension[1], "frail", "active mutators are ordered by the catalog, not the request") + eq(game.ascension[2], "glasscannon", "active mutators are ordered by the catalog, not the request") + eq(game.player.maxHp, 15, "glasscannon halves the fresh player's max hp") + eq(game.player.defense, 0, "frail halves the fresh player's base defense down from 1") +end + +function tests.gameMonsterMeleeDamageStacksBloodboundWithBloodmoonMultiplicatively() + local meta = Meta.new() + meta.bestDepth = 3 + local game = Game.new(1, { meta = meta, mutators = { "bloodbound" } }) + game.level = arena(21, 11) + game.player.x, game.player.y = 3, 6 + game:computeVisible() + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + + local before = game.player.hp + game.ascension = {} + game:monsterMelee(orc) + local plainDamage = before - game.player.hp + + game.player.hp = before + game.ascension = { "bloodbound" } + game:monsterMelee(orc) + local bloodboundDamage = before - game.player.hp + eq(bloodboundDamage, math.floor(plainDamage * 1.5), "bloodbound alone scales melee damage by its own multiplier") + + game.player.hp = before + game.level.event = { key = Events.BLOODMOON, turnsLeft = 5 } + game:monsterMelee(orc) + local stacked = before - game.player.hp + eq(stacked, math.floor(plainDamage * 1.5 * Events.BLOODMOON_MULTIPLIER), + "bloodbound and bloodmoon stack multiplicatively on the same hit") +end + +function tests.gameKillEssenceStacksFamineWithRichVeinsMultiplicatively() + local meta = Meta.new() + meta.bestDepth = 2 + local game = Game.new(1, { meta = meta, mutators = { "famine" } }) + game.level = arena(21, 11) + game.player.x, game.player.y = 3, 6 + game:computeVisible() + + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + rat.hp = 1 + game.level.entities = { rat } + game.ascension = {} + local before = game.player.essence + game:playerAttack(rat) + local plainEssence = game.player.essence - before + + local rat2 = Monsters.create(Monsters.byKey.rat, 2, 4, 6, 1) + rat2.hp = 1 + game.level.entities = { rat2 } + game.player.essence = before + game.ascension = { "famine" } + game:playerAttack(rat2) + local famineEssence = game.player.essence - before + eq(famineEssence, math.floor(plainEssence * 0.5), "famine alone halves essence earned from a kill") + + local rat3 = Monsters.create(Monsters.byKey.rat, 3, 4, 6, 1) + rat3.hp = 1 + game.level.entities = { rat3 } + game.player.essence = before + game.level.event = { key = Events.RICHVEINS, turnsLeft = 5 } + game:playerAttack(rat3) + local stackedEssence = game.player.essence - before + eq(stackedEssence, plainEssence, + "famine's half and rich veins' double multiply to exactly cancel back to the plain amount") +end + +function tests.gameEffectiveRadiusStacksNightblindWithHush() + local meta = Meta.new() + meta.bestDepth = 2 + local game = Game.new(1, { meta = meta, mutators = { "nightblind" } }) + game.ascension = {} + local noAscension = game:effectiveRadius() + game.ascension = { "nightblind" } + eq(game:effectiveRadius(), noAscension - 2, "nightblind alone shrinks the effective sight radius by its own penalty") + game.level.event = { key = Events.HUSH, turnsLeft = 5 } + eq(game:effectiveRadius(), noAscension - 2 - Events.HUSH_SIGHT_PENALTY, + "nightblind and hush both shrink the effective sight radius, summed together") +end + +function tests.metaRenownForAddsTheAscensionBonusOnTopOfTheBaseScore() + local stats = { deepestDepth = 5, bossKills = 0, questsCompleted = 0, won = false } + local base = Meta.renownFor(stats) + stats.mutators = { "famine" } + eq(Meta.renownFor(stats), base + (base * 10) // 100, "famine's 10% renown bonus is added on top of the base score") + stats.mutators = { "famine", "nightblind" } + eq(Meta.renownFor(stats), base + (base * 20) // 100, "two mutators' renown bonuses sum before being applied") +end + +function tests.ascensionFieldSurvivesASaveLoadRoundTrip() + local meta = Meta.new() + meta.bestDepth = 3 + local game = Game.new(4, { meta = meta, mutators = { "bloodbound" } }) + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(#restored.ascension, 1, "the active ascension mutator list survives a save/load round trip") + eq(restored.ascension[1], "bloodbound", "the restored mutator key matches what was active") +end + +function tests.renderPanelShowsActiveAscensionMutatorsByKey() + local game = stagedGame(1, arena(21, 11), 3, 6) + ok(not contains(Render.panel(game), "ascension"), "no active mutators shows no ascension line at all") + game.ascension = { "famine", "nightblind" } + ok(contains(Render.panel(game), "ascension famine,nightblind"), "active mutators are listed by key in the panel") +end + +-- durability.lua: weapon/armor/bow wear and repair. + +function tests.durabilityFreshWeaponStartsAtMaxAndIsNotBroken() + local sword = Items.weapon("sword", 4) + eq(sword.durability, Durability.MAX, "a freshly forged weapon starts at max durability") + eq(sword.maxDurability, Durability.MAX, "a freshly forged weapon's max durability matches the catalog default") + ok(not Durability.isBroken(sword), "a freshly forged weapon is not broken") +end + +function tests.durabilityWearWeaponDecrementsAndBreaksAtZeroSubtractingItsPower() + local hero = { attack = 5, inventory = {}, kind = "player", name = "you" } + Items.equipWeapon(Items.weapon("sword", 4), hero) + eq(hero.attack, 9, "the sword's power is baked into attack on equip") + local broke + for _ = 1, Durability.MAX - 1 do + broke = Durability.wearWeapon(hero) + ok(broke == nil, "the sword survives every hit before its last") + end + eq(hero.weapon.durability, 1, "one point of durability remains before the final hit") + broke = Durability.wearWeapon(hero) + ok(broke ~= nil, "the hit that empties durability reports a break") + ok(contains(broke, "breaks apart"), "the break line names what happened") + eq(hero.weapon.durability, 0, "durability floors at exactly zero") + eq(hero.attack, 5, "the sword's power is pulled back out of attack the instant it breaks") +end + +function tests.durabilityWearWeaponIsANoOpOnceAlreadyBroken() end local order = {