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
|
||||
Reference in new issue
Block a user