From f5f4e93cd3c9aa27172c7b6abbe41c0e00632aad Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 21:19:47 +0200 Subject: [PATCH] test: cover tests.sellItemGrantsHalfPriceAndRemovesIt --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index bad457e..35a7e33 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -2205,6 +2205,179 @@ function tests.passiveShopkeeperIgnoresTheAdjacentPlayer() eq(game.player.hp, hpBefore, "an un-robbed shopkeeper does not attack even when adjacent") end +function tests.sellItemGrantsHalfPriceAndRemovesIt() + local game = stagedGame(1, arena(21, 11), 3, 3) + local index = #game.player.inventory + local item = game.player.inventory[index] + local expected = Shop.sellValue(item) + local goldBefore = game.player.gold + local value = game:sellItem(index) + eq(value, expected, "selling returns the catalog half-price") + eq(game.player.gold, goldBefore + expected, "the sale proceeds land in the wallet") + eq(#game.player.inventory, index - 1, "the sold item leaves the inventory") +end + +function tests.builderOccasionallySeedsAShopWithMatchingWares() + local level = Builder.build(4, Rng.new(1), {}) + local keeper + for _, e in ipairs(level.entities) do + if e.species == "shopkeeper" then keeper = e end + end + ok(keeper ~= nil, "this seed's depth-4 floor spawns a shopkeeper") + local waresFound = 0 + for _, ground in ipairs(level.floorItems) do + if ground.price then + waresFound = waresFound + 1 + eq(ground.shopId, keeper.id, "every priced ware is tagged with its shopkeeper's id") + ok(ground.price > 0, "every priced ware has a positive price") + end + end + ok(waresFound >= 3, "the shop stocks at least three wares") +end + +function tests.shopAndLooseGoldStayOffTheShallowFloors() + for depth = 1, 3 do + local level = Builder.build(depth, Rng.new(1), {}) + for _, ground in ipairs(level.floorItems) do + ok(ground.item.kind ~= Items.GOLD, "no loose gold appears above depth 4") + ok(not ground.price, "no shop ware appears above depth 4") + end + for _, e in ipairs(level.entities) do + ok(e.species ~= "shopkeeper", "no shopkeeper appears above depth 4") + end + end +end + +local function arenaDark(w, h) + local map = Map.new(w, h) + for y = 2, h - 1 do for x = 2, w - 1 do map:set(x, y, Map.FLOOR) end end + return Level.new({ + depth = 1, generator = "arena", map = map, + entities = {}, floorItems = {}, traps = {}, lights = {}, dark = true, explored = {}, + upX = 2, upY = 2, downX = w - 1, downY = h - 1, + }) +end + +function tests.classApplyGrantsFighterStatBonuses() + local base = Game.new(9) + local fighter = Game.new(9, { class = "fighter" }) + eq(fighter.player.class, "fighter", "the requested class is stamped onto the player") + eq(fighter.player.maxHp, base.player.maxHp + 8, "fighter grants a flat max hp bonus") + eq(fighter.player.hp, base.player.hp + 8, "the hp bonus also tops up current hp") + eq(fighter.player.attack, base.player.attack + 2, "fighter grants a flat attack bonus") +end + +function tests.classApplyGrantsRogueGoldAndAttack() + local base = Game.new(9) + local rogue = Game.new(9, { class = "rogue" }) + eq(rogue.player.gold, base.player.gold + 15, "rogue starts with extra gold") + eq(rogue.player.attack, base.player.attack + 1, "rogue grants a small attack bonus") +end + +function tests.classApplyGrantsMageManaAndAttack() + local base = Game.new(9) + local mage = Game.new(9, { class = "mage" }) + eq(mage.player.maxMana, base.player.maxMana + 8, "mage grants a flat mana bonus") + eq(mage.player.mana, base.player.mana + 8, "the mana bonus also tops up current mana") + eq(mage.player.attack, base.player.attack - 1, "mage trades some attack for its mana pool") +end + +function tests.classApplyGrantsRangerBowAndAmmo() + local ranger = Game.new(9, { class = "ranger" }) + ok(ranger.player.bow ~= nil, "ranger starts with a bow equipped") + local hasAmmo = false + for _, item in ipairs(ranger.player.inventory) do + if item.kind == Items.AMMO then hasAmmo = true end + end + ok(hasAmmo, "ranger starts with a stack of arrows") +end + +function tests.invalidClassIsIgnored() + local base = Game.new(9) + local weird = Game.new(9, { class = "wizard" }) + eq(weird.player.class, nil, "an unrecognised class name is not applied") + eq(weird.player.maxHp, base.player.maxHp, "stats are unaffected by an invalid class") +end + +function tests.skillLearnRespectsClassLevelAndPoints() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "fighter" + game.player.skillPoints = 0 + eq(game:learnSkill("cleave"), false, "learning fails without a banked skill point") + game.player.skillPoints = 1 + eq(game:learnSkill("backstab"), false, "learning a skill from another class fails") + eq(game.player.skillPoints, 1, "a failed learn spends no point") + eq(game:learnSkill("berserk"), false, "learning fails below the skill's level requirement") + eq(game:learnSkill("cleave"), true, "a same-class, level-eligible skill with a point banked learns") + eq(game.player.skillPoints, 0, "learning spends the point") + eq(Skills.has(game.player, "cleave"), true, "the skill is now known") + game.player.skillPoints = 1 + eq(game:learnSkill("cleave"), false, "an already-known skill cannot be learned twice") + eq(game.player.skillPoints, 1, "re-learning a known skill spends no point") +end + +function tests.skillCooldownBlocksImmediateReuseAndTicksDown() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "fighter" + game.player.skills.secondWind = true + game.player.hp = game.player.maxHp - 10 + eq(game:useSkill("secondWind"), true, "second wind activates while off cooldown") + eq(game:useSkill("secondWind"), false, "an immediate reuse fails while on cooldown") + for _ = 1, Skills.active.secondWind.cooldown do + game:endPlayerTurn() + end + eq(game.player.skillCooldowns.secondWind, 0, "the cooldown ticks down to zero over that many player turns") + game.player.hp = game.player.maxHp - 10 + eq(game:useSkill("secondWind"), true, "second wind is usable again once its cooldown fully elapses") +end + +function tests.fighterCleaveHitsPrimaryAndAdjacentMonsters() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "fighter" + game.player.skills.cleave = true + local primary = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + local splash = Monsters.create(Monsters.byKey.orc, 2, 3, 5, 1) + game.level.entities = { primary, splash } + local success = game:useSkill("cleave", primary) + eq(success, true, "cleave lands on an adjacent primary target") + ok(primary.hp < primary.maxHp, "the primary target takes damage") + ok(splash.hp < splash.maxHp, "a second monster adjacent to the player also takes splash damage") + ok((splash.maxHp - splash.hp) < (primary.maxHp - primary.hp), "the splash hit deals less than the primary hit") + eq(game.player.skillCooldowns.cleave, Skills.active.cleave.cooldown, "cleave starts its own cooldown") +end + +-- Cleave's splash targets must be mitigated by the wielder's own weapon +-- damage type exactly like the primary target already is through +-- Combat.damage/Combat.attack (see combat.lua's damageTypeOf and affixes.lua's +-- own comment that a flaming sword "can do nothing at all against something +-- fire-immune") - before this test the splash loop subtracted a raw +-- attack-minus-defense number straight off hp with no Resistances.apply call +-- at all, so a flaming weapon's cleave splash burned through fire immunity +-- that the very same swing's primary hit already respected. +function tests.fighterCleaveSplashRespectsAFireImmuneTargetLikeThePrimaryHitAlreadyDoes() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "fighter" + game.player.skills.cleave = true + game.player.weapon = Affixes.decorate(Items.weapon("dagger", 2), Affixes.weapon[3]) + eq(game.player.weapon.damageType, "fire", "the flaming affix retags the dagger's swing as fire") + local primary = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + local immune = Monsters.create(Monsters.byKey.dragon, 2, 3, 5, 6) + game.level.entities = { primary, immune } + game:useSkill("cleave", primary) + ok(primary.hp < primary.maxHp, "the primary target still burns for full mitigated damage") + eq(immune.hp, immune.maxHp, "a fire-immune splash target takes zero cleave splash from a flaming weapon") +end + +function tests.fighterSecondWindRestoresPortionOfMaxHp() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.class = "fighter" + game.player.skills.secondWind = true + game.player.hp = 5 + game:useSkill("secondWind") + eq(game.player.hp, 5 + math.floor(game.player.maxHp * 0.3), "second wind restores three tenths of max hp") + + game.player.skillCooldowns.secondWind = 0 + game.player.hp = game.player.maxHp - 1 end local order = {