From cb014788903844a05980e8e730cddae08eb1da9f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 20:02:24 +0200 Subject: [PATCH] feat: extend game with Graves.record and Graves.eligibleFor --- src/game.lua | 81 +++++++++++++++++++++++++++++++++++-- src/graves.lua | 106 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 src/graves.lua diff --git a/src/game.lua b/src/game.lua index cc66fa3..d30453e 100644 --- a/src/game.lua +++ b/src/game.lua @@ -2638,35 +2638,108 @@ function Game:toData() identify = self.identify, dungeon = self.dungeon:toData(), player = self.player:toData(), - playerExtra = { satiation = self.player.satiation, light = self.player.light }, + playerExtra = { + satiation = self.player.satiation, sanity = self.player.sanity, light = self.player.light, + level = self.player.level, xp = self.player.xp, xpNext = self.player.xpNext, + mana = self.player.mana, maxMana = self.player.maxMana, spellsKnown = self.player.spellsKnown, + gold = self.player.gold, essence = self.player.essence, + class = self.player.class, skills = self.player.skills, + skillPoints = self.player.skillPoints, skillCooldowns = self.player.skillCooldowns, + quest = self.player.quest, reputation = self.player.reputation, + pendingPoisonBlade = self.player.pendingPoisonBlade, + }, log = self.log:toData(), + compendium = self.compendium, + ascension = self.ascension, + nemesis = self.nemesis, + rift = self.rift or false, + riftLevels = Branches.levelsToData(self.riftLevels), + riftReturn = self.riftReturn and { + depth = self.riftReturn.depth, x = self.riftReturn.x, y = self.riftReturn.y, + } or nil, + -- The arena itself lives outside self.dungeon (see Game:startHorde), so + -- without its own snapshot here a mid-arena save would resume from + -- self.dungeon:get(self.depth) instead - the ordinary floor the arena + -- replaced, abandoned but still cached. horde.wave rides along in the + -- same field, since a restored arena with no wave counter would strand + -- the player once the surviving wave dies with nothing left to trigger + -- the next one. + horde = self.horde, + hordeLevel = self.horde and self.level:toData() or nil, } end +-- Expects a complete, current-shape snapshot: every field GameSave's +-- migration chain owns (essence, the skill-tree fields, reputation, sanity, +-- stats.vaultsOpened/setPiecesFound - see the migrations above) must already +-- be present. Game.load guarantees that by running GameSave.load first; +-- Game:toData()'s own output always has them too. A handful of unversioned +-- fields (won, monsterSeq, deepestDepth, the whole stats table) keep their +-- own "or" defaults here since they can be missing outright rather than just +-- missing individual leaves, which the migration chain does not model. function Game.fromData(snapshot) local game = setmetatable({}, Game) game.seed = snapshot.seed game.depth = snapshot.depth game.turn = snapshot.turn + game.won = snapshot.won or false + game.monsterSeq = snapshot.monsterSeq or 0 + game.deepestDepth = snapshot.deepestDepth or snapshot.depth + game.stats = snapshot.stats or { + kills = 0, bossKills = 0, questsCompleted = 0, artifactsFound = 0, vaultsOpened = 0, + setPiecesFound = 0, + } game.rng = Rng.deserialize(snapshot.rng) game.identify = snapshot.identify game.dungeon = Dungeon.fromData(snapshot.dungeon) - game.level = game.dungeon:get(game.depth) + game.maxDepth = Builder.maxDepth(game.dungeon.options) + game.rift = snapshot.rift or false + game.riftLevels = Branches.levelsFromData(snapshot.riftLevels) + game.riftReturn = snapshot.riftReturn and { + depth = snapshot.riftReturn.depth, x = snapshot.riftReturn.x, y = snapshot.riftReturn.y, + level = game.dungeon:get(snapshot.riftReturn.depth), + } or nil + game.horde = snapshot.horde + game.level = game.horde and Level.fromData(snapshot.hordeLevel) + or (game.rift and game.riftLevels[game.depth] or game.dungeon:get(game.depth)) game.player = Entity.fromData(snapshot.player) game.player.satiation = snapshot.playerExtra.satiation + game.player.sanity = snapshot.playerExtra.sanity game.player.light = snapshot.playerExtra.light + game.player.level = snapshot.playerExtra.level + game.player.xp = snapshot.playerExtra.xp + game.player.xpNext = snapshot.playerExtra.xpNext + game.player.mana = snapshot.playerExtra.mana + game.player.maxMana = snapshot.playerExtra.maxMana + game.player.spellsKnown = snapshot.playerExtra.spellsKnown + game.player.gold = snapshot.playerExtra.gold + game.player.essence = snapshot.playerExtra.essence + game.player.class = snapshot.playerExtra.class + game.player.skills = snapshot.playerExtra.skills + game.player.skillPoints = snapshot.playerExtra.skillPoints + game.player.skillCooldowns = snapshot.playerExtra.skillCooldowns + game.player.quest = snapshot.playerExtra.quest + game.player.reputation = snapshot.playerExtra.reputation + game.player.pendingPoisonBlade = snapshot.playerExtra.pendingPoisonBlade game.log = MessageLog.fromData(snapshot.log) + game.compendium = snapshot.compendium or Compendium.new() + game.ascension = snapshot.ascension or {} + game.nemesis = snapshot.nemesis game.visible = {} game:computeVisible() return game end function Game:save(path) - Serialize.saveFile(path, self:toData()) + GameSave.save(path, self:toData()) end function Game.load(path) - return Game.fromData(Serialize.loadFile(path)) + local data, err = GameSave.load(path) + if not data then + error("cannot load save " .. path .. ": " .. err) + end + return Game.fromData(data) end return Game diff --git a/src/graves.lua b/src/graves.lua new file mode 100644 index 0000000..d5d2655 --- /dev/null +++ b/src/graves.lua @@ -0,0 +1,106 @@ +-- Cross-run bones: the moment a run ends in death (see warren.lua's --meta +-- path and Game:graveEntry), a grave entry is appended to meta.lua's +-- persistent save - what the fallen was still carrying, and how deep they +-- got. From the very next run on, Builder.build may roll one of those +-- graves onto a floor at or shallower than where it was dug (bones drift +-- upward over time, never down - see Graves.eligibleFor) as an inert +-- floorItem, drawn dead last of all so a run without any recorded deaths +-- (or one played without --meta at all) makes no extra rng draw and its +-- floor layouts stay byte-identical to before this module existed. +-- +-- Unlike mimics.lua's disguise, a grave marker is honest about what it is - +-- disturbing it (Game:pickupHere, mirroring Game:triggerMimic) wakes a +-- vengeful shade rather than pretending to be ordinary loot. The shade's +-- hp and attack scale with how much the fallen was carrying at death, and +-- felling it in turn pays out a fraction of that same hoard - a richer +-- death makes for a tougher fight and a richer reward, but always strictly +-- less than what was actually lost. + +local Entity = require("entity") +local Factions = require("factions") + +local Graves = {} + +-- Oldest graves fall off once a save accumulates more than this many, so a +-- long-lived meta file never grows without bound. +Graves.MAX_RECORDED = 20 + +-- Appends `entry` (see Game:graveEntry) to meta.graves, most recent first, +-- trimming anything past Graves.MAX_RECORDED. +function Graves.record(meta, entry) + meta.graves = meta.graves or {} + table.insert(meta.graves, 1, entry) + while #meta.graves > Graves.MAX_RECORDED do + table.remove(meta.graves) + end +end + +-- Graves eligible to be rolled onto `depth`: any dug at that depth or +-- deeper. A grave dug on floor 7 can still turn up waiting on floor 4, but +-- a floor 2 grave never surfaces on floor 7 - bones drift shallower with +-- time, never the other way around. +function Graves.eligibleFor(graves, depth) + local eligible = {} + if not graves then return eligible end + for _, grave in ipairs(graves) do + if grave.depth >= depth then eligible[#eligible + 1] = grave end + end + return eligible +end + +-- The inert floorItem a builder drops for a rolled grave: visibly a grave +-- from the moment it is seen (no disguise to unmask), carrying just enough +-- of the fallen's own data for Graves.reveal to build their shade later. +function Graves.floorItem(grave) + return { + kind = "grave", grave = true, name = "a weathered grave marker", glyph = "b", + graveClass = grave.className, graveGold = grave.gold, graveEssence = grave.essence, + } +end + +-- The hostile shade a grave marker becomes the instant it is disturbed: +-- hp and attack both scale with the wealth it is guarding, capped so even +-- the richest grave never spawns something the player cannot fight back +-- against. Mirrors Mimics.reveal's shape (a fresh monster entity, tagged so +-- awardKill and corpses.lua both know to treat it specially). +function Graves.reveal(id, x, y, ground) + local gold = ground.graveGold or 0 + local essence = ground.graveEssence or 0 + local wealth = gold + essence * 2 + local bonusHp = math.min(20, wealth // 8) + local bonusAttack = math.min(6, wealth // 30) + local entity = Entity.new({ + id = id, x = x, y = y, + glyph = "G", name = "vengeful shade", kind = "monster", + species = "shade", ai = "chase", + hp = 10 + bonusHp, maxHp = 10 + bonusHp, + attack = 4 + bonusAttack, defense = 1, speed = 105, + }) + entity.cooldown = 0 + entity.faction = Factions.UNDEAD + entity.grave = true + entity.graveClass = ground.graveClass + entity.graveGold = gold + entity.graveEssence = essence + return entity +end + +-- Flat xp reward for felling a revealed shade - kept separate from +-- Monsters.xpFor exactly like Mimics.xpFor, since a shade is never a +-- member of the wild bestiary Monsters.forDepth draws its per-floor +-- population from. +function Graves.xpFor() + return 12 +end + +-- What a felled shade actually leaves behind: half the gold and a third of +-- the essence the fallen was carrying at the moment of death - the rest is +-- gone for good, so a grave is always a net loss across the two runs it +-- touches, never a way to launder wealth forward. +function Graves.lootFor(entity) + local gold = (entity.graveGold or 0) // 2 + local essence = (entity.graveEssence or 0) // 3 + return gold, essence +end + +return Graves