From f920e8d57fa680662195f48a6ab7a898c615b655 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 21:47:00 +0200 Subject: [PATCH] feat: add SaveGame.fnv1a32, migrateForward and Scavenging.herbFor --- src/savegame.lua | 112 +++++++++++++++++++++++++++++++++++++++++++++ src/scavenging.lua | 64 ++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/savegame.lua create mode 100644 src/scavenging.lua diff --git a/src/savegame.lua b/src/savegame.lua new file mode 100644 index 0000000..b2da845 --- /dev/null +++ b/src/savegame.lua @@ -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 diff --git a/src/scavenging.lua b/src/scavenging.lua new file mode 100644 index 0000000..3470471 --- /dev/null +++ b/src/scavenging.lua @@ -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