feat: add Meta.new and Meta.load
This commit is contained in:
1 parent
f8ae5122a5
commit
06714fc72a
1 file changed
+200
+200
@@ -0,0 +1,200 @@
|
|||||||
|
-- Progression that survives a single run: a "renown" currency banked when a
|
||||||
|
-- run ends (in death or victory), scored from that run's own tally, plus
|
||||||
|
-- achievements unlocked once a run's tally crosses a fixed threshold and
|
||||||
|
-- permanent perks bought with banked renown. Meta state lives in its own
|
||||||
|
-- save file (see warren.lua's --meta flag), entirely separate from a
|
||||||
|
-- mid-run save (serialize.lua/game.lua toData) - a run ending in death still
|
||||||
|
-- destroys that run's save, but its renown and any newly met achievement
|
||||||
|
-- persist into the next one.
|
||||||
|
--
|
||||||
|
-- A perk is applied once, in Game.new, before options.class runs (see
|
||||||
|
-- Meta.applyAll's caller) so a class delta always lands on top of whatever a
|
||||||
|
-- perk already raised - the same ordering classes.lua itself relies on for
|
||||||
|
-- its own one-time deltas.
|
||||||
|
|
||||||
|
local SaveGame = require("savegame")
|
||||||
|
local Ascension = require("ascension")
|
||||||
|
local Streaks = require("streaks")
|
||||||
|
|
||||||
|
local Meta = {}
|
||||||
|
|
||||||
|
-- Real feature history, oldest first: graves and the leaderboard arrived
|
||||||
|
-- after the original renown/achievements/perks core, and runHistory (for
|
||||||
|
-- Streaks) after that - so a meta file written before either feature landed
|
||||||
|
-- is missing those fields, not just hypothetically but in practice for any
|
||||||
|
-- save from before this module tracked them.
|
||||||
|
local MetaSave = SaveGame.new({
|
||||||
|
[1] = function(data)
|
||||||
|
data.graves = data.graves or {}
|
||||||
|
data.leaderboard = data.leaderboard or {}
|
||||||
|
return data
|
||||||
|
end,
|
||||||
|
[2] = function(data)
|
||||||
|
data.runHistory = data.runHistory or {}
|
||||||
|
return data
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
Meta.PERKS = {
|
||||||
|
{ key = "vigor", name = "Vigor", cost = 30, apply = function(player)
|
||||||
|
player.maxHp = player.maxHp + 5
|
||||||
|
player.hp = player.hp + 5
|
||||||
|
end },
|
||||||
|
{ key = "purse", name = "Deep Pockets", cost = 20, apply = function(player)
|
||||||
|
player.gold = player.gold + 25
|
||||||
|
end },
|
||||||
|
{ key = "focus", name = "Arcane Focus", cost = 25, apply = function(player)
|
||||||
|
player.maxMana = player.maxMana + 5
|
||||||
|
player.mana = player.mana + 5
|
||||||
|
end },
|
||||||
|
{ key = "keeneye", name = "Keen Eye", cost = 40, apply = function(player)
|
||||||
|
player.baseAttack = player.baseAttack + 1
|
||||||
|
player.attack = player.attack + 1
|
||||||
|
end },
|
||||||
|
{ key = "tough", name = "Thick Hide", cost = 40, apply = function(player)
|
||||||
|
player.baseDefense = player.baseDefense + 1
|
||||||
|
player.defense = player.defense + 1
|
||||||
|
end },
|
||||||
|
{ key = "lightbearer", name = "Lightbearer", cost = 25, apply = function(player)
|
||||||
|
player.light.fuel = player.light.fuel + 200
|
||||||
|
player.light.radius = player.light.radius + 1
|
||||||
|
end },
|
||||||
|
}
|
||||||
|
|
||||||
|
Meta.byKey = {}
|
||||||
|
for _, def in ipairs(Meta.PERKS) do
|
||||||
|
Meta.byKey[def.key] = def
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Each condition reads a run's own metaStats snapshot (see Game:metaStats):
|
||||||
|
-- deepestDepth, kills, bossKills, questsCompleted, artifactsFound,
|
||||||
|
-- vaultsOpened, setPiecesFound, chestsOpened, won - plus, for the rare
|
||||||
|
-- condition that needs more than a single run's own tally can offer, the
|
||||||
|
-- persistent meta state itself, already updated with this very run's own
|
||||||
|
-- outcome by the time Meta.recordRun evaluates any of these.
|
||||||
|
Meta.ACHIEVEMENTS = {
|
||||||
|
{ key = "first_blood", name = "First Blood", condition = function(stats) return stats.kills >= 1 end },
|
||||||
|
{ key = "boss_slayer", name = "Boss Slayer", condition = function(stats) return stats.bossKills >= 1 end },
|
||||||
|
{ key = "deep_delver", name = "Deep Delver", condition = function(stats) return stats.deepestDepth >= 5 end },
|
||||||
|
{ key = "bounty_hunter", name = "Bounty Hunter", condition = function(stats) return stats.questsCompleted >= 3 end },
|
||||||
|
{ key = "artificer", name = "Artificer", condition = function(stats) return stats.artifactsFound >= 1 end },
|
||||||
|
{ key = "vault_breaker", name = "Vault Breaker", condition = function(stats) return (stats.vaultsOpened or 0) >= 1 end },
|
||||||
|
{ key = "set_collector", name = "Set Collector", condition = function(stats) return (stats.setPiecesFound or 0) >= 3 end },
|
||||||
|
{ key = "amulet_bearer", name = "Amulet Bearer", condition = function(stats) return stats.won end },
|
||||||
|
{ key = "hoarder", name = "Hoarder", condition = function(stats) return (stats.chestsOpened or 0) >= 3 end },
|
||||||
|
-- Needs meta.runHistory (see streaks.lua), already carrying this very run
|
||||||
|
-- as its newest entry - a win right after a loss, not just any win.
|
||||||
|
{ key = "comeback", name = "Comeback", condition = function(stats, meta)
|
||||||
|
local h = meta.runHistory
|
||||||
|
return stats.won == true and #h >= 2 and h[#h] == true and h[#h - 1] == false
|
||||||
|
end },
|
||||||
|
}
|
||||||
|
|
||||||
|
function Meta.new()
|
||||||
|
return {
|
||||||
|
renown = 0, totalRuns = 0, wins = 0, bestDepth = 0,
|
||||||
|
achievements = {}, perks = {}, graves = {}, leaderboard = {}, runHistory = {},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Loads meta state from `path`, or a fresh state if the file does not exist
|
||||||
|
-- yet (a player's first run). A file that exists but fails to parse or fails
|
||||||
|
-- its checksum is corruption, not a first run - that gets a warning on
|
||||||
|
-- stderr rather than silently discarding banked renown and achievements, but
|
||||||
|
-- still hands back a fresh state so the caller always has something to play
|
||||||
|
-- with. Fields missing on an older-version save default in via MetaSave's
|
||||||
|
-- migration chain; fields missing despite matching CURRENT_VERSION (a
|
||||||
|
-- hand-edited or partially-written file that still happens to parse and
|
||||||
|
-- checksum) default in here as a second line of defense.
|
||||||
|
function Meta.load(path)
|
||||||
|
local data, err = MetaSave.load(path)
|
||||||
|
if not data then
|
||||||
|
if err ~= "missing" then
|
||||||
|
io.stderr:write("warning: meta file " .. path .. " is " .. err .. "; starting fresh\n")
|
||||||
|
end
|
||||||
|
return Meta.new()
|
||||||
|
end
|
||||||
|
-- graves, leaderboard and runHistory are not re-defaulted here: MetaSave's
|
||||||
|
-- migration chain above already guarantees them on anything that reaches
|
||||||
|
-- this point. The base fields below predate versioning itself (there is no
|
||||||
|
-- earlier step that could have filled them), so they keep their own
|
||||||
|
-- fallback against a hand-edited or partially-written file that still
|
||||||
|
-- parses and checksums.
|
||||||
|
data.renown = data.renown or 0
|
||||||
|
data.totalRuns = data.totalRuns or 0
|
||||||
|
data.wins = data.wins or 0
|
||||||
|
data.bestDepth = data.bestDepth or 0
|
||||||
|
data.achievements = data.achievements or {}
|
||||||
|
data.perks = data.perks or {}
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
|
||||||
|
function Meta.save(meta, path)
|
||||||
|
MetaSave.save(path, meta)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Renown a single completed run banks, from its own tally alone: depth
|
||||||
|
-- reached, bosses felled, bounties resolved, vaults cracked open, set
|
||||||
|
-- pieces gathered, chests cracked open, and - far above any of those -
|
||||||
|
-- escaping with the Amulet. Any ascension.lua mutator active for the run
|
||||||
|
-- (stats.mutators, see Game:metaStats) then tops that base score up by its
|
||||||
|
-- own renown bonus percentage, rewarding the extra risk taken on.
|
||||||
|
function Meta.renownFor(stats)
|
||||||
|
local total = stats.deepestDepth * 4 + stats.bossKills * 15 + stats.questsCompleted * 8
|
||||||
|
+ (stats.vaultsOpened or 0) * 10 + (stats.setPiecesFound or 0) * 3 + (stats.chestsOpened or 0) * 2
|
||||||
|
if stats.won then total = total + 100 end
|
||||||
|
local bonusPercent = Ascension.renownBonusPercent(stats.mutators)
|
||||||
|
if bonusPercent > 0 then
|
||||||
|
total = total + (total * bonusPercent) // 100
|
||||||
|
end
|
||||||
|
return total
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Folds a finished run's stats into the persistent meta state: banks its
|
||||||
|
-- renown, advances the run/win/best-depth tallies, and unlocks any
|
||||||
|
-- achievement whose condition the run's tally satisfies for the first time.
|
||||||
|
-- Returns the newly unlocked achievement names, in catalog order, so a
|
||||||
|
-- caller (warren.lua) can announce them; an achievement already owned from an
|
||||||
|
-- earlier run is silently skipped rather than re-announced.
|
||||||
|
function Meta.recordRun(meta, stats)
|
||||||
|
meta.totalRuns = meta.totalRuns + 1
|
||||||
|
if stats.won then meta.wins = meta.wins + 1 end
|
||||||
|
if stats.deepestDepth > meta.bestDepth then meta.bestDepth = stats.deepestDepth end
|
||||||
|
meta.renown = meta.renown + Meta.renownFor(stats)
|
||||||
|
Streaks.record(meta.runHistory, stats.won == true)
|
||||||
|
local unlocked = {}
|
||||||
|
for _, ach in ipairs(Meta.ACHIEVEMENTS) do
|
||||||
|
if not meta.achievements[ach.key] and ach.condition(stats, meta) then
|
||||||
|
meta.achievements[ach.key] = true
|
||||||
|
unlocked[#unlocked + 1] = ach.name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return unlocked
|
||||||
|
end
|
||||||
|
|
||||||
|
function Meta.isOwned(meta, key)
|
||||||
|
return meta.perks[key] == true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Spends renown to permanently unlock perk `key`. Fails without spending
|
||||||
|
-- anything given an unknown key, a perk already owned, or too little banked
|
||||||
|
-- renown; returns (false, reason) in each case.
|
||||||
|
function Meta.purchase(meta, key)
|
||||||
|
local def = Meta.byKey[key]
|
||||||
|
if not def then return false, "no such perk" end
|
||||||
|
if meta.perks[key] then return false, "already owned" end
|
||||||
|
if meta.renown < def.cost then return false, "not enough renown" end
|
||||||
|
meta.renown = meta.renown - def.cost
|
||||||
|
meta.perks[key] = true
|
||||||
|
return true, "unlocked " .. def.name
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Applies every perk `meta` already owns to a freshly created player, in
|
||||||
|
-- catalog order. Meant to run once in Game.new, before any class delta.
|
||||||
|
function Meta.applyAll(meta, player)
|
||||||
|
for _, def in ipairs(Meta.PERKS) do
|
||||||
|
if meta.perks[def.key] then def.apply(player) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return Meta
|
||||||
Reference in new issue
Block a user