diff --git a/tests/run.lua b/tests/run.lua index 404bbc9..f1d10f5 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -5319,6 +5319,179 @@ function tests.metaSaveLoadRoundTrip() local path = os.tmpname() Meta.save(meta, path) local restored = Meta.load(path) + os.remove(path) + eq(restored.renown, meta.renown, "renown survives a save/load round trip") + eq(restored.totalRuns, meta.totalRuns, "run tally survives a save/load round trip") + eq(restored.bestDepth, meta.bestDepth, "best depth survives a save/load round trip") + eq(restored.achievements.boss_slayer, true, "achievements survive a save/load round trip") + eq(restored.perks.focus, true, "owned perks survive a save/load round trip") +end + +function tests.gameAwardKillTracksKillsAndBossKillsForMetaStats() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + rat.hp = 1 + game.level.entities = { rat } + game:playerAttack(rat) + eq(game.stats.kills, 1, "felling a plain monster counts toward the kill tally") + eq(game.stats.bossKills, 0, "a plain monster does not count as a boss kill") + + local lich = Bosses.create(Bosses.byKey.lich, 2, 4, 6) + lich.hp = 1 + game.level.entities = { lich } + game:playerAttack(lich) + eq(game.stats.kills, 2, "felling a boss also counts toward the plain kill tally") + eq(game.stats.bossKills, 1, "felling a boss counts toward the boss-kill tally") +end + +function tests.gameResolveQuestTracksQuestsCompletedForMetaStats() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.quest = { kind = Quests.CULL, species = "rat", need = 1, have = 0, rewardGold = 30, rewardXp = 15 } + game:resolveQuest() + eq(game.stats.questsCompleted, 0, "an unmet bounty does not count toward quests completed") + game.player.quest = { kind = Quests.CULL, species = "rat", need = 1, have = 1, rewardGold = 30, rewardXp = 15 } + game:resolveQuest() + eq(game.stats.questsCompleted, 1, "resolving a bounty counts toward quests completed") +end + +function tests.gamePickupTracksArtifactsFoundForMetaStats() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.floorItems = { { x = 5, y = 5, item = Artifacts.create("bloodfang") } } + game:pickupHere() + eq(game.stats.artifactsFound, 1, "picking up an artifact counts toward artifacts found") + ok(game.player.weapon == nil, "picking up an artifact only stashes it in inventory, it is not auto-equipped") +end + +function tests.gameDescendTracksDeepestDepthEvenAfterAscending() + local game = Game.new(9, { maxDepth = 4 }) + eq(game.deepestDepth, 1, "a fresh game's deepest depth is its starting depth") + game.player.x, game.player.y = game.level.downX, game.level.downY + game:descend() + game.player.x, game.player.y = game.level.downX, game.level.downY + game:descend() + eq(game.deepestDepth, 3, "descending twice raises the deepest depth to the floor just reached") + game.player.x, game.player.y = game.level.upX, game.level.upY + game:ascend() + eq(game.depth, 2, "ascending drops the live depth") + eq(game.deepestDepth, 3, "ascending back up does not erase the deepest depth already reached") +end + +function tests.gameMetaStatsFieldsSurviveSerialization() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + rat.hp = 1 + game.level.entities = { rat } + game:playerAttack(rat) + game.deepestDepth = 3 + local restored = Game.fromData(game:toData()) + eq(restored.stats.kills, game.stats.kills, "the kill tally survives a save/load round trip") + eq(restored.deepestDepth, game.deepestDepth, "the deepest depth survives a save/load round trip") + local snapshot = restored:metaStats() + eq(snapshot.kills, 1, "metaStats reads the restored kill tally") + eq(snapshot.deepestDepth, 3, "metaStats reads the restored deepest depth") +end + +-- Elemental resistance (resistances.lua): a damage-type layer that sits +-- underneath every existing damage pipeline. Default behaviour (no resist +-- table anywhere) must stay perfectly transparent - that is what every test +-- above this point already relies on - so these tests build entities and +-- gear with resist tables explicitly rather than reaching for bestiary data. + +function tests.resistancesDefaultTierPassesDamageThroughUnchanged() + local plain = Entity.new({ x = 1, y = 1, glyph = "r", name = "rat", kind = "monster", hp = 10, maxHp = 10, attack = 2, defense = 0 }) + eq(Resistances.tierOf(plain, Resistances.FIRE), Resistances.NORMAL, "an entity with no resist table is normal to every type") + eq(Resistances.apply(plain, 7, Resistances.FIRE), 7, "normal tier leaves the amount untouched") + eq(Resistances.apply(plain, 0, Resistances.FIRE), 0, "zero damage stays zero regardless of tier") +end + +function tests.resistancesResistHalvesAndFloors() + local warded = Entity.new({ x = 1, y = 1, glyph = "@", name = "warded one", kind = "monster", hp = 20, maxHp = 20, attack = 2, defense = 0 }) + warded.resist = { fire = Resistances.RESIST } + eq(Resistances.apply(warded, 9, Resistances.FIRE), 4, "resist halves and floors an odd amount") + eq(Resistances.apply(warded, 1, Resistances.FIRE), 0, "resist can floor a small hit all the way to zero") + eq(Resistances.apply(warded, 9, Resistances.COLD), 9, "a resist entry for one type does not bleed into another") +end + +function tests.resistancesWeakAmplifies() + local frail = Entity.new({ x = 1, y = 1, glyph = "@", name = "frail one", kind = "monster", hp = 20, maxHp = 20, attack = 2, defense = 0 }) + frail.resist = { cold = Resistances.WEAK } + eq(Resistances.apply(frail, 4, Resistances.COLD), 6, "weak deals 1.5x, floored") + eq(Resistances.apply(frail, 4, Resistances.FIRE), 4, "an unrelated type is unaffected by a cold weakness") +end + +function tests.resistancesImmuneZeroesEvenAHugeHit() + local warded = Entity.new({ x = 1, y = 1, glyph = "@", name = "warded one", kind = "monster", hp = 20, maxHp = 20, attack = 2, defense = 0 }) + warded.resist = { poison = Resistances.IMMUNE } + eq(Resistances.apply(warded, 500, Resistances.POISON), 0, "immune drops even a massive hit to nothing") +end + +function tests.resistancesEquipmentGrantsATierOnTopOfABaseProfile() + local wearer = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, maxHp = 20, attack = 5, defense = 1 }) + eq(Resistances.tierOf(wearer, Resistances.COLD), Resistances.NORMAL, "an unequipped entity has no resist from thin air") + wearer.ring = Items.ring("frost band", 0, 0) + wearer.ring.resist = { cold = Resistances.RESIST } + eq(Resistances.tierOf(wearer, Resistances.COLD), Resistances.RESIST, "a worn ring grants its resist tier") + eq(Resistances.apply(wearer, 10, Resistances.COLD), 5, "the granted tier scales incoming damage of that type") + wearer.armor = Items.armor("plate", 2) + wearer.armor.resist = { cold = Resistances.IMMUNE } + eq(Resistances.tierOf(wearer, Resistances.COLD), Resistances.IMMUNE, "the best tier across every equipped slot wins") +end + +function tests.resistancesInnateWeaknessIsMaskedByAResistantItem() + local dragon = Entity.new({ x = 1, y = 1, glyph = "D", name = "young dragon", kind = "monster", hp = 30, maxHp = 30, attack = 8, defense = 3 }) + dragon.resist = { cold = Resistances.WEAK } + eq(Resistances.tierOf(dragon, Resistances.COLD), Resistances.WEAK, "the innate weakness applies with nothing worn") + dragon.ring = Items.ring("frost band", 0, 0) + dragon.ring.resist = { cold = Resistances.RESIST } + eq(Resistances.tierOf(dragon, Resistances.COLD), Resistances.RESIST, "a resistant ring outranks and masks an innate weakness") +end + +function tests.combatDefenderResistanceHalvesTheFinalBlow() + local attacker = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, maxHp = 20, attack = 10, defense = 1 }) + local defender = Entity.new({ x = 2, y = 1, glyph = "o", name = "orc", kind = "monster", hp = 30, maxHp = 30, attack = 4, defense = 2 }) + local baseline = Combat.damage(attacker, defender) + defender.resist = { physical = Resistances.RESIST } + local resisted = Combat.damage(attacker, defender) + eq(resisted, math.floor(baseline / 2), "a physical resist halves combat.damage's own output for the same matchup") +end + +function tests.combatWeaponElementOverridesPhysicalAndCanBeFullyImmune() + local attacker = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, maxHp = 20, attack = 10, defense = 1 }) + attacker.weapon = Affixes.decorate(Items.weapon("dagger", 2), Affixes.weapon[3]) + eq(attacker.weapon.damageType, "fire", "a flaming affix retags the weapon's own damage type") + local mundane = Entity.new({ x = 2, y = 1, glyph = "o", name = "orc", kind = "monster", hp = 30, maxHp = 30, attack = 4, defense = 2 }) + ok(Combat.damage(attacker, mundane) > 0, "a flaming weapon still hurts an ordinary target") + local salamander = Entity.new({ x = 2, y = 1, glyph = "s", name = "salamander", kind = "monster", hp = 30, maxHp = 30, attack = 4, defense = 2 }) + salamander.resist = { fire = Resistances.IMMUNE } + eq(Combat.damage(attacker, salamander), 0, "the same flaming weapon does nothing at all against a fire-immune target") +end + +function tests.statusPoisonAndBurningTicksRespectResistance() + local immunePoison = Entity.new({ x = 1, y = 1, glyph = "z", name = "zombie", kind = "monster", hp = 10, maxHp = 10, attack = 2, defense = 0 }) + immunePoison.resist = { poison = Resistances.IMMUNE } + Status.apply(immunePoison, Status.POISON, 3, 4) + local lines = Status.tick(immunePoison) + eq(immunePoison.hp, 10, "a poison-immune entity takes no damage from a poison tick") + ok(contains(lines[1], "shrugs off"), "the immune tick logs a distinct shrug-off line") + + local frailToFire = Entity.new({ x = 1, y = 1, glyph = "T", name = "troll", kind = "monster", hp = 20, maxHp = 20, attack = 6, defense = 2 }) + frailToFire.resist = { fire = Resistances.WEAK } + Status.apply(frailToFire, Status.BURNING, 3, 2) + Status.tick(frailToFire) + eq(frailToFire.hp, 17, "a fire-weak entity takes 1.5x the tick's potency, floored") +end + +function tests.hazardsAfflictAppliesTargetResistance() + local level = arena(21, 11) + Hazards.ignite(level, 5, 5, 3, 4) + local salamander = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + salamander.resist = { fire = Resistances.RESIST } + local hpBefore = salamander.hp + Hazards.afflict(level, salamander) + eq(salamander.hp, hpBefore - 2, "standing in fire as fire-resistant halves the usual potency") +end + +function tests.spellMissileAndFireballRespectTargetResistance() end local order = {