From 808016f9258d66d23a42322415314faca837ac3a Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 13:53:13 +0200 Subject: [PATCH] test: cover tests.commandsApplyStealFailsWithNothingWardedUnderfootWi... --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 24692ec..fc3d5f2 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -13623,6 +13623,179 @@ end function tests.commandsApplyStealTakesTheWardedWareUnderfootAndSpendsTheTurn() local game = stagedGame(1, arena(21, 11), 5, 5) local keeper = Monsters.create(Monsters.byKey.shopkeeper, 1, 10, 10, 4) + game.level.entities = { keeper } + game.level.floorItems = { { x = 5, y = 5, item = Items.scroll("identify"), price = 18, shopId = keeper.id } } + local invBefore = #game.player.inventory + local consumesTurn = Commands.apply(game, "steal") + eq(consumesTurn, true, "stealing a warded ware underfoot spends the turn") + eq(#game.player.inventory, invBefore + 1, "the stolen item joins the inventory") + eq(keeper.hostile, true, "the keeper turns hostile after the theft") +end + +function tests.commandsApplyStealFailsWithNothingWardedUnderfootWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local consumesTurn, message = Commands.apply(game, "steal") + eq(consumesTurn, false, "stealing with nothing underfoot spends no turn") + eq(message, "there is nothing here worth stealing", "the failure explains itself") +end + +function tests.commandsApplyDismissReleasesTheHirelingAndSpendsTheTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + Mercenaries.hire(game, "sellsword") + ok(game:hasHireling(), "the hire itself succeeded") + local consumesTurn = Commands.apply(game, "dismiss") + eq(consumesTurn, true, "dismissing an under-contract hireling spends the turn") + eq(game:hasHireling(), false, "the hireling is gone") +end + +function tests.commandsApplyDismissFailsWithNoHirelingWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local consumesTurn = Commands.apply(game, "dismiss") + eq(consumesTurn, false, "dismissing with nobody under contract spends no turn") +end + +function tests.commandsApplyLearnSpendsAPointNeverATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.class = "fighter" + game.player.skillPoints = 1 + local turnBefore = game.turn + local success = Commands.apply(game, "learn cleave") + eq(success, false, "learning a skill never spends a turn") + eq(game.turn, turnBefore, "the turn counter is untouched by learning") + eq(Skills.has(game.player, "cleave"), true, "the skill is actually learned") + eq(game.player.skillPoints, 0, "the skill point is spent") + ok(contains(game.log:last(), "learn"), "learning is logged") + + local missing, message = Commands.apply(game, "learn") + eq(missing, false, "learn with no skill name spends no turn") + ok(message ~= nil, "the failure names what is missing") + + local alreadyKnown = Commands.apply(game, "learn cleave") + eq(alreadyKnown, false, "re-learning an already-known skill spends no turn") + ok(contains(game.log:last(), "already"), "the failure to re-learn is logged") +end + +-- Skills.active/Skills.passive keys are camelCase (secondWind, poisonBlade, +-- manaShield), but Commands.parse lowercases every token including a typed +-- skill name before any handler sees it - a player typing the skill's own +-- printed name would otherwise never match it. Typed exactly as a human +-- would type it (mixed case), routed through the real Commands.apply/parse +-- pipeline, not a pre-lowercased literal. +function tests.commandsApplyLearnAcceptsTheSkillsOwnCamelCaseSpellingDespiteTheParserLowercasingIt() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.class = "fighter" + game.player.level = 5 + game.player.skillPoints = 2 + local success = Commands.apply(game, "learn secondWind") + eq(success, false, "learning a skill never spends a turn") + eq(Skills.has(game.player, "secondWind"), true, "the camelCase skill is actually learned under its real key") + ok(contains(game.log:last(), "second wind"), "learning is logged under the skill's real name") + + local rogue = stagedGame(2, arena(21, 11), 5, 5) + rogue.player.class = "rogue" + rogue.player.level = 5 + rogue.player.skillPoints = 2 + Commands.apply(rogue, "learn poisonBlade") + eq(Skills.has(rogue.player, "poisonBlade"), true, "a differently-classed camelCase skill also resolves correctly") + + local mage = stagedGame(3, arena(21, 11), 5, 5) + mage.player.class = "mage" + mage.player.level = 5 + mage.player.skillPoints = 2 + Commands.apply(mage, "learn manaShield") + eq(Skills.has(mage.player, "manaShield"), true, "a camelCase PASSIVE skill resolves too, not only active ones") +end + +function tests.commandsApplyUseskillAcceptsACamelCaseSkillNameDespiteTheParserLowercasingIt() + local game = stagedGame(4, arena(21, 11), 5, 5) + game.player.class = "fighter" + game.player.level = 5 + Skills.learn(game.player, "secondWind") + game.player.hp = 1 + local consumesTurn = Commands.apply(game, "useskill secondWind") + eq(consumesTurn, true, "using a camelCase-named skill by its real spelling spends the turn") + ok(game.player.hp > 1, "second wind actually healed the player, proving the real skill ran") + eq(game.player.skillCooldowns.secondWind, Skills.active.secondWind.cooldown, + "the cooldown lands on the real camelCase key, not a lowercased one") + + local rogue = stagedGame(5, arena(21, 11), 5, 5) + rogue.player.class = "rogue" + rogue.player.level = 5 + Skills.learn(rogue.player, "poisonBlade") + local consumesTurn2 = Commands.apply(rogue, "useskill poisonBlade") + eq(consumesTurn2, true, "using poisonBlade by its real camelCase spelling spends the turn") + eq(rogue.player.pendingPoisonBlade, true, "the venom is actually readied, proving the real skill ran") +end + +function tests.commandsApplyBrewCombinesTwoHerbsAtACauldronAndSpendsTheTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.cauldrons = { { x = 5, y = 5 } } + game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL), Alchemy.herb(Alchemy.BLOODROOT) } + local consumesTurn = Commands.apply(game, "brew 1 2") + eq(consumesTurn, true, "brewing two herbs at a cauldron underfoot spends the turn") + eq(game.player.inventory[1].kind, Items.POTION, "the brewed potion replaces the spent herbs") +end + +function tests.commandsApplyBrewFailsAwayFromACauldronWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL), Alchemy.herb(Alchemy.BLOODROOT) } + local consumesTurn = Commands.apply(game, "brew 1 2") + eq(consumesTurn, false, "brewing away from any cauldron spends no turn") + eq(#game.player.inventory, 2, "a failed brew for lack of a cauldron spends nothing") + local missing, message = Commands.apply(game, "brew") + eq(missing, false, "brew with no indices spends no turn") + ok(message ~= nil, "the failure names what is missing") +end + +-- Real bug found while manually verifying the new moonbell/sunpetal curdle +-- pairing through the full command pipeline: Game:brewAt returns false both +-- when nothing was ever attempted (no cauldron, bad indices) and when a real +-- attempt curdled (both herbs already spent, gas already vented) - the old +-- handler read either shape of false as "give the turn back", so a player +-- who curdled a pairing got a free retry exactly like a typo would. Fixed by +-- duplicating the preconditions in the handler (same shape as disarm's own +-- fix earlier today) so anything that reaches Game:brewAt always spends the +-- turn regardless of which of its two outcomes it lands on. +function tests.commandsApplyBrewSpendsTheTurnEvenWhenThePairingCurdles() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.cauldrons = { { x = 5, y = 5 } } + game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL), Alchemy.herb(Alchemy.NIGHTCAP) } + local consumesTurn, message = Commands.apply(game, "brew 1 2") + eq(consumesTurn, true, "a curdled brew still consumed a real attempt and must spend the turn") + eq(message, nil, "the curdle's own log line speaks for it, like a successful brew's message does") + eq(#game.player.inventory, 0, "the curdle already spent both herbs before the handler ever saw the outcome") + ok(contains(game.log:recent(1)[1] or "", "curdle"), "the curdle is still logged through the command pipeline") +end + +function tests.commandsApplyBrewRejectsTheSameIndexTwiceOrANonHerbItemWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.level.cauldrons = { { x = 5, y = 5 } } + game.player.inventory = { Alchemy.herb(Alchemy.SUNPETAL), Items.potion(10) } + local sameIndex = Commands.apply(game, "brew 1 1") + eq(sameIndex, false, "naming the same inventory slot twice spends no turn") + local nonHerb = Commands.apply(game, "brew 1 2") + eq(nonHerb, false, "pairing a herb with a non-herb item spends no turn") + eq(#game.player.inventory, 2, "neither rejected attempt spends any item") +end + +function tests.commandsApplyHelpMentionsTheCraftingAndEconomyVerbs() + local game = stagedGame(1, arena(21, 11), 5, 5) + local _, message = Commands.apply(game, "help") + for _, word in ipairs({ "steal", "dismiss", "learn", "forge", "merge", "brew", "socket", "temper", "enchant", "repair" }) do + ok(contains(message, word), "help mentions " .. word) + end +end + +function tests.commandsApplyHelpMentionsAppeaseDisarmAndHorde() + local game = stagedGame(1, arena(21, 11), 5, 5) + local _, message = Commands.apply(game, "help") + for _, word in ipairs({ "appease", "disarm", "horde" }) do + ok(contains(message, word), "help mentions " .. word) + end +end + +-- Game:manualStep - the turn-cycle integration around commands.lua. + end local order = {