diff --git a/tests/run.lua b/tests/run.lua index 27847f0..868d41d 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -10509,6 +10509,179 @@ end function tests.gameAscendCarriesEveryLivingAllyBackOntoThePreviousFloorToo() local game = stagedGame(1, arena(21, 11), 3, 6) + game.player.x, game.player.y = game.level.downX, game.level.downY + game:descend() + eq(game.depth, 2, "now on the second floor") + local golem = Companions.summon(game, "golem") + local hireling = Mercenaries.hire(game, "sellsword") + ok(golem ~= nil and hireling ~= nil, "both allies bond on the second floor") + local descendLevel = game.level + + game.player.x, game.player.y = game.level.upX, game.level.upY + ok(game:ascend(), "ascending succeeds") + eq(game.depth, 1, "back on the first floor") + ok(game:hasCompanion(), "the companion follows back up the stairs") + ok(game:hasHireling(), "the hireling follows back up the stairs too") + + local leftBehind = 0 + for _, entity in ipairs(descendLevel.entities) do + if entity.kind == "companion" or entity.kind == "hireling" then leftBehind = leftBehind + 1 end + end + eq(leftBehind, 0, "neither ally is stranded on the floor just climbed away from") +end + +function tests.gameAwardKillOnANemesisGrantsBonusXpAndBountyGoldAndClearsTheHunt() + local game = stagedGame(1, arena(21, 11), 3, 6) + game.nemesis = { species = "orc", grudge = 3, hpFraction = 1 } + local hunter = Nemesis.spawn(game.nemesis, 1, 4, 6, 1) + hunter.hp = 1 + game.level.entities = { hunter } + local xpBefore, goldBefore = game.player.xp, game.player.gold + game:playerAttack(hunter) + ok(game.player.xp > xpBefore or game.player.level > 1, "felling the nemesis grants xp on top of its species reward") + eq(game.player.gold, goldBefore + Nemesis.bountyGold(3), "felling the nemesis pays out its grudge-scaled bounty gold") + eq(game.nemesis, nil, "the hunt is over once the nemesis is actually killed") + ok(contains(table.concat(game.log:recent(5), " "), "hunt is over"), "the kill is logged as ending the hunt") +end + +function tests.nemesisFieldsSurviveEntityAndGameSerialization() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.nemesis = { species = "orc", grudge = 3, hpFraction = 0.75 } + local hunter = Nemesis.spawn(game.nemesis, 1, 6, 5, 1) + game.level.entities = { hunter } + + local restoredLevel = Level.fromData(game.level:toData()) + eq(restoredLevel.entities[1].nemesis, true, "the nemesis flag survives a level round trip") + eq(restoredLevel.entities[1].nemesisGrudge, 3, "the nemesis's grudge survives a level round trip") + + local restoredGame = Game.fromData(game:toData()) + eq(restoredGame.nemesis.species, "orc", "the pursuing nemesis descriptor survives a game round trip") + eq(restoredGame.nemesis.grudge, 3, "the descriptor's grudge survives a game round trip") + eq(restoredGame.nemesis.hpFraction, 0.75, "the descriptor's carried hp fraction survives a game round trip") +end + +function tests.factionsAreRivalsChecksBothDirectionsAndExcludesSameFactionAndNil() + ok(Factions.areRivals(Factions.GOBLINOID, Factions.ORCISH), "goblinoid and orcish are a listed rivalry") + ok(Factions.areRivals(Factions.ORCISH, Factions.GOBLINOID), "the rivalry check reads either direction") + ok(not Factions.areRivals(Factions.GOBLINOID, Factions.GOBLINOID), "a faction is never its own rival") + ok(not Factions.areRivals(Factions.MERCHANT, Factions.DRAKE), "an unlisted pair is not a rivalry") + ok(not Factions.areRivals(nil, Factions.ORCISH), "a nil faction on either side never counts as a rival") + ok(not Factions.areRivals(Factions.GOBLINOID, nil), "a nil faction on either side never counts as a rival") +end + +function tests.infightingTargetPicksTheNearestVisibleRivalOverACloserNonRival() + local game = stagedGame(1, arena(21, 11), 3, 3) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 10, 6, 1) + local orc = Monsters.create(Monsters.byKey.orc, 2, 12, 6, 1) + local rat = Monsters.create(Monsters.byKey.rat, 3, 9, 6, 1) + game.level.entities = { goblin, orc, rat } + local target = Infighting.target(game, goblin) + eq(target.id, orc.id, "the goblin's rival target is the orc, not the nearer but unrelated rat") +end + +function tests.infightingTargetIgnoresARivalBeyondSightRange() + local game = stagedGame(1, arena(21, 11), 3, 3) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 3, 6, 1) + local farOrc = Monsters.create(Monsters.byKey.orc, 2, 19, 6, 1) + game.level.entities = { goblin, farOrc } + ok(Infighting.target(game, goblin) == nil, "a rival far beyond sight range is not a target") +end + +function tests.infightingTargetIgnoresARivalBehindAWallEvenWithinSightRange() + local map = Map.new(21, 11) + for y = 2, 9 do + for x = 2, 9 do map:set(x, y, Map.FLOOR) end + for x = 12, 19 do map:set(x, y, Map.FLOOR) end + end + local level = Level.new({ + depth = 1, generator = "arena", map = map, + entities = {}, floorItems = {}, traps = {}, lights = {}, dark = false, explored = {}, + upX = 2, upY = 2, downX = 19, downY = 9, + }) + local game = stagedGame(1, level, 2, 2) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 9, 5, 1) + local orc = Monsters.create(Monsters.byKey.orc, 2, 12, 5, 1) + game.level.entities = { goblin, orc } + ok(Infighting.target(game, goblin) == nil, + "a rival on the far side of a solid wall is not a target despite being close in raw distance") +end + +function tests.infightingTargetIgnoresNonRivalsWithoutDiscord() + local game = stagedGame(1, arena(21, 11), 3, 3) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 10, 6, 1) + local rat = Monsters.create(Monsters.byKey.rat, 2, 11, 6, 1) + game.level.entities = { goblin, rat } + ok(Infighting.target(game, goblin) == nil, "an unrelated species is never a target on its own") +end + +function tests.infightingTargetUnderDiscordPicksNearestCreatureOfAnyKindIncludingACompanion() + local game = stagedGame(1, arena(21, 11), 3, 3) + local orc = Monsters.create(Monsters.byKey.orc, 1, 10, 6, 1) + Status.apply(orc, Status.DISCORD, 5, 1) + local companion = Entity.new({ + id = 2, x = 11, y = 6, glyph = "d", name = "wolf", kind = "companion", + hp = 10, maxHp = 10, attack = 2, defense = 0, speed = 100, + }) + local rat = Monsters.create(Monsters.byKey.rat, 3, 15, 6, 1) + game.level.entities = { orc, companion, rat } + local target = Infighting.target(game, orc) + eq(target.id, companion.id, + "a discorded monster attacks the nearest creature of any kind, even a companion, over a farther non-rival") +end + +function tests.infightingTargetReturnsNilOnceDiscordHasExpired() + local game = stagedGame(1, arena(21, 11), 3, 3) + local orc = Monsters.create(Monsters.byKey.orc, 1, 10, 6, 1) + local rat = Monsters.create(Monsters.byKey.rat, 2, 11, 6, 1) + game.level.entities = { orc, rat } + ok(Infighting.target(game, orc) == nil, "without discord an orc has no quarrel with a nearby rat") +end + +function tests.infightingStrikeOnAPlainMonsterSpawnsACorpseButAwardsThePlayerNothing() + local game = stagedGame(1, arena(21, 11), 3, 3) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 10, 6, 1) + local orc = Monsters.create(Monsters.byKey.orc, 2, 11, 6, 1) + orc.hp, orc.maxHp = 1, 1 + game.level.entities = { goblin, orc } + local xpBefore = game.player.xp + local killsBefore = game.stats.kills + Infighting.strike(game, goblin, orc) + ok(not orc:isAlive(), "the orc dies to the goblin's blow") + eq(game.player.xp, xpBefore, "the player earns no xp for a kill it did not land") + eq(game.stats.kills, killsBefore, "the run's kill counter only tracks the player's own kills") + eq(#game.level.corpses, 1, "the fallen orc still leaves a corpse behind for scavenging or reanimation") + ok(contains(game.log:last(), "dies"), "the death is still logged") +end + +function tests.infightingStrikeThatKillsACompanionRoutesThroughTheNormalAllyDeathMessage() + local game = stagedGame(1, arena(21, 11), 3, 3) + local orc = Monsters.create(Monsters.byKey.orc, 1, 10, 6, 1) + local wolf = Entity.new({ + id = 2, x = 11, y = 6, glyph = "d", name = "wolf", kind = "companion", + hp = 1, maxHp = 10, attack = 2, defense = 0, speed = 100, + }) + game.level.entities = { orc, wolf } + Infighting.strike(game, orc, wolf) + ok(contains(game.log:last(), "falls"), "a companion killed by infighting dies with the same message as any other death") +end + +-- Same gap as companionActAttackingAnAdjacentMonsterEmitsNoise... and its +-- hireling/thrall siblings, but here on neither side of the fight is a +-- player-aligned ally at all: two monsters brawling among themselves (a +-- rivalry, or one of them discorded) were exactly as silent as a companion's +-- own melee, despite Infighting.strike running the very same Combat.attack +-- every other fight in the engine does. A dormant third monster standing +-- nearby but uninvolved never had a chance to overhear either kind of fight. +function tests.infightingStrikeEmitsNoiseThatCanStirADormantBystanderNotInvolvedInTheFight() + local stirred = false + for seed = 1, 200 do + local game = stagedGame(seed, arena(21, 11), 3, 3) + local goblin = Monsters.create(Monsters.byKey.goblin, 1, 10, 6, 1) + local orc = Monsters.create(Monsters.byKey.orc, 2, 11, 6, 1) + local bystander = Monsters.create(Monsters.byKey.rat, 3, 14, 6, 1) + bystander.awareness = Stealth.DORMANT + game.level.entities = { goblin, orc, bystander } + Infighting.strike(game, goblin, orc) end local order = {