diff --git a/tests/run.lua b/tests/run.lua index d172d6a..68bc204 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -3935,6 +3935,179 @@ function tests.companionFollowsThePlayerWhenNoMonsterIsVisible() end -- Companions.act never dispatches through ai.lua, so without Game:allyActs +-- Confused wired in first it would leave Status.CONFUSE completely inert on +-- a companion despite Game:springTrap already applying it to "any victim". +-- Mirrors tests.aiActConfusedMonsterSometimesStumblesInsteadOfChasing's own +-- loop-over-seeds shape, but for a point-blank melee decision instead of a +-- chase step. +function tests.companionActConfusedCompanionSometimesStumblesInsteadOfFightingAnAdjacentHostile() + local sawAttack, sawStumble = false, false + for seed = 1, 200 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + local golem = Companions.summon(game, "golem") + local rat = Monsters.create(Monsters.byKey.rat, 50, golem.x + 1, golem.y, 1) + game.level.entities[#game.level.entities + 1] = rat + Status.apply(golem, Status.CONFUSE, 5, 1) + local hpBefore = rat.hp + Companions.act(game, golem) + if rat.hp < hpBefore then + sawAttack = true + else + sawStumble = true + end + if sawAttack and sawStumble then break end + end + ok(sawAttack, "a confused companion's roll sometimes lets it fight an adjacent hostile as normal") + ok(sawStumble, "a confused companion's roll sometimes overrides even a point-blank attack") +end + +-- Unlike a confused monster's stumble (Ai.act -> Game:moveMonster, which +-- treats a step onto the player's cell as an attack), a confused companion +-- must never land a hit on the very player it is bonded to defend - the +-- companion spawns due east of the player (Companions.summon's own +-- ADJACENT order), so a westward stumble roll aims exactly at the player's +-- cell across this seed range, and the player's hp must stay untouched +-- every single time regardless of which of the 4 directions actually rolls. +function tests.companionActConfusedCompanionNeverAttacksThePlayerEvenIfTheStumbleAimsAtItsCell() + local sawMoved = false + for seed = 1, 300 do + local game = stagedGame(seed, arena(21, 11), 5, 5) + local golem = Companions.summon(game, "golem") + Status.apply(golem, Status.CONFUSE, 5, 1) + local hpBefore = game.player.hp + Companions.act(game, golem) + eq(game.player.hp, hpBefore, "a confused companion never attacks the player even when its stumble aims at their cell") + ok(not (golem.x == game.player.x and golem.y == game.player.y), + "a confused companion's stumble is blocked, not allowed to share the player's own cell") + if golem.x ~= 6 or golem.y ~= 5 then sawMoved = true end + end + ok(sawMoved, "the confused companion's stumble roll actually moves it somewhere across many seeds") +end + +function tests.hearthSpriteMendsThePlayerWhenIdle() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.hp = game.player.maxHp - 5 + local sprite = Companions.summon(game, "sprite") + local before = game.player.hp + Companions.act(game, sprite) + eq(game.player.hp, before + Companions.byKey.sprite.heal, "a hearth sprite mends its bonded player each idle turn") +end + +function tests.useItemOnACharmBondsACompanionAndConsumesIt() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.inventory = { Items.charm("hound") } + ok(game:useItem(1), "using a charm succeeds") + eq(#game.player.inventory, 0, "the charm leaves the inventory once bonded") + eq(#game.level.entities, 1, "the bonded hound is added to the level") + eq(game.level.entities[1].species, "hound", "the bonded companion matches the charm's species") +end + +function tests.takeMonsterTurnRoutesACompanionThroughCompanionsAct() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + local rat = Monsters.create(Monsters.byKey.rat, 50, hound.x + 1, hound.y, 1) + game.level.entities[#game.level.entities + 1] = rat + game:takeMonsterTurn(hound) + eq(rat.hp, 0, "the full monster-turn pipeline dispatches a companion's turn to companions.act") +end + +-- Game:hungerTorchTick already ticks Disease.tick(self.player) every player +-- turn; Game:takeMonsterTurn never called the same generic, entity-agnostic +-- function for anyone else, so an ally infected through the new +-- infighting.lua bite chance above would sit permanently incubating - +-- never erupting, never escalating, never curable by anything that reads +-- disease state - since nothing ever advanced its clock. +function tests.gameTakeMonsterTurnTicksAnInfectedCompanionsDiseaseExactlyLikeThePlayersOwnEndOfTurnDoes() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + hound.hp, hound.maxHp = 1000, 1000 + Disease.infect(hound, Disease.PARASITES) + local incubation = Disease.defs[Disease.PARASITES].incubation + for _ = 1, incubation - 1 do + game:takeMonsterTurn(hound) + eq(hound.disease.stage, 0, "the companion's infection keeps quietly incubating turn by turn") + end + game:takeMonsterTurn(hound) + eq(hound.disease.stage, 1, "the companion's infection erupts on schedule, exactly like the player's own would") + ok(contains(game.log:last(), "breaks out in gut parasites"), "the eruption is logged with the companion's own name") +end + +function tests.aFallenCompanionIsLoggedAndSweptWithoutAwardingXp() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + hound.hp = 1 + Status.apply(hound, Status.POISON, 1, 5) + local xpBefore = game.player.xp + game:takeMonsterTurn(hound) + eq(#game.level.entities, 0, "a companion that dies to its own status tick is swept from the level") + eq(game.player.xp, xpBefore, "a fallen companion grants the player no xp") + ok(contains(game.log:last(), "falls"), "a fallen companion's death is logged") +end + +function tests.builderOccasionallyPlacesACharmFromDepthThree() + local level = Builder.build(3, Rng.new(1), {}) + local found + for _, ground in ipairs(level.floorItems) do + if ground.item.kind == Items.CHARM then found = ground.item end + end + ok(found ~= nil, "this seed's depth-3 floor places a companion's charm") + ok(Companions.byKey[found.companion] ~= nil, "the charm names a real companion species") +end + +function tests.charmsStayOffTheShallowestFloors() + for seed = 1, 5 do + for depth = 1, 2 do + local level = Builder.build(depth, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + ok(ground.item.kind ~= Items.CHARM, "no charm appears above depth 3") + end + end + end +end + +function tests.companionFieldsSurviveSerialization() + local game = stagedGame(1, arena(21, 11), 5, 5) + Companions.summon(game, "sprite") + local restoredLevel = Level.fromData(game.level:toData()) + eq(#restoredLevel.entities, 1, "the companion survives a level round trip") + eq(restoredLevel.entities[1].kind, "companion", "the companion's kind survives a round trip") + eq(restoredLevel.entities[1].species, "sprite", "the companion's species survives a round trip") +end + +function tests.companionGainXpBelowFirstThresholdBanksXpWithoutEvolving() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + Companions.gainXp(game, hound, 5) + eq(hound.companionXp, 5, "xp below the first threshold still accumulates") + eq(hound.companionLevel, 1, "a companion under its first threshold stays at level 1") + eq(hound.name, "hunting hound", "an unevolved companion keeps its base name") + eq(hound.attack, 4, "an unevolved companion keeps its base attack") +end + +function tests.companionGainXpAtFirstThresholdEvolvesRenamesAndGrowsStats() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + Companions.gainXp(game, hound, 12) + eq(hound.companionLevel, 2, "reaching the first threshold advances the companion to level 2") + eq(hound.name, "warhound", "the first evolution renames the companion") + eq(hound.maxHp, 20, "the first evolution's hp bonus raises max hp") + eq(hound.hp, 20, "the first evolution's hp bonus also heals the same amount") + eq(hound.attack, 6, "the first evolution's attack bonus is applied") + eq(hound.defense, 1, "the first evolution's defense bonus is applied") + ok(contains(game.log:last(), "evolves"), "an evolution is logged") +end + +function tests.companionGainXpCrossingBothThresholdsAtOnceAppliesBothStagesCumulatively() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + Companions.gainXp(game, hound, 40) + eq(hound.companionLevel, 3, "a single large xp grant can vault past an intermediate threshold") + eq(hound.name, "dire hound", "the companion carries the highest stage's name, not the first") + eq(hound.maxHp, 30, "both stages' hp bonuses stack (14 base + 6 + 10)") + eq(hound.attack, 9, "both stages' attack bonuses stack (4 base + 2 + 3)") + eq(hound.defense, 2, "both stages' defense bonuses stack (0 base + 1 + 1)") +end + end local order = {