diff --git a/tests/run.lua b/tests/run.lua index 1b841d3..fb60dd5 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -3243,6 +3243,179 @@ function tests.corpsesGoesRottenPastItsThresholdButStaysButcherableUntilThen() corpse.decay = Corpses.ROT_TURNS ok(Corpses.isRotten(corpse), "a corpse right at its rot threshold counts as rotten") corpse.decay = Corpses.ROT_TURNS + 1 + ok(not Corpses.isRotten(corpse), "a corpse one round above the threshold is still good") +end + +function tests.corpsesButcheringFreshMeatAddsFoodToInventoryAndConsumesTheCorpse() + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + local level = { corpses = {} } + Corpses.spawn(level, rat) + local corpse = level.corpses[1] + local game = { player = corpsePlayer(), level = level } + local line = Corpses.butcher(game, corpse) + eq(#level.corpses, 0, "butchering removes the corpse from the level") + eq(#game.player.inventory, 1, "butchering fresh meat adds a food item to the inventory") + eq(game.player.inventory[1].kind, Items.FOOD, "the yield is a food item") + eq(game.player.inventory[1].nutrition, corpse.nutrition, "the food item carries the corpse's own nutrition value") + ok(contains(line, "carve"), "a fresh butchering is logged as carving meat") + ok(#game.player.statuses == 0, "fresh meat never poisons the player") +end + +function tests.corpsesButcheringRottenMeatPoisonsThePlayerInsteadAndStillConsumesTheCorpse() + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + local level = { corpses = {} } + Corpses.spawn(level, rat) + local corpse = level.corpses[1] + corpse.decay = Corpses.ROT_TURNS + local game = { player = corpsePlayer(), level = level } + local line = Corpses.butcher(game, corpse) + eq(#level.corpses, 0, "butchering removes a rotten corpse too") + eq(#game.player.inventory, 0, "rotten meat yields no food item") + ok(Status.has(game.player, Status.POISON), "eating rotten meat poisons the player") + ok(contains(line, "rotted"), "a rotten butchering is logged as such") +end + +function tests.corpseFieldsSurviveSerialization() + local level = arena(21, 11) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(level, rat) + level.corpses[1].x, level.corpses[1].y = 4, 4 + local restored = Level.fromData(level:toData()) + eq(#restored.corpses, 1, "the corpse list survives a round trip") + eq(restored.corpses[1].species, "rat", "a corpse's species survives a round trip") + eq(restored.corpses[1].nutrition, level.corpses[1].nutrition, "a corpse's nutrition survives a round trip") + eq(restored.corpses[1].decay, level.corpses[1].decay, "a corpse's decay survives a round trip") +end + +function tests.corpseRendersItsGlyphWhenVisible() + local rendered = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 6, 1) + Corpses.spawn(rendered.level, rat) + rendered:computeVisible() + ok(contains(rendered:render(), "%"), "a corpse on a visible cell renders as its glyph") +end + +function tests.gameAwardKillLeavesACorpseForAWildMonsterButNotForUndeadOrMimic() + local game = stagedGame(1, arena(21, 11), 3, 6) + local rat = Monsters.create(Monsters.byKey.rat, 1, 4, 6, 1) + rat.hp = 0 + game.level.entities = { rat } + game:awardKill(rat) + eq(#game.level.corpses, 1, "killing a wild monster through awardKill leaves a corpse") + eq(game.level.corpses[1].x, 4, "the corpse sits exactly where the monster fell") + + local zombie = Monsters.create(Monsters.byKey.zombie, 2, 4, 7, 4) + zombie.hp = 0 + game.level.entities = { zombie } + game:awardKill(zombie) + eq(#game.level.corpses, 1, "killing an undead monster through awardKill leaves no new corpse") +end + +function tests.gameButcherCorpseAtFreshMeatFeedsThePlayerAndClearsTheCorpse() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(game.level, rat) + local before = #game.player.inventory + ok(game:butcherCorpse(), "butchering the corpse underfoot succeeds") + eq(#game.level.corpses, 0, "the corpse is gone after butchering") + eq(#game.player.inventory, before + 1, "the player's inventory gains the carved meat") +end + +function tests.gameButcherCorpseFailsWithoutACorpseUnderfoot() + local game = stagedGame(1, arena(21, 11), 5, 5) + ok(not game:butcherCorpse(), "butchering empty ground fails") +end + +function tests.autopilotButchersAFreshCorpseWhenIdleAndStandingOnIt() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(game.level, rat) + local before = #game.player.inventory + game:choosePlayerAction() + eq(#game.level.corpses, 0, "the autopilot butchers a fresh corpse it stands on") + eq(#game.player.inventory, before + 1, "the autopilot's butchering actually yields food") +end + +function tests.autopilotLeavesARottenCorpseAloneRatherThanRiskingPoison() + local game = stagedGame(1, arena(21, 11), 5, 5) + local rat = Monsters.create(Monsters.byKey.rat, 1, 5, 5, 1) + Corpses.spawn(game.level, rat) + game.level.corpses[1].decay = Corpses.ROT_TURNS + game:choosePlayerAction() + eq(#game.level.corpses, 1, "the autopilot never touches a rotten corpse") + ok(not Status.has(game.player, Status.POISON), "the autopilot never risks poisoning itself on rotten meat") +end + +function tests.hungerCurrentDefaultsToCapForAFreshEntity() + eq(Hunger.current({}), Hunger.CAP, "an entity with no satiation field reads as fully fed") +end + +function tests.hungerTierOfClassifiesEachBandAtItsBoundaries() + eq(Hunger.tierOf({ satiation = Hunger.CAP }), Hunger.FED, "a full meter is fed") + eq(Hunger.tierOf({ satiation = Hunger.HUNGRY_MAX + 1 }), Hunger.FED, "just above the hungry ceiling is still fed") + eq(Hunger.tierOf({ satiation = Hunger.HUNGRY_MAX }), Hunger.HUNGRY, "the hungry ceiling itself reads as hungry") + eq(Hunger.tierOf({ satiation = Hunger.WEAK_MAX + 1 }), Hunger.HUNGRY, "just above the weak ceiling is still hungry") + eq(Hunger.tierOf({ satiation = Hunger.WEAK_MAX }), Hunger.WEAK, "the weak ceiling itself reads as weak") + eq(Hunger.tierOf({ satiation = 1 }), Hunger.WEAK, "one point above zero is still weak, never starving") + eq(Hunger.tierOf({ satiation = 0 }), Hunger.STARVING, "zero itself is starving") +end + +function tests.hungerAttackAndDefensePenaltiesEscalateByTier() + eq(Hunger.attackPenalty({ satiation = Hunger.CAP }), 0, "a fed body carries no attack penalty") + eq(Hunger.attackPenalty({ satiation = Hunger.HUNGRY_MAX }), 0, "merely hungry still carries no attack penalty") + eq(Hunger.attackPenalty({ satiation = Hunger.WEAK_MAX }), -2, "weak costs two points of attack") + eq(Hunger.attackPenalty({ satiation = 0 }), -4, "starving costs four points of attack") + eq(Hunger.defensePenalty({ satiation = Hunger.CAP }), 0, "a fed body carries no defense penalty") + eq(Hunger.defensePenalty({ satiation = Hunger.HUNGRY_MAX }), 0, "merely hungry still carries no defense penalty") + eq(Hunger.defensePenalty({ satiation = Hunger.WEAK_MAX }), -1, "weak costs one point of defense") + eq(Hunger.defensePenalty({ satiation = 0 }), -3, "starving costs three points of defense") +end + +function tests.entityEffectiveAttackAndDefenseFoldInTheHungerPenalty() + local entity = Entity.new({ x = 1, y = 1, glyph = "@", name = "you", kind = "player", hp = 20, attack = 6, defense = 4 }) + eq(entity:effectiveAttack(), 6, "a fresh entity with no satiation field carries no penalty") + entity.satiation = 0 + eq(entity:effectiveAttack(), 2, "a starving body's attack penalty is live in effectiveAttack") + eq(entity:effectiveDefense(), 1, "a starving body's defense penalty is live in effectiveDefense") +end + +function tests.hungerSuppressesRegenOnlyAtWeakOrWorse() + ok(not Hunger.suppressesRegen({ satiation = Hunger.HUNGRY_MAX }), "merely hungry never blocks passive regen") + ok(Hunger.suppressesRegen({ satiation = Hunger.WEAK_MAX }), "weak blocks passive regen") + ok(Hunger.suppressesRegen({ satiation = 0 }), "starving blocks passive regen") +end + +function tests.hungerTickDrainsByOneAndLogsOnlyOnATierCrossing() + local entity = { satiation = Hunger.HUNGRY_MAX + 2, hp = 20 } + eq(Hunger.tick(entity), nil, "a drain turn that stays within its tier reports no line") + eq(entity.satiation, Hunger.HUNGRY_MAX + 1, "the drain was still deducted even without a tier crossing") + eq(Hunger.tick(entity), "you feel hungry", "crossing down into hungry is announced") + eq(entity.satiation, Hunger.HUNGRY_MAX, "the meter keeps draining turn over turn") + eq(entity.hp, 20, "hunger alone never costs hp above starving") +end + +function tests.hungerTickBitesForOneHpEveryRoundWhileStarving() + local entity = { satiation = 1, hp = 10 } + eq(Hunger.tick(entity), "you are starving", "crossing down into starving is announced") + eq(entity.satiation, 0, "starving is a floor, not a negative value") + eq(entity.hp, 9, "reaching starving costs a point of hp immediately") + eq(Hunger.tick(entity), "you are starving", "starving keeps announcing and biting every round it holds") + eq(entity.hp, 8, "a second starving round costs a second point of hp") +end + +function tests.hungerEatCapsAtTheCeilingAndWastesAnOverfullMealIntoBloat() + local entity = { satiation = Hunger.CAP, statuses = {} } + local bloated = Hunger.eat(entity, 400) + ok(bloated, "eating while already at the cap reports the meal as wasted") + eq(entity.satiation, Hunger.CAP, "an overfull meal never lifts satiation past the cap") + ok(Status.has(entity, Status.SLOW), "an overfull meal leaves the diner slowed") +end + +function tests.hungerEatBelowTheCeilingBanksNutritionWithNoWaste() + local entity = { satiation = Hunger.HUNGRY_MAX, statuses = {} } + local bloated = Hunger.eat(entity, 100) + ok(not bloated, "an ordinary meal below the cap is never wasted") + eq(entity.satiation, Hunger.HUNGRY_MAX + 100, "the full nutrition value is banked") end local order = {