diff --git a/tests/run.lua b/tests/run.lua index 7ee1b90..7fe2d99 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -8433,6 +8433,179 @@ function tests.gravesRecordAppendsNewestFirstAndTrimsToMaxRecorded() Graves.record(meta, { className = "fighter", depth = i, gold = i, essence = 0, turn = i }) end eq(#meta.graves, Graves.MAX_RECORDED, "recording past the cap trims the oldest graves off") + eq(meta.graves[1].depth, Graves.MAX_RECORDED + 5, "the most recently recorded grave sits at the front") +end + +function tests.gravesEligibleForOnlyIncludesGravesAtOrDeeperThanTheQueriedDepth() + local graves = { + { className = "a", depth = 2 }, + { className = "b", depth = 5 }, + { className = "c", depth = 5 }, + } + local atFive = Graves.eligibleFor(graves, 5) + eq(#atFive, 2, "only the two graves dug at depth 5 or deeper are eligible for a depth-5 floor") + local atOne = Graves.eligibleFor(graves, 1) + eq(#atOne, 3, "every recorded grave is eligible for the entrance floor, however deep it was dug") + eq(#Graves.eligibleFor(nil, 3), 0, "a caller with no recorded graves at all gets an empty list, not an error") +end + +function tests.gravesFloorItemBuildsAnInertGraveMarkerShape() + local grave = { className = "ranger", depth = 4, gold = 50, essence = 6, turn = 20 } + local item = Graves.floorItem(grave) + eq(item.kind, "grave", "a grave's floor item is its own kind, not a real item kind") + eq(item.grave, true, "a grave's floor item is tagged so pickupHere knows to wake it") + eq(item.graveClass, "ranger", "the marker carries the fallen's own class") + eq(item.graveGold, 50, "the marker carries the fallen's gold for Graves.reveal to read later") + eq(item.graveEssence, 6, "the marker carries the fallen's essence for Graves.reveal to read later") +end + +function tests.gravesRevealScalesHpAndAttackWithCarriedWealthAndCaps() + local poor = Graves.reveal(1, 4, 6, { graveGold = 0, graveEssence = 0 }) + eq(poor.hp, 10, "a penniless grave's shade has no hp bonus at all") + eq(poor.attack, 4, "a penniless grave's shade has no attack bonus at all") + eq(poor.faction, Factions.UNDEAD, "a shade is undead like every other restless dead in the bestiary") + + local rich = Graves.reveal(2, 4, 6, { graveGold = 400, graveEssence = 200 }) + eq(rich.hp, 30, "a rich grave's shade hp bonus caps at 20 rather than scaling without bound") + eq(rich.attack, 10, "a rich grave's shade attack bonus caps at 6 rather than scaling without bound") +end + +function tests.gravesLootForReturnsHalfGoldAndAThirdEssenceRoundedDown() + local shade = Graves.reveal(1, 4, 6, { graveGold = 55, graveEssence = 10 }) + local gold, essence = Graves.lootFor(shade) + eq(gold, 27, "grave loot pays out half the carried gold, rounded down") + eq(essence, 3, "grave loot pays out a third of the carried essence, rounded down") +end + +function tests.graveFieldsSurviveSerialization() + local shade = Graves.reveal(1, 4, 6, { graveClass = "ranger", graveGold = 88, graveEssence = 15 }) + local restored = Entity.fromData(shade:toData()) + eq(restored.grave, true, "a revealed shade's grave tag survives a save/load round trip") + eq(restored.graveClass, "ranger", "the fallen's class survives a save/load round trip") + eq(restored.graveGold, 88, "the grave's gold survives a save/load round trip") + eq(restored.graveEssence, 15, "the grave's essence survives a save/load round trip") +end + +function tests.gamePickupHereWakesAGraveShadeAndAmbushesThePlayer() + local game = stagedGame(1, arena(21, 11), 5, 5) + game.player.defense, game.player.baseDefense = 0, 0 + local grave = { className = "rogue", depth = 3, gold = 0, essence = 0, turn = 10 } + game.level.floorItems = { { x = 5, y = 5, item = Graves.floorItem(grave) } } + local hpBefore = game.player.hp + game:pickupHere() + eq(#game.level.floorItems, 0, "the grave marker leaves the floor the instant it is disturbed") + eq(#game.level.entities, 1, "a disturbed grave joins the level as a real hostile monster") + eq(game.level.entities[1].grave, true, "the spawned monster is tagged as a grave shade") + eq(game.player.hp, hpBefore - 4, "the shade lands one free ambush hit for its full attack") + ok(contains(game.log:last(), "damage"), "the ambush hit is logged") +end + +function tests.gameKillingARevealedShadeGrantsItsFixedXpAndPaysOutGraveLoot() + local game = stagedGame(1, arena(21, 11), 3, 6) + local shade = Graves.reveal(1, 4, 6, { graveGold = 40, graveEssence = 9 }) + shade.hp = 1 + game.level.entities = { shade } + local essenceBefore, goldBefore = game.player.essence, game.player.gold + game:playerAttack(shade) + eq(game.player.essence, essenceBefore + Crafting.essenceFor(Graves.xpFor()) + 3, + "felling a shade grants essence scaled to its own fixed xp reward, plus a third of its grave essence") + eq(game.player.gold, goldBefore + 20, "felling a shade also pays out half its grave gold") + ok(contains(game.log:last(), "gold"), "the grave payout is logged") +end + +function tests.gravesShadeLeavesNoCorpseLikeAnyOtherUndead() + local game = stagedGame(1, arena(21, 11), 3, 6) + local shade = Graves.reveal(1, 4, 6, { graveGold = 0, graveEssence = 0 }) + shade.hp = 1 + game.level.entities = { shade } + game:playerAttack(shade) + eq(#game.level.corpses, 0, "a felled shade leaves no corpse behind, same as any other undead") +end + +function tests.gameGraveEntryCapturesDepthGoldEssenceAndClassAtTheMomentOfDeath() + local game = Game.new(4, { class = "mage" }) + game.deepestDepth = 6 + game.player.gold = 77 + game.player.essence = 14 + game.turn = 120 + local entry = game:graveEntry() + eq(entry.className, "mage", "the grave records the class the run actually played") + eq(entry.depth, 6, "the grave records the deepest depth reached, not just the current floor") + eq(entry.gold, 77, "the grave records exactly what gold was still on hand at death") + eq(entry.essence, 14, "the grave records exactly what essence was still on hand at death") + eq(entry.turn, 120, "the grave records the turn count at death") +end + +function tests.gameGraveEntryDefaultsToWandererForAClasslessRun() + local game = Game.new(4) + eq(game:graveEntry().className, "wanderer", "a run that never picked a class leaves a wanderer's grave") +end + +function tests.builderNeverPlacesAGraveWhenOptionsCarriesNone() + for seed = 1, 15 do + local level = Builder.build(5, Rng.new(seed), {}) + for _, ground in ipairs(level.floorItems) do + ok(not ground.item.grave, "no grave appears without the caller supplying any") + end + end +end + +function tests.buildersOnlyRollAGraveOntoAFloorAtOrShallowerThanWhereItWasDug() + local shallowGrave = { className = "rogue", depth = 2, gold = 40, essence = 5, turn = 30 } + local found = false + for seed = 1, 40 do + local level = Builder.build(5, Rng.new(seed), { graves = { shallowGrave } }) + for _, ground in ipairs(level.floorItems) do + if ground.item.grave then found = true end + end + end + ok(not found, "a grave dug at depth 2 never surfaces on a floor as deep as 5") +end + +function tests.buildersOccasionallyPlaceAnEligibleGraveFromSuppliedOptions() + local deepGrave = { className = "mage", depth = 6, gold = 80, essence = 12, turn = 90 } + local found + for seed = 1, 40 do + local level = Builder.build(3, Rng.new(seed), { graves = { deepGrave } }) + for _, ground in ipairs(level.floorItems) do + if ground.item.grave then found = ground.item end + end + if found then break end + end + ok(found ~= nil, "a floor eventually rolls an eligible grave across a handful of seeds") + eq(found.graveClass, "mage", "the placed marker carries the fallen's own class") +end + +function tests.buildersInsertAGraveWithoutDisturbingAnyOtherPlacement() + local grave = { className = "fighter", depth = 8, gold = 60, essence = 9, turn = 50 } + local perturbed = false + local sawExtra = false + for seed = 1, 40 do + local plain = Builder.build(4, Rng.new(seed), {}) + local withGrave = Builder.build(4, Rng.new(seed), { graves = { grave } }) + if table.concat(plain.map.tiles) ~= table.concat(withGrave.map.tiles) then perturbed = true end + if #withGrave.entities ~= #plain.entities then perturbed = true end + if #withGrave.floorItems == #plain.floorItems + 1 then + sawExtra = true + local extra = withGrave.floorItems[#withGrave.floorItems] + ok(extra.item.grave, "the one extra floor item a supplied grave adds is the grave marker itself") + elseif #withGrave.floorItems ~= #plain.floorItems then + perturbed = true + end + end + ok(not perturbed, "supplying a grave never changes the map or any other placement, only adds at most its own marker") + ok(sawExtra, "across a handful of seeds, at least one actually places the grave marker") +end + +-- proficiency.lua: weapon mastery. + +function tests.proficiencyKeyForReadsFistsWhenUnarmedAndTheWeaponsNameOnceEquipped() + eq(Proficiency.keyFor({}), Proficiency.UNARMED, "an entity with no weapon field masters bare fists") + eq(Proficiency.keyFor({ weapon = { name = "sword" } }), "sword", "an equipped weapon is keyed by its own name") +end + +function tests.proficiencyOnMeleeHitAccumulatesAndReturnsOnlyOnARankCrossing() + local entity = {} end local order = {