test: cover tests.aiActConfusedMonsterSometimesStumblesInsteadOfChasing
This commit is contained in:
1 parent
25c412bcd3
commit
9601bf2949
1 file changed
+215
+215
@@ -538,6 +538,221 @@ function tests.monsterAiDeterministic()
|
|||||||
ok(x1 == x2 and y1 == y2, "erratic behaviour is deterministic per seed")
|
ok(x1 == x2 and y1 == y2, "erratic behaviour is deterministic per seed")
|
||||||
end
|
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()
|
function tests.doorsAndKeys()
|
||||||
local game = stagedGame(1, arena(21, 11), 3, 6)
|
local game = stagedGame(1, arena(21, 11), 3, 6)
|
||||||
game.player.inventory = {}
|
game.player.inventory = {}
|
||||||
|
|||||||
Reference in new issue
Block a user