Files
2026-11-03 12:41:09 -05:00

262 lines
13 KiB
Lua
Executable File

-- Disposable M1 acceptance course. MAIN loads this before any player joins.
-- Launcher contract (not implemented here): fresh HEARTH_HOME/course world,
-- hearth_m1_course=true, hearth_m1_course_nonce=<new UUID>, and an exclusive
-- world/.hearth-m1-course-nonce file containing that UUID plus optional LF.
-- Use the summer fixture (UTC 1782043200, clear, latitude 45, longitude 0).
-- A setting alone NEVER authorizes editing an existing survival world.
local C, H = core, hearth
if H.course then return H.course end -- One command, generation and sampler.
local course = {}
H.course = course
local LOW, HIGH = {x = 0, y = 48, z = 0}, {x = 90, y = 58, z = 40}
local START = {x = 5, y = 50.5, z = 5}
local route = {
{15, 51, 5, "Half steps / walk without jumping"},
{26, 50.5, 5, "One-wide, two-high doorway"},
{39, 49.5, 10, "Pool / enter, swim, exit both ends"},
{50, 50.5, 7, "Two-node fall / compare landing"},
{62, 50.5, 7, "Four-node fall / compare landing"},
{71, 50.5, 10, "Logs and flint / axe and hand gathering"},
{79, 50.5, 20, "Construction / wall, roof, support preview"},
{82, 50.5, 33, "Safe arena / opt in with /practice or /spar"},
{60, 50.5, 33, "Target lane / measure reach, dodge and brace"},
{8, 50.5, 33, "Finish / /course report; /course retry"},
}
local kit = {"hearth:axe", "hearth:knife", "hearth:spear", "hearth:planks 64",
"hearth:wall 64", "hearth:roof 64", "hearth:berries 12", "hearth:water_clean 6"}
local runs, pending = {}, {}
local phase, generation_failed, generation = "idle", false, 0
local nonce, enrolled
local function finite(n) return type(n) == "number" and n == n and math.abs(n) < math.huge end
local function time() return C.get_us_time() / 1000000 end
local function feedback(player, message, kind)
if H.feedback then H.feedback(player, message, kind or "info") end
end
local function environment_ok(name)
local env = C.settings:get("hearth_environment")
if env == nil then env = C.is_singleplayer() and "local" or "production" end
if env == "production" then return false end -- Even server admins.
if env == "local" then return C.is_singleplayer() == true end
return env == "staging" and (name == nil or C.check_player_privs(name, {server = true}) == true)
end
local function uuid(value)
return type(value) == "string" and #value == 36
and value:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$") ~= nil
end
local function marker_matches(value)
local world = C.get_worldpath():gsub("\\", "/"):gsub("/+$", "")
if world:match("([^/]+)$") ~= "course" then return false end
-- No user-controlled filename, deserialization, code loading or unbounded read.
local ok, result = pcall(function()
local f = io.open(world .. "/.hearth-m1-course-nonce", "rb")
if not f then return false end
local bytes = f:read(65); f:close()
return bytes == value or bytes == value .. "\n"
end)
return ok and result == true
end
local function configured()
return C.settings:get_bool("hearth_m1_course") == true and environment_ok(nil)
and uuid(nonce) and C.settings:get("hearth_m1_course_nonce") == nonce
end
-- Enroll ONLY on a fresh world's first mod load, before normal spawn/claims
-- migration. Never mint a marker from /course or infer one from empty claims.
nonce = C.settings:get("hearth_m1_course_nonce")
if configured() and marker_matches(nonce) and H.storage then
local storage = H.storage
local existing = storage:get_string("m1_course_world_nonce")
if existing == "" and not next(H.players) and not next(H.sessions) then
local fresh = true
for _, key in ipairs({"spawn", "claims", "location", "m0_backup:spawn",
"m0_backup:claims", "m0_backup:location"}) do
if storage:get_string(key) ~= "" then fresh = false end
end
if fresh then storage:set_string("m1_course_world_nonce", nonce) end
end
enrolled = storage:get_string("m1_course_world_nonce") == nonce
end
local function authorized(name)
-- Recheck settings and privileges after asynchronous emergence as well.
return enrolled == true and configured() and environment_ok(name)
and H.storage:get_string("m1_course_world_nonce") == nonce
end
local function live(player, session)
if not player or not player:is_player() then return false end
local name = player:get_player_name()
return C.get_player_by_name(name) == player and H.sessions[name] == session
and H.players[name] ~= nil and session ~= nil and not session.waiting
and player:get_hp() > 0
end
local function inside(p)
return p and finite(p.x) and finite(p.y) and finite(p.z)
and p.x >= LOW.x and p.x <= HIGH.x and p.y >= LOW.y and p.y <= HIGH.y
and p.z >= LOW.z and p.z <= HIGH.z
end
local function put(x, y, z, node)
local p = {x = x, y = y, z = z}
assert(inside(p), "Course write outside fixed bounds")
C.set_node(p, {name = node})
end
local slab = "hearth:m1_course_half_step"
if enrolled then
C.register_node(slab, {description = "Disposable course half step", drawtype = "nodebox",
tiles = {H.texture("#a48a61")}, paramtype = "light", walkable = true,
node_box = {type = "fixed", fixed = {-0.5, -0.5, -0.5, 0.5, 0, 0.5}},
groups = {choppy = 3, not_in_creative_inventory = 1}, drop = ""})
end
local function generate()
-- Full loaded preflight BEFORE the first write. The volume is fixed (41041
-- nodes); no terrain search, on_generated callback, or per-player generation.
for x = LOW.x, HIGH.x do for z = LOW.z, HIGH.z do for y = LOW.y, HIGH.y do
local n = C.get_node_or_nil({x = x, y = y, z = z})
if not n or n.name == "ignore" then return false end
end end end
for x = LOW.x, HIGH.x do for z = LOW.z, HIGH.z do for y = LOW.y, HIGH.y do
local edge = x == 0 or x == 90 or z == 0 or z == 40
put(x, y, z, (y <= 50 or (edge and y <= 53)) and "hearth:stone" or "air")
end end end
-- .5-node rises, full-node steps and a descending ramp with no autojump need.
for x = 11, 18 do for z = 4, 6 do
local height = math.min(x - 10, 19 - x)
for y = 51, 50 + math.ceil(height / 2) do
put(x, y, z, height % 2 == 1 and y == 50 + math.ceil(height / 2) and slab or "hearth:planks")
end
end end
-- Exactly one node wide, two clear nodes high; optional separate snag.
for z = 2, 8 do for y = 51, 53 do
if z ~= 5 or y == 53 then put(26, y, z, "hearth:wall") end
end end
if C.settings:get_bool("hearth_m1_course_snag") == true then
for x = 28, 30 do put(x, 52, 9, "hearth:roof") end
end
-- Contained two-deep pool, one-deep entry/exit shelves.
for x = 34, 44 do for z = 7, 13 do
for y = (x == 34 or x == 44) and 50 or 49, 50 do put(x, y, z, "hearth:water_source") end
end end
-- Approach ramps to fall platforms, then open landing pads at floor level.
for _, fall in ipairs({{46, 2}, {56, 4}}) do
for i = 0, fall[2] do for z = 6, 8 do
for y = 51, 50 + i do put(fall[1] + i, y, z, "hearth:planks") end
end end
end
for x = 68, 72, 2 do put(x, 51, 10, "hearth:log"); put(x, 51, 12, "hearth:loose_stone") end
-- Distinct construction anchor; arena/target lane remain clear and dry.
put(77, 51, 20, "hearth:wall")
for x = 8, 66, 2 do put(x, 50, 33, "hearth:planks") end
for x = 73, 87 do for z = 28, 38 do put(x, 50, z, "hearth:planks") end end
return true
end
local function append(buffer, value)
buffer.index = buffer.index % 600 + 1
buffer[buffer.index] = value
buffer.count = math.min(600, buffer.count + 1)
end
local function samples() return {index = 0, count = 0} end
local function percentile(buffer)
if buffer.count == 0 then return "n/a" end
local sorted = {}
for i = 1, buffer.count do sorted[i] = buffer[i] end
table.sort(sorted)
return string.format("%.2f", sorted[math.ceil(#sorted * 0.95)])
end
function course.report(name)
local run = runs[name]
if not run then return "No course run in this session. Use /course." end
return string.format("Course %s: %.1fs; checkpoints %d/%d. Engine globalstep dt p95 %sms (last %d samples); course Lua callback wall duration p95 %sus. Not client FPS or CPU profiling. Hits: not instrumented.",
run.finished and "complete" or "running", (run.finished or time()) - run.started,
run.checkpoint, #route, percentile(run.dt), run.dt.count, percentile(run.lua))
end
local function begin(player, session)
local name = player:get_player_name()
if not authorized(name) or not live(player, session) then return end
-- Intentionally replaces the kit/needs only in the enrolled disposable world.
player:get_inventory():set_list("main", kit)
for key, value in pairs({food = 100, water = 100, warmth = 75, wet = 0, stamina = 100}) do
H.players[name][key] = value
end
player:set_hp(20, {type = "set_hp", from = "mod"})
player:set_pos({x = START.x, y = START.y, z = START.z})
player:set_velocity({x = 0, y = 0, z = 0})
-- Keep session combat cooldowns intact; teleport sampling is handled by combat.
runs[name] = {player = player, session = session, started = time(), checkpoint = 0,
dt = samples(), lua = samples(), next_check = 0}
feedback(player, "Course started / east: half steps, doorway, pool; /course report", "success")
C.chat_send_player(name, "Disposable course kit. Walk east through steps/doorway/pool, 2/4-node falls, logs/flint, build pad, arena then west along target lane. Use /practice or /spar explicitly; no automatic opponent. /course report shows server dt, not FPS; /course retry resets kit/needs/timer. Optional snag is at (29,51,9).")
end
local function request(player, session)
local name = player:get_player_name()
if phase == "ready" then begin(player, session); return true, "Course started. /course report for telemetry." end
if phase == "failed" then return false, "Course generation failed; relaunch a fresh disposable course world." end
pending[name] = {player = player, session = session}
if phase == "loading" then return true, "Course emergence pending." end
phase, generation_failed = "loading", false
generation = generation + 1
local ticket = generation
C.emerge_area(LOW, HIGH, function(_, action, left)
if phase ~= "loading" or ticket ~= generation then return end
if action == (C.EMERGE_CANCELLED or 0) or action == (C.EMERGE_ERRORED or 1) then generation_failed = true end
if left ~= 0 then return end
-- An opt-in request must still be live and authorized before ANY writes.
local consent = false
for n, entry in pairs(pending) do
if authorized(n) and live(entry.player, entry.session) then consent = true end
end
if not consent then pending, phase = {}, "idle"; return end
local ok, built = false, false
if not generation_failed then ok, built = pcall(generate) end
phase = ok and built and "ready" or "failed"
local queued = pending; pending = {}
for n, entry in pairs(queued) do
if authorized(n) and live(entry.player, entry.session) then
if phase == "ready" then begin(entry.player, entry.session)
else C.chat_send_player(n, "Course generation failed; relaunch a fresh disposable course world.") end
end
end
end)
return true, "Preparing bounded disposable course; inventory changes only after emergence."
end
C.register_chatcommand("course", {
params = "[retry|report]", description = "Opt in to the disposable M1 course (never production)",
func = function(name, param)
if not authorized(name) then return false, "Course denied: launch a fresh marked local/staging course world; staging requires server privilege." end
local player, session = C.get_player_by_name(name), H.sessions[name]
if not live(player, session) then return false, "Join, finish arrival and be alive before opting in." end
if param == "report" then return true, course.report(name) end
if param ~= "" and param ~= "retry" then return false, "Use /course, /course retry or /course report." end
local count = 0
for _ in pairs(runs) do count = count + 1 end
for n in pairs(pending) do if not runs[n] then count = count + 1 end end
if not runs[name] and not pending[name] and count >= 16 then return false, "Course session limit reached (16)." end
return request(player, session)
end,
})
C.register_globalstep(function(dt)
if not finite(dt) or dt <= 0 then return end
for name, run in pairs(runs) do
if not authorized(name) or not live(run.player, run.session) then runs[name] = nil
elseif not run.finished then
local before = C.get_us_time()
local t = before / 1000000
append(run.dt, dt * 1000) -- Engine-provided step interval, NOT Lua CPU time.
if t - run.started > 1800 then runs[name] = nil
elseif t >= run.next_check then
run.next_check = t + 0.1 -- No catch-up loop or terrain work in globalstep.
local p, checkpoint = run.player:get_pos(), route[run.checkpoint + 1]
if inside(p) and (p.x-checkpoint[1])^2 + (p.y-checkpoint[2])^2 + (p.z-checkpoint[3])^2 <= 25 then
run.checkpoint = run.checkpoint + 1
feedback(run.player, "Checkpoint " .. run.checkpoint .. "/" .. #route .. " / " .. checkpoint[4], "success")
if run.checkpoint == #route then run.finished = t end
end
end
append(run.lua, math.max(0, C.get_us_time() - before))
end
end
end)
C.register_on_leaveplayer(function(player)
local name = player:get_player_name(); runs[name], pending[name] = nil, nil
end)
C.register_on_dieplayer(function(player)
local name = player:get_player_name(); runs[name], pending[name] = nil, nil
end)
return course