test: cover tests.aiActConfusedMonsterSometimesStumblesInsteadOfChasing

This commit is contained in:
dualfroz committed 2026-09-08 17:12:53 +02:00
1 parent 25c412bcd3
commit 9601bf2949
1 file changed
+215
+215
View File
@@ -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 = {}