Files
last-hearth/tests/recovery_probe.lua
2026-11-03 12:41:09 -05:00

304 lines
14 KiB
Lua
Executable File

-- Private two-boot recovery integration probe. Executed ONLY in restored copies.
-- "capture" runs in the hearth mod context before its real init; "probe" runs
-- as a dependent worldmod after startup. No engine globals or live players mocked.
return function(mode)
local C = core
local native_assert = assert
local verified_assertions = 0
local failure_label = "checkpoint-probe-start"
-- Assertion labels below are source-code constants, never engine/save input.
-- Never expose pcall errors, original records or parser diagnostics.
local function assert(value, label)
label = label or "Unlabelled engine serialization"
if not value then
failure_label = "checkpoint-" .. label:lower():gsub("[^a-z]+", "-"):gsub("%-$", ""):sub(1, 96)
C.log("error", "LAST_HEARTH_RECOVERY_FAIL " .. failure_label)
return native_assert(value, failure_label)
end
verified_assertions = verified_assertions + 1
return value
end
local phase = C.settings:get("hearth_recovery_phase")
local token = C.settings:get("hearth_recovery_token")
local root = C.get_worldpath()
local kinds = {"claims", "homes", "access", "location", "spawn"}
assert(phase == "seed" or phase == "restart", "Invalid recovery phase")
assert(token and token:match("^[%x%-]+$"), "Invalid recovery nonce")
local function equal(a, b)
if type(a) ~= type(b) then return false end
if type(a) ~= "table" then return a == b end
for k, v in pairs(a) do if not equal(v, b[k]) then return false end end
for k in pairs(b) do if a[k] == nil then return false end end
return true
end
local function decode(raw)
local value = C.parse_json(raw, nil, true)
assert(type(value) == "table", "Invalid recovery JSON")
return value
end
local function read(name)
local file = assert(io.open(root .. "/" .. name, "rb"), "Missing recovery evidence")
local raw = file:read("*a")
file:close()
return decode(raw)
end
local function write(name, value)
assert(C.safe_file_write(root .. "/" .. name, assert(C.write_json(value))), "Evidence write failed")
end
if mode == "capture" then
assert(C.get_current_modname() == "hearth", "Capture requires real hearth storage context")
if phase == "seed" then
local storage = C.get_mod_storage()
local expected = {format = 1, token = token, keys = {}, backups = {}}
for _, kind in ipairs(kinds) do
expected.keys[kind] = storage:get_string(kind)
expected.backups[kind] = storage:get_string("m0_backup:" .. kind)
end
write("m0-recovery-expected.json", expected)
else
local expected = read("m0-recovery-expected.json")
assert(expected.format == 1 and expected.token == token, "Original evidence missing on restart")
end
return
end
assert(mode == "probe", "Invalid recovery operation")
local finished, ready = false, false
local report = {format = 1, phase = phase, token = token, existingKeys = 0, legacyKeys = 0}
-- The game's mapgen_limit is 512. Stay inside complete generatable chunks,
-- high above terrain; refuse occupied space rather than overwrite any copy data.
local fixture_pos = {x = 320, y = 320, z = 320}
local expired_pos = {x = 321, y = 320, z = 320}
local live_pos = {x = 322, y = 320, z = 320}
local function no_clients()
assert(#C.get_connected_players() == 0, "Recovery forbids live clients")
end
local function fail()
if finished then return end
finished = true
-- Never include assertion/parser strings: original records may be private.
C.log("error", "LAST_HEARTH_RECOVERY_FAIL " .. failure_label)
C.request_shutdown("Private recovery failed", false, 0)
end
local function guard(fn)
if finished then return end
local ok = pcall(fn)
if not ok then fail() end
end
local function envelope(kind, raw)
local parsed = decode(raw)
assert(parsed.schema == 1 and parsed.kind == kind and type(parsed.data) == "table", "Missing schema-1 envelope")
local result, migrated = hearth.schema.decode(kind, parsed)
assert(migrated == false and equal(result, parsed), "Envelope did not roundtrip")
return parsed.data
end
local function world_check()
failure_label = "checkpoint-world-check"
assert(C.get_game_info().id == C.settings:get("hearth_recovery_gameid"), "Disposable game selection mismatch")
assert(type(hearth) == "table" and type(hearth.persistence) == "table", "Persistence not wired")
assert(type(hearth.schema) == "table" and type(hearth.storage) == "userdata", "Real storage/schema missing")
assert(not C.get_modpath("hearth_smoke"), "Legacy smoke mod loaded")
assert(C.settings:get("hearth_environment") == "staging", "Not a staging boot")
assert(C.settings:get("bind_address") == "127.0.0.1" and C.settings:get("max_users") == "0", "Client isolation missing")
assert(C.settings:get_bool("secure.enable_security") == true, "Mod security disabled")
no_clients()
local expected = read("m0-recovery-expected.json")
assert(expected.format == 1 and expected.token == token, "Wrong original evidence")
local saved = {}
for _, kind in ipairs(kinds) do
local raw, before_backup = expected.keys[kind], expected.backups[kind]
assert(type(raw) == "string" and type(before_backup) == "string", "Missing pre-migration bytes")
local backup_key = "m0_backup:" .. kind
local backup = hearth.storage:get_string(backup_key)
if raw ~= "" then
report.existingKeys = report.existingKeys + 1
local normalized, legacy = hearth.schema.decode(kind, decode(raw))
if legacy then report.legacyKeys = report.legacyKeys + 1 end
-- Inspect BEFORE load_world: a lazy probe must not hide missing startup migration.
local actual = envelope(kind, hearth.storage:get_string(kind))
assert(equal(actual, normalized.data), "Stored original data changed during migration")
local expected_backup = before_backup ~= "" and before_backup or (legacy and raw or "")
assert(backup == expected_backup, "Original backup bytes changed")
if legacy then
local recovered, was_legacy = hearth.schema.decode(kind, decode(backup))
assert(was_legacy and equal(recovered, normalized), "Legacy backup cannot recover original data")
end
saved[kind] = hearth.persistence.load_world(kind)
assert(equal(saved[kind], normalized.data), "Adapter changed original world data")
hearth.persistence.save_world(kind, saved[kind])
assert(hearth.storage:get_string(backup_key) == backup, "Save replaced immutable backup")
else
assert(backup == before_backup, "Missing record created a backup")
end
end
assert(report.existingKeys > 0 and report.legacyKeys > 0, "Not an existing legacy save")
assert(saved.spawn, "Original saved spawn required")
assert(equal(hearth.persistence.load_world("spawn"), saved.spawn), "Saved spawn changed")
local claims, homes = saved.claims or {}, saved.homes or {}
assert(type(hearth.claim_allowed) == "function", "Claims protection missing")
assert(type(hearth.home_for) == "function", "Home lookup missing")
for owner, home in pairs(homes) do
local exposed = hearth.home_for(owner)
assert(equal(exposed, home), "Persisted home lookup changed")
exposed.x = exposed.x + 1
assert(equal(hearth.home_for(owner), home), "Home lookup exposed mutable storage")
end
report.homes = true
local outsider = "__hearth_recovery_outsider"
while claims[outsider] do outsider = outsider .. "_" end
report.claimCount = 0
for owner, pos in pairs(claims) do
report.claimCount = report.claimCount + 1
assert(not hearth.claim_allowed(pos, outsider) and C.is_protected(pos, outsider), "Original claim lost protection")
local allowed = true
for other, center in pairs(claims) do
if other ~= owner and math.abs(pos.x - center.x) <= 12 and math.abs(pos.z - center.z) <= 12
and math.abs(pos.y - center.y) <= 24 then allowed = false end
end
assert(hearth.claim_allowed(pos, owner) == allowed, "Claim ownership changed")
end
report.claims = true
return saved.spawn
end
local original_needs = {food = 72, water = 63, warmth = 54, wet = 9, stamina = 81}
local saved_needs = {food = 61, water = 52, warmth = 43, wet = 8, stamina = 79}
local function fixture_check()
failure_label = "checkpoint-fixture-check"
local meta = C.get_meta(fixture_pos)
-- The deterministic synthetic player only adapts get_meta; its backing store
-- is REAL engine node metadata, and the adapter uses REAL engine JSON.
local player = {get_meta = function() return meta end}
if phase == "seed" then
for _, pos in ipairs({fixture_pos, expired_pos, live_pos}) do
assert(C.get_node(pos).name == "air", "Isolated fixture space occupied")
end
C.set_node(fixture_pos, {name = "hearth:wall", param2 = 2})
meta = C.get_meta(fixture_pos)
local original_raw = assert(C.write_json(original_needs))
meta:set_string("hearth:needs", original_raw)
assert(equal(hearth.persistence.load_player(player), original_needs), "Real-JSON player migration failed")
assert(meta:get_string("hearth:m0_backup:needs") == original_raw, "Player legacy bytes not backed up")
hearth.persistence.save_player(player, saved_needs)
assert(equal(envelope("needs", meta:get_string("hearth:needs")), saved_needs), "Player fixture not saved")
assert(meta:get_string("hearth:m0_backup:needs") == original_raw, "Player save overwrote backup")
meta:set_string("recovery_token", token)
local inv = meta:get_inventory()
inv:set_size("main", 4)
local stack = ItemStack("hearth:axe")
stack:set_wear(1379)
stack:get_meta():set_string("recovery", token)
inv:set_stack("main", 1, stack)
inv:set_stack("main", 2, ItemStack("hearth:berries 3"))
local now = os.time()
local fixture = {format = 1, token = token, originalNeeds = original_raw,
liveDeadline = now + 300, expiredDeadline = now - 60,
stack = stack:to_string(), berries = inv:get_stack("main", 2):to_string()}
for _, pos in ipairs({expired_pos, live_pos}) do C.set_node(pos, {name = "hearth:fire_lit"}) end
C.get_meta(expired_pos):set_string("fuel_until", tostring(fixture.expiredDeadline))
C.get_meta(live_pos):set_string("fuel_until", tostring(fixture.liveDeadline))
hearth.storage:set_string("m0_recovery:marker", token)
write("m0-recovery-fixture.json", fixture)
else
local fixture = read("m0-recovery-fixture.json")
assert(fixture.format == 1 and fixture.token == token, "Fixture evidence not restored")
assert(hearth.storage:get_string("m0_recovery:marker") == token, "Real mod storage marker lost")
local node = C.get_node(fixture_pos)
assert(node.name == "hearth:wall" and node.param2 == 2, "Map node did not survive backup/restore")
assert(meta:get_string("recovery_token") == token, "Real node metadata lost")
assert(equal(hearth.persistence.load_player(player), saved_needs), "Player fixture did not survive restart")
assert(meta:get_string("hearth:m0_backup:needs") == fixture.originalNeeds, "Player immutable backup changed on restart")
assert(equal(decode(fixture.originalNeeds), original_needs), "Player original fixture changed")
local inv = meta:get_inventory()
assert(inv:get_size("main") == 4 and inv:get_stack("main", 1):to_string() == fixture.stack
and inv:get_stack("main", 2):to_string() == fixture.berries
and inv:get_stack("main", 3):is_empty() and inv:get_stack("main", 4):is_empty(), "Real inventory bytes changed")
assert(inv:get_stack("main", 1):get_wear() == 1379
and inv:get_stack("main", 1):get_meta():get_string("recovery") == token, "ItemStack metadata lost")
-- Forceload activation must execute the game's real run-at-every-load LBM;
-- do not invoke its callback manually or replace the engine clock.
assert(C.get_node(expired_pos).name == "hearth:fire"
and C.get_meta(expired_pos):get_string("fuel_until") == "0", "Expired fire was not reconciled by LBM")
assert(fixture.liveDeadline > os.time(), "Fixture live deadline exceeded")
assert(C.get_node(live_pos).name == "hearth:fire_lit"
and tonumber(C.get_meta(live_pos):get_string("fuel_until")) == fixture.liveDeadline, "Absolute fire deadline extended or lost")
end
report.fixture = true
end
local function emerge(minimum, maximum, done, context)
local complete = false
C.emerge_area(minimum, maximum, function(_, action, remaining)
guard(function()
if action == C.EMERGE_ERRORED or action == C.EMERGE_CANCELLED then
failure_label = "checkpoint-" .. context .. (action == C.EMERGE_ERRORED and "-emerge-errored" or "-emerge-cancelled")
fail()
return
end
if remaining == 0 and not complete then
complete = true
C.after(0, function() guard(done) end)
end
end)
end)
end
local function await_activation()
guard(function()
failure_label = "checkpoint-fixture-activation"
no_clients()
-- Forceload activation follows the engine's active-block management
-- interval, not necessarily the emerge callback or the next server step.
-- Wait for actual LBM effects; the independent 75s watchdog bounds this.
if not C.compare_block_status(fixture_pos, "active") then
C.after(0.1, await_activation)
return
end
if phase == "restart" and (C.get_node(expired_pos).name ~= "hearth:fire"
or C.get_meta(expired_pos):get_string("fuel_until") ~= "0") then
C.after(0.1, await_activation)
return
end
fixture_check()
ready = true
C.request_shutdown("Private recovery complete", false, 0)
end)
end
C.register_on_prejoinplayer(function() return "Private offline recovery does not accept clients" end)
C.register_on_joinplayer(function() fail() end)
C.register_on_shutdown(function()
if not ready or finished then return end
local ok = pcall(function()
failure_label = "checkpoint-shutdown-evidence"
no_clients()
report.noClients = true
report.verifiedAssertions = verified_assertions
write("m0-recovery-" .. phase .. ".json", report)
end)
finished = true
if ok then C.log("action", "LAST_HEARTH_RECOVERY_PASS " .. phase .. " " .. token)
else C.log("error", "LAST_HEARTH_RECOVERY_FAIL " .. failure_label) end
end)
C.after(0, function()
guard(function()
local spawn = world_check()
local p = vector.round(spawn)
emerge(vector.offset(p, -2, -2, -2), vector.offset(p, 2, 2, 2), function()
local node = C.get_node_or_nil(p)
assert(node and node.name ~= "ignore" and C.registered_nodes[node.name], "Saved spawn map unavailable")
assert(equal(hearth.persistence.load_world("spawn"), spawn), "Spawn changed during emergence")
report.spawn = true
assert(C.forceload_block(fixture_pos, true), "Cannot activate fixture mapblock")
emerge(fixture_pos, vector.offset(fixture_pos, 3, 1, 1), function()
C.after(0, await_activation)
end, "fixture")
end, "spawn")
end)
end)
C.after(75, function() if not ready then fail() end end)
end