feat: extend game with Graves.record and Graves.eligibleFor

This commit is contained in:
dualfroz committed 2026-09-06 20:02:24 +02:00
1 parent 5f13d7012e
commit cb01478890
2 files changed
+183 -4

No files matched your search

+77 -4
View File
@@ -2638,35 +2638,108 @@ function Game:toData()
identify = self.identify, identify = self.identify,
dungeon = self.dungeon:toData(), dungeon = self.dungeon:toData(),
player = self.player: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(), 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 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) function Game.fromData(snapshot)
local game = setmetatable({}, Game) local game = setmetatable({}, Game)
game.seed = snapshot.seed game.seed = snapshot.seed
game.depth = snapshot.depth game.depth = snapshot.depth
game.turn = snapshot.turn 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.rng = Rng.deserialize(snapshot.rng)
game.identify = snapshot.identify game.identify = snapshot.identify
game.dungeon = Dungeon.fromData(snapshot.dungeon) 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 = Entity.fromData(snapshot.player)
game.player.satiation = snapshot.playerExtra.satiation game.player.satiation = snapshot.playerExtra.satiation
game.player.sanity = snapshot.playerExtra.sanity
game.player.light = snapshot.playerExtra.light 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.log = MessageLog.fromData(snapshot.log)
game.compendium = snapshot.compendium or Compendium.new()
game.ascension = snapshot.ascension or {}
game.nemesis = snapshot.nemesis
game.visible = {} game.visible = {}
game:computeVisible() game:computeVisible()
return game return game
end end
function Game:save(path) function Game:save(path)
Serialize.saveFile(path, self:toData()) GameSave.save(path, self:toData())
end end
function Game.load(path) 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 end
return Game return Game
+106
View File
@@ -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