From 8250eacdede7efdf5d832221c861f2308e36ba38 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 15:25:05 +0200 Subject: [PATCH] test: cover tests.prefabVaultFieldsSurviveSerialization --- tests/run.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index ae3faa8..6542389 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -11547,6 +11547,179 @@ function tests.builderDiggingThroughAPrefabVaultsOuterWallOpensARouteToItsSealed "digging through the outer wall opens a genuine route into the vault's previously sealed interior") end +function tests.prefabVaultFieldsSurviveSerialization() + local level = arena(21, 11) + level.prefabVaults = { { name = "guardPost", x = 5, y = 4, w = 7, h = 5 } } + local restored = Level.fromData(level:toData()) + eq(#restored.prefabVaults, 1, "the prefab vault list survives a round trip") + eq(restored.prefabVaults[1].name, "guardPost", "a placed vault's template name survives a round trip") + eq(restored.prefabVaults[1].x, 5, "a placed vault's anchor x survives a round trip") + eq(restored.prefabVaults[1].w, 7, "a placed vault's footprint width survives a round trip") +end + +function tests.saveGameFnv1a32MatchesThePublishedTestVectors() + eq(SaveGame.fnv1a32(""), 2166136261, "the empty string hashes to the FNV-1a32 offset basis") + eq(SaveGame.fnv1a32("a"), 3826002220, "a single byte matches the published FNV-1a32 test vector") + eq(SaveGame.fnv1a32("warren"), SaveGame.fnv1a32("warren"), "hashing the same string twice is deterministic") + ok(SaveGame.fnv1a32("warren") ~= SaveGame.fnv1a32("warreo"), "a one-character change changes the hash") +end + +function tests.saveGameEncodeDecodeRoundTripsAValueUnchanged() + local codec = SaveGame.new({}) + local data = { hp = 12, name = "vault-keeper", tags = { "brave", "unlucky" } } + local decoded = codec.decode(codec.encode(data)) + eq(Serialize.encode(decoded), Serialize.encode(data), "a value survives an encode/decode round trip byte for byte") +end + +function tests.saveGameSaveThenLoadRoundTripsThroughARealFile() + local codec = SaveGame.new({}) + local path = os.tmpname() + codec.save(path, { hp = 12, tags = { "brave", "unlucky" } }) + local data = codec.load(path) + os.remove(path) + eq(data.hp, 12, "a saved number survives a real file round trip") + eq(data.tags[1], "brave", "a saved array survives a real file round trip") + eq(data.tags[2], "unlucky", "array order survives a real file round trip") +end + +function tests.saveGameLoadReportsMissingRatherThanErroringOnANonexistentFile() + local codec = SaveGame.new({}) + local data, err = codec.load("/tmp/warren_savegame_does_not_exist_998877.sav") + eq(data, nil, "a missing file yields no data") + eq(err, "missing", "the reason is reported as missing, distinct from corruption") +end + +function tests.saveGameDecodeRejectsTextThatIsNotValidLuaAtAll() + local codec = SaveGame.new({}) + local data, err = codec.decode("{ this is not } valid ( lua at all") + eq(data, nil, "unparseable text yields no data") + ok(contains(err or "", "corrupt"), "the failure is reported as corruption") +end + +function tests.saveGameDecodeDetectsATamperedPayloadViaChecksumMismatch() + local codec = SaveGame.new({}) + local text = codec.encode({ marker = "TAMPERMARKER", count = 3 }) + local tampered = text:gsub("TAMPERMARKER", "TAMPERMARKED", 1) + ok(tampered ~= text, "the substitution actually changed the encoded text") + local data, err = codec.decode(tampered) + eq(data, nil, "a payload that no longer matches its checksum is rejected") + ok(contains(err or "", "checksum mismatch"), "the rejection names the checksum as the reason") +end + +function tests.saveGameDecodeRefusesASaveFromAVersionNewerThanTheCodecSupports() + -- One throwaway migration just to make CURRENT_VERSION 2 for this check. + local codec = SaveGame.new({ function(d) return d end }) + local payload = Serialize.encode({ x = 1 }) + local envelope = { + magic = "WARRENSAVE", version = codec.CURRENT_VERSION + 1, + checksum = SaveGame.fnv1a32(payload), payload = payload, + } + local data, err = codec.decode(Serialize.encode(envelope)) + eq(data, nil, "a save newer than this codec understands is refused rather than guessed at") + ok(contains(err or "", "future version"), "the refusal names the version mismatch as the reason") +end + +function tests.saveGameWithNoMigrationHistoryPassesLegacyDataThroughUnchanged() + local codec = SaveGame.new({}) + local path = os.tmpname() + Serialize.saveFile(path, { greeting = "hi", count = 7 }) + local data, err = codec.load(path) + os.remove(path) + eq(err, nil, "a magic-less legacy file loads without an error") + eq(data.greeting, "hi", "legacy string fields survive untouched") + eq(data.count, 7, "legacy numeric fields survive untouched") +end + +function tests.saveGameMigrationChainAppliesEachStepInOrderStartingFromVersionOne() + local codec = SaveGame.new({ + [1] = function(d) d.trail = d.trail or {}; table.insert(d.trail, "one"); return d end, + [2] = function(d) d.trail = d.trail or {}; table.insert(d.trail, "two"); return d end, + }) + eq(codec.CURRENT_VERSION, 3, "two migrations put the current version one past the last step") + local path = os.tmpname() + Serialize.saveFile(path, { trail = {} }) + local data = codec.load(path) + os.remove(path) + eq(#data.trail, 2, "both migration steps ran") + eq(data.trail[1], "one", "step one ran before step two") + eq(data.trail[2], "two", "step two ran after step one") +end + +function tests.saveGameMigrationChainSkipsStepsAlreadyPastTheStoredVersion() + local codec = SaveGame.new({ + [1] = function(d) d.trail = d.trail or {}; table.insert(d.trail, "one"); return d end, + [2] = function(d) d.trail = d.trail or {}; table.insert(d.trail, "two"); return d end, + }) + local payload = Serialize.encode({ trail = {} }) + local envelope = { + magic = "WARRENSAVE", version = 2, + checksum = SaveGame.fnv1a32(payload), payload = payload, + } + local data = codec.decode(Serialize.encode(envelope)) + eq(#data.trail, 1, "a save already at version 2 only runs the one remaining step") + eq(data.trail[1], "two", "the remaining step is the one from version 2 onward, not version 1's") +end + +function tests.metaLoadUpgradesALegacyPreEnvelopeFileWithoutLosingItsRenown() + local path = os.tmpname() + Serialize.saveFile(path, { + renown = 77, totalRuns = 3, wins = 1, bestDepth = 4, + achievements = { boss_slayer = true }, perks = { vigor = true }, + }) + local meta = Meta.load(path) + os.remove(path) + eq(meta.renown, 77, "renown from a pre-envelope legacy file is not discarded") + eq(meta.achievements.boss_slayer, true, "achievements from a legacy file survive the upgrade") + eq(#meta.graves, 0, "graves defaults in for a file predating the graves feature") + eq(#meta.leaderboard, 0, "leaderboard defaults in for a file predating the leaderboard feature") + eq(#meta.runHistory, 0, "runHistory defaults in for a file predating streaks") +end + +function tests.metaLoadStartsFreshWithoutCrashingOnACorruptedFile() + local path = os.tmpname() + local meta = Meta.new() + meta.renown = 500 + Meta.save(meta, path) + local file = assert(io.open(path, "r")) + local text = file:read("a") + file:close() + local tampered = text:gsub("500", "999", 1) + local out = assert(io.open(path, "w")) + out:write(tampered) + out:close() + local restored = Meta.load(path) + os.remove(path) + eq(restored.renown, 0, "a checksum-broken meta file starts fresh rather than trusting tampered renown") +end + +function tests.gameLoadUpgradesALegacyPreEnvelopeSaveMissingNewerPlayerFields() + local game = Game.new(1, { class = "fighter" }) + local data = game:toData() + data.playerExtra.essence = nil + data.playerExtra.skills = nil + data.playerExtra.skillPoints = nil + data.playerExtra.skillCooldowns = nil + data.playerExtra.reputation = nil + data.playerExtra.sanity = nil + data.stats.vaultsOpened = nil + data.stats.setPiecesFound = nil + local path = os.tmpname() + Serialize.saveFile(path, data) + local restored = Game.load(path) + os.remove(path) + eq(restored.player.essence, 0, "essence defaults in for a save predating it") + eq(restored.stats.vaultsOpened, 0, "vaultsOpened defaults in for a save predating it") + eq(restored.stats.setPiecesFound, 0, "setPiecesFound defaults in for a save predating it") + eq(type(restored.player.skills), "table", "skills defaults to an empty table for a save predating the skill tree") + eq(restored.player.skillPoints, 0, "skillPoints defaults in for a save predating the skill tree") + eq(type(restored.player.skillCooldowns), "table", + "skillCooldowns defaults to an empty table for a save predating the skill tree") + eq(type(restored.player.reputation), "table", "reputation defaults to an empty table for a save predating it") + eq(restored.player.sanity, Sanity.MAX, "sanity defaults to full for a save predating the sanity system") +end + +function tests.gameLoadRaisesAClearErrorOnATruncatedSaveFileInsteadOfCrashingObscurely() + local game = Game.new(1) end local order = {