From bf8645b0ad1d50be8c28bc94c555f113b9910748 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 13:05:13 +0200 Subject: [PATCH] test: cover tests.craftingTemperRefusesWithoutEnoughEssence --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 92a60a7..8084e1e 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -2724,6 +2724,179 @@ end function tests.craftingTemperRaisesPowerAtRisingCostUpToACap() 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 + local totalCost = 0 + for level = 0, Crafting.TEMPER_MAX - 1 do + totalCost = totalCost + (level + 1) * 4 + end + game.player.essence = totalCost + + for level = 1, Crafting.TEMPER_MAX do + ok(Crafting.temper(game.player, weapon), "temper step " .. level .. " succeeds with enough essence") + end + eq(weapon.power, 2 + Crafting.TEMPER_MAX, "each temper step adds exactly one flat power") + eq(game.player.attack, attackBaseline + Crafting.TEMPER_MAX, + "tempering an equipped weapon updates attack immediately") + eq(game.player.essence, 0, "the full ladder of rising costs is spent exactly") + + local success, reason = Crafting.temper(game.player, weapon) + eq(success, false, "a fully tempered item refuses a further step") + eq(reason, "fully tempered", "the refusal names the reason") +end + +function tests.craftingTemperRefusesWithoutEnoughEssence() + local game = stagedGame(1, arena(21, 11), 3, 6) + local weapon = Items.weapon("mace", 3) + Items.equipWeapon(weapon, game.player) + game.player.essence = 3 + local success, reason = Crafting.temper(game.player, weapon) + eq(success, false, "tempering fails when essence is short of the cost") + eq(reason, "not enough essence", "the refusal names the reason") + eq(weapon.power, 3, "a failed temper leaves power untouched") + eq(game.player.essence, 3, "a failed temper spends nothing") +end + +function tests.craftingEnchantGrantsAChosenAffixOnce() + 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 + game.player.essence = Crafting.ENCHANT_COST + + ok(Crafting.enchant(game.player, weapon, "flaming"), "enchanting an unaffixed weapon succeeds") + eq(weapon.affix.key, "flaming", "the chosen affix is attached, not a random roll") + eq(weapon.power, 3, "the affix's power lands on the item") + eq(weapon.name, "flaming dagger", "the affix prefix is applied to the name") + eq(game.player.attack, attackBaseline + 1, "enchanting an equipped weapon updates attack immediately") + eq(game.player.essence, 0, "the enchant cost is spent") + + local repeatSuccess, repeatReason = Crafting.enchant(game.player, weapon, "vicious") + eq(repeatSuccess, false, "an already-affixed weapon refuses a second enchant") + eq(repeatReason, "already bears an affix", "the refusal names the reason") + + local badSuccess, badReason = Crafting.enchant(game.player, Items.armor("leather armor", 1), "not-a-real-affix") + eq(badSuccess, false, "an unknown affix key is refused") + eq(badReason, "unknown affix", "the refusal names the reason") + + local ringSuccess, ringReason = Crafting.enchant(game.player, Items.ring("ring of power", 1, 1), "sharp") + eq(ringSuccess, false, "gear without an affix pool cannot be enchanted") + eq(ringReason, "cannot be enchanted", "the refusal names the reason") +end + +function tests.craftingDisenchantStripsAffixAndRefundsEssence() + local game = stagedGame(1, arena(21, 11), 3, 6) + local armor = Affixes.decorate(Items.armor("chain mail", 2), Affixes.armor[2]) + Items.equipArmor(armor, game.player) + local defenseBaseline = game.player.defense + eq(armor.name, "reinforced chain mail", "sanity check: the drop rolled the expected affix") + + local success, refund = Crafting.disenchant(game.player, armor) + ok(success, "disenchanting an affixed armor succeeds") + eq(refund, Crafting.FORGE_COST + 2 * 3, "the refund scales with the stripped affix's power") + eq(armor.power, 2, "power drops back to its base value") + eq(armor.name, "chain mail", "the affix prefix is removed from the name") + eq(armor.affix, nil, "the affix is gone") + eq(game.player.defense, defenseBaseline - 2, "disenchanting an equipped armor updates defense immediately") + eq(game.player.essence, refund, "the refund lands in the player's essence") + + local again, reason = Crafting.disenchant(game.player, armor) + eq(again, false, "an already-plain item has nothing left to disenchant") + eq(reason, "nothing to disenchant", "the refusal names the reason") +end + +function tests.craftingSocketedGemRidersStackWithARolledAffixOnHit() + local game = stagedGame(1, arena(21, 11), 3, 6) + local weapon = Affixes.decorate(Items.weapon("dagger", 2), Affixes.weapon[4]) + Items.equipWeapon(weapon, game.player) + ok(Crafting.socket(game.player, weapon, Crafting.gem(Crafting.SHOCK, 1)), + "a shock gem sockets alongside the rolled affix") + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.hp, orc.maxHp = 30, 30 + game.level.entities = { orc } + game:playerAttack(orc) + eq(Status.has(orc, Status.POISON), true, "the rolled venomed affix still lands its rider") + eq(Status.has(orc, Status.STUN), true, "the socketed shock gem lands its own rider on the same hit") +end + +-- Regression: a shock rider applied during the PLAYER's turn is ticked for +-- the first time on the very same round, inside the victim's own upcoming +-- Game:takeMonsterTurn (Status.tick runs there before Status.skipsTurn is +-- ever checked) - so a duration of 1 would already read 0 and be gone +-- before skipsTurn saw it, and the "stun" would silently never do anything. +-- This confirms the fix (duration 2, see crafting.lua) actually skips the +-- victim's very next turn, not just that the status gets attached. +function tests.craftingShockRiderActuallyStunsTheVictimOnItsNextTurnNotJustOnPaper() + local game = stagedGame(1, arena(21, 11), 3, 6) + local weapon = Items.weapon("dagger", 2) + Items.equipWeapon(weapon, game.player) + ok(Crafting.socket(game.player, weapon, Crafting.gem(Crafting.SHOCK, 1)), + "a bare shock gem sockets onto an unaffixed weapon") + local orc = Monsters.create(Monsters.byKey.orc, 1, 4, 6, 1) + orc.hp, orc.maxHp = 30, 30 + game.level.entities = { orc } + + game:playerAttack(orc) + eq(Status.has(orc, Status.STUN), true, "the shock rider lands immediately on the hit") + + local hpBefore, xBefore, yBefore = game.player.hp, orc.x, orc.y + game:takeMonsterTurn(orc) + eq(game.player.hp, hpBefore, "a stunned orc's very next turn deals no melee damage") + eq(orc.x, xBefore, "a stunned orc's very next turn does not move") + eq(orc.y, yBefore, "a stunned orc's very next turn does not move") + + game:takeMonsterTurn(orc) + eq(Status.has(orc, Status.STUN), false, "the stun has fully expired by the turn after that") + ok(game.player.hp < hpBefore, "once unstunned the orc resumes attacking normally") +end + +function tests.gameCraftingWrappersDriveTheFullPipeline() + 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 + + game.player.essence = Crafting.FORGE_COST * 2 + ok(game:forgeGem(Crafting.FIRE), "forging a gem through the game wrapper succeeds") + ok(game:forgeGem(Crafting.FIRE), "forging a second matching gem succeeds") + local invBefore = #game.player.inventory + + local firstGemIndex, secondGemIndex + for i, item in ipairs(game.player.inventory) do + if item.kind == Items.GEM then + if not firstGemIndex then firstGemIndex = i else secondGemIndex = i end + end + end + ok(game:mergeGemsAt(firstGemIndex, secondGemIndex), "merging through the game wrapper succeeds") + eq(#game.player.inventory, invBefore - 1, "merging removes both source gems and adds one merged gem") + + local mergedIndex + for i, item in ipairs(game.player.inventory) do + if item.kind == Items.GEM then mergedIndex = i end + end + ok(game:socketItem("weapon", mergedIndex), "socketing the equipped weapon through the game wrapper succeeds") + eq(weapon.socket.element, Crafting.FIRE, "the socketed gem's element lands on the weapon") + eq(game.player.attack, attackBaseline + 2, "socketing a tier-2 gem adds its power to attack") + ok(not game:socketItem("weapon", mergedIndex), "the gem slot is consumed and cannot be socketed twice") + + game.player.essence = Crafting.temperCost(weapon) + ok(game:temperItem("weapon"), "tempering the equipped weapon through the game wrapper succeeds") + eq(weapon.power, 2 + 2 + 1, "temper stacks on top of the socket's power") + eq(game.player.attack, attackBaseline + 3, "tempering the equipped weapon updates attack immediately") + + game.player.essence = Crafting.ENCHANT_COST + ok(game:enchantItem("weapon", "vicious"), "enchanting the equipped weapon through the game wrapper succeeds") + eq(weapon.affix.key, "vicious", "the chosen affix lands on the weapon") + + local beforeDisenchant = game.player.attack + local expectedRefund = Crafting.FORGE_COST + 2 * 3 + ok(game:disenchantItem("weapon"), "disenchanting the equipped weapon through the game wrapper succeeds") + eq(game.player.attack, beforeDisenchant - 2, "disenchanting removes the affix's power from attack") + eq(game.player.essence, expectedRefund, "the disenchant refund lands in essence") + ok(contains(game.log:recent(1)[1] or "", "disenchant"), "the disenchant is logged") +end + +function tests.monsterKillsGrantEssenceScaledToTheirXpReward() end local order = {