791 lines
60 KiB
Lua
Executable File
791 lines
60 KiB
Lua
Executable File
local C = core
|
|
local esc = C.formspec_escape
|
|
local function tooltip(name,text)
|
|
-- Luanti 5.18 requires both tooltip colors when the optional color slots are
|
|
-- parsed. Supplying the Mocha pair also keeps high-density creator menus legible.
|
|
return "tooltip[" .. esc(name) .. ";" .. esc(text) .. ";#181825;#cdd6f4]"
|
|
end
|
|
-- 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 100 named species in eight uneven ecological groups, not eight groups of ten. Use /botany [search] for names, habitat, seasons, toxicity labels and roles; /fauna lists 23 animal identities with sources and lifecycle status. 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 trees including crab apple, wild cherry and field maple thin in Winter. Willow favors wet alluvium/peat. Nitrogen-fixer policy can add at most two nutrient units per support after 90 real calendar days, 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 plant survey counts supporting plants, not insects; wildlife counts include every active species. Animals use finite browse, nectar, fruit, seeds, litter or modeled prey. Use /ecosystem for observations. Moisture is a bounded habitat estimate, not fully simulated hydrology, erosion or flooding.\n\nRoles are conditional policies or descriptors: parasitism and allelopathy cause no plant damage; a resin tag is not an extraction recipe. Calendar ages include unloaded time. Ecological work advances only in loaded areas with no offline catch-up or missed-event replay. Claims, placed plants, crops and construction are excluded from autonomous changes. Named tree stages use real years to decades, not active sessions; fungi do not accelerate growth. Source-reported fields, policy fallbacks and UNKNOWN are distinct. All incomplete animal profiles disable breeding; initial established wildlife are immigration, not adult births. Unknown age and lifespan endpoints never authorize automatic death."},
|
|
{"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, spruce, crab apple, wild cherry and field maple 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 ecological groups of uneven sizes.\nUse /botany [search] for scientific names, habitat windows, seasons and ecological roles; /fauna for 23 animal identities and lifecycle evidence."
|
|
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[6.3,8.15;2.1,0.55;fauna_index;Fauna guide]"
|
|
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.")
|
|
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.")
|
|
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:crab_apple_planks", "hearth:wild_cherry_planks", "hearth:field_maple_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, spruce, crab_apple, wild_cherry or field_maple 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", "After 90 real calendar days, 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 and conditions are warm. This cadence is game policy, not a measured species rate. 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, spruce, crab_apple, wild_cherry and field_maple 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 crops rest one mean real year (365.2425 days), then require their fruit season; no 24-hour refill or missed-crop replay. Human and animal botanical fruit share one readiness deadline. Pollination adds at most one berry to an existing crop once. A planted generic sapling requires one real year and clear bounded tree volume; named trees use species lifecycle endpoints, often years to decades. Unknown timing remains labelled policy or UNKNOWN, never invented biology. No resource grows through claims or map limits."},
|
|
{"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.")
|
|
.. tooltip("search","Search crafting recipes and real-time hearth processes.")
|
|
.. 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 ecological groups of uneven sizes",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 "")
|
|
.. "button[10.05,8.3;3.3,0.45;fauna_index;Fauna guide]"
|
|
.. (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
|
|
|
|
-- Observational catalogue, independent of the plant-only article reader.
|
|
local function fauna_observations(player, discovered_id)
|
|
local F=hearth.fauna
|
|
if not F or type(F.list)~="function" or type(player.get_meta)~="function" then return nil end
|
|
local allowed={}; for _,record in ipairs(F.list()) do allowed[record.id]=true end
|
|
local ok,meta=pcall(function() return player:get_meta() end)
|
|
if not ok or not meta or type(meta.get_string)~="function" or type(meta.set_string)~="function" then return nil end
|
|
local key="hearth:fauna_observations_v1"
|
|
local read_ok,raw=pcall(meta.get_string,meta,key)
|
|
if not read_ok or type(raw)~="string" or #raw>512 then return nil end
|
|
local seen,ordered={},{}
|
|
if raw~="" then
|
|
for id in raw:gmatch("[^,]+") do
|
|
if not allowed[id] or seen[id] then return nil end
|
|
seen[id]=true; ordered[#ordered+1]=id
|
|
end
|
|
table.sort(ordered)
|
|
if table.concat(ordered,",")~=raw then return nil end
|
|
end
|
|
local first=discovered_id and not seen[discovered_id] or false
|
|
if first then
|
|
seen[discovered_id]=true; ordered[#ordered+1]=discovered_id; table.sort(ordered)
|
|
local encoded=table.concat(ordered,",")
|
|
local write_ok=pcall(meta.set_string,meta,key,encoded)
|
|
local verify_ok,verified=pcall(meta.get_string,meta,key)
|
|
if not write_ok or not verify_ok or verified~=encoded then return nil end
|
|
end
|
|
return seen,first
|
|
end
|
|
|
|
function hearth.inspect_fauna(player,id,snapshot)
|
|
local ok,name=pcall(function() return player:get_player_name() end)
|
|
if not ok or type(name)~="string" or #name<1 or #name>64 or not hearth.sessions[name]
|
|
or type(C.get_player_by_name)~="function" then return false end
|
|
local online_ok,online=pcall(C.get_player_by_name,name)
|
|
if not online_ok or online~=player then return false end
|
|
local F=hearth.fauna
|
|
if type(id)~="string" or #id>80 or not F or type(F.get)~="function" then return false end
|
|
local loaded,record=pcall(F.get,id)
|
|
if not loaded or type(record)~="table" or record.id~=id then return false end
|
|
local view
|
|
if snapshot~=nil then
|
|
if type(snapshot)~="table" or getmetatable(snapshot)~=nil then return false end
|
|
view={}
|
|
for _,key in ipairs({"born_at","origin","foodreserve","resident","observation"}) do
|
|
local value=snapshot[key]
|
|
if key=="born_at" or key=="foodreserve" then
|
|
if type(value)~="number" or value~=value or math.abs(value)==math.huge then return false end
|
|
elseif value~=nil and type(value)~="string" and type(value)~="boolean" then return false end
|
|
view[key]=value
|
|
end
|
|
if view.born_at<0 or view.born_at%1~=0 or view.foodreserve<0 or view.foodreserve>1
|
|
or type(view.resident)~="boolean"
|
|
or (view.origin~="established" and view.origin~="born")
|
|
or (view.observation~="rest" and view.observation~="awake") then return false end
|
|
end
|
|
local sightings,first_sighting=fauna_observations(player,view and id or nil)
|
|
local function safe(value,limit)
|
|
if type(value)~="string" then return "unknown" end
|
|
return value:gsub("[%c]"," "):sub(1,limit or 240)
|
|
end
|
|
local function safe_words(set)
|
|
local out={}
|
|
if type(set)=="table" then
|
|
for key,enabled in pairs(set) do
|
|
if enabled and type(key)=="string" and #out<32 then out[#out+1]=safe(key,40):gsub("_"," ") end
|
|
end
|
|
end
|
|
table.sort(out)
|
|
return #out>0 and table.concat(out,", ") or "not specified"
|
|
end
|
|
local profile=record.profile or {}
|
|
local lines={}
|
|
local now_ok,now=pcall(os.time)
|
|
local phase
|
|
if view and now_ok and type(F.phase)=="function" then
|
|
local phase_ok,result=pcall(F.phase,id,view.born_at,now)
|
|
if phase_ok and type(result)=="table" then phase=result end
|
|
end
|
|
if phase then
|
|
lines[#lines+1]=string.format("Lifecycle: %s / approx %.2f model years; %.1f real calendar days",safe(phase.phase,40),phase.age_days/365.2425,phase.age_days)
|
|
else
|
|
lines[#lines+1]="Lifecycle age: unavailable for this profile or no individual selected."
|
|
end
|
|
if view then
|
|
local reserve=math.max(0,math.min(1,view.foodreserve))
|
|
lines[#lines+1]=string.format("Coarse modeled food reserve (policy, not biology): %.0f%%",reserve*100)
|
|
lines[#lines+1]="Current animation observation: "..(view.observation=="rest" and "resting" or "awake / active")
|
|
lines[#lines+1]="Residency: "..(view.resident and "persistent registered resident" or "persistent mapblock resident; registry ID unavailable")
|
|
.." ("..safe(view.origin,24).." origin); individual position is not shown."
|
|
lines[#lines+1]=sightings and (first_sighting and "Field journal: first recorded encounter with this species."
|
|
or "Field journal: this species is already recorded.") or "Field journal: unavailable; encounter was not recorded."
|
|
else
|
|
lines[#lines+1]="Individual observation: none; this is a species profile, not a local census."
|
|
lines[#lines+1]="Field journal: browsing this catalogue entry does not count as a wildlife encounter."
|
|
end
|
|
lines[#lines+1]="Modeled diet: "..safe_words(record.foods)
|
|
lines[#lines+1]="Activity / sleep policy: "..safe(record.activity,32).." / "..safe(record.sleep and record.sleep.kind,40)
|
|
lines[#lines+1]="Winter rest policy: "..(record.winter_dormant and "coarse dormancy; resident remains persistent" or "no special winter dormancy modeled")
|
|
lines[#lines+1]="Modeled behavior: "..safe(profile.movement or "movement details unavailable",220)
|
|
lines[#lines+1]="Modeled prey: "..safe_words(record.prey)
|
|
if profile.reproduction_enabled==true then
|
|
lines[#lines+1]="Reproduction: enabled only within the coarse model; mature compatible pairs, season, food and safe habitat are still required."
|
|
else
|
|
local evidence=profile.evidence and profile.evidence.reproduction_context
|
|
lines[#lines+1]="Reproduction: disabled / incomplete species-specific lifecycle evidence. "
|
|
..safe(evidence and evidence.note or "No breeding policy is enabled for this profile.",220)
|
|
end
|
|
local status=hearth.wildlife
|
|
if type(status)=="table" and type(status.status)=="function" then
|
|
local status_ok,data=pcall(status.status)
|
|
if status_ok and type(data)=="table" and type(data.counts)=="table" then
|
|
local n=tonumber(data.counts[id]); if n and n==n and math.abs(n)<math.huge then
|
|
lines[#lines+1]="Active loaded animals of this species: "..math.max(0,math.floor(n))
|
|
end
|
|
end
|
|
end
|
|
local source=safe(type(record.source_url)=="string" and record.source_url or "source unavailable",300)
|
|
local lifecycle_source=safe(type(profile.source_url)=="string" and profile.source_url or "UNKNOWN",300)
|
|
C.show_formspec(name,"hearth:fauna_detail",journal(14,10)
|
|
..label(0.65,0.55,"eom.dev / FAUNA FIELD NOTES",ink.mauve)
|
|
..label(0.65,1.15,safe(record.common,80):upper().." / "..safe(record.latin or "identity unverified",100),ink.yellow)
|
|
..divider(0.65,1.65,12.7)
|
|
.."textarea[0.65,2.0;12.7,5.7;;;"..esc(table.concat(lines,"\n\n")).."]"
|
|
.."textarea[0.65,7.8;12.7,0.8;;;"..esc("Identity source: "..source.."\nLifecycle source: "..lifecycle_source).."]"
|
|
.."button[6.15,8.85;3.3,0.65;fauna_index;Back to fauna guide]"
|
|
.."button_exit[10.05,8.85;3.3,0.65;close;Close]" )
|
|
return true
|
|
end
|
|
|
|
function hearth.fauna_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 F=hearth.fauna
|
|
local records=F and type(F.list)=="function" and F.list() or {}
|
|
local observations=fauna_observations(player)
|
|
local observed_count=0
|
|
if observations then for _ in pairs(observations) do observed_count=observed_count+1 end end
|
|
local all,all_ids,needle={},{},query:lower()
|
|
local function timing(p,field)
|
|
local r=p and p[field]
|
|
if type(r)~="table" then return "UNKNOWN" end
|
|
return tostring(r.min).."-"..tostring(r.max).." real days ["..tostring(r.confidence).."]"
|
|
end
|
|
for _,r in ipairs(records) do
|
|
local p=r.profile
|
|
local marker=observations and observations[r.id] and " / recorded encounter" or " / not yet encountered"
|
|
local row=r.id.." / "..r.common.." / "..(r.latin or "UNKNOWN species identity (legacy deer)")..marker
|
|
.."\nHabitat/movement medium: "..r.medium.."; activity: "..r.activity
|
|
.."; food roles: "..words(r.foods)
|
|
.."\nMaturity: "..timing(p,"maturity_days").."; longevity context: "..timing(p,"lifespan_days")
|
|
.."\nLifecycle evidence: "..(p and p.confidence or "UNKNOWN")
|
|
.."; automatic breeding "..(p and p.reproduction_enabled==true and "enabled only for sourced profiles" or "disabled (incomplete profile)")
|
|
.."; no lifespan-endpoint death."
|
|
.."\nIdentity source: "..r.source_url
|
|
.."\nLifecycle source: "..(p and p.source_url or "UNKNOWN")
|
|
if needle=="" or row:lower():find(needle,1,true) then all[#all+1]=row; all_ids[#all_ids+1]=r.id end
|
|
end
|
|
local pages_count=math.max(1,math.ceil(#all/5)); page=index(page,pages_count)
|
|
local lines,choices={},{}
|
|
session.fauna_ids,session.fauna_selected={},1
|
|
for i=(page-1)*5+1,math.min(#all,page*5) do
|
|
lines[#lines+1]=all[i]; session.fauna_ids[#session.fauna_ids+1]=all_ids[i]; choices[#choices+1]=esc(all_ids[i])
|
|
end
|
|
if #lines==0 then lines[1]=#records==0 and "Fauna catalogue unavailable in this runtime."
|
|
or "No animal matches that literal search." end
|
|
session.fauna_query,session.fauna_page=query,page
|
|
C.show_formspec(name,"hearth:fauna",journal(14,10)
|
|
..label(0.65,0.55,"eom.dev / FAUNA FIELD GUIDE",ink.mauve)
|
|
..label(0.65,1.15,(observations and (observed_count.." / "..#records.." species recorded in your field journal")
|
|
or "Field journal unavailable / source evidence is not complete biology"),ink.yellow)
|
|
..divider(0.65,1.65,12.7)
|
|
.."field[0.7,1.95;9.2,0.7;query;Species, scientific name, food or status;"..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.." / "..pages_count.." / "..#all.." matches",ink.muted)
|
|
.."textarea[0.65,3.3;12.7,4.0;;;"..esc(table.concat(lines,"\n\n")).."]"
|
|
.."textarea[0.65,7.4;12.7,0.8;;;"..esc("Real calendar age includes unloaded time; no offline meals, births or event replay. Movement and feeding cadence are conservative game policies, not complete true-life behavior. No web requests or world changes.").."]"
|
|
..(#choices>0 and "dropdown[0.7,8.25;5.0,0.45;species_choice;"..table.concat(choices,",")..";1;true]"
|
|
..tooltip("species_choice","Choose one of the species shown on this page.")
|
|
.."button[6,8.2;3.3,0.55;species_details;Species details]" or "")
|
|
..(page>1 and "button[0.7,8.85;2.2,0.65;previous;Previous]" or "")
|
|
..(page<pages_count and "button[3.05,8.85;2.2,0.65;next;Next]" or "")
|
|
.."button[6,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
|
|
|
|
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.")
|
|
.. "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.")
|
|
.. tooltip("longitude","Local world only. Shared servers use administrator-selected coordinates.")
|
|
.. "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 = "Wildlife observations are not available in this runtime."
|
|
if w then
|
|
local counts,lines=w.counts,{}
|
|
-- Legacy optional API fallback; production uses every returned species.
|
|
counts=type(counts)=="table" and counts or {hare=w.hare,deer=w.deer,fox=w.fox}
|
|
local ids={}; for id in pairs(counts) do if type(id)=="string" then ids[#ids+1]=id end end
|
|
table.sort(ids)
|
|
for i=1,math.min(#ids,64) do
|
|
local id=ids[i]
|
|
local name=({hare="Hares",deer="Deer",fox="Foxes"})[id] or id:sub(1,64):gsub("_"," ")
|
|
lines[#lines+1]=name .. ": " .. number(counts[id])
|
|
end
|
|
fauna=table.concat(lines," / ") .. string.format("\nActive total: %d / %d globally Local cap: %d Per-player cap: %d",
|
|
number(w.total),number(w.global_cap),number(w.local_cap),number(w.player_cap))
|
|
local recruitment=w.recruitment
|
|
if type(recruitment)=="table" then
|
|
local reason=type(recruitment.last_reason)=="string" and recruitment.last_reason:sub(1,40):gsub("[%c]"," ") or "unavailable"
|
|
fauna=fauna..string.format("\nAdmissions: %d / %d attempts Latest outcome: %s",
|
|
number(recruitment.successes),number(recruitment.attempts),reason)
|
|
end
|
|
end
|
|
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.55;;;" .. esc(fauna) .. "]"
|
|
.. "textarea[0.65,7.15;12.7,1.05;;;" .. esc("Observation does not advance succession or spawn animals. No terrain is loaded.\nCalendar age includes unloaded time; ecological work is bounded and loaded-only. No missed-event replay; unknown breeding is disabled.") .. "]"
|
|
.. "button[0.7,8.85;2.8,0.65;refresh;Observe again]"
|
|
.. tooltip("refresh","Read one fresh sample without changing the world.")
|
|
.. "button[3.7,8.85;3.3,0.65;botany_index;Botanical guide]"
|
|
.. "button[7.15,8.85;2.6,0.65;fauna_index;Fauna 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:fauna_detail" then
|
|
local session=hearth.sessions[player:get_player_name()]
|
|
if not session then return true end
|
|
if fields.fauna_index then hearth.fauna_guide(player,session.fauna_query or "",session.fauna_page or 1) end
|
|
return true
|
|
elseif form == "hearth:fauna" then
|
|
local session=hearth.sessions[player:get_player_name()]; if not session 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.species_details then
|
|
local value=fields.species_choice
|
|
local n=type(value)=="string" and #value<=2 and value:match("^%d+$") and tonumber(value)
|
|
if n and n>=1 and n<=#(session.fauna_ids or {}) then
|
|
session.fauna_selected=n
|
|
local id=(session.fauna_ids or {})[n]
|
|
if id then hearth.inspect_fauna(player,id,nil) end
|
|
end
|
|
elseif fields.search or fields.key_enter then hearth.fauna_guide(player,fields.query,1)
|
|
elseif fields.next then hearth.fauna_guide(player,session.fauna_query,(session.fauna_page or 1)+1)
|
|
elseif fields.previous then hearth.fauna_guide(player,session.fauna_query,(session.fauna_page or 1)-1) end
|
|
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.fauna_index then hearth.fauna_guide(player,"",1)
|
|
elseif 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.fauna_index then hearth.fauna_guide(player,"",1)
|
|
elseif 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.fauna_index then hearth.fauna_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("fauna", {params="[search]",description="Observe animal catalogue sources and conservative lifecycle status",
|
|
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.fauna_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
|