From 62e694d52260b4b57df741cea8ff89ef81de3fd8 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 17:13:56 +0200 Subject: [PATCH] test: cover tests.gameSpringTrapConfuseEffectAppliesToAnyVictimNotJus... --- tests/run.lua | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 9754330..b813b74 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -785,6 +785,137 @@ function tests.traps() poisonGame.level.traps = { { x = 4, y = 6, effect = "poison", revealed = false } } poisonGame:movePlayer(1, 0) eq(Status.has(poisonGame.player, Status.POISON), true, "a poison trap poisons the stepper") + + local confuseGame = stagedGame(1, arena(21, 11), 3, 6) + confuseGame.level.traps = { { x = 4, y = 6, effect = "confuse", revealed = false } } + confuseGame:movePlayer(1, 0) + eq(Status.has(confuseGame.player, Status.CONFUSE), true, "a confuse trap confuses the stepper") + eq(confuseGame.level.traps[1].revealed, true, "a sprung confuse trap becomes revealed too") + + local blindGame = stagedGame(1, arena(21, 11), 3, 6) + blindGame.level.traps = { { x = 4, y = 6, effect = "blind", revealed = false } } + blindGame:movePlayer(1, 0) + eq(Status.has(blindGame.player, Status.BLIND), true, "a blind trap blinds the stepper") + eq(blindGame.level.traps[1].revealed, true, "a sprung blind trap becomes revealed too") + eq(blindGame:effectiveRadius(), 1, "the freshly blinded stepper's own sight radius is clamped straight away") + + local stunGame = stagedGame(1, arena(21, 11), 3, 6) + stunGame.level.traps = { { x = 4, y = 6, effect = "stun", revealed = false } } + stunGame:movePlayer(1, 0) + eq(Status.has(stunGame.player, Status.STUN), true, "a stun trap stuns the stepper") + eq(stunGame.level.traps[1].revealed, true, "a sprung stun trap becomes revealed too") +end + +-- Game:springTrap is written to work against whatever `victim` it is handed, +-- not just self.player - the same generic shape a builder-placed trap needs +-- to also harm a monster that blunders into a player-planted trap kit. +function tests.gameSpringTrapConfuseEffectAppliesToAnyVictimNotJustThePlayer() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 10, 6, 1) + game.level.entities = { rat } + local trap = { x = 10, y = 6, effect = "confuse", revealed = false } + game:springTrap(trap, rat) + eq(Status.has(rat, Status.CONFUSE), true, "a confuse trap sprung on a monster confuses that monster") + eq(Status.has(game.player, Status.CONFUSE), false, "the player is untouched by a trap sprung on someone else") +end + +function tests.gameSpringTrapBlindEffectAppliesToAnyVictimNotJustThePlayer() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 10, 6, 1) + game.level.entities = { rat } + local trap = { x = 10, y = 6, effect = "blind", revealed = false } + game:springTrap(trap, rat) + eq(Status.has(rat, Status.BLIND), true, "a blind trap sprung on a monster blinds that monster") + eq(Status.has(game.player, Status.BLIND), false, "the player is untouched by a trap sprung on someone else") +end + +-- STUN previously had the opposite gap from BLIND (see abilities.lua's +-- tryShriek comment): a player's crafted weapon could already stun a +-- monster, but nothing on the environment/monster side could ever stun the +-- player. A trap is a fitting, low-risk source for that missing direction - +-- rare and avoidable/disarmable, unlike say an elite's on-hit rider firing +-- on every single blow it lands. +function tests.gameSpringTrapStunEffectAppliesToAnyVictimNotJustThePlayer() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 10, 6, 1) + game.level.entities = { rat } + local trap = { x = 10, y = 6, effect = "stun", revealed = false } + game:springTrap(trap, rat) + eq(Status.has(rat, Status.STUN), true, "a stun trap sprung on a monster stuns that monster") + eq(Status.has(game.player, Status.STUN), false, "the player is untouched by a trap sprung on someone else") +end + +-- A confused player's every attempted move rolls the same override chance a +-- confused monster's Ai.act does (status.lua's shared CONFUSE_NUMERATOR/ +-- DENOMINATOR): in the middle of an open arena every one of the 8 compass +-- directions leads to open floor, so either outcome always still moves the +-- player somewhere and always spends the turn. +function tests.gameMovePlayerConfusionEitherFollowsTheChosenDirectionOrStumblesElsewhere() + local sawStraight, sawStumble = false, false + for seed = 1, 200 do + local game = stagedGame(seed, arena(21, 11), 10, 6) + Status.apply(game.player, Status.CONFUSE, 5, 1) + local consumesTurn = game:movePlayer(1, 0) + ok(consumesTurn, "a confused player's attempted move always spends the turn on open floor") + if game.player.x == 11 and game.player.y == 6 then + sawStraight = true + else + sawStumble = true + end + if sawStraight and sawStumble then break end + end + ok(sawStraight, "confusion sometimes lets the chosen direction through untouched") + ok(sawStumble, "confusion sometimes overrides the chosen direction") +end + +-- The turn-spending contract that makes confusion different from an ordinary +-- rejected move: bumping a wall on purpose spends nothing (see +-- tests.doorsAndKeys's locked-door case and moveHandler's own "you cannot go +-- that way"), but a confused player who stumbles into a wall instead of their +-- own chosen direction still spent the turn on the stumble itself. +function tests.gameMovePlayerConfusionStumbleIntoAWallStillSpendsTheTurn() + local level = arena(21, 11) + -- Wall off every neighbour of (10, 6) except due east, so any redirect + -- other than the player's own chosen direction bumps a wall in place. + for _, cell in ipairs({ { 9, 5 }, { 10, 5 }, { 11, 5 }, { 9, 6 }, { 9, 7 }, { 10, 7 }, { 11, 7 } }) do + level.map:set(cell[1], cell[2], Tiles.WALL) + end + local sawBlockedStumble = false + for seed = 1, 500 do + local game = stagedGame(seed, level, 10, 6) + Status.apply(game.player, Status.CONFUSE, 5, 1) + local consumesTurn = game:movePlayer(1, 0) + ok(consumesTurn, "a confused move, straight or stumbled into this walled-off pocket, always spends the turn") + if game.player.x == 10 and game.player.y == 6 then + sawBlockedStumble = true + end + end + ok(sawBlockedStumble, "at least one seed in range stumbled into a wall instead of moving east") +end + +-- Without Status.CONFUSE present at all, Game:movePlayer never consults the +-- rng for a redirect - the roll/pick machinery added for confusion must be a +-- true no-op for every unconfused move the engine already relied on. +function tests.gameMovePlayerWithoutConfusionNeverStumbles() + for seed = 1, 20 do + local game = stagedGame(seed, arena(21, 11), 10, 6) + game:movePlayer(1, 0) + eq(game.player.x, 11, "an unconfused move always lands exactly where it was aimed") + eq(game.player.y, 6, "an unconfused move never drifts off its own row") + end +end + +-- Game:allyActsConfused is the companion/hireling/thrall counterpart of the +-- confusion machinery above - unlike Game:movePlayer and Ai.act, it is never +-- called at all unless a caller opts in, so an entity without Status.CONFUSE +-- must bail out before it ever touches the rng (companionsAct*/mercenaryAct*/ +-- reanimationAct* below cover the roll actually firing). +function tests.gameAllyActsConfusedReturnsFalseImmediatelyWithoutTheStatus() + local game = stagedGame(1, arena(21, 11), 5, 5) + local golem = Companions.summon(game, "golem") + local rollsBefore = game.rng.state + eq(game:allyActsConfused(golem), false, "an ally without Status.CONFUSE never triggers a stumble") + eq(game.rng.state, rollsBefore, "checking an unconfused ally never advances the rng") end function tests.lightAndDark() @@ -816,6 +947,19 @@ function tests.identification() eq(Identify.label(a, healing), "potion of healing", "identifying reveals the true name") end +function tests.identifyGivesEveryPotionTypeItsOwnDistinctUnidentifiedLook() + local state = Identify.new(Rng.new(3)) + local seen = {} + for _, entry in ipairs(Items.potionTypes) do + local label = Identify.label(state, Items.potionOf(entry.effect)) + ok(seen[label] == nil, "no two potion types share an unidentified look (collided on " .. label .. ")") + seen[label] = true + end + local count = 0 + for _ in pairs(seen) do count = count + 1 end + eq(count, #Items.potionTypes, "every potion type, including confuse, got its own look") +end + function tests.effects() local game = Game.new(31) game.player.hp = 10 @@ -824,6 +968,12 @@ function tests.effects() Effects.apply("poison", game, game.player, { power = 3 }) eq(Status.has(game.player, Status.POISON), true, "poison effect afflicts the target") + Effects.apply("confuse", game, game.player, {}) + eq(Status.has(game.player, Status.CONFUSE), true, "confuse effect afflicts the drinker just like poison") + + Effects.apply("blind", game, game.player, {}) + eq(Status.has(game.player, Status.BLIND), true, "blind effect afflicts the drinker just like confuse") + Effects.apply("mapping", game, game.player, {}) local map = game.level.map local exploredAll = true