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
+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")