From 9601bf29491a50222de591b54250f9210e427e42 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 17:12:53 +0200 Subject: [PATCH] test: cover tests.aiActConfusedMonsterSometimesStumblesInsteadOfChasing --- tests/run.lua | 215 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index bb5aee7..9754330 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -538,6 +538,221 @@ function tests.monsterAiDeterministic() ok(x1 == x2 and y1 == y2, "erratic behaviour is deterministic per seed") end +-- A confused chaser rolls the exact same override chance every turn that a +-- confused player rolls in Game:movePlayer (see status.lua's shared +-- CONFUSE_NUMERATOR/DENOMINATOR) - some seeds let its own chase behaviour +-- through untouched, others send it stumbling in a random direction instead. +function tests.aiActConfusedMonsterSometimesStumblesInsteadOfChasing() + local sawChase, sawStumble = false, false + for seed = 1, 200 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 12, 6, 1) + game.level.entities = { orc } + Status.apply(orc, Status.CONFUSE, 5, 1) + Ai.act(game, orc) + if orc.x == 11 and orc.y == 6 then + sawChase = true + else + sawStumble = true + end + if sawChase and sawStumble then break end + end + ok(sawChase, "a confused monster's roll sometimes lets its normal behaviour run") + ok(sawStumble, "a confused monster's roll sometimes overrides its normal behaviour") +end + +-- Without the status at all, a chaser's step toward the player is exactly as +-- deterministic and direct as tests.monsterAiChase already established - +-- confusion introduces randomness only once Status.CONFUSE is actually +-- present, never as a side effect of the roll/pick machinery merely existing. +function tests.aiActWithoutConfusionAlwaysRunsTheNormalBehaviour() + for seed = 1, 20 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 1, 12, 6, 1) + game.level.entities = { orc } + Ai.act(game, orc) + eq(orc.x, 11, "an unconfused chaser always steps straight toward the player") + eq(orc.y, 6, "an unconfused chaser never drifts off its own row") + end +end + +-- Every ai.lua behaviour (chase/pack/ranged/flee/erratic/tunnel) resolves +-- through Game:canSeePlayer, so blinding a monster is a single choke point +-- that mutes every one of them at once - no per-behaviour special casing +-- needed. An archer is the sharpest demonstration: with sight it holds its +-- ground and snipes from range (tests.monsterAiRanged); blinded at the exact +-- same distance it neither shoots nor closes in, it just idles in place. +function tests.aiActBlindedArcherNeitherShootsNorChasesEvenWithAClearShot() + local game = stagedGame(1, arena(21, 11), 3, 6) + local archer = Monsters.create(Monsters.byKey.archer, 1, 8, 6, 1) + game.level.entities = { archer } + Status.apply(archer, Status.BLIND, 6, 1) + local hpBefore = game.player.hp + Ai.act(game, archer) + eq(game.player.hp, hpBefore, "a blinded archer with an otherwise clear shot does not fire") + eq(archer.x, 8, "a blinded archer does not close the distance either") + eq(archer.y, 6, "a blinded archer does not drift off its own row") +end + +function tests.gameAdjacentAllyFindsACompanionStandingRightNextToAMonster() + local game = stagedGame(1, arena(21, 11), 3, 6) + local golem = Companions.summon(game, "golem") + local orc = Monsters.create(Monsters.byKey.orc, 10, golem.x + 1, golem.y, 5) + game.level.entities[#game.level.entities + 1] = orc + eq(game:adjacentAlly(orc), golem, "a companion standing right next to a monster is found") +end + +function tests.gameAdjacentAllyIgnoresAHostileMonsterAndOnlyCountsCardinalAdjacency() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 10, 10, 6, 1) + local rat = Monsters.create(Monsters.byKey.rat, 5, 11, 6, 2) + game.level.entities = { orc, rat } + eq(game:adjacentAlly(orc), nil, "a hostile monster standing adjacent is never mistaken for an ally") + + local golem = Companions.summon(game, "golem") + golem.x, golem.y = 9, 7 + eq(game:adjacentAlly(orc), nil, + "a companion only diagonally adjacent does not count - cardinal-only, like Game:playerAdjacent") + golem.x, golem.y = 9, 6 + eq(game:adjacentAlly(orc), golem, "moving the same companion to cardinal adjacency makes it found") +end + +function tests.gameAdjacentAllyBreaksTiesByTheLowerEntityIdAndIgnoresADeadAlly() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 10, 10, 6, 999) + game.level.entities = { orc } + local golem = Companions.summon(game, "golem") + local hireling = Mercenaries.hire(game, "sellsword") + golem.x, golem.y = 9, 6 + hireling.x, hireling.y = 11, 6 + ok(golem.id < hireling.id, "sanity check: the companion summoned first has the lower entity id") + eq(game:adjacentAlly(orc), golem, "ties between two equally adjacent allies break toward the lower entity id") + golem.hp = 0 + eq(game:adjacentAlly(orc), hireling, "a dead ally is skipped even though it is still standing adjacent") +end + +-- Reproduces the exact shape Game:aStarNeverRoutesAMonsterThroughABlockedCell +-- already proved for a pushable block: a wall spanning every row except one +-- floor gap leaves stepMonsterToward with no route at all once something is +-- standing in that gap. The difference is what stands there - a living +-- companion, not an inert block - so where a block earns a permanent, +-- correct stalemate, a companion should be fought, not stonewalled forever. +function tests.aiChaseAttacksAnAllyBlockingTheOnlyCorridorToThePlayerInsteadOfIdlingForever() + local game = stagedGame(1, arena(21, 11), 3, 6) + for y = 2, 10 do game.level.map:set(11, y, Tiles.WALL) end + game.level.map:set(11, 6, Tiles.FLOOR) + local golem = Companions.summon(game, "golem") + golem.x, golem.y = 11, 6 + local orc = Monsters.create(Monsters.byKey.orc, 10, 12, 6, 5) + game.level.entities[#game.level.entities + 1] = orc + local hpBefore = golem.hp + Ai.act(game, orc) + ok(golem.hp < hpBefore, "a chaser with no route around a blocking ally fights it instead of idling") + eq(orc.x, 12, "the chaser never steps onto the ally's occupied cell") + eq(orc.y, 6, "the chaser's y is unchanged - it attacked in place rather than moving") +end + +function tests.aiChasePrefersAnAdjacentPlayerOverAnAdjacentAllyWhenBothAreInMeleeRange() + local game = stagedGame(1, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 10, 4, 6, 1) + game.level.entities = { orc } + local golem = Companions.summon(game, "golem") + golem.x, golem.y = 4, 7 + local playerHpBefore, golemHpBefore = game.player.hp, golem.hp + Ai.act(game, orc) + ok(game.player.hp < playerHpBefore, "a monster adjacent to both the player and an ally still attacks the player") + eq(golem.hp, golemHpBefore, "the ally adjacent to the same monster takes no damage when the player is also in range") +end + +function tests.aiPackAttacksAnAllyBlockingTheOnlyCorridorToThePlayerInsteadOfIdlingForever() + local game = stagedGame(1, arena(21, 11), 3, 6) + for y = 2, 10 do game.level.map:set(11, y, Tiles.WALL) end + game.level.map:set(11, 6, Tiles.FLOOR) + local golem = Companions.summon(game, "golem") + golem.x, golem.y = 11, 6 + local kobold = Monsters.create(Monsters.byKey.kobold, 10, 12, 6, 5) + game.level.entities[#game.level.entities + 1] = kobold + local hpBefore = golem.hp + Ai.act(game, kobold) + ok(golem.hp < hpBefore, "a pack monster with no route around a blocking ally fights it instead of idling") +end + +function tests.aiTunnelAttacksAnAllyBlockingItsOnlyGapInsteadOfIdling() + local level = arena(21, 11) + for y = 2, 10 do level.map:set(11, y, Tiles.WALL) end + level.map:set(11, 6, Tiles.FLOOR) + local game = stagedGame(1, level, 3, 6) + local golem = Companions.summon(game, "golem") + golem.x, golem.y = 11, 6 + local burrower = Entity.new({ + id = game:nextMonsterId(), x = 12, y = 6, glyph = "b", name = "rock burrower", kind = "monster", + ai = "tunnel", hp = 20, maxHp = 20, attack = 5, defense = 0, speed = 100, + }) + game.level.entities[#game.level.entities + 1] = burrower + local hpBefore = golem.hp + Ai.act(game, burrower) + ok(golem.hp < hpBefore, "a burrower blocked by a living ally fights it rather than trying (and failing) to path around") + eq(burrower.x, 12, "the burrower stays put while it attacks - it never steps onto the ally's cell") +end + +function tests.aiRangedAttacksABlockingAllyWhenItHasNoLineOfSightToThePlayerAtAll() + local game = stagedGame(1, arena(21, 11), 3, 6) + for y = 2, 10 do game.level.map:set(8, y, Tiles.WALL) end + local golem = Companions.summon(game, "golem") + golem.x, golem.y = 11, 6 + local archer = Monsters.create(Monsters.byKey.archer, 6, 12, 6, 5) + game.level.entities[#game.level.entities + 1] = archer + local hpBefore, playerHpBefore = golem.hp, game.player.hp + Ai.act(game, archer) + ok(golem.hp < hpBefore, "an archer with no line of sight to the player still fights an ally standing next to it") + eq(game.player.hp, playerHpBefore, "the archer never reaches or harms the player while its view is fully walled off") +end + +function tests.gameMoveMonsterAttacksAnAllyItBumpsIntoInsteadOfDoingNothing() + local game = stagedGame(1, arena(21, 11), 3, 6) + local golem = Companions.summon(game, "golem") + local orc = Monsters.create(Monsters.byKey.orc, 10, golem.x + 1, golem.y, 5) + game.level.entities[#game.level.entities + 1] = orc + local hpBefore = golem.hp + game:moveMonster(orc, -1, 0) + ok(golem.hp < hpBefore, "bumping into an ally's cell fights it rather than silently doing nothing") + eq(orc.x, golem.x + 1, "the bumping monster does not step onto the ally's occupied cell") +end + +-- Mirrors aiActConfusedMonsterSometimesStumblesInsteadOfChasing's own seed +-- loop: a confused monster's random stumble (Ai.act -> Game:moveMonster) can +-- land it squarely on a blocking ally's cell exactly as often as the +-- player's, so the fix belongs in Game:moveMonster itself, not only in the +-- deliberate per-behaviour pathing checks above. +-- Isolates the stumble path from chase()'s own independent attackBlockingAlly +-- check: Ai.behaviours.chase is swapped for a stub that only records whether +-- it ran, so a turn where the confusion roll fails (falling through to real +-- chase) is detectable and excluded - any hp drop counted here can only have +-- come from the top-of-Ai.act stumble branch calling Game:moveMonster +-- directly, never from chase's own fix a few lines below it. +function tests.aiActConfusedMonsterCanStumbleIntoAttackingABlockingAllyInsteadOfIdling() + local sawAttack = false + local realChase = Ai.behaviours.chase + for seed = 1, 200 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + local orc = Monsters.create(Monsters.byKey.orc, 10, 12, 6, 1) + local golem = Companions.summon(game, "golem") + golem.x, golem.y = 11, 6 + game.level.entities[#game.level.entities + 1] = orc + Status.apply(orc, Status.CONFUSE, 5, 1) + local hpBefore = golem.hp + local behaviourRan = false + Ai.behaviours.chase = function() behaviourRan = true end + Ai.act(game, orc) + Ai.behaviours.chase = realChase + if not behaviourRan and golem.hp < hpBefore then + sawAttack = true + break + end + end + ok(sawAttack, "a confused monster's stumble (Ai.act's own branch, not chase's) can land an attack on a blocking ally via Game:moveMonster") +end + function tests.doorsAndKeys() local game = stagedGame(1, arena(21, 11), 3, 6) game.player.inventory = {}