diff --git a/tests/run.lua b/tests/run.lua index 05f29ed..295de40 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -13104,6 +13104,179 @@ function tests.commandsApplyFireWithAnExplicitTargetNumberShootsThatHostileInste game.level.entities = { near, far } game.player.bow = Items.bow("short bow", 2, 6) game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 2) + local consumesTurn = Commands.apply(game, "fire 2") + eq(consumesTurn, true, "firing at an explicit in-sight target number spends the turn") + ok(far.hp < 20, "the explicitly named second hostile takes the shot") + eq(near.hp, 20, "the nearer, non-targeted hostile is left untouched") +end + +function tests.commandsApplyCastWithAnExplicitTargetNumberHitsThatHostileInsteadOfTheNearestOne() + local game = stagedGame(4, arena(21, 11), 5, 5) + local near = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + local far = Monsters.create(Monsters.byKey.rat, 1, 5, 8, 1) + near.hp, far.hp = 20, 20 + game.level.entities = { near, far } + local consumesTurn = Commands.apply(game, "cast missile 2") + eq(consumesTurn, true, "casting at an explicit in-sight target number spends the turn") + ok(far.hp < 20, "the explicitly named second hostile takes the hit") + eq(near.hp, 20, "the nearer, non-targeted hostile is left untouched") +end + +function tests.commandsApplyCaptureWithAnExplicitTargetNumberTargetsThatHostileInsteadOfTheNearestOne() + local game = stagedGame(4, arena(21, 11), 5, 5) + local near = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + local far = Monsters.create(Monsters.byKey.rat, 1, 5, 8, 1) + near.hp, far.hp = 1, 1 + game.level.entities = { near, far } + game.player.inventory[#game.player.inventory + 1] = Items.net() + local index = #game.player.inventory + game.rng = Rng.new(2) + local consumesTurn = Commands.apply(game, "capture " .. index .. " 2") + eq(consumesTurn, true, "capturing at an explicit in-sight target number spends the turn") + eq(#game.level.captives, 1, "exactly one captive results from the attempt") + eq(game.level.captives[1].x, far.x, "the explicitly named second hostile is the one caught") + eq(game.level.captives[1].y, far.y, "the explicitly named second hostile is the one caught") + ok(near:isAlive(), "the nearer, non-targeted hostile is left alive and free") +end + +-- Cleave hits its named target through the full attack path and every other +-- monster standing next to the player for a halved splash (see skills.lua's +-- Skills.useCleave) - so with two rats both adjacent to the player, an +-- explicit target number cannot be proven by "the other one is untouched" +-- (the splash still reaches it), only by which one takes the bigger, full +-- hit. Combat.damage is a plain, RNG-free formula (attack minus defense, +-- floored, minimum 1 - see combat.lua), so two identical rats make that +-- comparison deterministic. +function tests.commandsApplyUseskillCleaveWithAnExplicitTargetNumberHitsThatAdjacentHostileInsteadOfTheDefaultNearestOne() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.class = "fighter" + Skills.learn(game.player, "cleave") + local ratA = Monsters.create(Monsters.byKey.rat, 1, 6, 5, 1) + local ratB = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + ratA.hp, ratB.hp = 20, 20 + game.level.entities = { ratA, ratB } + local consumesTurn = Commands.apply(game, "useskill cleave 2") + eq(consumesTurn, true, "cleave against an explicit in-sight target number spends the turn") + local dealtToB, dealtToA = 20 - ratB.hp, 20 - ratA.hp + ok(dealtToB > dealtToA, "the explicitly named second hostile takes cleave's full hit, not just its splash") +end + +function tests.commandsApplyUseskillMultishotWithExplicitTargetNumbersHitsExactlyThoseTwoHostiles() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.class = "ranger" + Skills.learn(game.player, "multishot") + game.player.bow = Items.bow("short bow", 2, 6) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 2) + local first = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + local middle = Monsters.create(Monsters.byKey.rat, 1, 6, 5, 1) + local third = Monsters.create(Monsters.byKey.rat, 1, 5, 8, 1) + first.hp, middle.hp, third.hp = 20, 20, 20 + game.level.entities = { first, middle, third } + local consumesTurn = Commands.apply(game, "useskill multishot 1 3") + eq(consumesTurn, true, "multishot against two explicit in-sight target numbers spends the turn") + ok(first.hp < 20, "the first explicitly named hostile takes a shot") + ok(third.hp < 20, "the second explicitly named hostile takes a shot") + eq(middle.hp, 20, "the un-named, nearer-by-distance hostile in between is left untouched") +end + +function tests.commandsApplyUseskillMultishotWithOnlyOneExplicitTargetNumberFallsBackToAutoTargetingBoth() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.class = "ranger" + Skills.learn(game.player, "multishot") + game.player.bow = Items.bow("short bow", 2, 6) + game.player.inventory[#game.player.inventory + 1] = Items.ammo("arrow", 2) + local near = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + near.hp = 20 + game.level.entities = { near } + local consumesTurn = Commands.apply(game, "useskill multishot 1") + eq(consumesTurn, true, "an ambiguous single explicit target still falls back to auto-targeting") + ok(near.hp < 20, "the only visible hostile still takes a shot via the auto-target fallback") +end + +function tests.commandsApplyContextActionsFailCleanlyWithNothingHereWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + for _, verb in ipairs({ + "open", "pray", "quest", "butcher", "raise", "scavenge", "harvest", + "ransom", "sellcaptive", "interrogate", "descend", "ascend", "enterrift", "exitrift", + }) do + local consumesTurn, message = Commands.apply(game, verb) + eq(consumesTurn, false, verb .. " with nothing here spends no turn") + ok(message ~= nil and #message > 0, verb .. " gives a reason") + end +end + +function tests.commandsApplyDigRejectsAnUnknownDirectionAndAnUnreadiedPickWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local badDirection, message1 = Commands.apply(game, "dig sideways") + eq(badDirection, false, "an unrecognized direction word spends no turn") + eq(message1, "dig which direction?", "the failure names what is missing") + local noPick, message2 = Commands.apply(game, "dig north") + eq(noPick, false, "digging without a readied pick spends no turn") + ok(message2 ~= nil, "the failure explains itself") +end + +function tests.commandsApplyDisarmRejectsAMissingDirectionOrAnUndetectedTrapWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local badDirection, message1 = Commands.apply(game, "disarm sideways") + eq(badDirection, false, "an unrecognized direction word spends no turn") + eq(message1, "disarm which direction?", "the failure names what is missing") + + local undetected = { x = 6, y = 5, effect = "dart", revealed = false } + game.level.traps = { undetected } + local noTarget, message2 = Commands.apply(game, "disarm east") + eq(noTarget, false, "a trap never yet noticed spends no turn") + eq(message2, "there is nothing here you have noticed to disarm", "the failure explains itself") + eq(#game.level.traps, 1, "the untouched trap is still there") + + local sprung = { x = 4, y = 5, effect = "dart", revealed = true, detected = true } + game.level.traps = { sprung } + local alreadySprung = Commands.apply(game, "disarm west") + eq(alreadySprung, false, "an already-sprung trap spends no turn either") +end + +function tests.commandsApplyDisarmAlwaysSpendsTheTurnAgainstADetectedTrapWhetherItSucceedsOrSprings() + local sawSuccess, sawFailure = false, false + for seed = 1, 300 do + if sawSuccess and sawFailure then break end + local game = stagedGame(seed, arena(21, 11), 3, 6) + local trap = { x = 4, y = 6, effect = "dart", revealed = false, detected = true } + game.level.traps = { trap } + local consumesTurn = Commands.apply(game, "disarm east") + eq(consumesTurn, true, "a disarm attempt against a real detected trap always spends the turn") + if #game.level.traps == 0 then + sawSuccess = true + else + eq(trap.revealed, true, "a failed disarm still springs and reveals the trap") + sawFailure = true + end + end + ok(sawSuccess, "disarming through the command eventually succeeds across a handful of seeds") + ok(sawFailure, "disarming through the command eventually springs across a handful of seeds") +end + +function tests.commandsApplyAppeaseBuysFactionStandingWithGoldAndSpendsTheTurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.gold = 100 + local repBefore = Factions.reputationFor(game.player, Factions.MERCHANT) + local costBefore = Factions.appeaseCost(game.player, Factions.MERCHANT) + local consumesTurn = Commands.apply(game, "appease merchant") + eq(consumesTurn, true, "affording tribute to a real faction spends the turn") + eq(game.player.gold, 100 - costBefore, "the tribute's cost is charged in gold") + ok(Factions.reputationFor(game.player, Factions.MERCHANT) > repBefore, "standing actually improves") +end + +function tests.commandsApplyAppeaseRejectsAMissingOrUnknownFactionWithoutSpendingATurn() + local game = stagedGame(1, arena(21, 11), 5, 5) + local missing, message1 = Commands.apply(game, "appease") + eq(missing, false, "appease with no faction name spends no turn") + ok(contains(message1, "merchant"), "the failure lists the valid faction names") + + local unknown, message2 = Commands.apply(game, "appease atlanteans") + eq(unknown, false, "an unrecognized faction name spends no turn") + ok(contains(message2, "merchant"), "the failure lists the valid faction names") +end + +function tests.commandsApplyAppeaseFailsWithoutSpendingATurnWhenGoldIsShort() end local order = {