Initial commit

This commit is contained in:
2026-11-01 19:35:46 -05:00
commit 6ebe95b208
17 changed files with 2204 additions and 0 deletions
Executable
+21
View File
@@ -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.
Executable
+160
View File
@@ -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.
Executable
+7
View File
@@ -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
Executable
+10
View File
@@ -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
+520
View File
@@ -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",
})
+75
View File
@@ -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})
+51
View File
@@ -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")
+122
View File
@@ -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
+5
View File
@@ -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
+72
View File
@@ -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
+221
View File
@@ -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)())
+79
View File
@@ -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');
}
+257
View File
@@ -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 <check|smoke|play|test>');
}
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; });
+5
View File
@@ -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
+370
View File
@@ -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)
+169
View File
@@ -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)
+60
View File
@@ -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")