feat: add SaveGame.fnv1a32, migrateForward and Scavenging.herbFor
This commit is contained in:
1 parent
35b97b6c04
commit
f920e8d57f
2 files changed
+176
No files matched your search
@@ -0,0 +1,112 @@
|
|||||||
|
-- A reusable versioned, checksummed save container laid on top of
|
||||||
|
-- serialize.lua's plain Lua-expression encoding. serialize.lua only knows how
|
||||||
|
-- to turn a value into text and back; it has no notion of "this text might be
|
||||||
|
-- truncated, hand-edited, or written by a codebase three versions ago". This
|
||||||
|
-- module adds that layer: an envelope carrying a schema version and an
|
||||||
|
-- FNV-1a32 checksum of its payload, plus a migration chain that walks old
|
||||||
|
-- data forward one version at a time.
|
||||||
|
--
|
||||||
|
-- A file written before this module existed - a bare Serialize.saveFile dump
|
||||||
|
-- with no envelope at all - is treated as implicit version 1, so an existing
|
||||||
|
-- save on disk keeps loading instead of being rejected outright.
|
||||||
|
--
|
||||||
|
-- SaveGame.new(migrations) builds one codec for one call site. `migrations`
|
||||||
|
-- is a 1-indexed array where migrations[v] transforms data from version v to
|
||||||
|
-- v+1; the codec's CURRENT_VERSION is #migrations + 1. Two call sites with
|
||||||
|
-- differently shaped data (a run's game state, a player's meta progression)
|
||||||
|
-- each get their own codec with their own migration chain - this module
|
||||||
|
-- knows nothing about either shape.
|
||||||
|
|
||||||
|
local Serialize = require("serialize")
|
||||||
|
|
||||||
|
local SaveGame = {}
|
||||||
|
|
||||||
|
local MAGIC = "WARRENSAVE"
|
||||||
|
local MASK32 = 0xFFFFFFFF
|
||||||
|
|
||||||
|
-- FNV-1a, 32-bit. Verified below against the algorithm's published test
|
||||||
|
-- vectors (fnv1a32("") and fnv1a32("a")), not just self-consistency.
|
||||||
|
function SaveGame.fnv1a32(str)
|
||||||
|
local hash = 2166136261
|
||||||
|
for i = 1, #str do
|
||||||
|
hash = (hash ~ str:byte(i)) & MASK32
|
||||||
|
hash = (hash * 16777619) & MASK32
|
||||||
|
end
|
||||||
|
return hash
|
||||||
|
end
|
||||||
|
|
||||||
|
local function migrateForward(data, fromVersion, migrations, currentVersion)
|
||||||
|
for v = fromVersion, currentVersion - 1 do
|
||||||
|
data = migrations[v](data)
|
||||||
|
end
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Builds a codec bound to one migration chain. `migrations` may be empty
|
||||||
|
-- (CURRENT_VERSION == 1); a version-1-only codec still gets checksumming and
|
||||||
|
-- corruption detection for free.
|
||||||
|
function SaveGame.new(migrations)
|
||||||
|
migrations = migrations or {}
|
||||||
|
local currentVersion = #migrations + 1
|
||||||
|
local codec = { CURRENT_VERSION = currentVersion }
|
||||||
|
|
||||||
|
function codec.encode(data)
|
||||||
|
local payload = Serialize.encode(data)
|
||||||
|
local envelope = {
|
||||||
|
magic = MAGIC,
|
||||||
|
version = currentVersion,
|
||||||
|
checksum = SaveGame.fnv1a32(payload),
|
||||||
|
payload = payload,
|
||||||
|
}
|
||||||
|
return Serialize.encode(envelope)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Returns (data) on success, or (nil, reason) on any failure. `reason` is
|
||||||
|
-- always one of: "missing" (only from .load, never from .decode), a string
|
||||||
|
-- starting with "corrupt:", or a string starting with "future version:".
|
||||||
|
function codec.decode(text)
|
||||||
|
local ok, value = pcall(Serialize.decode, text)
|
||||||
|
if not ok or type(value) ~= "table" then
|
||||||
|
return nil, "corrupt: cannot parse save data"
|
||||||
|
end
|
||||||
|
if value.magic ~= MAGIC then
|
||||||
|
-- No envelope at all: a save from before this codec existed. The
|
||||||
|
-- decoded value IS the raw data, at implicit version 1.
|
||||||
|
return migrateForward(value, 1, migrations, currentVersion)
|
||||||
|
end
|
||||||
|
if type(value.version) ~= "number" or type(value.payload) ~= "string"
|
||||||
|
or type(value.checksum) ~= "number" then
|
||||||
|
return nil, "corrupt: malformed save envelope"
|
||||||
|
end
|
||||||
|
if value.version > currentVersion then
|
||||||
|
return nil, "future version: save is v" .. value.version
|
||||||
|
.. ", this build reads up to v" .. currentVersion
|
||||||
|
end
|
||||||
|
if SaveGame.fnv1a32(value.payload) ~= value.checksum then
|
||||||
|
return nil, "corrupt: checksum mismatch"
|
||||||
|
end
|
||||||
|
local ok2, data = pcall(Serialize.decode, value.payload)
|
||||||
|
if not ok2 or type(data) ~= "table" then
|
||||||
|
return nil, "corrupt: cannot parse save payload"
|
||||||
|
end
|
||||||
|
return migrateForward(data, value.version, migrations, currentVersion)
|
||||||
|
end
|
||||||
|
|
||||||
|
function codec.save(path, data)
|
||||||
|
local file = assert(io.open(path, "w"))
|
||||||
|
file:write(codec.encode(data))
|
||||||
|
file:close()
|
||||||
|
end
|
||||||
|
|
||||||
|
function codec.load(path)
|
||||||
|
local file = io.open(path, "r")
|
||||||
|
if not file then return nil, "missing" end
|
||||||
|
local text = file:read("a")
|
||||||
|
file:close()
|
||||||
|
return codec.decode(text)
|
||||||
|
end
|
||||||
|
|
||||||
|
return codec
|
||||||
|
end
|
||||||
|
|
||||||
|
return SaveGame
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- A third way to spend a fresh corpse, alongside Game:butcherCorpse's meal
|
||||||
|
-- and Game:raiseCorpse's thrall: carving it for the herb its own kind
|
||||||
|
-- carries rather than for food or an undead ally, so a cauldron (see
|
||||||
|
-- alchemy.lua) can draw on combat itself and not only on whatever the floor
|
||||||
|
-- happened to scatter. Every wild faction maps to a fixed herb, so the same
|
||||||
|
-- species always yields the same ingredient no matter which floor or seed
|
||||||
|
-- felled it - a goblinoid's crude poultice weed is always bloodroot, a
|
||||||
|
-- drake's scorched hide is always emberleaf. A corpse already past its rot
|
||||||
|
-- threshold (see Corpses.isRotten) has nothing left worth carving, and one
|
||||||
|
-- left behind by anything without a bestiary entry - a bonded companion or
|
||||||
|
-- a hired ally, never a wild monster - carries no herb at all; the corpse
|
||||||
|
-- itself is spent either way it fails, exactly like Corpses.butcher's own
|
||||||
|
-- certain trade, this is a deliberate trade the autopilot never makes on
|
||||||
|
-- its own (see Game:scavengeCorpse), the same way it never raises one.
|
||||||
|
|
||||||
|
local Corpses = require("corpses")
|
||||||
|
local Alchemy = require("alchemy")
|
||||||
|
local Monsters = require("monsters")
|
||||||
|
local Factions = require("factions")
|
||||||
|
|
||||||
|
local Scavenging = {}
|
||||||
|
|
||||||
|
Scavenging.HERB_BY_FACTION = {
|
||||||
|
[Factions.VERMIN] = Alchemy.NIGHTCAP,
|
||||||
|
[Factions.GOBLINOID] = Alchemy.BLOODROOT,
|
||||||
|
[Factions.ORCISH] = Alchemy.BLOODROOT,
|
||||||
|
[Factions.BEAST] = Alchemy.EMBERLEAF,
|
||||||
|
[Factions.CULT] = Alchemy.NIGHTCAP,
|
||||||
|
[Factions.DRAKE] = Alchemy.EMBERLEAF,
|
||||||
|
[Factions.BANDIT] = Alchemy.SUNPETAL,
|
||||||
|
[Factions.MERCHANT] = Alchemy.SUNPETAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- The herb key `corpse` would yield, or nil when its species is unknown to
|
||||||
|
-- the bestiary (a companion's or hireling's own remains) or belongs to a
|
||||||
|
-- faction (UNDEAD is the only one) with nothing mapped above - moot in
|
||||||
|
-- practice since Corpses.leavesCorpse already refuses the undead a corpse
|
||||||
|
-- at all, kept here only so an unmapped faction fails closed rather than
|
||||||
|
-- erroring.
|
||||||
|
function Scavenging.herbFor(corpse)
|
||||||
|
local spec = Monsters.byKey[corpse.species]
|
||||||
|
local faction = spec and spec.faction
|
||||||
|
return faction and Scavenging.HERB_BY_FACTION[faction]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Carves `corpse` for its herb instead of food, spending it in one stroke.
|
||||||
|
-- Refuses without spending anything when the corpse has already rotted past
|
||||||
|
-- use or its species yields no known herb; returns (false, reason) in
|
||||||
|
-- either case so a caller never has to re-derive why.
|
||||||
|
function Scavenging.harvest(game, corpse)
|
||||||
|
if Corpses.isRotten(corpse) then
|
||||||
|
return false, "the corpse has rotted too far to yield anything worth carving"
|
||||||
|
end
|
||||||
|
local herbKey = Scavenging.herbFor(corpse)
|
||||||
|
if not herbKey then
|
||||||
|
return false, "there is nothing worth carving from this corpse"
|
||||||
|
end
|
||||||
|
Corpses.remove(game.level, corpse)
|
||||||
|
local herb = Alchemy.herb(herbKey)
|
||||||
|
game.player.inventory[#game.player.inventory + 1] = herb
|
||||||
|
return true, string.format("you carve a %s from the %s corpse", herb.name, corpse.name)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Scavenging
|
||||||
Reference in new issue
Block a user