262 lines
13 KiB
Lua
Executable File
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
|