test: cover tests.companionGainXpNeverReappliesAnAlreadyCrossedStage

This commit is contained in:
dualfroz committed 2026-09-09 20:54:36 +02:00
1 parent 9b44abbbfd
commit cf5e984712
1 file changed
+173
+173
View File
@@ -4108,6 +4108,179 @@ function tests.companionGainXpCrossingBothThresholdsAtOnceAppliesBothStagesCumul
eq(hound.defense, 2, "both stages' defense bonuses stack (0 base + 1 + 1)") eq(hound.defense, 2, "both stages' defense bonuses stack (0 base + 1 + 1)")
end 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 end
local order = { local order = {