From 6ebe95b208239e0d93dd45cdf3bea3a583a220a7 Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Sun, 1 Nov 2026 19:35:46 -0500 Subject: [PATCH] Initial commit --- LICENSE.txt | 21 ++ README.md | 160 ++++++++++++ game.conf | 7 + minetest.conf | 10 + mods/hearth/content.lua | 520 +++++++++++++++++++++++++++++++++++++ mods/hearth/encounters.lua | 75 ++++++ mods/hearth/init.lua | 51 ++++ mods/hearth/interface.lua | 122 +++++++++ mods/hearth/mod.conf | 5 + mods/hearth/model.lua | 72 +++++ mods/hearth/runtime.lua | 221 ++++++++++++++++ scripts/assets.mjs | 79 ++++++ scripts/run.mjs | 257 ++++++++++++++++++ settingtypes.txt | 5 + tests/content_spec.lua | 370 ++++++++++++++++++++++++++ tests/engine_smoke.lua | 169 ++++++++++++ tests/model_spec.lua | 60 +++++ 17 files changed, 2204 insertions(+) create mode 100755 LICENSE.txt create mode 100755 README.md create mode 100755 game.conf create mode 100755 minetest.conf create mode 100755 mods/hearth/content.lua create mode 100755 mods/hearth/encounters.lua create mode 100755 mods/hearth/init.lua create mode 100755 mods/hearth/interface.lua create mode 100755 mods/hearth/mod.conf create mode 100755 mods/hearth/model.lua create mode 100755 mods/hearth/runtime.lua create mode 100755 scripts/assets.mjs create mode 100755 scripts/run.mjs create mode 100755 settingtypes.txt create mode 100755 tests/content_spec.lua create mode 100755 tests/engine_smoke.lua create mode 100755 tests/model_spec.lua diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100755 index 0000000..bda1b3c --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 eom.dev contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100755 index 0000000..ec6eb1e --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# The Last Hearth + +An original, standalone Luanti survival prototype: gather flint and sticks, make +tools, build a supported shelter, fuel a hearth, cook forage, and boil water. +This is an **environment simulation prototype, not a full physics simulator**. +It does not depend on Minetest Game (MTG), nor claim feature parity with another +survival game. Original game code and original procedural assets are MIT-licensed; +the separately distributed Luanti engine retains its own licenses. + +Season VI planning: [launch feature checklist](../../doc/season-vi-launch-features.md) +and [implementation roadmap](../../doc/season-vi-roadmap.md). The prototype is the +baseline, not a release candidate. + +## Local Windows launcher + +Requires Node.js 22+ (built-in modules only) and the official Luanti 5.18.0 Windows +64-bit distribution already installed under +`%LOCALAPPDATA%/last-hearth-runtime/luanti-5.18.0-win64/`. +Its executable is `bin/luanti.exe` within that runtime. The previously verified +distribution archive SHA-256 is +`bebce52b58555d8208bf16767d0717788479fef23c35ec8d7bf287774d641835`. +This is an archive digest, **not** an executable digest. The launcher does not +download software or pretend to reverify the archive from its extracted files. +Keep the runtime directory writable only by trusted local users. + +From this game directory: + +```text +node scripts/run.mjs check +node scripts/run.mjs smoke +node scripts/run.mjs test +node scripts/run.mjs play +``` + +- **check:** start a disposable headless engine world and compile all game and + test Lua files using the engine's Lua implementation. Loading the game also + catches startup errors. Standalone mocked tests are compiled, not executed. +- **smoke:** additionally verify the real `hearth` global, standalone content and + MTG absence, registered recipes and actual engine craft results, biome aliases, + and asynchronous map emergence with real registered terrain, required starting + resources, and functional enclosure checks. Model and content unit suites run + in isolated Lua environments: the mocked `core` and `os.time` never replace + globals in the running game. +- **test:** run both engine checks, including 19 content regressions and 11 + solar/climate/needs tests. No separate Lua interpreter is required. If + `HEARTH_LUA` is provided, also execute the standalone harnesses with that local + interpreter. It must be an absolute executable path. +- **play:** launch the graphical engine directly into a persistent singleplayer + world. This is the only command that opens gameplay; checks never launch it. + The normal engine camera, mouse and keyboard controls apply. Consult the + engine's Controls menu for bindings. + +The main game files must exist before these commands can run; incomplete parallel +development produces an explicit missing-file error, not a passing result. + +## Location, privacy and survival + +## First play + +- Press **I** for the crafting grid and field notes; `/guide` reopens the guide. +- Gather flint, sticks, berries and thatching rushes. Make an axe to harvest logs. +- Build supported walls/roof and fuel a stone hearth with sticks or logs. +- Fill a carved cup at water, then use it on a fueled hearth before drinking. +- Hold **Aux1** (normally E) while moving forward to sprint. +- Use `/practice` in local play to place the opt-in sparring target. Strike to + start, step away to evade, or hold right-click with a spear to brace. +- `/claim` protects one homestead; `/claim remove` releases it. + +There is deliberately no automatically granted equipment, fast travel, PvP, +quest system, or downloaded mod collection. + +### Known prototype limits + +Movement uses engine prediction and modest sprint overrides; this is **not** a +custom animated combat controller. The practice opponent is a stationary, +non-lethal sparring device, not wildlife AI. Tool silhouettes, material tiles and +the simple player mesh are original generated assets, not final character art. +Cooking consumes fuel immediately; needs and heat are game approximations. +Structural support is checked on wall/roof placement only; blocks do not collapse +when supports are removed, and generic terrain/planks are not structural physics. +No food spoilage, crops, insulation clothing, positional audio or animal hunting +is implemented. Atmospheric daylight approximates solar elevation through the +engine's fixed orbit; calendar labels are meteorological seasons. Real-world civil +timezones/DST display is not implemented: UTC and local solar time are explicit. + +The launcher generates PNG textures and an OBJ mesh from original deterministic +code into its managed runtime copy. If installing elsewhere, first stage with +this launcher or call the exported asset generator for that destination. + +### Location selection + +Optionally supply **both** `HEARTH_LATITUDE` and `HEARTH_LONGITUDE` as decimal +degrees before launch. The launcher validates latitude −90…90 and longitude +−180…180, then writes the game's `hearth_latitude` and `hearth_longitude` +settings. Coordinates are user-selected, not automatically detected. Neither +the launcher nor this setup uses IP geolocation or a remote location service. +Coordinates are stored in the local generated configuration; avoid publishing it. + +Without coordinates, use the singleplayer game's location prompt or `/location` +command. A server deployment must explicitly select its location rather than +silently assuming a player's location. This launcher is **not** a public server +deployment tool. Location controls the environment model's local solar time, +daylight, seasons and temperature; these are approximations, not live weather. +Disposable checks explicitly select a synthetic 45°N, 0°E test location when the +environment variables are absent; that fixture is never used for play. + +Damage and survival are enabled; creative mode and PvP are disabled. Needs do not +decay while the player is offline. Hearth fuel can still expire by real elapsed +time; that is distinct from offline player-needs decay. This prototype does not +provide accounts, credentials, federation, or public matchmaking. + +## Runtime storage and safety + +The launcher recursively copies the game into the runtime engine's +`games/last_hearth/` search directory, omitting `scripts`, `tests`, and `.git`. +It uses regular-file copying rather than `fs.cp` to support the repository's +Windows SMB location. Symlinks and junctions are rejected. Only copies marked as +launcher-managed can be replaced; an existing unmanaged installation is left +untouched. A runtime lock prevents simultaneous launchers from replacing a game +while it is running. After a crash, remove the lock only after confirming the +old launcher and engine have stopped. + +Persistent play data lives under +`%LOCALAPPDATA%/last-hearth-runtime/worlds/last_hearth/`. +Back up this directory before changing game versions. Launch configuration is +regenerated on every play run; environment settings are not silently inherited +from an old configuration. The launcher never deletes the persistent world. + +Checks stage their test mod under a disposable world's +`worldmods/hearth_smoke/init.lua`, then delete that world's files on completion +or failure. Test success requires **both** a zero engine exit status and a unique +per-run success marker. Lua assertions, startup errors, missing markers, process +failures, or deadlines cause a nonzero launcher exit. Engine checks are bounded +to 90 seconds each, their internal watchdog to 60 seconds, and standalone tests +to 30 seconds each. Play has an eight-hour safety limit; exit normally before it +to avoid a forced termination. Ctrl+C terminates the child process; normal in-game +Quit is preferable for saving a play session. UTF-8 output is streamed with a +bounded diagnostic tail; the persistent play log remains in the runtime root. + +Generated settings include: + +```ini +enable_damage = true +enable_pvp = false +creative_mode = false +bind_address = 127.0.0.1 +server_announce = false +enable_ipv6 = false +time_speed = 0 +movement_speed_walk = 4 +movement_speed_fast = 6 +movement_speed_jump = 6.5 +secure.enable_security = true +``` + +Smoke/check use a randomly selected high UDP port, loopback binding, and zero +allowed users. No public bind, remote inputs, shell commands, credentials, or +trusted-mod security bypasses are configured. A port collision fails visibly; +it is never treated as success. Local filesystem permissions remain those of the +current Windows account; the launcher does not claim to provision Windows ACLs. diff --git a/game.conf b/game.conf new file mode 100755 index 0000000..51713b8 --- /dev/null +++ b/game.conf @@ -0,0 +1,7 @@ +title = The Last Hearth +author = eom.dev +description = Make shelter. Stay warm and fed. Build a life that can endure. +allowed_mapgens = v7 +mapgen = v7 +first_mod = hearth +min_minetest_version = 5.18 diff --git a/minetest.conf b/minetest.conf new file mode 100755 index 0000000..ddfafb4 --- /dev/null +++ b/minetest.conf @@ -0,0 +1,10 @@ +time_speed = 0 +enable_damage = true +enable_pvp = false +creative_mode = false +movement_speed_walk = 4 +movement_speed_jump = 6.5 +movement_acceleration_default = 4 +movement_acceleration_air = 2 +mapgen_limit = 512 +mgv7_spflags = mountains,ridges,nofloatlands,caverns diff --git a/mods/hearth/content.lua b/mods/hearth/content.lua new file mode 100755 index 0000000..43afd6e --- /dev/null +++ b/mods/hearth/content.lua @@ -0,0 +1,520 @@ +-- Original, asset-free content for The Last Hearth (Luanti 5.18). +-- Survival state is owned/persisted by the caller; numeric needs use 0..100. +local api = core +local MAX_REACH, MAX_FUEL = 6, 600 +local FUEL = { ["hearth:sticks"] = 45, ["hearth:log"] = 180 } +local COOK_COST, BOIL_COST = 20, 10 +local directions = { + {x = 1, y = 0, z = 0}, {x = -1, y = 0, z = 0}, + {x = 0, y = 0, z = 1}, {x = 0, y = 0, z = -1}, +} + +local function texture(color) + return hearth.texture(color) +end + +local function lowpart(percent, color) + -- Texture-name arguments must escape modifiers/colons, including [fill. + local overlay = texture(color):gsub("[\\^:]", function(c) return "\\" .. c end) + return "^[lowpart:" .. percent .. ":" .. overlay +end + +local function near(player, pos) + if not player or not player:is_player() then return false end + local p = player:get_pos() + if not p then return false end + local dx, dy, dz = p.x - pos.x, p.y - pos.y, p.z - pos.z + return dx * dx + dy * dy + dz * dz <= MAX_REACH * MAX_REACH +end + +local function allowed(pos, player) + if not near(player, pos) then return false end + local name = player:get_player_name() + if api.is_protected(pos, name) or not hearth.claim_allowed(pos, name) then + api.record_protection_violation(pos, name) + return false + end + return true +end + +local function can_dig(pos, player) + return allowed(pos, player) +end + +local function node_def(pos) + local node = api.get_node_or_nil(pos) + return node and api.registered_nodes[node.name], node +end + +local function placement_pos(pointed) + if not pointed or pointed.type ~= "node" then return nil end + local under = node_def(pointed.under) + local above = node_def(pointed.above) + if not under or not above then return nil end + if under.buildable_to then return pointed.under end + if above.buildable_to then return pointed.above end +end + +local function place(stack, player, pointed) + if not pointed or pointed.type ~= "node" then return stack end + local def = node_def(pointed.under) + -- item_place dispatches right-click callbacks before attempting placement. + if def and def.on_rightclick and player and not player:get_player_control().sneak then + if not allowed(pointed.under, player) then return stack end + return api.item_place(stack, player, pointed) + end + local pos = placement_pos(pointed) + if not pos or not allowed(pos, player) then return stack end + return api.item_place(stack, player, pointed) +end + +local function is_building(name) + return name == "hearth:wall" or name == "hearth:roof" +end + +local function solid(def) + return def and def.walkable ~= false and not def.buildable_to + and (not def.liquidtype or def.liquidtype == "none") + and not (def.groups and def.groups.leaves) +end + +local function supported(pos) + -- Search existing construction only: at most two horizontal links and + -- sixteen blocks down. The horizontal budget never resets at a new block, + -- so sequential placement cannot extend a cantilever indefinitely. + local queue = {{pos = pos, span = 0, depth = 0}} + local seen, index = {}, 1 + while index <= #queue do + local entry = queue[index] + index = index + 1 + local p = entry.pos + local key = p.x .. ":" .. p.y .. ":" .. p.z .. ":" .. entry.span + if not seen[key] then + seen[key] = true + local below = {x = p.x, y = p.y - 1, z = p.z} + local def, node = node_def(below) + if solid(def) then + if not is_building(node.name) then return true end + if entry.depth < 15 then + queue[#queue + 1] = {pos = below, span = entry.span, + depth = entry.depth + 1} + end + end + if entry.span < 2 then + for _, d in ipairs(directions) do + local adjacent = {x = p.x + d.x, y = p.y, z = p.z + d.z} + local _, n = node_def(adjacent) + if n and is_building(n.name) then + queue[#queue + 1] = {pos = adjacent, span = entry.span + 1, + depth = entry.depth} + end + end + end + end + end + return false +end + +local function place_building(stack, player, pointed) + if not pointed or pointed.type ~= "node" then return stack end + local under = node_def(pointed.under) + if under and under.on_rightclick and player and not player:get_player_control().sneak then + if not allowed(pointed.under, player) then return stack end + return api.item_place(stack, player, pointed) + end + local pos = placement_pos(pointed) + if not pos or not allowed(pos, player) then return stack end + if not supported(pos) then + hearth.notify(player, "Needs solid support within two horizontal blocks (maximum height 16).") + return stack + end + return api.item_place(stack, player, pointed) +end + +local function owner(pos, player) + if player and player:is_player() then + api.get_meta(pos):set_string("owner", player:get_player_name()) + end +end + +local function register_node(name, def) + def.on_place = def.on_place or place + def.can_dig = can_dig + api.register_node("hearth:" .. name, def) +end + +register_node("stone", { + description = "Weathered stone", tiles = {texture("#79766b") .. "^[contrast:12"}, + groups = {cracky = 3, stone = 1}, drop = "hearth:loose_stone 3", +}) +register_node("soil", { + description = "Earth", tiles = {texture("#68513c")}, + groups = {crumbly = 3, soil = 1}, +}) +register_node("grass", { + description = "Meadow turf", tiles = {texture("#66764a"), texture("#68513c"), + texture("#68513c") .. lowpart(25, "#66764a")}, + groups = {crumbly = 3, soil = 1}, drop = "hearth:soil", +}) +register_node("sand", { + description = "River sand", tiles = {texture("#b7a47a")}, + groups = {crumbly = 3, falling_node = 1, sand = 1}, +}) + +for _, flowing in ipairs({false, true}) do + local name = flowing and "water_flowing" or "water_source" + register_node(name, { + description = flowing and "Flowing river water" or "River water (unsafe to drink)", + drawtype = flowing and "flowingliquid" or "liquid", + tiles = {texture("#527e82") .. "^[opacity:185"}, + special_tiles = { + {name = texture("#527e82") .. "^[opacity:185", backface_culling = false}, + {name = texture("#527e82") .. "^[opacity:185", backface_culling = true}, + }, + paramtype = "light", paramtype2 = flowing and "flowingliquid" or nil, + use_texture_alpha = "blend", walkable = false, pointable = false, + diggable = false, buildable_to = true, drowning = 1, + liquidtype = flowing and "flowing" or "source", + liquid_alternative_flowing = "hearth:water_flowing", + liquid_alternative_source = "hearth:water_source", + liquid_viscosity = 1, liquid_renewable = false, liquid_range = 4, + post_effect_color = {a = 100, r = 48, g = 83, b = 88}, + groups = {water = 3, liquid = 3, not_in_creative_inventory = 1}, drop = "", + }) +end + +register_node("log", { + description = "Timber log (requires an axe)", + tiles = {texture("#a18a62"), texture("#a18a62"), texture("#564534")}, + paramtype2 = "facedir", groups = {tree = 1, choppy = 2, flammable = 2}, +}) +register_node("leaves", { + description = "Canopy leaves", drawtype = "allfaces_optional", + tiles = {texture("#536744")}, paramtype = "light", sunlight_propagates = true, + groups = {snappy = 3, leaves = 1, flammable = 2}, drop = "hearth:sticks", +}) + +register_node("loose_stone", { + description = "Loose flint stone", drawtype = "nodebox", + tiles = {texture("#96938b")}, inventory_image = texture("#96938b"), + wield_image = texture("#96938b"), paramtype = "light", walkable = false, + node_box = {type = "fixed", fixed = {-0.25, -0.5, -0.2, 0.25, -0.32, 0.2}}, + selection_box = {type = "fixed", fixed = {-0.3, -0.5, -0.3, 0.3, -0.2, 0.3}}, + groups = {dig_immediate = 3, flint = 1}, +}) +register_node("sticks", { + description = "Dry sticks", drawtype = "nodebox", tiles = {texture("#8e7451")}, + inventory_image = texture("#8e7451"), wield_image = texture("#8e7451"), + paramtype = "light", walkable = false, + node_box = {type = "fixed", fixed = {-0.4, -0.48, -0.06, 0.4, -0.4, 0.06}}, + groups = {dig_immediate = 3, flammable = 2}, +}) + +local function eat(amounts, replacement) + return function(stack, player) + if not player or not player:is_player() then return stack end + local state = hearth.players[player:get_player_name()] + if not state then return stack end + local needed = false + for key, value in pairs(amounts) do + if type(state[key]) == "number" and state[key] < 100 and value > 0 then + needed = true + end + end + if not needed then return stack end + for key, value in pairs(amounts) do + if type(state[key]) == "number" then + state[key] = math.max(0, math.min(100, state[key] + value)) + end + end + if replacement then stack:replace(replacement) else stack:take_item() end + return stack + end +end + +api.register_craftitem("hearth:berries", { + description = "Wild berries", inventory_image = texture("#995a65"), + on_use = eat({food = 12, water = 4}), +}) +api.register_craftitem("hearth:raw_food", { + description = "Foraged food (cook at a hearth)", inventory_image = texture("#ad8263"), +}) +api.register_craftitem("hearth:cooked_food", { + description = "Hearth-cooked forage", inventory_image = texture("#bf985b"), + on_use = eat({food = 35, stamina = 10}), +}) + +register_node("berry_bush", { + description = "Wild berry bush", drawtype = "allfaces_optional", + tiles = {texture("#63734b") .. lowpart(20, "#995a65")}, + paramtype = "light", walkable = false, sunlight_propagates = true, + groups = {snappy = 3, flora = 1, flammable = 2}, drop = "hearth:berries 3", +}) +register_node("reeds", { + description = "Thatching rushes", drawtype = "plantlike", tiles = {texture("#a89965")}, + inventory_image = texture("#a89965"), wield_image = texture("#a89965"), + paramtype = "light", walkable = false, sunlight_propagates = true, + groups = {snappy = 3, flora = 1, flammable = 2}, +}) +register_node("planks", { + description = "Split timber planks", tiles = {texture("#a48a61")}, + groups = {choppy = 3, wood = 1, flammable = 2}, +}) +register_node("wall", { + description = "Timber wall", tiles = {texture("#947853")}, + groups = {choppy = 3, wood = 1, flammable = 2}, + on_place = place_building, after_place_node = owner, +}) +register_node("roof", { + description = "Thatch roof", tiles = {texture("#b4a16b")}, + groups = {snappy = 3, flammable = 3}, + on_place = place_building, after_place_node = owner, +}) + +-- No hand choppy/cracky capability: timber needs an axe, stone a pick. +api.register_item(":", { + type = "none", wield_image = texture("#c7aa83"), wield_scale = {x = 1, y = 1, z = 1}, + tool_capabilities = { + full_punch_interval = 1, max_drop_level = 0, + groupcaps = { + crumbly = {times = {[3] = 1.4}, uses = 0, maxlevel = 1}, + snappy = {times = {[3] = 0.6}, uses = 0, maxlevel = 1}, + }, damage_groups = {fleshy = 1}, + }, +}) + +local function tool(name, description, color, groupcaps, damage, interval) + api.register_tool("hearth:" .. name, { + description = description, inventory_image = "hearth_icon_" .. name .. ".png", + tool_capabilities = {full_punch_interval = interval, max_drop_level = 1, + groupcaps = groupcaps, damage_groups = {fleshy = damage}}, + }) +end +tool("knife", "Flint knife", "#b0aaa0", { + snappy = {times = {[3] = 0.2, [2] = 0.5, [1] = 1}, uses = 45, maxlevel = 1}, +}, 3, 0.7) +tool("axe", "Flint axe", "#89877e", { + choppy = {times = {[3] = 0.8, [2] = 1.8, [1] = 3}, uses = 40, maxlevel = 1}, +}, 4, 1.1) +tool("pick", "Flint pick", "#8e9292", { + cracky = {times = {[3] = 2.4}, uses = 35, maxlevel = 1}, +}, 3, 1.2) +tool("spear", "Flint spear", "#aa9a7a", {}, 5, 1.2) + +local function fuel_left(pos, now) + local deadline = tonumber(api.get_meta(pos):get_string("fuel_until")) or 0 + -- Corrupt/future metadata cannot produce an unbounded fuel reserve. + if deadline ~= deadline then return 0 end + return math.max(0, math.min(MAX_FUEL, deadline - now)) +end + +local function refresh_fire(pos, remaining, now) + local meta = api.get_meta(pos) + meta:set_string("fuel_until", tostring(remaining > 0 and now + remaining or 0)) + meta:set_string("infotext", remaining > 0 + and ("Last Hearth: " .. math.ceil(remaining) .. "s fuel; feed sticks/logs, cook forage or boil water") + or "Cold hearth: feed dry sticks or a log") + local node = api.get_node(pos) + local name = remaining > 0 and "hearth:fire_lit" or "hearth:fire" + if node.name ~= name then + api.swap_node(pos, {name = name, param2 = node.param2}) + end + if remaining > 0 then + api.get_node_timer(pos):start(math.min(5, remaining)) + else + api.get_node_timer(pos):stop() + end +end + +local function fire_tick(pos) + local now = os.time() + refresh_fire(pos, fuel_left(pos, now), now) + return false -- refresh_fire schedules the next absolute-deadline check. +end + +local function use_fire(pos, _, player, stack) + if not allowed(pos, player) or stack:is_empty() then return stack end + local now = os.time() + local remaining = fuel_left(pos, now) + local item = stack:get_name() + local burn = FUEL[item] + if burn then + if remaining + burn <= MAX_FUEL then + stack:take_item(1) + remaining = remaining + burn + else + hearth.notify(player, "The hearth has no room for that much fuel yet.") + end + else + local cost, result + if item == "hearth:raw_food" then + cost, result = COOK_COST, "hearth:cooked_food" + elseif item == "hearth:water_raw" then + cost, result = BOIL_COST, "hearth:water_clean" + end + if cost then + local inv = player:get_inventory() + -- A multi-item cooking stack needs room before either input or fuel + -- is touched. Single-item inputs are replaced directly in the hand. + if remaining < cost then + hearth.notify(player, "Add fuel before cooking or boiling water.") + elseif stack:get_count() == 1 then + stack:replace(result) + remaining = remaining - cost + elseif inv and inv:room_for_item("main", result) then + local leftover = inv:add_item("main", result) + if leftover:is_empty() then + stack:take_item(1) + remaining = remaining - cost + end + else + hearth.notify(player, "Make inventory space for the cooked food.") + end + end + end + refresh_fire(pos, remaining, now) + return stack +end + +for _, lit in ipairs({false, true}) do + register_node(lit and "fire_lit" or "fire", { + description = "Stone hearth", drawtype = "nodebox", paramtype = "light", + tiles = {texture(lit and "#dd9952" or "#6e695e")}, + node_box = {type = "fixed", fixed = {-0.5, -0.5, -0.5, 0.5, -0.15, 0.5}}, + light_source = lit and 10 or 0, drop = "hearth:fire", + groups = {cracky = 3, dig_immediate = 2, not_in_creative_inventory = lit and 1 or nil}, + on_construct = function(pos) refresh_fire(pos, 0, os.time()) end, + after_place_node = owner, on_rightclick = use_fire, on_timer = fire_tick, + -- There is deliberately no hearth inventory/formspec. Deny injected + -- inventory actions as well, including actions against legacy metadata. + allow_metadata_inventory_put = function() return 0 end, + allow_metadata_inventory_take = function() return 0 end, + allow_metadata_inventory_move = function() return 0 end, + }) +end +api.register_lbm({ + name = "hearth:expire_fire", label = "Reconcile real-time hearth fuel", + nodenames = {"hearth:fire", "hearth:fire_lit"}, run_at_every_load = true, + action = fire_tick, +}) + +api.register_craftitem("hearth:vessel", { + description = "Carved stone cup (place on water to fill)", + inventory_image = texture("#a19a88"), stack_max = 1, liquids_pointable = true, + on_place = function(stack, player, pointed) + if not pointed or pointed.type ~= "node" then return stack end + local pos = pointed.under + if not allowed(pos, player) then return stack end + local node = api.get_node(pos) + if node.name == "hearth:water_source" or node.name == "hearth:water_flowing" then + stack:replace("hearth:water_raw") + end + return stack + end, +}) +api.register_craftitem("hearth:water_raw", { + description = "Stone cup of untreated water (boil at a hearth)", + inventory_image = texture("#527e82"), stack_max = 1, + -- Primary use/placement on a hearth also works without targeting its top. + on_use = function(stack, player, pointed) + if pointed and pointed.type == "node" then + local node = api.get_node(pointed.under) + if node.name == "hearth:fire" or node.name == "hearth:fire_lit" then + return use_fire(pointed.under, node, player, stack) + end + end + return stack + end, + on_place = function(stack, player, pointed) + if pointed and pointed.type == "node" then + local node = api.get_node(pointed.under) + if node.name == "hearth:fire" or node.name == "hearth:fire_lit" then + return use_fire(pointed.under, node, player, stack) + end + end + return stack + end, +}) +api.register_craftitem("hearth:water_clean", { + description = "Stone cup of boiled water", inventory_image = texture("#82adb0"), + stack_max = 1, on_use = eat({water = 40}, "hearth:vessel"), +}) + +local recipes = { + {"hearth:knife", {{"hearth:loose_stone", "hearth:loose_stone"}, {"", "hearth:sticks"}}}, + {"hearth:axe", {{"hearth:loose_stone", "hearth:loose_stone"}, {"hearth:loose_stone", "hearth:sticks"}, {"", "hearth:sticks"}}}, + {"hearth:pick", {{"hearth:loose_stone", "hearth:loose_stone", "hearth:loose_stone"}, {"", "hearth:sticks", ""}, {"", "hearth:sticks", ""}}}, + {"hearth:spear", {{"hearth:loose_stone"}, {"hearth:sticks"}, {"hearth:sticks"}}}, + {"hearth:vessel", {{"hearth:loose_stone", "", "hearth:loose_stone"}, {"", "hearth:loose_stone", ""}}}, + {"hearth:fire", {{"hearth:loose_stone", "hearth:loose_stone", "hearth:loose_stone"}, {"hearth:loose_stone", "", "hearth:loose_stone"}, {"hearth:loose_stone", "hearth:loose_stone", "hearth:loose_stone"}}}, + {"hearth:wall 4", {{"hearth:planks", "hearth:planks"}, {"hearth:planks", "hearth:planks"}}}, + {"hearth:roof 4", {{"hearth:reeds", "hearth:reeds", "hearth:reeds"}, {"hearth:sticks", "", "hearth:sticks"}}}, +} +for _, recipe in ipairs(recipes) do + api.register_craft({output = recipe[1], recipe = recipe[2]}) +end +api.register_craft({type = "shapeless", output = "hearth:planks 4", recipe = {"hearth:log"}}) +api.register_craft({type = "shapeless", output = "hearth:sticks 4", recipe = {"hearth:planks"}}) +api.register_craft({type = "shapeless", output = "hearth:raw_food", recipe = {"hearth:berries", "hearth:berries"}}) + +-- Do not clear other mods' registrations or change global protection/mapgen. +api.register_alias("mapgen_stone", "hearth:stone") +api.register_alias("mapgen_water_source", "hearth:water_source") +api.register_alias("mapgen_river_water_source", "hearth:water_source") +api.register_biome({ + name = "hearth_temperate", node_top = "hearth:grass", depth_top = 1, + node_filler = "hearth:soil", depth_filler = 3, node_stone = "hearth:stone", + node_water_top = "hearth:water_source", depth_water_top = 1, + node_water = "hearth:water_source", node_river_water = "hearth:water_source", + node_riverbed = "hearth:sand", depth_riverbed = 2, + y_min = 2, y_max = 31000, heat_point = 50, humidity_point = 50, +}) +api.register_biome({ + name = "hearth_riverbank", node_top = "hearth:sand", depth_top = 1, + node_filler = "hearth:sand", depth_filler = 2, node_stone = "hearth:stone", + node_water = "hearth:water_source", node_river_water = "hearth:water_source", + node_riverbed = "hearth:sand", depth_riverbed = 2, + y_min = -31000, y_max = 1, heat_point = 50, humidity_point = 50, +}) + +local function decoration(name, surfaces, ratio, ymin, ymax) + api.register_decoration({ + name = "hearth:" .. name, deco_type = "simple", place_on = surfaces, + sidelen = 16, fill_ratio = ratio, y_min = ymin, y_max = ymax, + decoration = "hearth:" .. name, + }) +end +decoration("loose_stone", {"hearth:grass", "hearth:sand"}, 0.035, 1, 200) +decoration("sticks", {"hearth:grass"}, 0.03, 2, 200) +decoration("berry_bush", {"hearth:grass"}, 0.018, 2, 120) +decoration("reeds", {"hearth:sand", "hearth:grass"}, 0.025, 1, 200) + +-- Small seep springs make upland starts viable without handing out supplies. +-- Replace one surface block: surrounding earth contains water where level; +-- natural slopes can create a short flowing seep instead. +api.register_decoration({ + name = "hearth:seep_springs", deco_type = "simple", place_on = {"hearth:grass"}, + sidelen = 16, fill_ratio = 0.0015, y_min = 3, y_max = 150, + decoration = "hearth:water_source", place_offset_y = -1, flags = "force_placement", +}) + +-- Table schematics are x-fastest, then y, then z; no external assets. +local tree = {size = {x = 5, y = 6, z = 5}, data = {}} +for z = 0, 4 do + for y = 0, 5 do + for x = 0, 4 do + local trunk = x == 2 and z == 2 and y < 5 + local canopy = y >= 3 and math.abs(x - 2) + math.abs(z - 2) <= (y == 5 and 1 or 3) + tree.data[#tree.data + 1] = { + name = trunk and "hearth:log" or (canopy and "hearth:leaves" or "air"), + prob = (trunk or canopy) and 255 or 0, + } + end + end +end +api.register_decoration({ + name = "hearth:temperate_trees", deco_type = "schematic", + place_on = {"hearth:grass"}, sidelen = 16, fill_ratio = 0.006, + y_min = 3, y_max = 150, schematic = tree, rotation = "random", + flags = "place_center_x,place_center_z", +}) diff --git a/mods/hearth/encounters.lua b/mods/hearth/encounters.lua new file mode 100755 index 0000000..60c701f --- /dev/null +++ b/mods/hearth/encounters.lua @@ -0,0 +1,75 @@ +local C = core +-- A deliberately artificial sparring target: opt-in, persistent, non-lethal. +-- No claim of production combat animation/prediction: validates the first loop. +C.register_entity("hearth:practice", { + initial_properties = {physical = true, collide_with_objects = false, + collisionbox = {-0.3, 0, -0.3, 0.3, 1.6, 0.3}, visual = "cube", + visual_size = {x = 0.6, y = 1.6, z = 0.6}, + textures = {hearth.texture("#c7aa83"), hearth.texture("#947853"), hearth.texture("#947853"), + hearth.texture("#947853"), hearth.texture("#947853"), hearth.texture("#947853")}, + pointable = true, hp_max = 100, static_save = true, + nametag = "PRACTICE / strike to spar · step back to stop", nametag_color = "#e5c890"}, + on_activate = function(self) + self.object:set_armor_groups({immortal = 1}) + self.object:set_acceleration({x = 0, y = -9.81, z = 0}) + self.health, self.cooldowns = 30, {} + end, + on_punch = function(self, puncher) + if not puncher or not puncher:is_player() then return true end + local pos, p = self.object:get_pos(), puncher:get_pos() + if not pos or not p or vector.distance(pos, p) > 3.5 then return true end + if not C.line_of_sight(vector.offset(p, 0, 1.2, 0), vector.offset(pos, 0, 0.8, 0), 0.25) then return true end + local name = puncher:get_player_name() + local state = hearth.players[name] + if not state then return true end + local tool = puncher:get_wielded_item():get_name() + local attacks = {['hearth:spear'] = {5, 1.2}, ['hearth:axe'] = {4, 1.1}, ['hearth:knife'] = {3, 0.7}} + local attack = attacks[tool] or {1, 1} + local now = C.get_us_time() / 1000000 + if now - (self.cooldowns[name] or -100) < attack[2] or state.stamina < 10 then return true end + self.cooldowns[name] = now + state.stamina = state.stamina - 10 + self.health = self.health - attack[1] + -- A faster weapon cannot postpone an already committed counter forever. + if not self.target then self.target, self.delay = name, 0.75 end + self.object:set_properties({nametag = "COUNTER INCOMING / brace or step away", nametag_color = "#f38ba8"}) + if self.health <= 0 then + self.health, self.target = 30, nil + self.object:set_properties({nametag = "ROUND COMPLETE / ready again", nametag_color = "#a6e3a1"}) + hearth.notify(puncher, "Practice complete. Good footwork saves stamina; right-click your spear to brace.") + else hearth.notify(puncher, "Hit confirmed / target " .. self.health .. "/30") end + return true + end, + on_step = function(self, dt) + if not self.target then return end + self.delay = self.delay - math.min(dt, 0.1) + if self.delay > 0 then return end + local player = C.get_player_by_name(self.target) + if player and player:get_hp() > 2 then + local p, pos = player:get_pos(), self.object:get_pos() + if vector.distance(p, pos) < 2.5 and C.line_of_sight(vector.offset(pos, 0, 1, 0), vector.offset(p, 0, 1, 0), 0.25) then + local state, session = hearth.players[self.target], hearth.sessions[self.target] + local blocking = state and session and session.blocking and state.stamina >= 8 + if blocking then state.stamina = state.stamina - 8 end + player:set_hp(math.max(2, player:get_hp() - (blocking and 1 or 3)), {type = "set_hp", from = "mod"}) + hearth.notify(player, blocking and "Braced / reduced impact" or "Counter landed / step back beyond reach") + end + end + self.target = nil + self.object:set_properties({nametag = "PRACTICE / " .. self.health .. "/30 · strike to spar", nametag_color = "#e5c890"}) + end, +}) + +C.register_chatcommand("practice", {description = "Place one practice opponent near you (local play only)", + func = function(name) + if not C.is_singleplayer() then return false, "Practice placement is local-play only." end + local player = C.get_player_by_name(name) + if not player then return false end + local pos = vector.round(vector.add(player:get_pos(), vector.multiply(player:get_look_dir(), 3))) + for _, obj in ipairs(C.get_objects_inside_radius(pos, 30)) do + local e = obj:get_luaentity() + if e and e.name == "hearth:practice" then return false, "A practice target is already nearby." end + end + if C.get_node(pos).name ~= "air" or C.is_protected(pos, name) then return false, "Look at an open space nearby." end + return C.add_entity(pos, "hearth:practice") ~= nil, "Practice target placed. Strike it to start; step away to stop." + end}) diff --git a/mods/hearth/init.lua b/mods/hearth/init.lua new file mode 100755 index 0000000..e26c97f --- /dev/null +++ b/mods/hearth/init.lua @@ -0,0 +1,51 @@ +hearth = {players = {}, sessions = {}} +local path = core.get_modpath("hearth") +hearth.model = dofile(path .. "/model.lua") +-- Original texture generation: no downloaded assets or asset licensing ambiguity. +function hearth.texture(color) + return "hearth_" .. color:sub(2):lower() .. ".png" +end +function hearth.notify(player, message) + if player then core.chat_send_player(player:get_player_name(), message) end +end +local storage = core.get_mod_storage() +hearth.storage = storage +-- A small claim registry is persisted in mod storage; never deserialize code. +local claim_data = storage:get_string("claims") +local claims = claim_data ~= "" and core.parse_json(claim_data) or {} +function hearth.claim_allowed(pos, name) + for owner, p in pairs(claims) do + if owner ~= name and math.abs(pos.x - p.x) <= 12 and math.abs(pos.z - p.z) <= 12 + and math.abs(pos.y - p.y) <= 24 then return false end + end + return true +end +local old_protected = core.is_protected +core.is_protected = function(pos, name) + return not hearth.claim_allowed(pos, name) or old_protected(pos, name) +end +core.register_chatcommand("claim", { + description = "Protect a 25 x 25 homestead here (one per player). /claim remove to release.", + func = function(name, param) + local player = core.get_player_by_name(name) + if not player then return false, "Join the world first." end + if param == "remove" then + claims[name] = nil + elseif param == "" then + local p = vector.round(player:get_pos()) + if claims[name] then return false, "Release your existing claim before moving it." end + for _, other in pairs(claims) do + if math.abs(p.x - other.x) <= 25 and math.abs(p.z - other.z) <= 25 then + return false, "Too close to another homestead. Move at least 26 blocks away." + end + end + claims[name] = {x = p.x, y = p.y, z = p.z} + else return false, "Use /claim or /claim remove." end + storage:set_string("claims", core.write_json(claims)) + return true, param == "remove" and "Homestead released." or "Homestead protected. Others cannot build or harvest here." + end, +}) +dofile(path .. "/content.lua") +dofile(path .. "/interface.lua") +dofile(path .. "/runtime.lua") +dofile(path .. "/encounters.lua") diff --git a/mods/hearth/interface.lua b/mods/hearth/interface.lua new file mode 100755 index 0000000..55f36a6 --- /dev/null +++ b/mods/hearth/interface.lua @@ -0,0 +1,122 @@ +local C = core +local esc = C.formspec_escape +local bg, text, dim, gold = "#1e1e2e", "#cdd6f4", "#a6adc8", "#e5c890" +local pages = { + {"First light", "YOUR FIRST HEARTH", "Start with what the valley gives you.", + "Collect loose flint and dry sticks from the ground. Gather berries for food and leaves for extra sticks.\n\nOpen your inventory with I. Make a flint axe, then fell timber. Split logs into planks; craft walls and a thatch roof.\n\nA roof keeps the rain off. Walls stop exposure. A fueled hearth dries and warms you. Leave a small opening: enclosed fires need ventilation.\n\nThere is no quest timer. Your first success is a home that works."}, + {"Craft & build", "WORK WITH YOUR HANDS", "All recipes use the 3 x 3 inventory grid.", + "AXE: two flints across the top; flint + stick below; a stick beneath the handle.\nKNIFE: two flints above one stick (right column).\nSPEAR: flint above two sticks.\nCUP: flint at top-left and top-right, flint at center.\nHEARTH: eight flints around an empty center.\n\nOne log -> four planks. One plank -> four sticks. Four planks in a square -> four walls.\nROOF: three reeds across the top, two sticks at the ends of the next row.\n\nWalls and roofs need support within two horizontal blocks. Columns may be up to 16 blocks high. Existing unsupported blocks do not collapse in this prototype."}, + {"Fire & food", "A FIRE WORTH KEEPING", "Gather. Prepare. Eat. Recover.", + "Place your stone hearth. Right-click it with sticks or logs to add fuel. Each stick burns for 45 real seconds; a log for three minutes.\n\nCraft two berries into forage. Right-click a fueled hearth with forage to cook; left-click with cooked food to eat. Berries can also be eaten raw.\n\nUse the stone cup on river water to fill it. Right-click a fueled hearth with untreated water to boil it. Left-click to drink boiled water.\n\nCooking is a fuel-consuming instant prototype, not a realistic timed process. No spoilage, farming or pottery yet."}, + {"Read the world", "SHELTER IS A SYSTEM", "The HUD tells you why you feel the way you do.", + "A covered roof plus nearby walls reduces exposure. Wetness rises in rain and falls under cover; a hearth helps you dry. Check the shelter indicator as you build.\n\nA very enclosed fire carries a smoke warning and causes harm: provide ventilation. This is a bounded enclosure approximation, not smoke simulation.\n\nAux1 (usually E) sprints. Right-click while holding a spear to brace against the practice opponent; bracing costs stamina and reduces walking speed.\n\nApproach the practice target near arrival. It retaliates only after you strike it. Step outside its short reach to recover. PvP is disabled."}, + {"Your homestead", "MAKE A PLACE YOURS", "A community without compulsory chores.", + "Use /claim at your homestead to protect a 25 x 25 area, extending 24 blocks up/down. One claim per person. /claim remove releases it. Stand at least 26 blocks from another claim.\n\nYour inventory and needs persist between sessions. Logged-out characters do not starve or freeze. Fuel continues to expire in real time.\n\nDeath keeps your inventory and returns you to arrival, with needs partially restored. This forgiving prototype policy will be revisited after playtests.\n\nThis is not yet a hardened public server: moderation, shared claim membership and all grief scenarios need further testing."}, + {"Clock & location", "ONE WORLD. ONE CLOCK.", "A real calendar, not a daily login requirement.", + "Local play asks you to choose approximate coordinates. No location service, IP lookup or external request is used. Use /location to change them. Coordinates stay in local world storage.\n\nA community server must set its shared latitude and longitude in configuration. Remote players cannot override them. The world follows the host's real UTC clock.\n\nSun height and day length follow latitude, longitude and date. The engine's fixed sky orbit is approximated; it is not an exact astronomical renderer. The HUD uses UTC and local solar time, not civil timezone labels.\n\nClimate and rain are synthetic. Calendar-season labels use meteorological months; southern latitudes reverse them."}, +} +function hearth.guide(player, page) + page = math.max(1, math.min(#pages, page or 1)) + hearth.sessions[player:get_player_name()].page = page + local p = pages[page] + local f = {"formspec_version[6]size[14,10]bgcolor[", bg, ";true]box[0,0;14,10;#1e1e2eff]", + "style_type[button;border=false;bgcolor=#313244;textcolor=", text, "]", + "box[0,0;3.3,10;#181825]label[0.45,0.65;", esc("eom.dev / SEASON VI"), "]", + "label[0.45,1.3;", esc("THE LAST HEARTH"), "]"} + for i, pg in ipairs(pages) do + f[#f + 1] = "button[0.35," .. (2 + i * 0.72) .. ";2.6,0.58;page_" .. i .. ";" .. esc(pg[1]) .. "]" + end + f[#f + 1] = "label[3.85,0.7;" .. esc("FIELD NOTES / " .. string.format("%02d", page)) .. "]" + f[#f + 1] = "style[title;textcolor=" .. gold .. "]label[3.85,1.35;" .. esc(p[2]) .. "]" + f[#f + 1] = "label[3.85,2.0;" .. esc(p[3]) .. "]" + f[#f + 1] = "textarea[3.8,2.7;9.65,5.6;;;" .. esc(p[4]) .. "]" + f[#f + 1] = "button_exit[10.5,8.85;2.8,0.65;close;Back to the valley]" + C.show_formspec(player:get_player_name(), "hearth:guide", table.concat(f)) +end + +function hearth.inventory(player) + player:set_inventory_formspec("formspec_version[6]size[12.4,11.3]bgcolor[" .. bg .. ";true]box[0,0;12.4,11.3;#1e1e2eff]" + .. "label[0.5,0.45;eom.dev / THE LAST HEARTH]label[0.5,1.05;Prepare something worth carrying.]" + .. "listcolors[#313244;#45475a;#cdd6f4;#181825;#cdd6f4]" + .. "list[current_player;craft;0.6,1.8;3,3;]list[current_player;craftpreview;4.8,2.9;1,1;]" + .. "label[4.9,2.2;RESULT]label[6.5,1.8;GATHER / BUILD / ENDURE]" + .. "textarea[6.5,2.4;5.4,2.6;;;Collect flint and sticks first.\nA roof keeps rain off.\nA hearth makes shelter a home.]" + .. "button[7.1,4.35;3.8,0.6;field_guide;Open field notes]" + .. "list[current_player;main;0.6,5.7;10,4;]listring[current_player;main]listring[current_player;craft]") +end + +function hearth.location_form(player, error_text) + C.show_formspec(player:get_player_name(), "hearth:location", + "formspec_version[6]size[10.8,7.3]bgcolor[" .. bg .. ";true]box[0,0;10.8,7.3;#1e1e2eff]" + .. "label[0.65,0.6;THE LAST HEARTH / SET YOUR WORLD'S LOCATION]" + .. "textarea[0.65,1.2;9.5,1.7;;;" .. esc("Choose approximate coordinates near you. No location is collected automatically.\nThey stay in this world; no internet lookup is performed.\nExample only: 45 latitude, 0 longitude.") .. "]" + .. "field[0.65,3.2;4.4,0.8;latitude;Latitude (-90 to 90);]" + .. "field[5.6,3.2;4.4,0.8;longitude;Longitude (-180 to 180);]" + .. "label[0.65,4.5;" .. esc(error_text or "No location chosen: neutral equatorial preview until configured.") .. "]" + .. "button[0.65,5.3;4.4,0.75;save_location;Use this location]" + .. "button_exit[5.6,5.3;4.4,0.75;later;Choose later]") +end + +C.register_on_player_receive_fields(function(player, form, fields) + if form == "hearth:guide" then + for i = 1, #pages do if fields["page_" .. i] then hearth.guide(player, i); return true end end + elseif form == "hearth:location" and fields.save_location then + if not C.is_singleplayer() then return true end + local lat, lon = fields.latitude or "", fields.longitude or "" + if #lat > 16 or #lon > 16 or not hearth.model.location(lat, lon) then + hearth.location_form(player, "Enter valid finite coordinates within the ranges above.") + else + hearth.set_location(tonumber(lat), tonumber(lon)) + hearth.guide(player) + end + return true + elseif form == "" and fields.field_guide then hearth.guide(player); return true end +end) +C.register_chatcommand("guide", {description = "Open the Last Hearth field notes", + func = function(name) local p = C.get_player_by_name(name); if p then hearth.guide(p) end; return true end}) +C.register_chatcommand("location", {description = "Choose local-world reference coordinates", + func = function(name) + if not C.is_singleplayer() then return false, "Server coordinates are selected by its administrator." end + local p = C.get_player_by_name(name); if p then hearth.location_form(p) end; return true + end}) + +function hearth.make_hud(player) + local s = hearth.sessions[player:get_player_name()] + s.hud = {} + s.hud.title = player:hud_add({type = "text", position = {x = 0.025, y = 0.04}, + alignment = {x = 1, y = 0}, text = "THE LAST HEARTH / eom.dev", number = 0xe5c890, size = {x = 1, y = 1}}) + s.hud.clock = player:hud_add({type = "text", position = {x = 0.025, y = 0.085}, + alignment = {x = 1, y = 0}, text = "", number = 0xcdd6f4, size = {x = 1, y = 1}}) + s.hud.needs = player:hud_add({type = "text", position = {x = 0.025, y = 0.14}, + alignment = {x = 1, y = 0}, text = "", number = 0xcdd6f4, size = {x = 1, y = 1}}) + s.hud.context = player:hud_add({type = "text", position = {x = 0.5, y = 0.77}, + alignment = {x = 0, y = 0}, text = "", number = 0xe5c890, size = {x = 1, y = 1}}) + s.hud.help = player:hud_add({type = "text", position = {x = 0.975, y = 0.04}, + alignment = {x = -1, y = 0}, text = "I Craft & field notes | E Sprint | /claim Homestead", + number = 0xa6adc8, size = {x = 1, y = 1}}) + player:hud_set_flags({minimap = false}) + player:hud_set_hotbar_itemcount(10) +end + +function hearth.update_hud(player, env) + local name = player:get_player_name() + local s, state = hearth.sessions[name], hearth.players[name] + if not s or not s.hud then return end + local clock = hearth.clock + local hour = math.floor(clock.hour) + player:hud_change(s.hud.clock, "text", clock.season .. " / " .. clock.date + .. string.format(" / Solar %02d:%02d / %d C%s", hour, math.floor(clock.hour % 1 * 60), + math.floor(env.temperature), hearth.has_location and "" or " [LOCATION NOT SET]")) + local function bar(n) return string.rep("|", math.floor(n / 10)) .. string.rep(".", 10 - math.floor(n / 10)) end + player:hud_change(s.hud.needs, "text", "WARMTH " .. bar(state.warmth) + .. "\nWATER " .. bar(state.water) .. "\nFOOD " .. bar(state.food) + .. "\nSTAMINA " .. bar(state.stamina)) + local context = env.shelter >= 0.6 and "SHELTERED" or (env.roof and "ROOFED / add walls" or "EXPOSED / find cover") + context = context .. (env.rain and " | RAIN" or " | CLEAR") + if state.wet > 25 then context = context .. " | WET CLOTHING" end + if env.heat > 0 then context = context .. " | HEARTH WARMTH" end + if env.smoke then context = "SMOKE / open a wall or roof vent" end + if state.warmth < 20 then context = context .. " | COLD: shelter and fire needed" end + player:hud_change(s.hud.context, "text", context) + player:hud_change(s.hud.context, "number", (env.smoke or state.warmth < 20) and 0xf38ba8 or 0xe5c890) +end diff --git a/mods/hearth/mod.conf b/mods/hearth/mod.conf new file mode 100755 index 0000000..891a954 --- /dev/null +++ b/mods/hearth/mod.conf @@ -0,0 +1,5 @@ +name = hearth +title = The Last Hearth +description = Original survival-first eom.dev prototype. No external game dependencies. +author = eom.dev +min_minetest_version = 5.18 diff --git a/mods/hearth/model.lua b/mods/hearth/model.lua new file mode 100755 index 0000000..e12f652 --- /dev/null +++ b/mods/hearth/model.lua @@ -0,0 +1,72 @@ +-- Pure simulation functions: no engine state, I/O, network, or mutable clock. +local M = {} +function M.clamp(n, low, high) return math.max(low, math.min(high, n)) end +function M.location(lat, lon) + lat, lon = tonumber(lat), tonumber(lon) + if not lat or not lon or lat ~= lat or lon ~= lon or math.abs(lat) > 90 + or math.abs(lon) > 180 then return nil end + return {latitude = lat, longitude = lon} +end + +function M.solar(timestamp, latitude, longitude) + local t = os.date("!*t", timestamp) + local leap = t.year % 4 == 0 and (t.year % 100 ~= 0 or t.year % 400 == 0) + local days = leap and 366 or 365 + local utc = t.hour + t.min / 60 + t.sec / 3600 + local gamma = 2 * math.pi / days * (t.yday - 1 + (utc - 12) / 24) + -- NOAA fractional-year approximation; weather is synthetic, not a forecast. + local equation = 229.18 * (0.000075 + 0.001868 * math.cos(gamma) + - 0.032077 * math.sin(gamma) - 0.014615 * math.cos(2 * gamma) + - 0.040849 * math.sin(2 * gamma)) + local declination = 0.006918 - 0.399912 * math.cos(gamma) + + 0.070257 * math.sin(gamma) - 0.006758 * math.cos(2 * gamma) + + 0.000907 * math.sin(2 * gamma) - 0.002697 * math.cos(3 * gamma) + + 0.00148 * math.sin(3 * gamma) + local solar_hour = (utc + longitude / 15 + equation / 60) % 24 + local lat = math.rad(latitude) + local sin_elevation = math.sin(lat) * math.sin(declination) + + math.cos(lat) * math.cos(declination) * math.cos(math.rad((solar_hour - 12) * 15)) + local elevation = math.deg(math.asin(M.clamp(sin_elevation, -1, 1))) + -- Adapt the engine's fixed equinox orbit so its daylight matches this latitude. + local engine_time + if solar_hour <= 12 then engine_time = (elevation + 90) / 360 + else engine_time = (270 - elevation) / 360 end + local south = latitude < 0 and 1 or 0 + local index = (math.floor((t.month % 12) / 3) + south * 2) % 4 + 1 + local season = ({"Winter", "Spring", "Summer", "Autumn"})[index] + local annual = math.cos(2 * math.pi * (t.yday - 205) / days) * (south == 1 and -1 or 1) + local mean = 27 - math.abs(latitude) * 0.43 + local amplitude = 3 + math.abs(latitude) * 0.22 + return {hour = solar_hour, timeofday = engine_time, elevation = elevation, + daylight = elevation > -0.833, season = season, + temperature = mean + annual * amplitude + math.sin(math.rad(elevation)) * 4, + date = os.date("!%Y-%m-%d %H:%M UTC", timestamp)} +end + +function M.weather(timestamp, latitude, longitude) + -- UTC-indexed deterministic six-hour fronts: restart/unloading do not reroll. + local front = math.floor(timestamp / 21600) + local wave = math.sin(front * 12.9898 + latitude * 0.1 + longitude * 0.03) * 43758.5453 + return wave - math.floor(wave) > 0.68 and "rain" or "clear" +end + +function M.needs(state, env, dt) + dt = M.clamp(dt, 0, 2) -- stalled frames never replay lethal elapsed time + local s = {} + for k, v in pairs(state) do s[k] = v end + local shelter = M.clamp(env.shelter or 0, 0, 1) + s.wet = M.clamp(s.wet + ((env.rain and shelter < 0.6) and 1.2 or -0.35) * dt, 0, 100) + local feels = env.temperature - s.wet * 0.09 + (env.heat or 0) * 14 + shelter * 5 + local target = M.clamp(60 + feels * 1.5, 0, 100) + s.warmth = M.clamp(s.warmth + M.clamp(target - s.warmth, -0.16, 0.3) * dt, 0, 100) + s.food = M.clamp(s.food - (env.sprinting and 0.035 or 0.012) * dt, 0, 100) + s.water = M.clamp(s.water - (env.sprinting and 0.06 or 0.025) * dt, 0, 100) + s.stamina = M.clamp(s.stamina + (env.sprinting and -13 or + (s.food > 0 and s.water > 0 and 9 or 2)) * dt, 0, 100) + return s, (s.warmth < 8 or s.food <= 0 or s.water <= 0) and dt or 0 +end + +function M.fuel_remaining(deadline, now) + return M.clamp(deadline - now, 0, 600) +end +return M diff --git a/mods/hearth/runtime.lua b/mods/hearth/runtime.lua new file mode 100755 index 0000000..58e9989 --- /dev/null +++ b/mods/hearth/runtime.lua @@ -0,0 +1,221 @@ +local C, M = core, hearth.model +local lat_setting, lon_setting = C.settings:get("hearth_latitude"), C.settings:get("hearth_longitude") +local configured = M.location(lat_setting, lon_setting) +local location_data = hearth.storage:get_string("location") +local stored = location_data ~= "" and C.parse_json(location_data) or nil +local selected = configured or (C.is_singleplayer() and stored and M.location(stored.latitude, stored.longitude)) +if not C.is_singleplayer() and not configured then + error("Last Hearth server requires explicit hearth_latitude and hearth_longitude settings.") +end +hearth.has_location = selected ~= nil +local location = selected or {latitude = 0, longitude = 0} +function hearth.set_location(lat, lon) + assert(C.is_singleplayer(), "Only local worlds can select location in game") + local value = assert(M.location(lat, lon), "Invalid reference coordinates") + location, hearth.has_location = value, true + hearth.storage:set_string("location", C.write_json(value)) +end +local function timestamp() + -- Date fixtures are only accepted for local play, never a public server. + if C.is_singleplayer() then + local fixture = tonumber(C.settings:get("hearth_preview_date")) + if fixture and fixture >= 946684800 and fixture <= 4102444800 then return fixture end + end + return os.time() +end +local function clock_update() + local now = timestamp() + hearth.clock = M.solar(now, location.latitude, location.longitude) + hearth.weather = M.weather(now, location.latitude, location.longitude) + if C.is_singleplayer() then + local weather = C.settings:get("hearth_preview_weather") + if weather == "rain" or weather == "clear" then hearth.weather = weather end + end + C.set_timeofday(hearth.clock.timeofday) +end +C.after(0, clock_update) + +local function solid(pos) + local n = C.get_node_or_nil(pos) + if not n then return false end + local def = C.registered_nodes[n.name] + return def and def.walkable ~= false and not def.buildable_to + and C.get_item_group(n.name, "leaves") == 0 +end +function hearth.environment(pos) + local head = vector.round({x = pos.x, y = pos.y + 1, z = pos.z}) + local roof = false + for y = 1, 5 do if solid(vector.offset(head, 0, y, 0)) then roof = true; break end end + local walls = 0 + for _, d in ipairs({{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) do + for r = 1, 3 do + if solid(vector.offset(head, d[1] * r, 0, d[2] * r)) then walls = walls + 1; break end + end + end + local shelter = roof and (0.25 + walls * 0.1875) or 0 + local fire = C.find_node_near(pos, 4, {"hearth:fire_lit"}) + local heat = 0 + if fire then + local deadline = tonumber(C.get_meta(fire):get_string("fuel_until")) or 0 + if M.fuel_remaining(deadline, os.time()) > 0 then + -- A wall between the hearth and player blocks its warmth. + local from = vector.offset(fire, 0, 0.6, 0) + if C.line_of_sight(from, head, 0.5) then heat = 1 - math.min(0.8, vector.distance(pos, fire) / 5) end + end + end + return {roof = roof, shelter = shelter, heat = heat, smoke = heat > 0 and roof and walls == 4, + rain = hearth.weather == "rain", temperature = hearth.clock.temperature - math.max(0, pos.y) * 0.0065} +end + +local defaults = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100} +local function save(player) + local s = hearth.players[player:get_player_name()] + if s then player:get_meta():set_string("hearth:needs", C.write_json(s)) end +end +local spawn_data = hearth.storage:get_string("spawn") +local spawn = spawn_data ~= "" and C.parse_json(spawn_data) or nil +local spawn_pending = false +local waiting = {} +local function arrive(player) + if spawn then + player:set_pos(spawn) + player:set_velocity({x = 0, y = 0, z = 0}) + end +end +local function locate_spawn(player) + if spawn then arrive(player); return end + waiting[player:get_player_name()] = true + if spawn_pending then return end + spawn_pending = true + C.emerge_area({x = -48, y = -16, z = -48}, {x = 48, y = 96, z = 48}, function(_, _, left) + if left ~= 0 then return end + local best, best_score + for x = -44, 44, 4 do for z = -44, 44, 4 do + for y = 78, 3, -1 do + local n = C.get_node({x = x, y = y, z = z}) + if n.name == "hearth:grass" then + local p = {x = x, y = y + 1, z = z} + if C.get_node(p).name == "air" and C.get_node(vector.offset(p, 0, 1, 0)).name == "air" then + local score = math.abs(x) + math.abs(z) + y * 0.2 + if not best_score or score < best_score then best, best_score = p, score end + end + break + end + end + end end + if not best then + C.log("error", "[hearth] No safe arrival in initial area; select a different world seed.") + for name in pairs(waiting) do C.kick_player(name, "No safe spawn found. Please choose another world seed.") end + waiting, spawn_pending = {}, false + return + end + spawn = best + hearth.storage:set_string("spawn", C.write_json(spawn)) + for name in pairs(waiting) do local p = C.get_player_by_name(name); if p then arrive(p) end end + waiting = {} + end) +end + +C.register_on_joinplayer(function(player) + local name, meta = player:get_player_name(), player:get_meta() + local saved_data = meta:get_string("hearth:needs") + local saved = saved_data ~= "" and C.parse_json(saved_data) or {} + local state = {} + for key, value in pairs(defaults) do state[key] = M.clamp(tonumber(saved[key]) or value, 0, 100) end + hearth.players[name], hearth.sessions[name] = state, {page = 1, damage = 0, save = 0, ready = false} + local inv = player:get_inventory() + inv:set_size("main", 40); inv:set_size("craft", 9); inv:set_width("craft", 3) + inv:set_size("craftpreview", 1); inv:set_size("craftresult", 1) + player:set_properties({visual = "mesh", mesh = "hearth_person.obj", textures = {hearth.texture("#947853")}, + visual_size = {x = 1, y = 1}, collisionbox = {-0.3, 0, -0.3, 0.3, 1.75, 0.3}, + stepheight = 0.6, eye_height = 1.55, hp_max = 20}) + player:set_armor_groups({fleshy = 100}) + hearth.inventory(player); hearth.make_hud(player) + player:set_clouds({density = 0.35, color = "#bec4c8e0", height = 140, speed = {x = 2, z = -1}}) + if meta:get_int("hearth:arrived") == 0 then + meta:set_int("hearth:arrived", 1) + locate_spawn(player) + end + C.after(1, function() + local p = C.get_player_by_name(name) + if not p then return end + if not hearth.has_location and C.is_singleplayer() then hearth.location_form(p) + else hearth.guide(p) end + end) +end) +C.register_on_leaveplayer(function(player) + save(player) + local name = player:get_player_name() + hearth.players[name], hearth.sessions[name], waiting[name] = nil, nil, nil +end) +C.register_on_shutdown(function() for _, p in ipairs(C.get_connected_players()) do save(p) end end) +C.register_on_respawnplayer(function(player) + local s = hearth.players[player:get_player_name()] + if s then for k, v in pairs(defaults) do s[k] = v end end + locate_spawn(player) + return true +end) +-- PvP is off regardless of a deployment accidentally changing enable_pvp. +C.register_on_punchplayer(function() return true end) + +local elapsed, clock_elapsed = 0, 0 +C.register_globalstep(function(dt) + elapsed, clock_elapsed = elapsed + dt, clock_elapsed + dt + if clock_elapsed >= 10 then clock_update(); clock_elapsed = 0 end + -- Small bounded steps for smooth sprint state; needs/HUD at 1 Hz. + for _, player in ipairs(C.get_connected_players()) do + local name = player:get_player_name() + local s, state = hearth.sessions[name], hearth.players[name] + if s then + local controls = player:get_player_control() + s.sprinting = controls.aux1 and controls.up and not controls.sneak and state.stamina > 8 + s.blocking = player:get_wielded_item():get_name() == "hearth:spear" and controls.RMB + local speed = s.blocking and 0.6 or (s.sprinting and 1.45 or 1) + if speed ~= s.speed then player:set_physics_override({speed = speed, jump = 1, gravity = 1}); s.speed = speed end + end + end + if elapsed < 1 then return end + local step = math.min(elapsed, 2); elapsed = 0 + for _, player in ipairs(C.get_connected_players()) do + local name, pos = player:get_player_name(), player:get_pos() + local s, state = hearth.sessions[name], hearth.players[name] + if s and not waiting[name] and player:get_hp() > 0 then + local env = hearth.environment(pos) + env.sprinting = s.sprinting + local updated, danger = M.needs(state, env, step) + if env.heat > 0 then updated.wet = math.max(0, updated.wet - env.heat * step) end + hearth.players[name] = updated + s.damage = s.damage + danger + (env.smoke and step or 0) + if s.damage >= 4 then + player:set_hp(player:get_hp() - 1, {type = "set_hp", from = "mod"}); s.damage = 0 + end + if pos.y < -100 then arrive(player) end + hearth.update_hud(player, env) + s.save = s.save + step + if s.save >= 10 then save(player); s.save = 0 end + end + end +end) + +-- Tiny client-local rain effect, not thousands of entities or a global ABM. +C.register_globalstep((function() + local timer = 0 + return function(dt) + timer = timer + dt; if timer < 3 then return end; timer = 0 + for _, p in ipairs(C.get_connected_players()) do + local density = hearth.weather == "rain" and 0.65 or 0.25 + p:set_clouds({density = density}) + if hearth.weather == "rain" then + local pos = p:get_pos() + if not hearth.environment(pos).roof then + C.add_particlespawner({amount = 80, time = 3, + minpos = vector.offset(pos, -7, 7, -7), maxpos = vector.offset(pos, 7, 10, 7), + minvel = {x = -1, y = -12, z = 0}, maxvel = {x = -1, y = -10, z = 0}, + minexptime = 0.5, maxexptime = 0.8, minsize = 0.4, maxsize = 0.7, + collisiondetection = true, collision_removal = true, + texture = hearth.texture("#a4c4d4"), playername = p:get_player_name()}) + end + end + end + end +end)()) diff --git a/scripts/assets.mjs b/scripts/assets.mjs new file mode 100755 index 0000000..fe4cc22 --- /dev/null +++ b/scripts/assets.mjs @@ -0,0 +1,79 @@ +// Original deterministic assets, generated without libraries or downloaded art. +import { deflateSync } from 'node:zlib'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; + +function crc32(data) { + let crc = 0xffffffff; + for (const byte of data) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + return (crc ^ 0xffffffff) >>> 0; +} +function chunk(type, data) { + const body = Buffer.concat([Buffer.from(type), data]); + const size = Buffer.alloc(4), crc = Buffer.alloc(4); + size.writeUInt32BE(data.length); crc.writeUInt32BE(crc32(body)); + return Buffer.concat([size, body, crc]); +} +function png(size, pixel) { + const rows = Buffer.alloc(size * (size * 4 + 1)); + for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) { + const color = pixel(x, y), offset = y * (size * 4 + 1) + 1 + x * 4; + rows.set(color, offset); + } + const header = Buffer.alloc(13); + header.writeUInt32BE(size); header.writeUInt32BE(size, 4); header[8] = 8; header[9] = 6; + return Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]), chunk('IHDR', header), + chunk('IDAT', deflateSync(rows)), chunk('IEND', Buffer.alloc(0))]); +} +const icons = { + knife: (x,y) => (Math.abs(x + y - 28) < 3 && x > 9 && x < 24) ? [180,185,173,255] + : (Math.abs(x + y - 28) < 2 && x >= 23) ? [119,86,57,255] : null, + axe: (x,y) => Math.abs(x-y) < 2 && x > 7 && x < 27 ? [139,103,65,255] + : x > 5 && x < 20 && y > 4 && y < 14 && x+y < 29 ? [157,164,151,255] : null, + pick: (x,y) => Math.abs(x-y) < 2 && x > 10 && x < 28 ? [139,103,65,255] + : y > 5 && y < 10 && x > 4 && x < 25 ? [157,164,151,255] : null, + spear: (x,y) => Math.abs(x+y-31) < 2 && x > 5 && x < 27 ? [157,124,80,255] + : x > 22 && y < 10 && x-y > 18 ? [185,188,177,255] : null, +}; + +export async function generateAssets(destination, source) { + const textures = path.join(destination, 'mods/hearth/textures'); + await fs.mkdir(textures, {recursive:true}); + const files = ['content.lua','encounters.lua','runtime.lua']; + const colors = new Set(['c7aa83','947853']); + for (const file of files) { + const text = await fs.readFile(path.join(source, 'mods/hearth', file), 'utf8'); + for (const match of text.matchAll(/#[0-9a-fA-F]{6}\b/g)) colors.add(match[0].slice(1).toLowerCase()); + } + for (const color of colors) { + const rgb = [0,2,4].map(i=>parseInt(color.slice(i,i+2),16)); + const image = png(32, (x,y)=> { + const noise = ((x*73+y*151+x*y*7) % 19)-9; + const grain = (y % 8 === 0) ? -12 : (x % 13 === 0 ? 5 : 0); + return [...rgb.map(c=>Math.max(0,Math.min(255,c+noise+grain))),255]; + }); + await fs.writeFile(path.join(textures, `hearth_${color}.png`), image); + } + for (const [name, shape] of Object.entries(icons)) { + await fs.writeFile(path.join(textures, `hearth_icon_${name}.png`), png(32,(x,y)=>shape(x,y)||[0,0,0,0])); + } + // A low-poly original player silhouette; stable engine mesh instead of a billboard. + const models = path.join(destination, 'mods/hearth/models'); + await fs.mkdir(models, {recursive:true}); + let vertices = 0; + const obj = ['# Original Last Hearth block character', 'vt 0 0','vt 1 0','vt 1 1','vt 0 1']; + function box(x0,y0,z0,x1,y1,z1) { + for(const v of [[x0,y0,z0],[x1,y0,z0],[x1,y1,z0],[x0,y1,z0], + [x0,y0,z1],[x1,y0,z1],[x1,y1,z1],[x0,y1,z1]]) obj.push(`v ${v.join(' ')}`); + for(const f of [[1,4,3,2],[5,6,7,8],[1,2,6,5],[4,8,7,3],[1,5,8,4],[2,3,7,6]]) + obj.push(`f ${f.map((n,i)=>`${vertices+n}/${i+1}`).join(' ')}`); + vertices += 8; + } + box(-2.3,8,-1.3,2.3,14,1.3); box(-2,14,-2,2,18,2); + box(-4,8,-1,-2.3,14,1); box(2.3,8,-1,4,14,1); + box(-2.2,0,-1.2,-0.2,8,1.2); box(0.2,0,-1.2,2.2,8,1.2); + await fs.writeFile(path.join(models,'hearth_person.obj'), obj.join('\n')+'\n'); +} diff --git a/scripts/run.mjs b/scripts/run.mjs new file mode 100755 index 0000000..d7e14f6 --- /dev/null +++ b/scripts/run.mjs @@ -0,0 +1,257 @@ +#!/usr/bin/env node +// Original MIT-licensed launcher. Node built-ins only; never invokes a shell. +import { spawn } from 'node:child_process'; +import { randomInt, randomUUID } from 'node:crypto'; +import { constants } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { generateAssets } from './assets.mjs'; + +const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const command = process.argv[2]; +const excluded = new Set(['scripts', 'tests', '.git']); +const marker = '.last-hearth-launcher-managed'; +const TEST_TIMEOUT = 90_000; +const PLAY_TIMEOUT = 8 * 60 * 60 * 1000; + +async function exists(filename) { + try { await fs.access(filename, constants.F_OK); return true; } + catch (error) { if (error.code === 'ENOENT') return false; throw error; } +} + +// copyFile avoids fs.cp's realpath failures on Windows SMB shares. Refuse +// symlinks/junctions rather than following them out of the selected source. +async function copyTree(from, to, skip = new Set()) { + const stat = await fs.lstat(from); + if (stat.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${from}`); + if (stat.isDirectory()) { + await fs.mkdir(to, { recursive: true }); + for (const entry of await fs.readdir(from)) { + if (!skip.has(entry)) await copyTree(path.join(from, entry), path.join(to, entry), skip); + } + } else if (stat.isFile()) { + await fs.copyFile(from, to); + } else { + throw new Error(`Not a regular file or directory: ${from}`); + } +} + +async function luaFiles(directory, prefix = '') { + const result = []; + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + if (entry.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${entry.name}`); + if (entry.name === '.git' || entry.name === 'scripts') continue; + const relative = prefix + entry.name; + if (entry.isDirectory()) result.push(...await luaFiles(path.join(directory, entry.name), relative + '/')); + else if (entry.isFile() && entry.name.endsWith('.lua')) result.push(relative); + } + return result.sort(); +} + +function coordinates() { + const latitude = process.env.HEARTH_LATITUDE; + const longitude = process.env.HEARTH_LONGITUDE; + if (latitude === undefined && longitude === undefined) return []; + if (latitude === undefined || longitude === undefined) { + throw new Error('Provide both HEARTH_LATITUDE and HEARTH_LONGITUDE, or neither.'); + } + const parse = (value, limit, name) => { + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value)) throw new Error(`Invalid ${name}.`); + const number = Number(value); + if (!Number.isFinite(number) || Math.abs(number) > limit) throw new Error(`${name} out of range.`); + return number; + }; + return [ + `hearth_latitude = ${parse(latitude, 90, 'latitude')}`, + `hearth_longitude = ${parse(longitude, 180, 'longitude')}`, + ]; +} + +function run(executable, args, cwd, timeout, successMarker) { + return new Promise((resolve, reject) => { + const child = spawn(executable, args, { + cwd, shell: false, windowsHide: command !== 'play', stdio: ['ignore', 'pipe', 'pipe'], + }); + let tail = '', timedOut = false, interrupted = false; + const capture = stream => { + stream.setEncoding('utf8'); + stream.on('data', text => { + // Bound memory even if the engine emits excessive diagnostics. + tail = (tail + text).slice(-1024 * 1024); + process.stdout.write(text); + }); + }; + capture(child.stdout); + capture(child.stderr); + const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeout); + const interrupt = () => { interrupted = true; child.kill('SIGKILL'); }; + process.once('SIGINT', interrupt); + process.once('SIGTERM', interrupt); + const cleanup = () => { + clearTimeout(timer); + process.removeListener('SIGINT', interrupt); + process.removeListener('SIGTERM', interrupt); + }; + child.once('error', error => { cleanup(); reject(error); }); + child.once('close', (code, signal) => { + cleanup(); + if (timedOut) reject(new Error(`Process exceeded ${timeout / 1000}s timeout.`)); + else if (interrupted) reject(new Error('Interrupted; child process stopped.')); + else if (code !== 0) reject(new Error(`Process failed (exit ${code}, signal ${signal || 'none'}).`)); + else if (successMarker && !tail.includes(successMarker)) reject(new Error('Engine exited without the required success marker.')); + else resolve(); + }); + }); +} + +async function stageGame(engine) { + for (const required of ['game.conf', 'mods/hearth/init.lua', 'mods/hearth/model.lua', 'mods/hearth/runtime.lua', 'mods/hearth/content.lua']) { + if (!await exists(path.join(source, required))) throw new Error(`Game is incomplete: missing ${required}.`); + } + const games = path.join(engine, 'games'); + await fs.mkdir(games, { recursive: true }); + const destination = path.join(games, 'last_hearth'); + if (await exists(destination)) { + if ((await fs.lstat(destination)).isSymbolicLink() || !await exists(path.join(destination, marker))) { + throw new Error(`Refusing to overwrite an unmanaged game: ${destination}`); + } + } + const stage = await fs.mkdtemp(path.join(games, '.last-hearth-stage-')); + const backup = path.join(games, `.last-hearth-backup-${randomUUID()}`); + let backedUp = false; + try { + await copyTree(source, stage, excluded); + await generateAssets(stage, source); + await fs.writeFile(path.join(stage, marker), 'Managed copy; edit the repository, not this directory.\n'); + if (await exists(destination)) { await fs.rename(destination, backup); backedUp = true; } + try { await fs.rename(stage, destination); } + catch (error) { if (backedUp) await fs.rename(backup, destination); throw error; } + if (backedUp) await fs.rm(backup, { recursive: true, force: true }); + } finally { + await fs.rm(stage, { recursive: true, force: true }); + } +} + +async function writeWorld(world, config, location, extra = []) { + await fs.mkdir(world, { recursive: true }); + const worldFile = path.join(world, 'world.mt'); + if (!await exists(worldFile)) { + await fs.writeFile(worldFile, [ + 'gameid = last_hearth', 'backend = sqlite3', 'player_backend = sqlite3', + 'auth_backend = sqlite3', 'mod_storage_backend = sqlite3', 'load_mod_hearth = true', '', + ].join('\n'), { flag: 'wx' }); + } + await fs.writeFile(config, [ + 'enable_damage = true', 'enable_pvp = false', 'creative_mode = false', + 'bind_address = 127.0.0.1', 'server_announce = false', 'enable_ipv6 = false', + 'time_speed = 0', 'movement_speed_walk = 4', 'movement_speed_fast = 6', + 'movement_speed_jump = 6.5', 'secure.enable_security = true', + 'mg_name = v7', 'fixed_map_seed = 731104', + ...location, ...extra, '', + ].join('\n'), { mode: 0o600 }); +} + +async function engineTest(mode, runtime, engine, executable, location) { + const temporary = await fs.mkdtemp(path.join(runtime, `${mode}-`)); + try { + const world = path.join(temporary, 'world'); + const config = path.join(temporary, 'minetest.conf'); + const token = randomUUID(); + const mod = path.join(world, 'worldmods', 'hearth_smoke'); + await fs.mkdir(mod, { recursive: true }); + await fs.copyFile(path.join(source, 'tests', 'engine_smoke.lua'), path.join(mod, 'init.lua')); + await fs.writeFile(path.join(mod, 'mod.conf'), 'name = hearth_smoke\ndepends = hearth\n'); + // Compile standalone tests, never execute their mocked core in the live engine. + const syntax = path.join(mod, 'syntax'); + await fs.mkdir(syntax); + const files = await luaFiles(source); + const names = []; + for (let index = 0; index < files.length; index++) { + const name = `${index}.lua`; + await fs.copyFile(path.join(source, files[index]), path.join(syntax, name)); + names.push(`{file = ${JSON.stringify(name)}, label = ${JSON.stringify(files[index])}}`); + } + await fs.writeFile(path.join(mod, 'syntax_manifest.lua'), `return {${names.join(',\n')}}\n`); + // Explicit synthetic fixture only; real play never defaults to a location. + const testLocation = location.length ? location : ['hearth_latitude = 45', 'hearth_longitude = 0']; + await writeWorld(world, config, testLocation, [ + 'load_mod_hearth_smoke = true', `hearth_launcher_mode = ${mode}`, + `hearth_smoke_token = ${token}`, 'max_users = 0', + ]); + await run(executable, [ + '--server', '--world', world, '--gameid', 'last_hearth', '--config', config, + '--port', String(randomInt(49152, 65536)), '--logfile', path.join(temporary, 'engine.log'), + ], engine, TEST_TIMEOUT, `LAST_HEARTH_${mode.toUpperCase()}_PASS ${token}`); + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } +} + +async function standaloneTests(runtime) { + const candidates = process.env.HEARTH_LUA + ? [process.env.HEARTH_LUA] + : ['luajit.exe', 'lua.exe', 'bin/luajit.exe', 'bin/lua.exe'].map(name => path.join(runtime, name)); + let lua; + for (const candidate of candidates) { + if (!path.isAbsolute(candidate) || path.extname(candidate).toLowerCase() !== '.exe') { + throw new Error('HEARTH_LUA must be an absolute path to a local Lua executable (.exe).'); + } + if (await exists(candidate)) { lua = candidate; break; } + } + if (!lua) throw new Error('Standalone Lua unavailable: engine checks passed, but unit tests were NOT run. Install Lua 5.1+/LuaJIT locally and set HEARTH_LUA to its absolute executable path.'); + const specs = (await fs.readdir(path.join(source, 'tests'))).filter(name => name.endsWith('_spec.lua')).sort(); + if (!specs.includes('content_spec.lua')) throw new Error('Missing content_spec.lua; refusing an empty test suite.'); + for (const spec of specs) { + const module = spec.replace(/_spec\.lua$/, '.lua'); + const modulePath = path.join(source, 'mods', 'hearth', module); + if (!await exists(modulePath)) throw new Error(`Cannot determine module for ${spec}.`); + await run(lua, [path.join(source, 'tests', spec), modulePath], source, 30_000); + } +} + +async function main() { + if (!['check', 'smoke', 'play', 'test'].includes(command) || process.argv.length !== 3) { + throw new Error('Usage: node scripts/run.mjs '); + } + if (process.platform !== 'win32' || !process.env.LOCALAPPDATA || !path.isAbsolute(process.env.LOCALAPPDATA)) { + throw new Error('This launcher requires Windows and an absolute LOCALAPPDATA path.'); + } + const location = coordinates(); + const runtime = path.join(process.env.LOCALAPPDATA, 'last-hearth-runtime'); + const engine = path.join(runtime, 'luanti-5.18.0-win64'); + const executable = path.join(engine, 'bin', 'luanti.exe'); + if (!await exists(executable)) throw new Error(`Official Luanti 5.18.0 runtime not found: ${executable}`); + const lockPath = path.join(runtime, 'launcher.lock'); + let lock; + try { lock = await fs.open(lockPath, 'wx', 0o600); } + catch (error) { + if (error.code === 'EEXIST') throw new Error('Another launcher may be active. If it crashed, close its engine before removing LOCALAPPDATA/last-hearth-runtime/launcher.lock.'); + throw error; + } + try { + await lock.writeFile(`${process.pid}\n`); + await stageGame(engine); + if (command === 'play') { + const world = path.join(runtime, 'worlds', 'last_hearth'); + const config = path.join(runtime, 'last-hearth-play.conf'); + await writeWorld(world, config, location); + await run(executable, [ + '--go', '--world', world, '--gameid', 'last_hearth', '--config', config, + '--port', String(randomInt(49152, 65536)), '--logfile', path.join(runtime, 'last-hearth-play.log'), + ], engine, PLAY_TIMEOUT); + } else if (command === 'test') { + await engineTest('check', runtime, engine, executable, location); + await engineTest('smoke', runtime, engine, executable, location); + if (process.env.HEARTH_LUA) await standaloneTests(runtime); + console.log('LAST_HEARTH_TEST_PASS: real-engine integration and isolated model/content suites completed.'); + } else { + await engineTest(command, runtime, engine, executable, location); + } + } finally { + await lock.close(); + await fs.unlink(lockPath); + } +} + +main().catch(error => { console.error(`Last Hearth: ${error.message}`); process.exitCode = 1; }); diff --git a/settingtypes.txt b/settingtypes.txt new file mode 100755 index 0000000..9bd0721 --- /dev/null +++ b/settingtypes.txt @@ -0,0 +1,5 @@ +# Coordinates stay local. Servers must choose one shared location in configuration. +hearth_latitude (World reference latitude; leave unset for local setup) string +hearth_longitude (World reference longitude; leave unset for local setup) string +hearth_preview_weather (Private playtest weather) enum auto auto,clear,rain +hearth_preview_date (Private playtest UTC timestamp; empty follows real time) string diff --git a/tests/content_spec.lua b/tests/content_spec.lua new file mode 100755 index 0000000..2d00aec --- /dev/null +++ b/tests/content_spec.lua @@ -0,0 +1,370 @@ +-- Standalone Lua 5.1+ regression harness; no engine or MTG required. +-- Pass the content module's path as the first command-line argument. +local nodes, items, crafts, biomes, decorations, lbms = {}, {}, {}, {}, {}, {} +local world, metas, timers, messages, state = {}, {}, {}, {}, {} +local now, protected, claim, violations = 100000, false, true, 0 +local player +local function key(p) return p.x .. ":" .. p.y .. ":" .. p.z end +local function pos(x, y, z) return {x = x or 0, y = y or 0, z = z or 0} end +local function put(p, name) world[key(p)] = {name = name, param2 = 2} end +local function point(under, above) return {type = "node", under = under, above = above} end + +local Stack = {} +Stack.__index = Stack +local function stack(text) + local name, count = (text or ""):match("^(%S+)%s*(%d*)$") + return setmetatable({name = name or "", count = name and (tonumber(count) or 1) or 0}, Stack) +end +function Stack:get_name() return self.name end +function Stack:get_count() return self.count end +function Stack:is_empty() return self.count == 0 end +function Stack:take_item(n) + self.count = math.max(0, self.count - (n or 1)) + if self.count == 0 then self.name = "" end +end +function Stack:replace(text) + local new = stack(text) + self.name, self.count = new.name, new.count +end + +core = {registered_nodes = nodes} +function core.register_node(name, def) nodes[name], items[name] = def, def end +function core.register_item(name, def) items[name] = def end +function core.register_tool(name, def) items[name] = def end +function core.register_craftitem(name, def) items[name] = def end +function core.register_craft(def) crafts[#crafts + 1] = def end +function core.register_biome(def) biomes[#biomes + 1] = def end +function core.register_decoration(def) decorations[#decorations + 1] = def end +function core.register_lbm(def) lbms[#lbms + 1] = def end +local aliases = {} +function core.register_alias(name, target) aliases[name] = target end +function core.get_node(p) return world[key(p)] or {name = "air"} end +function core.get_node_or_nil(p) + if world[key(p)] == false then return nil end + return core.get_node(p) +end +function core.swap_node(p, node) world[key(p)] = node end +function core.is_protected() return protected end +function core.record_protection_violation() violations = violations + 1 end +function core.get_meta(p) + local k = key(p) + if not metas[k] then + local fields = {} + metas[k] = { + set_string = function(_, name, value) fields[name] = value end, + get_string = function(_, name) return fields[name] or "" end, + } + end + return metas[k] +end +function core.get_node_timer(p) + local k = key(p) + if not timers[k] then + timers[k] = { + start = function(self, seconds) self.timeout = seconds end, + stop = function(self) self.timeout = nil end, + } + end + return timers[k] +end +function core.item_place(s, who, pointed) + local under = core.get_node(pointed.under) + local def = nodes[under.name] + if def.on_rightclick and not who:get_player_control().sneak then + return def.on_rightclick(pointed.under, under, who, s) + end + local p = def.buildable_to and pointed.under or pointed.above + if core.is_protected(p, who:get_player_name()) then return s end + local name = s:get_name() + put(p, name) + if nodes[name].on_construct then nodes[name].on_construct(p) end + if nodes[name].after_place_node then nodes[name].after_place_node(p, who) end + s:take_item() + return s, p +end +nodes.air = {buildable_to = true, walkable = false} +hearth = { + model = {}, players = {survivor = state}, + texture = function(color) return "[fill:16x16:" .. color end, + claim_allowed = function() return claim end, + notify = function(_, message) messages[#messages + 1] = message end, +} +local inventory = {room = true, contents = {}} +function inventory:room_for_item() return self.room end +function inventory:add_item(_, name) + self.contents[#self.contents + 1] = name + return stack() +end +player = {position = pos(), sneak = false} +function player:is_player() return true end +function player:get_pos() return self.position end +function player:get_player_name() return "survivor" end +function player:get_inventory() return inventory end +function player:get_player_control() return {sneak = self.sneak} end +local real_time = os.time +os.time = function() return now end +assert(arg and arg[1], "Pass the path to mods/hearth/content.lua") +dofile(arg[1]) + +local passed = 0 +local function eq(actual, expected) + assert(actual == expected, "expected " .. tostring(expected) .. ", got " .. tostring(actual)) +end +local function reset() + world, metas, timers, messages = {}, {}, {}, {} + now, protected, claim, violations = 100000, false, true, 0 + player.position, player.sneak = pos(), false + inventory.room, inventory.contents = true, {} + state.food, state.water, state.stamina = 100, 100, 100 +end +local function test(name, fn) + reset() + local ok, err = pcall(fn) + if not ok then error(name .. ": " .. tostring(err), 0) end + passed = passed + 1 + print("ok " .. passed .. " - " .. name) +end +local firepos = pos() +local function fire() + put(firepos, "hearth:fire") + nodes["hearth:fire"].on_construct(firepos) +end +local function use(text) + local s = stack(text) + local node = core.get_node(firepos) + nodes[node.name].on_rightclick(firepos, node, player, s) + return s +end +local function deadline() return tonumber(core.get_meta(firepos):get_string("fuel_until")) end + +test("standalone registrations, recipes and mapgen resolve", function() + for _, name in ipairs({"stone", "soil", "grass", "sand", "water_source", "water_flowing", + "log", "leaves", "loose_stone", "sticks", "berry_bush", "reeds", "planks", "wall", + "roof", "fire", "fire_lit", "berries", "raw_food", "cooked_food", "vessel", + "water_raw", "water_clean", "knife", "axe", "pick", "spear"}) do + assert(items["hearth:" .. name], name) + end + for _, recipe in ipairs(crafts) do + assert(items[stack(recipe.output):get_name()]) + for _, row in ipairs(recipe.recipe) do + for _, ingredient in ipairs(type(row) == "table" and row or {row}) do + assert(ingredient == "" or items[ingredient], ingredient) + end + end + end + eq(#biomes, 2) + eq(#decorations, 6) + eq(aliases.mapgen_river_water_source, "hearth:water_source") + for _, deco in ipairs(decorations) do + if deco.schematic then + eq(#deco.schematic.data, 150) + for _, entry in ipairs(deco.schematic.data) do assert(nodes[entry.name]) end + else assert(nodes[deco.decoration]) end + end +end) +test("hand gathers soil and shrubs but cannot harvest trees or stone", function() + local caps = items[":"].tool_capabilities.groupcaps + assert(caps.crumbly and caps.snappy) + eq(caps.choppy, nil) + eq(caps.cracky, nil) + assert(items["hearth:axe"].tool_capabilities.groupcaps.choppy) + assert(items["hearth:pick"].tool_capabilities.groupcaps.cracky) +end) +test("fire consumes exactly one fuel and retains metadata through swaps", function() + fire() + core.get_meta(firepos):set_string("owner", "survivor") + eq(use("hearth:sticks 3"):get_count(), 2) + eq(deadline(), now + 45) + eq(core.get_node(firepos).name, "hearth:fire_lit") + eq(core.get_node(firepos).param2, 2) + eq(core.get_meta(firepos):get_string("owner"), "survivor") + eq(use("hearth:log"):get_count(), 0) + eq(deadline(), now + 225) +end) +test("full hearth rejects fuel rather than granting or wasting it", function() + fire() + for _ = 1, 3 do use("hearth:log") end + eq(use("hearth:log"):get_count(), 1) + eq(deadline(), now + 540) + eq(use("hearth:sticks"):get_count(), 0) + eq(use("hearth:sticks"):get_count(), 1) + eq(deadline(), now + 585) +end) +test("timer and reload expire offline fuel without resetting the clock", function() + fire() + use("hearth:sticks") + now = now + 10 + nodes["hearth:fire_lit"].on_timer(firepos, 999) + eq(deadline(), 100045) + now = now + 1000 + lbms[1].action(firepos) + eq(core.get_node(firepos).name, "hearth:fire") + eq(deadline(), 0) + eq(core.get_node_timer(firepos).timeout, nil) + eq(use("hearth:raw_food"):get_name(), "hearth:raw_food") +end) +test("single food cooks in hand and debits fuel", function() + fire() + use("hearth:sticks") + eq(use("hearth:raw_food"):get_name(), "hearth:cooked_food") + eq(deadline(), now + 25) + eq(use("hearth:raw_food"):get_name(), "hearth:cooked_food") + eq(deadline(), now + 5) + eq(use("hearth:raw_food"):get_name(), "hearth:raw_food") +end) +test("full inventory does not destroy cooking input or fuel", function() + fire() + use("hearth:sticks") + inventory.room = false + eq(use("hearth:raw_food 4"):get_count(), 4) + eq(deadline(), now + 45) + inventory.room = true + eq(use("hearth:raw_food 4"):get_count(), 3) + eq(inventory.contents[1], "hearth:cooked_food") + eq(deadline(), now + 25) +end) +test("protected, claim-denied and distant fire interactions change nothing", function() + fire() + protected = true + eq(use("hearth:log"):get_count(), 1) + protected, claim = false, false + eq(use("hearth:sticks"):get_count(), 1) + claim, player.position = true, pos(7) + eq(use("hearth:log"):get_count(), 1) + eq(deadline(), 0) + eq(violations, 2) + for _, action in ipairs({"put", "take", "move"}) do + eq(nodes["hearth:fire"]["allow_metadata_inventory_" .. action](firepos, "main", 1, stack("hearth:log"), player), 0) + end +end) +test("vessel filling validates water, protection and six-block reach", function() + local fill = items["hearth:vessel"].on_place + local water = pos(6) + put(water, "hearth:water_source") + local pointed = point(water, pos(6, 1)) + eq(fill(stack("hearth:vessel"), player, pointed):get_name(), "hearth:water_raw") + protected = true + eq(fill(stack("hearth:vessel"), player, pointed):get_name(), "hearth:vessel") + protected, claim = false, false + eq(fill(stack("hearth:vessel"), player, pointed):get_name(), "hearth:vessel") + claim, player.position = true, pos(-0.1) + eq(fill(stack("hearth:vessel"), player, pointed):get_name(), "hearth:vessel") + eq(core.get_node(water).name, "hearth:water_source") +end) +test("boiling requires fuel and drinking returns the carved cup only when thirsty", function() + fire() + eq(use("hearth:water_raw"):get_name(), "hearth:water_raw") + use("hearth:sticks") + local water = use("hearth:water_raw") + eq(water:get_name(), "hearth:water_clean") + eq(deadline(), now + 35) + local drink = items["hearth:water_clean"].on_use + drink(water, player) + eq(water:get_name(), "hearth:water_clean") + state.water = 80 + drink(water, player) + eq(state.water, 100) + eq(water:get_name(), "hearth:vessel") +end) +test("food consumption is conditional and clamps restored needs", function() + local food = stack("hearth:berries 2") + items["hearth:berries"].on_use(food, player) + eq(food:get_count(), 2) + state.food = 95 + items["hearth:berries"].on_use(food, player) + eq(food:get_count(), 1) + eq(state.food, 100) + eq(state.water, 100) +end) +test("wall placement is supported, protected and records ownership", function() + local ground, target = pos(0, -1), pos() + put(ground, "hearth:stone") + local s = stack("hearth:wall 2") + nodes["hearth:wall"].on_place(s, player, point(ground, target)) + eq(s:get_count(), 1) + eq(core.get_node(target).name, "hearth:wall") + eq(core.get_meta(target):get_string("owner"), "survivor") + protected = true + eq(nodes["hearth:wall"].can_dig(target, player), false) + protected, claim = false, false + eq(nodes["hearth:wall"].can_dig(target, player), false) +end) +test("sequential roofs cannot extend a horizontal span beyond two blocks", function() + put(pos(0, -1), "hearth:stone") + put(pos(), "hearth:wall") + for x = 1, 3 do + local s = stack("hearth:roof") + nodes["hearth:roof"].on_place(s, player, point(pos(x - 1), pos(x))) + eq(s:get_count(), x <= 2 and 0 or 1) + end +end) +test("unloaded, unsupported, protected and distant placements fail closed", function() + local s = stack("hearth:wall 4") + local p = point(pos(0, -1), pos()) + nodes["hearth:wall"].on_place(s, player, p) + eq(s:get_count(), 4) + put(pos(0, -1), "hearth:stone") + claim = false + nodes["hearth:wall"].on_place(s, player, p) + claim, protected = true, true + nodes["hearth:wall"].on_place(s, player, p) + protected, player.position = false, pos(7) + nodes["hearth:wall"].on_place(s, player, p) + player.position = pos() + world[key(pos(0, -1))] = false + nodes["hearth:wall"].on_place(s, player, p) + eq(s:get_count(), 4) +end) +test("node-item rightclick feeds fire even when the space above is occupied", function() + fire() + put(pos(0, 1), "hearth:stone") + local s = stack("hearth:sticks 2") + nodes["hearth:sticks"].on_place(s, player, point(firepos, pos(0, 1))) + eq(s:get_count(), 1) + eq(deadline(), now + 45) +end) + +test("construction height is limited to sixteen blocks above an anchor", function() + put(pos(0, -1), "hearth:stone") + for y = 0, 16 do + player.position = pos(0, y) + local s = stack("hearth:wall") + nodes["hearth:wall"].on_place(s, player, point(pos(0, y - 1), pos(0, y))) + eq(s:get_count(), y < 16 and 0 or 1) + end +end) +test("water primary-use boiling obeys claim and distance checks", function() + fire() + use("hearth:sticks") + local s = stack("hearth:water_raw") + local pointed = point(firepos, pos(0, 1)) + claim = false + items["hearth:water_raw"].on_use(s, player, pointed) + eq(s:get_name(), "hearth:water_raw") + claim, player.position = true, pos(7) + items["hearth:water_raw"].on_use(s, player, pointed) + eq(s:get_name(), "hearth:water_raw") + player.position = pos() + items["hearth:water_raw"].on_use(s, player, pointed) + eq(s:get_name(), "hearth:water_clean") + eq(deadline(), now + 35) +end) +test("all content nodes enforce claim-aware digging and placement", function() + put(pos(0, -1), "hearth:stone") + claim = false + for name, def in pairs(nodes) do + if name ~= "air" then + eq(def.can_dig(pos(), player), false) + local s = stack(name) + def.on_place(s, player, point(pos(0, -1), pos())) + eq(s:get_count(), 1) + end + end +end) +test("nested procedural fill texture arguments escape colons", function() + assert(nodes["hearth:grass"].tiles[3]:find("[fill\\:16x16\\:", 1, true)) + assert(nodes["hearth:berry_bush"].tiles[1]:find("[fill\\:16x16\\:", 1, true)) +end) + +os.time = real_time +print("1.." .. passed) diff --git a/tests/engine_smoke.lua b/tests/engine_smoke.lua new file mode 100755 index 0000000..6d9d3c3 --- /dev/null +++ b/tests/engine_smoke.lua @@ -0,0 +1,169 @@ +-- Original MIT-licensed integration checks. Staged as a disposable world mod; +-- never copy this into the persistent play world. No engine globals are mocked. +local mode = core.settings:get("hearth_launcher_mode") +local token = core.settings:get("hearth_smoke_token") +assert(mode == "check" or mode == "smoke", "Invalid launcher test mode") +assert(token and token:match("^[%x%-]+$"), "Missing launcher test token") +local modpath = core.get_modpath(core.get_current_modname()) +local finished = false + +local function finish(ok, message) + if finished then return end + finished = true + if ok then + core.log("action", "LAST_HEARTH_" .. mode:upper() .. "_PASS " .. token) + else + core.log("error", "LAST_HEARTH_TEST_FAIL: " .. tostring(message)) + end + core.request_shutdown("Last Hearth " .. mode .. " complete", false, 0) +end + +local function guarded(fn) + local ok, message = pcall(fn) + if not ok then finish(false, message) end +end + +local function syntax_check() + local manifest = dofile(modpath .. "/syntax_manifest.lua") + assert(#manifest > 0, "Empty syntax manifest") + for _, entry in ipairs(manifest) do + local chunk, message = loadfile(modpath .. "/syntax/" .. entry.file) + assert(chunk, entry.label .. ": " .. tostring(message)) + end + core.log("action", "Last Hearth: compiled " .. #manifest .. " Lua files (standalone specs not executed)") +end + +local function registry_check() + assert(type(hearth) == "table", "Real hearth global was not loaded") + assert(type(hearth.model) == "table", "hearth.model missing") + assert(type(hearth.players) == "table", "hearth.players missing") + assert(core.get_modpath("hearth"), "hearth mod missing") + for _, name in ipairs({"default", "doors", "farming", "fire", "stairs", "wool", "sfinv"}) do + assert(not core.get_modpath(name), "Unexpected MTG dependency: " .. name) + end + for name in pairs(core.registered_items) do + assert(not name:match("^default:"), "MTG item leaked into standalone game: " .. name) + end + for _, name in ipairs({"stone", "soil", "grass", "sand", "water_source", "water_flowing", + "log", "leaves", "loose_stone", "sticks", "berry_bush", "reeds", "planks", + "wall", "roof", "fire", "fire_lit"}) do + assert(core.registered_nodes["hearth:" .. name], "Missing node: " .. name) + end + for _, name in ipairs({"berries", "raw_food", "cooked_food", "vessel", "water_raw", + "water_clean", "knife", "axe", "pick", "spear"}) do + assert(core.registered_items["hearth:" .. name], "Missing item: " .. name) + end + for _, name in ipairs({"knife", "axe", "pick", "spear", "vessel", "fire", "wall", + "roof", "planks", "sticks", "raw_food"}) do + local output = "hearth:" .. name + local recipe = core.get_craft_recipe(output) + assert(recipe.items and next(recipe.items), "Missing craft: " .. output) + for _, ingredient in pairs(recipe.items) do + assert(ingredient == "" or core.registered_items[ingredient], "Unknown ingredient: " .. ingredient) + end + local inputs = {} + for index = 1, (recipe.width == 0 and #recipe.items or recipe.width * 3) do + inputs[index] = ItemStack(recipe.items[index] or "") + end + local width = recipe.width > 0 and recipe.width or #inputs + local result = core.get_craft_result({method = "normal", width = width, items = inputs}) + assert(result.item:get_name() == output, "Engine craft result mismatch: " .. output) + assert(result.item:get_count() == ItemStack(recipe.output):get_count(), "Craft quantity mismatch: " .. output) + end + assert(core.registered_aliases.mapgen_stone == "hearth:stone", "Missing mapgen stone alias") + assert(core.registered_aliases.mapgen_water_source == "hearth:water_source", "Missing water alias") + assert(core.registered_biomes.hearth_temperate, "Missing temperate biome") + assert(core.registered_biomes.hearth_riverbank, "Missing riverbank biome") + assert(core.settings:get_bool("enable_pvp") == false, "PvP must be disabled") + assert(core.settings:get_bool("secure.enable_security") == true, "Mod security must be enabled") + assert(core.settings:get("bind_address") == "127.0.0.1", "Server must bind loopback only") +end + +local function unit_check() + -- Each standalone harness receives its own globals and os table. Mocked core, + -- hearth and os.time never replace globals of the running game. + local manifest = dofile(modpath .. "/syntax_manifest.lua") + local lookup = {} + for _, entry in ipairs(manifest) do lookup[entry.label] = modpath .. "/syntax/" .. entry.file end + local count = 0 + for label, filename in pairs(lookup) do + if label:match("_spec%.lua$") then + local module = label:match("tests/(.+)_spec%.lua$") + local target = assert(lookup["mods/hearth/" .. module .. ".lua"], "Missing test module") + local env = setmetatable({arg = {target}, os = {}}, {__index = _G}) + for k, v in pairs(os) do env.os[k] = v end + env._G = env + env.dofile = function(path) + assert(path == target, "Unit harness attempted unexpected file access") + local chunk = assert(loadfile(path)); setfenv(chunk, env); return chunk() + end + local chunk = assert(loadfile(filename)); setfenv(chunk, env); chunk() + count = count + 1 + end + end + assert(count >= 2, "Expected model and content regression suites") + core.log("action", "Last Hearth: " .. count .. " isolated unit suites executed") +end + +local function emerge_check() + local callbacks = 0 + core.emerge_area({x = -64, y = -32, z = -64}, {x = 64, y = 96, z = 64}, + function(_, action, remaining) + if finished then return end + guarded(function() + assert(action ~= core.EMERGE_ERRORED and action ~= core.EMERGE_CANCELLED, + "Map emergence failed or was cancelled") + callbacks = callbacks + 1 + if remaining ~= 0 then return end + -- Inspect after mapgen callbacks have completed, on a server step. + core.after(0, function() + guarded(function() + assert(callbacks > 0, "No emerge callbacks received") + local material = false + for z = -16, 16, 4 do + for y = -32, 16, 4 do + for x = -16, 16, 4 do + local node = core.get_node_or_nil({x = x, y = y, z = z}) + assert(node and node.name ~= "ignore", "Emerged map contains unloaded nodes") + assert(core.registered_nodes[node.name], "Unregistered generated node: " .. node.name) + assert(node.name == "air" or node.name:match("^hearth:"), + "Foreign generated node: " .. node.name) + if node.name:match("^hearth:") then material = true end + end + end + end + assert(material, "Mapgen produced no hearth terrain in the test volume") + local resources = {} + for _, name in ipairs({"grass", "loose_stone", "sticks", "berry_bush", "reeds", "log", "water_source"}) do + local nodes = core.find_nodes_in_area({x = -64, y = -16, z = -64}, {x = 64, y = 96, z = 64}, {"hearth:" .. name}) + resources[#resources + 1] = name .. "=" .. #nodes + assert(#nodes > 0, "Starting region missing required resource: " .. name) + end + core.log("action", "Last Hearth starting resources: " .. table.concat(resources, ", ")) + local p = {x = 0, y = 85, z = 0} + core.set_node(vector.offset(p, 0, 3, 0), {name = "hearth:roof"}) + for _, d in ipairs({{1,0},{-1,0},{0,1},{0,-1}}) do + core.set_node(vector.offset(p, d[1]*2, 1, d[2]*2), {name = "hearth:wall"}) + end + local env = hearth.environment(p) + assert(env.roof and env.shelter == 1, "A roof and four walls must enclose a shelter") + core.remove_node(vector.offset(p, 0, 3, 0)) + assert(hearth.environment(p).shelter == 0, "Walls without a roof are exposed") + finish(true) + end) + end) + end) + end) +end + +core.after(1, function() + guarded(function() + syntax_check() + if mode == "check" then finish(true); return end + registry_check() + unit_check() + emerge_check() + end) +end) + +core.after(60, function() finish(false, "Engine test deadline exceeded") end) diff --git a/tests/model_spec.lua b/tests/model_spec.lua new file mode 100755 index 0000000..fc78dd1 --- /dev/null +++ b/tests/model_spec.lua @@ -0,0 +1,60 @@ +local M = dofile(assert(arg[1], "model.lua path required")) +local total = 0 +local function test(name, fn) + local ok, err = pcall(fn) + assert(ok, name .. ": " .. tostring(err)); total = total + 1 +end +-- Explicit UTC fixtures; os.time(table) would depend on the machine timezone. +local june_noon, december_noon = 1782043200, 1797854400 +test("reject malformed locations", function() + assert(not M.location("bad", 0)); assert(not M.location(91, 0)) + assert(not M.location(0, 181)); assert(not M.location(0 / 0, 0)) + assert(not M.location(math.huge, 0)); assert(M.location(0, 0)) +end) +test("latitude changes solstice elevation", function() + local summer = M.solar(june_noon, 45, 0) + local winter = M.solar(december_noon, 45, 0) + assert(summer.elevation > winter.elevation + 40) + assert(summer.temperature > winter.temperature) +end) +test("opposite hemispheres reverse seasons", function() + assert(M.solar(june_noon, 45, 0).season == "Summer") + assert(M.solar(june_noon, -45, 0).season == "Winter") +end) +test("longitude changes solar time", function() + local a, b = M.solar(june_noon, 0, 0), M.solar(june_noon, 0, 90) + assert(math.abs((b.hour - a.hour) % 24 - 6) < 0.01) +end) +test("polar night and polar day stay finite", function() + assert(M.solar(june_noon, 90, 0).daylight) + assert(not M.solar(december_noon, 90, 0).daylight) + assert(M.solar(june_noon, -90, 0).timeofday >= 0) +end) +test("deterministic weather across restarts", function() + assert(M.weather(june_noon, 30, -70) == M.weather(june_noon, 30, -70)) +end) +local initial = {food = 80, water = 80, warmth = 80, wet = 20, stamina = 70} +test("shelter dries while exposed rain wets", function() + local dry = M.needs(initial, {temperature = 5, shelter = 1, rain = true}, 1) + local wet = M.needs(initial, {temperature = 5, shelter = 0, rain = true}, 1) + assert(dry.wet < initial.wet and wet.wet > initial.wet) + assert(initial.wet == 20) -- no hidden mutation +end) +test("rest recovers sprint reserve", function() + local rest = M.needs(initial, {temperature = 20}, 1) + local run = M.needs(initial, {temperature = 20, sprinting = true}, 1) + assert(rest.stamina > initial.stamina and run.stamina < initial.stamina) +end) +test("large pauses never replay offline starvation", function() + local state = M.needs(initial, {temperature = -20}, 100000) + assert(state.food > 79 and state.water > 79) +end) +test("negative elapsed does not reverse needs", function() + local state = M.needs(initial, {temperature = -20}, -1) + assert(state.food == initial.food) +end) +test("fuel expires and is bounded", function() + assert(M.fuel_remaining(100, 101) == 0) + assert(M.fuel_remaining(10000000, 0) == 600) +end) +print("PASS " .. total .. " model tests")