diff --git a/tests/run.lua b/tests/run.lua index fa1e410..92a60a7 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -2551,6 +2551,179 @@ function tests.rangerMultishotRespectsPhysicalResistanceOnBothSplitArrows() 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 + if resistant then first.resist = { physical = Resistances.RESIST } end + local second = Monsters.create(Monsters.byKey.orc, 2, 3, 8, 1) + second.hp, second.maxHp = 100, 100 + game.level.entities = { first, second } + game:useSkill("multishot", first, second) + return 100 - first.hp + end + local plain = primaryDamageDealt(false) + local resisted = primaryDamageDealt(true) + ok(resisted < plain, "a physically resistant target takes measurably less multishot damage than an unresisted one") + eq(resisted, math.floor(plain / 2), "physical resistance halves the split-arrow hit, the same mitigation Resistances.apply gives a single Ranged.fire shot") +end + +function tests.rangerCamouflageShrinksDetectionOnDarkFloors() + local game = stagedGame(1, arenaDark(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 11, 6, 1) + eq(game:canSeePlayer(orc, 10), true, "without camouflage a dark floor does not shrink detection") + + game.player.class = "ranger" + game.player.skills.camouflage = true + eq(game:canSeePlayer(orc, 10), false, "camouflage shrinks a monster's detection range on a dark floor") + + game.level.dark = false + eq(game:canSeePlayer(orc, 10), true, "camouflage does nothing once the floor is lit") +end + +-- Blindness overrides Game:effectiveRadius outright rather than stacking as +-- another penalty term (contrast Atmosphere.sightPenalty/Events.sightPenalty/ +-- Ascension.sightPenalty, which all just shave a fixed amount off) - a fully +-- lit, unpenalized floor still clamps to a radius of one the instant the +-- player is blinded, and returns to normal the instant the status is gone. +function tests.effectiveRadiusBlindStatusOverridesLightAndWeatherToAMinimumOfOne() + local game = stagedGame(1, arena(21, 11), 3, 6) + local clear = game:effectiveRadius() + ok(clear > 1, "an unblinded, unpenalized floor has more than a radius of one to begin with") + + Status.apply(game.player, Status.BLIND, 6, 1) + eq(game:effectiveRadius(), 1, "a blinded player's sight radius clamps to one regardless of light or weather") + + game.player.statuses = {} + eq(game:effectiveRadius(), clear, "sight radius returns to normal once blindness is gone") +end + +-- A blinded monster keeps its ordinary listed detection range on paper, but +-- Game:canSeePlayer clamps every one of ai.lua's behaviours (chase/pack/ +-- ranged/flee/erratic/tunnel all route through this same helper) down to +-- melee-only awareness while it lasts. +function tests.canSeePlayerBlindedMonsterOnlyDetectsThePlayerAtMeleeRange() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 8, 6, 1) + eq(game:canSeePlayer(orc, 10), true, "an unblinded monster within its listed range sees the player") + + Status.apply(orc, Status.BLIND, 6, 1) + eq(game:canSeePlayer(orc, 10), false, "a blinded monster loses a five-cell-away sighting despite its listed range") + + local adjacentOrc = Monsters.create(Monsters.byKey.orc, 2, 4, 6, 1) + Status.apply(adjacentOrc, Status.BLIND, 6, 1) + eq(game:canSeePlayer(adjacentOrc, 10), true, "a blinded monster still senses the player once adjacent") +end + +function tests.rangerTrueshotAddsFlatRangedDamage() + local game = stagedGame(4, arena(21, 11), 3, 6) + local base = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) + game.level.entities = { base } + game.player.bow = Items.bow("short bow", 2, 6) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 2) + game:fireAt(base) + local dmgBase = base.maxHp - base.hp + + local game2 = stagedGame(4, arena(21, 11), 3, 6) + game2.player.class = "ranger" + game2.player.skills.trueshot = true + local boosted = Monsters.create(Monsters.byKey.orc, 1, 9, 6, 1) + game2.level.entities = { boosted } + game2.player.bow = Items.bow("short bow", 2, 6) + game2.player.inventory[#game2.player.inventory + 1] = Items.ammo("arrow", 2) + game2:fireAt(boosted) + local dmgTrueshot = boosted.maxHp - boosted.hp + + eq(dmgTrueshot, dmgBase + 3, "trueshot adds a flat +3 to every ranged hit") +end + +function tests.autopilotLearnsSkillsAutomaticallyAsPointsAllow() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "fighter" + game.player.skillPoints = 1 + game:choosePlayerAction() + ok(Skills.has(game.player, "cleave"), + "the autopilot spends a banked point on the first level-eligible skill in class order") + eq(game.player.skillPoints, 0, "learning the skill spends the banked point") +end + +function tests.skillFieldsSurviveSerialization() + local game = Game.new(5, { class = "mage" }) + game.player.skills.overload = true + game.player.skillCooldowns.meditate = 2 + game.player.skillPoints = 3 + local path = os.tmpname() + game:save(path) + local restored = Game.load(path) + os.remove(path) + eq(restored.player.class, "mage", "class survives a save/load round trip") + eq(restored.player.skillPoints, 3, "skill points survive a save/load round trip") + eq(Skills.has(restored.player, "overload"), true, "learned skills survive a save/load round trip") + eq(restored.player.skillCooldowns.meditate, 2, "skill cooldowns survive a save/load round trip") +end + +function tests.craftingForgeSpendsEssenceForATierOneGem() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.essence = Crafting.FORGE_COST - 1 + local shortSuccess, shortReason = Crafting.forge(game.player, Crafting.FIRE) + eq(shortSuccess, false, "forging refuses when essence is short of the cost") + eq(shortReason, "not enough essence", "the refusal names the reason") + + game.player.essence = Crafting.FORGE_COST + local success, gem = Crafting.forge(game.player, Crafting.FIRE) + ok(success, "forging succeeds with exactly the cost in essence") + eq(gem.tier, 1, "a forged gem starts at tier one") + eq(gem.element, Crafting.FIRE, "the gem carries the requested element") + eq(game.player.essence, 0, "the forge cost is spent") + + local badSuccess, badReason = Crafting.forge(game.player, "shadow") + eq(badSuccess, false, "an unknown element is refused") + eq(badReason, "no such element", "the refusal names the reason") +end + +function tests.craftingMergeGemsCombinesMatchingPairsIntoTheNextTier() + local success, _, merged = Crafting.mergeGems(Crafting.gem(Crafting.FIRE, 1), Crafting.gem(Crafting.FIRE, 1)) + ok(success, "two matching chip gems merge") + eq(merged.tier, 2, "the merged gem is one tier higher") + eq(merged.element, Crafting.FIRE, "the merged gem keeps the shared element") + eq(merged.power, 2, "a gem's power always matches its tier") + + local mismatchElement, elementReason = Crafting.mergeGems(Crafting.gem(Crafting.FIRE, 1), Crafting.gem(Crafting.FROST, 1)) + eq(mismatchElement, false, "gems of different elements refuse to merge") + eq(elementReason, "elements do not match", "the refusal names the reason") + + local mismatchTier, tierReason = Crafting.mergeGems(Crafting.gem(Crafting.FIRE, 1), Crafting.gem(Crafting.FIRE, 2)) + eq(mismatchTier, false, "gems of different tiers refuse to merge") + eq(tierReason, "tiers do not match", "the refusal names the reason") + + local atCap, capReason = Crafting.mergeGems( + Crafting.gem(Crafting.FIRE, Crafting.MAX_TIER), Crafting.gem(Crafting.FIRE, Crafting.MAX_TIER)) + eq(atCap, false, "a gem already at the maximum tier cannot merge further") + eq(capReason, "already at maximum tier", "the refusal names the reason") +end + +function tests.craftingSocketAddsPowerAndSwapsRiderOnResocket() + local game = stagedGame(1, arena(21, 11), 3, 6) + local weapon = Items.weapon("dagger", 2) + Items.equipWeapon(weapon, game.player) + local attackBaseline = game.player.attack + + ok(Crafting.socket(game.player, weapon, Crafting.gem(Crafting.FIRE, 1)), + "a gem sockets into an equipped weapon") + eq(weapon.power, 3, "the gem's power lands on the item") + eq(game.player.attack, attackBaseline + 1, "socketing an equipped weapon updates attack immediately") + eq(weapon.socket.element, Crafting.FIRE, "the socket records its element") + + ok(Crafting.socket(game.player, weapon, Crafting.gem(Crafting.FROST, 2)), + "resocketing replaces the previous gem") + eq(weapon.power, 4, "power reflects only the new gem, not a stack of both") + eq(game.player.attack, attackBaseline + 2, "attack reflects the swap, not an accumulation") + eq(weapon.socket.element, Crafting.FROST, "the new gem's element replaces the old one") + + local success, reason = Crafting.socket(game.player, weapon, { kind = Items.WEAPON }) + eq(success, false, "a non-gem refuses to socket") + eq(reason, "not a gem", "the refusal names the reason") +end + +function tests.craftingTemperRaisesPowerAtRisingCostUpToACap() + local game = stagedGame(1, arena(21, 11), 3, 6) + local weapon = Items.weapon("dagger", 2) end local order = {