From cf5e984712ee963ba7acb3699b288a298744109a Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 20:54:36 +0200 Subject: [PATCH] test: cover tests.companionGainXpNeverReappliesAnAlreadyCrossedStage --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index 68bc204..eb56231 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -4108,6 +4108,179 @@ function tests.companionGainXpCrossingBothThresholdsAtOnceAppliesBothStagesCumul eq(hound.defense, 2, "both stages' defense bonuses stack (0 base + 1 + 1)") end +function tests.companionGainXpNeverReappliesAnAlreadyCrossedStage() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + Companions.gainXp(game, hound, 12) + Companions.gainXp(game, hound, 1) + eq(hound.companionXp, 13, "further xp below the next threshold keeps accumulating") + eq(hound.companionLevel, 2, "a companion does not advance again before its next threshold") + eq(hound.attack, 6, "an already-crossed stage's bonus is never applied a second time") +end + +function tests.companionGainXpAtMaxLevelJustBanksFurtherXpWithNoFurtherEvolution() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + Companions.gainXp(game, hound, 40) + Companions.gainXp(game, hound, 100) + eq(hound.companionXp, 140, "xp keeps accumulating past the last stage") + eq(hound.companionLevel, 3, "a fully evolved companion caps out at its last stage") + eq(hound.attack, 9, "stats hold steady once every stage has already been crossed") +end + +function tests.companionGainXpIgnoresAZeroOrNegativeAmount() + local game = stagedGame(1, arena(21, 11), 5, 5) + local hound = Companions.summon(game, "hound") + Companions.gainXp(game, hound, 0) + Companions.gainXp(game, hound, -5) + eq(hound.companionXp, 0, "a zero or negative grant leaves companion xp untouched") +end + +function tests.companionActCreditsItsOwnKillWithXpOnTopOfThePlayers() + 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 + local xpBefore = game.player.xp + Companions.act(game, hound) + eq(rat.hp, 0, "the hound's hit still kills the rat") + eq(hound.companionXp, Monsters.xpFor(rat), "the companion banks the felled monster's own xp value") + ok(game.player.xp > xpBefore, "the player is still separately credited xp for the companion's kill") +end + +function tests.companionEvolutionBoostsAHearthSpriteHealingOnTopOfItsStaticRate() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.hp = game.player.maxHp - 10 + local sprite = Companions.summon(game, "sprite") + Companions.gainXp(game, sprite, 10) + eq(sprite.name, "kindled sprite", "the sprite's first evolution renames it") + local before = game.player.hp + Companions.act(game, sprite) + eq(game.player.hp, before + Companions.byKey.sprite.heal + 1, + "an evolved sprite heals its species rate plus its own evolution bonus") +end + +function tests.companionLevelXpAndHealBonusSurviveSerialization() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sprite = Companions.summon(game, "sprite") + Companions.gainXp(game, sprite, 10) + local restoredLevel = Level.fromData(game.level:toData()) + local restored = restoredLevel.entities[1] + eq(restored.companionLevel, 2, "the companion's level survives a round trip") + eq(restored.companionXp, 10, "the companion's banked xp survives a round trip") + eq(restored.companionHealBonus, 1, "the companion's evolved heal bonus survives a round trip") + eq(restored.name, "kindled sprite", "the companion's evolved name survives a round trip") +end + +function tests.mercenaryHireTakesOnAKnownRosterEntryAdjacentToThePlayer() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + ok(sellsword ~= nil, "hiring a valid roster key succeeds next to the player") + eq(sellsword.kind, "hireling", "a hired mercenary is tagged with the hireling kind") + eq(math.max(math.abs(sellsword.x - 5), math.abs(sellsword.y - 5)), 1, "the hireling lands adjacent to the player") + eq(sellsword.loyalty, Mercenaries.START_LOYALTY, "a freshly hired mercenary starts at full loyalty") + eq(sellsword.wage, Mercenaries.byKey.sellsword.wage, "the hireling carries its roster wage") +end + +function tests.mercenaryHireRejectsAnUnknownRosterKey() + local game = stagedGame(1, arena(21, 11), 5, 5) + eq(Mercenaries.hire(game, "dragon"), nil, "an unknown roster key hires nobody") + eq(#game.level.entities, 0, "no entity is added for an unknown roster key") +end + +function tests.mercenaryHireReplacesAnEarlierHireling() + local game = stagedGame(1, arena(21, 11), 5, 5) + local first = Mercenaries.hire(game, "sellsword") + local second = Mercenaries.hire(game, "medic") + ok(not first:isAlive(), "hiring a new mercenary retires the earlier one") + ok(second:isAlive(), "the freshly hired mercenary is alive") + local alive = 0 + for _, e in ipairs(game.level.entities) do + if e.kind == "hireling" and e:isAlive() then alive = alive + 1 end + end + eq(alive, 1, "only one hireling is ever alive at a time") +end + +function tests.mercenaryAttacksAdjacentHostileMonsterAndCanKillIt() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + local rat = Monsters.create(Monsters.byKey.rat, 50, sellsword.x + 1, sellsword.y, 1) + game.level.entities[#game.level.entities + 1] = rat + local xpBefore = game.player.xp + Mercenaries.act(game, sellsword) + ok(rat.hp <= 0, "a sellsword's hit kills the weak rat standing next to it") + ok(game.player.xp > xpBefore, "the player is credited xp for its hireling's kill") +end + +function tests.mercenaryAdvancesOnAVisibleMonsterItCannotYetReach() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + local orc = Monsters.create(Monsters.byKey.orc, 50, sellsword.x + 5, sellsword.y, 1) + game.level.entities[#game.level.entities + 1] = orc + local startX = sellsword.x + Mercenaries.act(game, sellsword) + ok(sellsword.x > startX, "the hireling closes the distance to a monster it can see but not reach") +end + +function tests.mercenaryIgnoresAMonsterBeyondItsSightRadius() + local game = stagedGame(1, arena(21, 11), 3, 3) + local medic = Mercenaries.hire(game, "medic") + local orc = Monsters.create(Monsters.byKey.orc, 50, 19, 9, 1) + game.level.entities[#game.level.entities + 1] = orc + local startX, startY = medic.x, medic.y + Mercenaries.act(game, medic) + eq(medic.x, startX, "a monster far outside sight range does not pull the hireling off course (x)") + eq(medic.y, startY, "a monster far outside sight range does not pull the hireling off course (y)") +end + +-- Same gap as companionActBlindedCompanionOnlyDetectsAnAdjacentHostileNot +-- OneWithinItsSightRadius, but for Mercenaries.act's own nearestHostile. +function tests.mercenaryActBlindedHirelingOnlyDetectsAnAdjacentHostileNotOneWithinItsSightRadius() + local game = stagedGame(1, arena(21, 11), 5, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + local farOrc = Monsters.create(Monsters.byKey.orc, 50, sellsword.x + 5, sellsword.y, 1) + game.level.entities[#game.level.entities + 1] = farOrc + Status.apply(sellsword, Status.BLIND, 6, 1) + local startX = sellsword.x + Mercenaries.act(game, sellsword) + eq(sellsword.x, startX, "a blinded hireling loses a sighting well within its listed sight radius") + + local rat = Monsters.create(Monsters.byKey.rat, 51, sellsword.x + 1, sellsword.y, 1) + game.level.entities[#game.level.entities + 1] = rat + local ratHpBefore = rat.hp + Mercenaries.act(game, sellsword) + ok(rat.hp < ratHpBefore, "a blinded hireling still fights a hostile once it stands adjacent") +end + +-- Same gap as companionActAttackingAnAdjacentMonsterEmitsNoise..., mirrored +-- onto the hireling side: Mercenaries.act's own Combat.attack call never +-- routed through Game:emitNoise either. +function tests.mercenaryActAttackingAnAdjacentMonsterEmitsNoiseThatCanStirADormantBystander() + local stirred = false + for seed = 1, 200 do + local game = stagedGame(seed, arena(21, 11), 3, 6) + local sellsword = Mercenaries.hire(game, "sellsword") + local target = Monsters.create(Monsters.byKey.rat, 50, sellsword.x + 1, sellsword.y, 1) + local bystander = Monsters.create(Monsters.byKey.orc, 51, sellsword.x + 4, sellsword.y, 1) + bystander.awareness = Stealth.DORMANT + game.level.entities[#game.level.entities + 1] = target + game.level.entities[#game.level.entities + 1] = bystander + Mercenaries.act(game, sellsword) + if bystander.awareness ~= Stealth.DORMANT then stirred = true; break end + end + ok(stirred, "a hireling's own melee is exactly as loud as a companion's or the player's") +end + +function tests.mercenaryFollowsThePlayerWhenNoMonsterIsVisible() + local game = stagedGame(1, arena(21, 11), 10, 5) + local sellsword = Mercenaries.hire(game, "sellsword") + sellsword.x, sellsword.y = 3, 5 + local startDist = math.abs(sellsword.x - game.player.x) + Mercenaries.act(game, sellsword) + ok(math.abs(sellsword.x - game.player.x) < startDist, "a hireling with no monster in sight closes in on the player") +end + +-- Same wiring proof as tests.companionActConfusedCompanionSometimesStumbles end local order = {