Files
last-hearth/mods/hearth/interface.lua
T
2026-11-03 12:41:09 -05:00

547 lines
45 KiB
Lua
Executable File

local C = core
local esc = C.formspec_escape
-- eom.dev Mocha anchors: shared by every journal surface, not terrain tints.
local ink = {base="#1e1e2e", mantle="#181825", raised="#292940", border="#3c3d56",
text="#cdd6f4", muted="#a6adc8", mauve="#cba6f7", teal="#94e2d5", yellow="#f9e2af", danger="#f38ba8"}
local function label(x, y, value, color)
return "label[" .. x .. "," .. y .. ";" .. esc(C.colorize(color or ink.text, value)) .. "]"
end
local function divider(x, y, width)
-- A little pressed sprig: a stem, paired leaves, then quiet ruled paper.
return "box[" .. x .. "," .. y .. ";" .. width .. ",0.02;" .. ink.border .. "]"
.. "box[" .. x .. "," .. (y-0.01) .. ";0.72,0.04;" .. ink.teal .. "]"
.. "box[" .. (x+0.16) .. "," .. (y-0.09) .. ";0.16,0.07;" .. ink.teal .. "]"
.. "box[" .. (x+0.39) .. "," .. (y+0.04) .. ";0.16,0.07;" .. ink.teal .. "]"
end
local function journal(width, height)
return "formspec_version[6]size[" .. width .. "," .. height .. "]bgcolor[" .. ink.base .. ";true]"
.. "box[0,0;" .. width .. "," .. height .. ";" .. ink.base .. "]"
.. "style_type[label;textcolor=" .. ink.text .. "]"
.. "style_type[button,button_exit;border=false;bgimg=" .. esc("[fill:1x1:#ffffff") .. ";bgcolor=" .. ink.raised .. ";textcolor=" .. ink.text .. "]"
.. "style_type[button:hovered,button_exit:hovered;bgcolor=" .. ink.border .. ";textcolor=" .. ink.teal .. "]"
.. "style_type[button:pressed,button_exit:pressed;bgcolor=" .. ink.mauve .. ";textcolor=" .. ink.mantle .. "]"
.. "style_type[field,textarea;textcolor=" .. ink.text .. ";border=false]"
.. "style_type[list;size=1,1;spacing=0.12,0.12]"
.. "listcolors[" .. ink.raised .. ";" .. ink.border .. ";" .. ink.border .. ";" .. ink.mantle .. ";" .. ink.text .. "]"
end
local function index(value, maximum)
value = tonumber(value)
if not value or value ~= value then return 1 end
return math.max(1, math.min(maximum, math.floor(value)))
end
local reader = dofile(C.get_modpath("hearth") .. "/species_reader.lua")(hearth, C, journal)
hearth.species_reader = reader
local function inspect(player, param)
if not hearth.sessions[player:get_player_name()] then return false, "Join the world first." end
local r
if param == "" then r = reader.look(player) else r = reader.resolve(param) end
if not r then return false, "Look at a known species within six blocks, or use /inspect <exact ID or Latin name>." end
return reader.show(player,r.id)
end
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. Fuel a cold hearth, then ignite it with the reusable flint firestarter. A torch brings portable light to the long real-world night.\n\nUse /claim to protect your homestead, then /sethome from a safe, dry position inside the claim.\n\nThere is no quest timer. Your first success is a home that works."},
{"Craft & build", "WORK WITH YOUR HANDS", "Search recipes in the recipe index.",
"Open the recipe index from any field-note page, or type /recipes <word> in chat. Search checks both ingredients and results. The inventory crafting preview shows the actual matching result.\n\nBuild from stone foundations and timber frames to walls, roofs, stairs, doors and reed-lattice windows. Right-click a door to open or close it. Roof vents keep enclosed hearth rooms breathable.\n\nHarvest wild grain and replant seed. Berries regrow on their bush; saplings can regrow a tree when planted in clear space. Crude iron ore is found in deep stone; combine a log and clay into a charcoal charge and process it at a lit hearth. Combine charcoal and ore into a smelting charge for a fueled hearth; an iron axe upgrades timber work.\n\nDry cooked food with reeds into shelf-stable rations; fire a clay jar and combine it with planks to make a pantry. General spoilage remains disabled."},
{"Fire & food", "A FIRE WORTH KEEPING", "Gather. Prepare. Eat. Recover.",
"Place a cold stone hearth. Right-click with sticks or logs to add fuel, then use a flint firestarter on it. Fuel burns in real time: one stick is 45 seconds; a log is three minutes. A torch is a permanent, portable light.\n\nCraft two berries into forage. Right-click the lit hearth with one raw portion to start a six-second cook. One untreated cup of river water takes four seconds to boil. The full heat cost is reserved on start; fuel continues to burn during the work time.\n\nWhen the infotext says the output is ready, right-click the hearth empty-handed to collect it. The output remains there if your inventory is full. Left-click cooked food or clean water to eat/drink. Smelting takes 60 seconds, pottery firing 120, and drying 300; each reserves its heat cost before consuming the charge.\n\nProcesses use real elapsed seconds and reconcile once after absence; absent players do not need to tend them. General food spoilage is disabled; dried rations are shelf-stable."},
{"Read the world", "SHELTER IS A SYSTEM", "The HUD tells you why you feel the way you do.",
"Shelter is measured by a bounded room probe, not just four wall rays. Closed doors and windows stop weather; an open doorway exposes that side. A roof vent or an open door lets smoke escape. An enclosed room with an unvented hearth is dangerous.\n\nRain wets exposed clothing. A woven cloak slows wetting and adds warmth. Dry indoors beside a lit hearth or at a drying rack. The room probe is bounded and cached; very large or unloaded spaces fail safely as exposed.\n\nAux1 (usually E) sprints. Right-click while holding a spear to brace; bracing costs stamina and reduces walking speed.\n\nOn the disposable M1 course, use /course to create and start the marked movement route. The stationary /practice target or opt-in moving /spar opponent starts combat only when you choose. Face the opponent and strike; step aside or back to evade, or face it and brace with spear + RMB. 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. Use /claim show for temporary boundary markers. Share with /claim share <player> resident (build and household use) or /claim share <player> storekeeper (storage only). /claim unshare <player> revokes access; /claim list shows members; /claim remove releases the claim and grants.\n\nPlace homestead storage inside a claim. Its inventory stays private to the owner, residents and storekeepers; non-empty storage cannot be removed. Workstations and construction require resident permissions.\n\nUse /sethome in your own safe claim; /home rechecks that position before moving you and safely falls back to the world arrival if the home is blocked or hazardous. Your inventory, food, water and wetness persist through death.\n\nLogged-out player needs pause. Hearth processes and fuel use real elapsed time; completed output remains stored until claimed. Food does not spoil in this slice. This remains a prototype, not a hardened public service."},
{"Living ecosystems", "NOTHING GROWS ALONE", "Watch a meadow change; there is no quest to complete.",
"An original succession model inspired by Dynamic Life connects light, moisture, plants and animals. The botanical catalogue has 80 named species: eight categories of ten each. Use the Botanical guide button or /botany [search] for common and scientific names, habitat, seasons, toxicity labels and roles. These are game-only notes, not a real-world forage guide.\n\nBirch pioneers open gaps; oak favors meadow/forest soils. Evergreen pine and spruce keep winter shade; deciduous oak, birch, willow, alder, beech, aspen, rowan and hazel thin in Winter. Willow favors wet alluvium/peat. Nitrogen-fixer policy can add at most two nutrient units per support across replacements/restarts while reserves are low. The legacy alder terrain profile alone is not a separate fertilizer process. Litter, fungi, deadwood and dung recycle finite nutrients. Fallen logs are ordinary sideways cubes: an axe yields one log, not hand-gathered sticks.\n\nSpring/Summer clover and flowers support pollinators; spring bluebells and autumn heather have seasonal windows. Wet sedge/bulrush, shade moss/ferns, rock lichen and nutrient-needing nettle have distinct niches. The survey counts supporting plants, not insects. Hares and deer graze natural cover; foxes pursue hares. Use /ecosystem for actual ground, light, moisture, canopy, nutrient reserve and active wildlife totals. Moisture is a bounded habitat estimate, not fully simulated hydrology, erosion or flooding.\n\nRoles are conditional policies or descriptors, not effects guaranteed for every species: parasitism and allelopathy cause no plant damage, and a resin tag is not an extraction recipe. Wild succession advances only around connected players in loaded areas. It pauses across unloading and restart, with no offline catch-up. Claims, placed plants, crops and construction are excluded from autonomous changes. Stages take active sessions, not a daily login. Crops and planted saplings keep separate growth rules."},
{"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."},
{"Geology & timber", "READ THE REGIONAL MATERIALS", "Ten biomes; shared sky, different ground.",
"Temperate meadow and oak woodland favor limestone; birch forest and pine taiga favor granite. Wet forest has peat over slate; floodplain has alluvium over sandstone. Dry scrub has sandstone, alpine meadow has turf/scree over slate, rocky peaks have snow/scree over granite, and riverbank has sand/gravel over sandstone. These are ten terrain communities, not ten independent weather simulations. The climate HUD remains shared; altitude still affects exposure temperature.\n\nQuartz seams occur in granite/slate; copper in basalt/limestone/slate up to Y=32; coal in sandstone/slate/limestone up to Y=70. Deeper beds and basalt intrusions differ from surface rock. Mine with a pick; each seam drops two resource items. Three quartz craft one existing window, without a glass furnace.\n\nOne copper ore + one charcoal craft one copper charge. At a lit hearth it takes 45 real seconds and reserves 30 fuel seconds, yielding one ingot. Three copper ingots across the top row, with two sticks down the center, make one faster, longer-lasting copper pick. Iron remains a separate 60-second smelt and axe route.\n\nOak, birch, pine, willow, alder, beech, aspen, rowan, hazel and spruce logs each burn for 180 seconds, or craft four matching planks. Convert two matching species logs to two canonical logs for log + clay charcoal charges; convert species planks 1:1 to canonical planks for exact-name building recipes. One regional rock crafts four matching bricks; two matching rocks convert to two canonical stones. Put each input in separate crafting slots (shapeless); there is no single-log or single-rock canonical conversion. Fallen logs are sideways full cubes; an axe yields one canonical log, never two hand-gathered sticks. Coal burns 240 seconds; charcoal 90; sticks 45. All fuel is capped at 600 seconds. Coal is geological and nonrenewable, not a renewable fuel loop.\n\nThe broad-relief terrain preset is for fresh worlds only. Existing chunks stay untouched; new biome registrations can change unexplored old-world boundaries. Operators must explicitly choose a new world or accept those seams; never delete map metadata to force a migration."},
}
local botanical_groups = {
{"trees", "Trees"}, {"shrubs", "Shrubs"}, {"grasses", "Grasses, sedges & rushes"},
{"herbs", "Herbs"}, {"ferns_mosses", "Ferns & mosses"}, {"fungi", "Fungi"},
{"aquatic", "Aquatic plants"}, {"lichens", "Lichens"},
}
local function catalogue()
local botany = hearth.botany
return botany and type(botany.list) == "function" and botany.list() or {}
end
local function botanical_summary()
local records, lines = catalogue(), {}
if #records == 0 then return "Botanical catalogue unavailable in this runtime. Use /botany when installed." end
lines[1] = "BOTANICAL CATALOGUE / " .. #records .. " named species; eight categories of ten in the full catalogue.\nUse /botany [search] for scientific names, habitat windows, seasons and ecological roles."
for _, group in ipairs(botanical_groups) do
local names = {}
for _, r in ipairs(records) do
if r.group == group[1] then names[#names+1] = r.common .. " (" .. r.latin .. ")" end
end
lines[#lines+1] = group[2] .. " / " .. #names .. ": " .. table.concat(names, "; ")
end
lines[#lines+1] = "Game-only field notes, not a real-world forage guide. Toxic is a catalogue flag; an unflagged species is NOT a food-safety recommendation. Roles describe conditional policy, not universal active effects."
return table.concat(lines, "\n\n")
end
function hearth.guide(player, page)
local session = hearth.sessions[player:get_player_name()]
if not session then return end
page = index(page, #pages)
session.page = page
local p = pages[page]
local f = {journal(14, 10), "box[0,0;3.3,10;" .. ink.mantle .. "]",
label(0.4,0.55,"eom.dev / SEASON VI",ink.mauve), label(0.4,1.15,"THE LAST HEARTH"),
divider(0.4,1.65,2.5), label(0.4,2.05,"FIELD JOURNAL",ink.muted)}
for i, pg in ipairs(pages) do
if i == page then f[#f+1] = "style[page_" .. i .. ";bgcolor=" .. ink.border .. ";textcolor=" .. ink.teal .. "]" end
f[#f + 1] = "button[0.35," .. (2.45 + (i-1)*0.68) .. ";2.6,0.56;page_" .. i .. ";" .. esc(pg[1]) .. "]"
end
f[#f+1] = label(0.4,8.95,"Observe. Make. Belong.",ink.muted)
f[#f + 1] = label(3.85,0.55,"FIELD NOTES / " .. string.format("%02d / %02d",page,#pages),ink.mauve)
f[#f + 1] = label(3.85,1.2,p[2],ink.yellow) .. label(3.85,1.85,p[3],ink.muted)
f[#f + 1] = divider(3.85,2.3,9.45)
local notes = p[4] .. (page == 6 and "\n\n" .. botanical_summary() or "")
f[#f + 1] = "textarea[3.8,2.65;9.6,5.6;;;" .. esc(notes) .. "]"
f[#f + 1] = "button[3.85,8.15;2.3,0.55;inspect_target;Inspect target]"
f[#f + 1] = "button[3.85,8.85;2.3,0.65;ecosystem_survey;Ecosystem survey]"
.. "tooltip[ecosystem_survey;Read a loaded habitat patch and global active wildlife. No world changes.;" .. ink.mantle .. ";" .. ink.text .. "]"
f[#f + 1] = "button[6.3,8.85;2.1,0.65;botany_index;Botanical guide]"
f[#f + 1] = "button[8.55,8.85;2.1,0.65;recipe_index;Recipe index]"
.. "tooltip[recipe_index;Search ingredients, results and real-time processes.;" .. ink.mantle .. ";" .. ink.text .. "]"
f[#f + 1] = "button_exit[10.8,8.85;2.5,0.65;close;Back to the valley]"
C.show_formspec(player:get_player_name(), "hearth:guide", table.concat(f))
end
local recipe_outputs = {"hearth:knife", "hearth:axe", "hearth:pick", "hearth:spear", "hearth:iron_axe",
"hearth:copper_charge", "hearth:copper_pick", "hearth:log", "hearth:stone",
"hearth:oak_planks", "hearth:birch_planks", "hearth:pine_planks", "hearth:willow_planks", "hearth:alder_planks",
"hearth:beech_planks", "hearth:aspen_planks", "hearth:rowan_planks", "hearth:hazel_planks", "hearth:spruce_planks",
"hearth:granite_bricks", "hearth:basalt_bricks", "hearth:limestone_bricks", "hearth:slate_bricks", "hearth:sandstone_bricks",
"hearth:firestarter", "hearth:vessel", "hearth:torch", "hearth:fire", "hearth:wall",
"hearth:roof", "hearth:foundation", "hearth:frame", "hearth:stairs", "hearth:door",
"hearth:window", "hearth:vent", "hearth:storage", "hearth:pantry", "hearth:drying_rack", "hearth:cloak",
"hearth:grain_seed", "hearth:charcoal_charge", "hearth:smelt_charge", "hearth:clay_jar_charge", "hearth:dried_food_charge",
"hearth:bedroll", "hearth:planks", "hearth:sticks", "hearth:fish_trap", "hearth:fish_bait",
"hearth:fish_drying_charge", "hearth:pantry", "hearth:raw_food"}
local process_notes = {
{"Seasonal forage", "Spring greens grow in moist sunny gaps only in Spring and can be eaten. Autumn mushrooms appear in moist shade only in Autumn; craft one into raw food and cook it. Patches rest one real day after picking; changing seasons never resets the harvest."},
{"Deciduous woodland", "Spring buds become green summer leaves, copper and gold autumn foliage, then bare winter branches. Leaf fall admits more light. Perennial groundcover rests through winter; wildflowers bloom in Spring and Summer. Use /ecosystem for a read-only survey."},
{"Cooked forage", "One portion raw food + lit hearth: 6 real seconds; 20 fuel seconds reserved at start."},
{"Boiled water", "One cup untreated water + lit hearth: 4 real seconds; 10 fuel seconds reserved at start."},
{"Smelting", "One iron ore/charcoal charge + lit hearth: 60 real seconds; 30 fuel seconds reserved at start. Copper uses its separate 45-second charge."},
{"Copper smelting", "One hearth:copper_charge (one hearth:copper_ore + one hearth:charcoal) + lit hearth: 45 real seconds; 30 fuel seconds reserved at start; one hearth:copper_ingot output. Requires at least 75 fuel seconds to queue. Collect empty-handed; a full inventory leaves the ingot waiting. Copper pick: three ingots across the top and two central sticks."},
{"Regional geology", "Quartz in granite/slate: three hearth:quartz craft one window; the reed/plank route remains. Copper ore in basalt/limestone/slate; coal in sandstone/slate/limestone. Each seam drops two items. One granite, basalt, limestone, slate or sandstone crafts four matching bricks. Convert two matching rocks to two canonical stones in separate crafting slots (shapeless); no single-rock canonical conversion."},
{"Species timber", "One oak, birch, pine, willow, alder, beech, aspen, rowan, hazel or spruce log crafts four matching planks. Convert two matching species logs in separate crafting slots (shapeless) to two canonical hearth:log; no single-log canonical conversion. Species planks convert 1:1 to canonical hearth:planks. Charcoal charges accept only canonical log + clay: convert species logs in matching pairs first, not four planks back into logs."},
{"Finite nitrogen", "Nitrogen-fixer policy, including clover and alder in the botanical runtime, can add at most two nutrient units per support across replacements and restarts while the reserve is low. The legacy alder terrain profile alone is not a fertilizer process. Nutrient reserve is bounded at eight; litter, deadwood, fungi and dung recycle finite material."},
{"Fallen logs", "Ordinary full-cube fallen logs lie sideways. Use an axe to harvest one canonical hearth:log; hands do not yield sticks. They also support decomposer habitat. Player-placed and claimed material is excluded from autonomous succession."},
{"Pottery firing", "One shaped clay-jar charge + lit hearth: 120 real seconds; 30 fuel seconds reserved at start."},
{"Food drying", "One cooked-food/reed charge + lit hearth: 300 real seconds; 30 fuel seconds reserved at start."},
{"River fish trap", "Craft from three sticks and three reeds. Place on firm sand or grass within three blocks of river water, then use one bait to set it for 600 real seconds. One catch per trap; a full inventory leaves the catch waiting."},
{"Fish preparation", "Roast one raw fish at a lit hearth for 8 real seconds; reserve 20 fuel seconds. Combine cooked fish with reeds into a drying charge, then dry for 300 seconds with 30 fuel seconds reserved. Dried fish is shelf-stable."},
{"Spring fish run", "A trap finishing during spring returns two fish; other seasons return one. The catch is determined by its scheduled deadline, even if you are away."},
{"Light a hearth", "Fuel first, then use a flint firestarter. Each starter has 20 successful ignitions."},
{"Fire fuel", "Each stick adds 45 real seconds; canonical, oak, birch, pine, willow, alder, beech, aspen, rowan, hazel and spruce logs each add 180; charcoal adds 90; coal adds 240 seconds. Stored reserve is capped at 600; a fuel item that would exceed it is not consumed. Planks and buildings are not direct fuel. Coal is geological and nonrenewable; it cannot be crafted or grown."},
{"Charcoal", "Combine one log with river clay, then burn the charge at a lit hearth for 60 seconds with 30 fuel seconds reserved. The resulting charcoal stores 90 seconds; total fuel recovered never exceeds the log's 180 seconds."},
{"Bedroll rest", "Craft from three planks and three reeds. Right-click beneath a roof; remain still and safe for five seconds."},
{"Crop growth", "Plant grain seed on grass or soil. Growth samples historical local solar climate; cold weather pauses it. Harvest ripe crops for grain, then reserve one kernel to replant."},
{"Renewal", "Berry bushes return after one real day. A planted sapling grows after three days when its bounded tree volume is clear. No resource can regrow above map limits or through claims."},
{"Preservation", "Combine cooked food with reeds into a drying charge, then process it at a lit hearth. Dried rations are shelf-stable; general food spoilage is currently off."},
{"Spring fish", "Spring catches can produce two fish per baited trap. Search this index for fish preparation; it is seasonal but not time-critical."},
{"Pottery", "Shape six river clay into an unfired jar, then fire it at a lit hearth. Use the fired jar with planks to make a larger pantry."},
{"Ironworking", "Find deep iron-bearing rock, prepare charcoal from a log, then combine ore and charcoal into a smelting charge. A fueled hearth smelts it into an ingot for a stronger iron axe."},
}
local function item_label(item)
if type(item) ~= "string" or item == "" then return "" end
local name, count = item:match("^(%S+)%s*(%d*)$")
local def = name and C.registered_items[name]
local label = def and def.description or name or item
if count and count ~= "" and tonumber(count) and tonumber(count) > 1 then label = count .. " x " .. label end
return label
end
local function recipe_rows(query)
local rows, needle = {}, query:lower()
for _, output_name in ipairs(recipe_outputs) do
local output = C.registered_items[output_name]
local recipes = C.get_all_craft_recipes and C.get_all_craft_recipes(output_name) or nil
if output and recipes then
for _, recipe in ipairs(recipes) do
if recipe.method == "normal" then
local ingredients, source_items = {}, {}
for _, item in pairs(recipe.items or {}) do
if item and item ~= "" then ingredients[#ingredients + 1] = item_label(item) end
if item and item ~= "" then source_items[#source_items + 1] = item end
end
table.sort(ingredients)
local result = item_label(recipe.output or output_name)
local row = result .. " <- " .. (#ingredients > 0 and table.concat(ingredients, " + ") or "no ingredients")
local searchable = (row .. " " .. output_name .. " " .. table.concat(source_items, " ")):lower()
if needle == "" or searchable:find(needle, 1, true) then
rows[#rows + 1] = row
end
end
end
end
end
for _, note in ipairs(process_notes) do
local row = note[1] .. " / " .. note[2]
if needle == "" or row:lower():find(needle, 1, true) then rows[#rows + 1] = row end
end
table.sort(rows)
return rows
end
function hearth.recipes(player, query, page)
query = tostring(query or ""):sub(1, 48):gsub("[%c]", " ")
page = index(page, 1000)
local all = recipe_rows(query)
local page_size = 8
local pages_count = math.max(1, math.ceil(#all / page_size))
page = math.min(page, pages_count)
local lines = {}
for i = (page - 1) * page_size + 1, math.min(#all, page * page_size) do
lines[#lines + 1] = all[i]
end
if #lines == 0 then lines[1] = "No recipe or process matches that search." end
local name = player:get_player_name()
local session = hearth.sessions[name]
if not session then return end
session.recipe_query, session.recipe_page = query, page
C.show_formspec(name, "hearth:recipes", journal(14,10)
.. label(0.65,0.55,"eom.dev / THE LAST HEARTH",ink.mauve)
.. label(0.65,1.15,"RECIPE & PROCESS INDEX",ink.yellow) .. divider(0.65,1.65,12.7)
.. "box[0.65,2.2;9.3,0.8;" .. ink.mantle .. "]"
.. "field[0.7,2.25;9.2,0.7;query;Result or ingredient (plain text);" .. esc(query) .. "]field_close_on_enter[query;false]"
.. "button[10.25,2.25;3.1,0.7;search;Search]"
.. "tooltip[query;Up to 48 characters. Search matches literal text, not patterns.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. "tooltip[search;Search crafting recipes and real-time hearth processes.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. label(0.7,3.3,"Page " .. page .. " / " .. pages_count .. " / " .. #all .. " matches",ink.muted)
.. "textarea[0.65,3.7;12.7,4.6;;;" .. esc(table.concat(lines, "\n\n")) .. "]"
.. (page > 1 and "button[0.7,8.85;2.8,0.65;previous;Previous]" or "")
.. (page < pages_count and "button[3.7,8.85;2.8,0.65;next;Next]" or "")
.. "button[10.05,8.85;3.3,0.65;close;Back to field notes]")
end
local function words(set)
local out = {}
for key, enabled in pairs(set or {}) do
if enabled then out[#out+1] = key:gsub("_", " ") end
end
table.sort(out)
return #out > 0 and table.concat(out, ", ") or "not specified"
end
local function season_words(set)
local out = {}
for _, season in ipairs({"Spring", "Summer", "Autumn", "Winter"}) do
if set and set[season] then out[#out+1] = season end
end
return #out > 0 and table.concat(out, ", ") or "not specified"
end
local role_names = {browse="wildlife browse", pollinator="flower-season pollinator support",
nitrogenfixer="bounded nitrogen-fixer policy", ectomycorrhiza="host-linked root exchange",
saprotroph="finite-substrate decomposition", nursery="habitat shelter",
engineer="habitat engineering (descriptive tag)", resin="resin-bearing (descriptor; no extraction here)",
parasite="parasitism (descriptor only; no plant damage)", allelopathy="chemical competition (descriptor only; no killing)"}
local function botanical_row(r)
local niche, roles = r.niche or {}, {}
for tag, enabled in pairs(r.tags or {}) do if enabled then roles[#roles+1] = role_names[tag] or tag end end
table.sort(roles)
local group_name = r.group
for _, g in ipairs(botanical_groups) do if g[1] == r.group then group_name = g[2] end end
local function range(value, scale)
return value and tostring(value[1]*(scale or 1)) .. "-" .. tostring(value[2]*(scale or 1)) or "unspecified"
end
return r.common .. " (" .. r.latin .. ") / " .. group_name .. " / " .. r.id
.. "\nHabitat: " .. words(niche.substrates) .. "; " .. (niche.aquatic or "land")
.. "; light " .. range(niche.light) .. "/15; moisture " .. range(niche.moisture,100)
.. "%; pH " .. range(niche.ph) .. (niche.hosts and "; hosts: " .. words(niche.hosts) or "")
.. (niche.nutrients and "; nutrient minimum " .. niche.nutrients or "")
.. (niche.aquatic ~= "land" and niche.water and "; water depth " .. range(niche.water) or "")
.. "\nActive seasons: " .. season_words(r.seasons)
.. (r.flowering and "; flowers: " .. season_words(r.flowering) or "")
.. (r.fruiting and "; fruit: " .. season_words(r.fruiting) or "")
.. "; toxic flag: " .. (r.toxic and "YES (catalogue label)" or "not flagged (NOT food permission)")
.. (r.food and "; seasonal game berry harvest" or "; no catalogue food harvest")
.. "\nRoles / conditional policy or descriptors: " .. table.concat(roles, ", ")
end
function hearth.botanical_guide(player, query, page)
local name = player:get_player_name()
local session = hearth.sessions[name]
if not session then return end
query = tostring(query or ""):sub(1,48):gsub("[%c]", " ")
local all, ids, records, needle = {}, {}, catalogue(), query:lower()
for _, r in ipairs(records) do
local row = botanical_row(r)
if needle == "" or (row .. " " .. r.group .. " " .. words(r.tags)):lower():find(needle,1,true) then
all[#all+1], ids[#ids+1] = row, r.id
end
end
local count = math.max(1,math.ceil(#all/10))
page = index(page,count)
local lines, choices = {}, {}
session.botany_ids, session.botany_selected = {}, 1
for i=(page-1)*10+1,math.min(#all,page*10) do
lines[#lines+1] = all[i]
session.botany_ids[#choices+1] = ids[i]
choices[#choices+1] = esc(ids[i])
end
if #lines == 0 then lines[1] = #records == 0 and "Botanical catalogue unavailable in this runtime."
or "No species matches that literal search." end
session.botany_query, session.botany_page = query, page
C.show_formspec(name,"hearth:botany",journal(14,10)
.. label(0.65,0.55,"eom.dev / BOTANICAL FIELD GUIDE",ink.mauve)
.. label(0.65,1.15,#records .. " named species / eight categories of ten in the full catalogue",ink.yellow)
.. divider(0.65,1.65,12.7)
.. "field[0.7,1.95;9.2,0.7;query;Species, scientific name, category, habitat or role;" .. esc(query) .. "]field_close_on_enter[query;false]"
.. "button[10.25,1.95;3.1,0.7;search;Search]"
.. label(0.7,2.95,"Page " .. page .. " / " .. count .. " / " .. #all .. " matches / up to 10 species per page",ink.muted)
.. "textarea[0.65,3.3;12.7,4.35;;;" .. esc(table.concat(lines,"\n\n")) .. "]"
.. "textarea[0.65,7.7;12.7,0.55;;;" .. esc("Game-only notes, NOT a real-world forage guide. Unflagged does not mean safe to eat. Roles are conditional, not universal effects.") .. "]"
.. (#choices > 0 and "dropdown[0.7,8.3;5,0.45;species_choice;" .. table.concat(choices,",") .. ";1;true]"
.. "tooltip[species_choice;Species article]button[6,8.3;3.3,0.45;species_article;Species article]" or "")
.. (page > 1 and "button[0.7,8.85;2.2,0.65;previous;Previous]" or "")
.. (page < count and "button[3.05,8.85;2.2,0.65;next;Next]" or "")
.. "button[6,8.85;3.3,0.65;ecosystem_survey;Ecosystem survey]"
.. "button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]")
end
function hearth.inventory(player)
player:set_inventory_formspec(journal(12.4,11.3)
.. label(0.6,0.45,"eom.dev / THE LAST HEARTH",ink.mauve)
.. label(0.6,1.05,"Prepare something worth carrying.",ink.muted) .. divider(0.6,1.45,11.2)
.. "list[current_player;craft;0.6,1.8;3,3;]list[current_player;craftpreview;4.8,2.9;1,1;]"
.. label(4.8,2.2,"RESULT",ink.teal) .. label(6.5,1.95,"GATHER / BUILD / BELONG",ink.yellow)
.. "textarea[6.5,2.4;5.2,1.7;;;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]"
.. "tooltip[field_guide;Read survival notes, recipes and the ecosystem survey.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. "button[7.1,3.65;3.8,0.6;character_creator;Character appearance]"
.. "tooltip[character_creator;Preview woodland clothing and features. Cosmetic only; Save / Apply to keep changes.]"
.. label(0.6,5.25,"SATCHEL / first row is your hotbar",ink.muted)
.. "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",
journal(10.8,7.3) .. label(0.65,0.55,"eom.dev / WORLD REFERENCE",ink.mauve)
.. label(0.65,1.15,"A PLACE UNDER THE SAME SKY",ink.yellow) .. divider(0.65,1.65,9.5)
.. "textarea[0.65,1.95;9.5,1.5;;;" .. esc("Choose approximate coordinates, never your address. Nothing is collected automatically.\nThey stay in this world; no internet lookup is performed.\nExample only: 45 latitude, 0 longitude.") .. "]"
.. "box[0.6,3.95;4.5,0.8;" .. ink.mantle .. "]box[5.55,3.95;4.5,0.8;" .. ink.mantle .. "]"
.. "field[0.65,4;4.4,0.7;latitude;Latitude (-90 to 90);]field_close_on_enter[latitude;false]"
.. "field[5.6,4;4.4,0.7;longitude;Longitude (-180 to 180);]field_close_on_enter[longitude;false]"
.. "tooltip[latitude;Approximate decimal degrees, at most 16 characters.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. "tooltip[longitude;Local world only. Shared servers use administrator-selected coordinates.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. "textarea[0.65,4.95;9.5,0.75;;;" .. esc(C.colorize(error_text and ink.danger or ink.muted,
tostring(error_text or "No location chosen: neutral equatorial preview until configured."):sub(1,180))) .. "]"
.. "button[0.65,6;4.4,0.7;save_location;Use this location]"
.. "button_exit[5.6,6;4.4,0.7;later;Choose later]")
end
-- Observational only: one bounded habitat stencil, one in-memory fauna snapshot.
-- Do not call step/forage/deposit, scan entities, emerge terrain or write metadata.
function hearth.ecosystem(player)
if not hearth.sessions[player:get_player_name()] then return end
local succession, wildlife = hearth.succession, hearth.wildlife
local h = succession and succession.habitat and succession.habitat(player:get_pos()) or nil
local w = wildlife and wildlife.status and wildlife.status() or nil
local function number(value)
value = tonumber(value)
if not value or value ~= value or math.abs(value) == math.huge then return 0 end
return math.max(0, math.min(1000000, value))
end
local plants = h and string.format("Natural grass cover: %d Flower-support cover: %d\nShrub / fern / aquatic / tree cover: %d Nearby water cells: %d\nCanopy influence: %.1f Estimated light: %.1f / 15 Moisture: %.0f%%",
number(h.grass),number(h.flowers),number(h.cover),number(h.near_water or h.water),number(h.canopy),number(h.light),number(h.moisture)*100)
or "Succession observations are not available in this runtime."
if h then
-- The habitat API returns the actual support kind, not a guessed biome.
local ground = type(h.ground) == "string" and h.ground:sub(1,80) or nil
local ground_name = ground and item_label(ground:find(":",1,true) and ground or "hearth:" .. ground)
plants = "Ground: " .. (ground_name or "unknown") .. "\n" .. plants
.. "\nPollinator-support plants: " .. (h.pollination ~= nil and tostring(number(h.pollination)) or "unavailable")
.. " Nutrient reserve: " .. (h.nutrients ~= nil and tostring(number(h.nutrients)) .. " / 8" or "unavailable")
.. "\nShade: pine and spruce stay evergreen; deciduous canopy thins in Winter."
local function optional(value) return value ~= nil and tostring(number(value)) or "unavailable" end
plants = plants .. "\nGame substrate pH: " .. optional(h.ph)
.. " Nursery-support plants: " .. optional(h.nursery)
.. "\nAquatic niche: " .. (type(h.aquatic) == "string" and h.aquatic:sub(1,32) or "unavailable")
.. " Water depth: " .. (h.near_water ~= nil and optional(h.water) or "unavailable")
end
local fauna = w and string.format("Hares: %d Deer: %d Foxes: %d\nActive total: %d / %d globally Local cap: %d Per-player cap: %d",
number(w.hare),number(w.deer),number(w.fox),number(w.total),number(w.global_cap),number(w.local_cap),number(w.player_cap))
or "Wildlife observations are not available in this runtime."
C.show_formspec(player:get_player_name(), "hearth:ecosystem", journal(14,10)
.. label(0.65,0.55,"eom.dev / LIVING ECOSYSTEMS",ink.mauve)
.. label(0.65,1.15,"A MOMENT IN THE MEADOW",ink.yellow) .. divider(0.65,1.65,12.7)
.. label(0.7,2.15,"LOCAL PATCH / 5 x 5 x 5 cells at your feet",ink.teal)
.. "textarea[0.65,2.55;12.7,2.2;;;" .. esc(plants) .. "]"
.. label(0.7,4.85,h and (h.loaded and "Loaded sample / natural-origin plant counts; ground and shade include terrain" or "Incomplete sample / unloaded cells are unknown, not empty") or "Sample unavailable",
h and h.loaded and ink.muted or ink.yellow)
.. label(0.7,5.15,"WILDLIFE / all currently active animals, not a local census",ink.teal)
.. "textarea[0.65,5.55;12.7,1.25;;;" .. esc(fauna) .. "]"
.. "textarea[0.65,7.15;12.7,1.05;;;" .. esc("Observation does not advance succession or spawn animals. No terrain is loaded.\nNatural succession pauses while unloaded; crops and planted saplings follow separate rules.") .. "]"
.. "button[0.7,8.85;2.8,0.65;refresh;Observe again]"
.. "tooltip[refresh;Read one fresh sample without changing the world.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. "button[3.7,8.85;3.3,0.65;botany_index;Botanical guide]"
.. "button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]")
end
C.register_on_player_receive_fields(function(player, form, fields)
if form == "hearth:species" then
reader.handle(player,fields)
return true
elseif form == "hearth:botany" then
local session = hearth.sessions[player:get_player_name()]
if not session then return true end
if fields.species_choice ~= nil then
local value = fields.species_choice
local n = type(value)=="string" and #value<=2 and value:match("^%d+$") and tonumber(value)
if not n or n<1 or n>#(session.botany_ids or {}) then return true end
session.botany_selected = n
end
if fields.field_notes then hearth.guide(player,6)
elseif fields.ecosystem_survey then hearth.ecosystem(player)
elseif fields.species_article then
local id = (session.botany_ids or {})[session.botany_selected or 1]
if id then reader.show(player,id) end
elseif fields.search or fields.key_enter then hearth.botanical_guide(player,fields.query,1)
elseif fields.next then hearth.botanical_guide(player,session.botany_query,(session.botany_page or 1)+1)
elseif fields.previous then hearth.botanical_guide(player,session.botany_query,(session.botany_page or 1)-1) end
return true
elseif form == "hearth:ecosystem" then
if not hearth.sessions[player:get_player_name()] then return true end
if fields.field_notes then hearth.guide(player,6)
elseif fields.botany_index then hearth.botanical_guide(player,"",1)
elseif fields.refresh then hearth.ecosystem(player) end
return true
elseif form == "hearth:recipes" then
local session = hearth.sessions[player:get_player_name()]
if not session then return true end
if fields.close then hearth.guide(player, 2)
elseif fields.search or fields.key_enter then hearth.recipes(player, fields.query, 1)
elseif fields.next then hearth.recipes(player, session.recipe_query, (session.recipe_page or 1) + 1)
elseif fields.previous then hearth.recipes(player, session.recipe_query, (session.recipe_page or 1) - 1) end
return true
elseif form == "hearth:guide" then
if not hearth.sessions[player:get_player_name()] then return true end
for i = 1, #pages do if fields["page_" .. i] then hearth.guide(player, i); return true end end
if fields.recipe_index then hearth.recipes(player, "", 1); return true end
if fields.ecosystem_survey then hearth.ecosystem(player); return true end
if fields.botany_index then hearth.botanical_guide(player,"",1); return true end
if fields.inspect_target then
local ok, message = inspect(player, "")
if not ok and hearth.notify then hearth.notify(player,message) end
return true
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 type(lat) ~= "string" or type(lon) ~= "string" or #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.character_creator then
if hearth.character then hearth.character.show(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("recipes", {params = "[search words]", description = "Search known crafting recipes and hearth processes",
func = function(name, param)
local player = C.get_player_by_name(name)
if not player or not hearth.sessions[name] then return false, "Join the world first." end
hearth.recipes(player, param or "", 1)
return true
end})
C.register_chatcommand("ecosystem", {description = "Observe loaded natural habitat and active wildlife without changing the world",
func = function(name)
local player = C.get_player_by_name(name)
if not player or not hearth.sessions[name] then return false, "Join the world first." end
hearth.ecosystem(player)
return true
end})
C.register_chatcommand("botany", {params = "[search]", description = "Browse named botanical species and game-only ecological field notes",
func = function(name, param)
local player = C.get_player_by_name(name)
if not player or not hearth.sessions[name] then return false, "Join the world first." end
hearth.botanical_guide(player,param or "",1)
return true
end})
C.register_chatcommand("inspect", {params = "[exact species ID or Latin name]",
description = "Read a targeted species' offline Wikipedia excerpt and current game effects",
func = function(name, param)
local player = C.get_player_by_name(name)
if not player then return false, "Join the world first." end
return inspect(player,param or "")
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 = {}
local function panel(key, texture, x, y)
s.hud[key] = player:hud_add({type="image",position={x=0,y=0},offset={x=x,y=y},
alignment={x=1,y=1},scale={x=1,y=1},text=texture,z_index=-10})
end
local function note(key, value, x, y, color)
s.hud[key] = player:hud_add({type="text",position={x=0,y=0},offset={x=x,y=y},
alignment={x=1,y=0},text=value,number=color,size={x=1,y=1},z_index=1})
end
panel("journal_panel","[fill:424x240:" .. ink.mantle .. "e8",12,12)
panel("journal_accent","[fill:3x240:" .. ink.teal,12,12)
note("title","THE LAST HEARTH / eom.dev",28,34,0xcba6f7)
note("clock","",28,68,0xa6adc8)
-- Keep the established session keys; runtime may replace context/help text.
note("needs","CONDITION / reserves",28,108,0xf9e2af)
for i, need in ipairs({"warmth","water","food","stamina"}) do
local y = 136+(i-1)*28
note(need .. "_label","",28,y,0xcdd6f4)
panel(need .. "_track","[fill:220x6:" .. ink.border,196,y-3)
panel(need .. "_fill","[fill:220x6:" .. ink.teal,196,y-3)
end
s.hud.context_panel = player:hud_add({type="image",position={x=0.5,y=0.77},
alignment={x=0,y=0},text="[fill:680x36:" .. ink.mantle .. "dd",scale={x=1,y=1},z_index=-10})
s.hud.context = player:hud_add({type = "text", position = {x = 0.5, y = 0.77},
alignment = {x = 0, y = 0}, text = "", number = 0xf9e2af, size = {x = 1, y = 1}})
s.hud.help = player:hud_add({type = "text", position = {x = 1, y = 0},offset={x=-20,y=34},
alignment = {x = -1, y = 0}, text = "I Field journal / E Sprint\n/claim Homestead / /ecosystem Survey",
number = 0xa6adc8, size = {x = 1, y = 1}})
player:hud_set_flags({minimap = false})
player:hud_set_hotbar_itemcount(10)
-- The authored PNG is ONE slot; [combine has no leading parenthesis.
if player.hud_set_hotbar_image then
local slots = {"[combine:320x32"}
for i=0,9 do slots[#slots+1] = ":" .. (i*32) .. ",0=hearth_hotbar.png" end
player:hud_set_hotbar_image(table.concat(slots))
end
if player.hud_set_hotbar_selected_image then player:hud_set_hotbar_selected_image("hearth_selected.png") end
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("\nSolar %02d:%02d / %d C%s", hour, math.floor(clock.hour % 1 * 60),
math.floor(env.temperature), hearth.has_location and "" or " / location unset"))
for _, need in ipairs({"warmth","water","food","stamina"}) do
local n = tonumber(state[need]) or 0
if n ~= n then n = 0 end
n = math.max(0,math.min(100,n))
local color = n < 20 and ink.danger or (n < 40 and ink.yellow or ink.teal)
player:hud_change(s.hud[need .. "_label"],"text",string.upper(need) .. " " .. math.floor(n) .. "%")
player:hud_change(s.hud[need .. "_label"],"number",tonumber(color:sub(2),16))
player:hud_change(s.hud[need .. "_fill"],"text","[fill:" .. math.max(1,math.floor(n*2.2)) .. "x6:" .. (n==0 and ink.mantle .. "00" or color))
end
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 0xf9e2af)
end