304 lines
14 KiB
Lua
Executable File
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
|