feat: add SaveGame.fnv1a32, migrateForward and Scavenging.herbFor

This commit is contained in:
dualfroz committed 2026-09-07 21:47:00 +02:00
1 parent 35b97b6c04
commit f920e8d57f
2 files changed
+176

No files matched your search

+112
View File
@@ -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
+64
View File
@@ -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