-- Bounded, restart-safe ecology for perennial forage, saplings and seasonal crops. return function(hearth, api, construction) local DAY, YEAR = 86400, 365.2425*86400 -- Unidentified game grain, not a researched named cereal cultivar. local CROP_STEP, CROP_MATURE_HOURS, CROP_SAMPLES = 21600, 120*24, 366 local function finite(value) value = tonumber(value) if not value or value ~= value or value == math.huge or value == -math.huge then return nil end return value end local function safe_now() local now = os.time() if type(now) ~= "number" or now ~= now or now < 0 or now == math.huge or now == -math.huge then return 0 end return math.floor(now) end local function node(pos) local n = api.get_node_or_nil(pos) if not n or n.name == "ignore" then return nil end return n end local function free_crop(pos, player) local below, here = node({x=pos.x,y=pos.y-1,z=pos.z}), node(pos) local here_def = here and api.registered_nodes[here.name] if not below or not here_def or not here_def.buildable_to then return false end if below.name ~= "hearth:grass" and below.name ~= "hearth:soil" then return false end return construction.allowed(pos, player, "build") end local function climate_progress(planted, now) planted, now = finite(planted), finite(now) if not planted or not now or planted < 0 or now < 0 then return 0 end planted, now = math.floor(planted), math.floor(now) if planted <= 0 or now <= planted then return 0 end -- Bounded midpoint quadrature covers the ENTIRE calendar interval. Long -- gaps coarsen samples, never silently discard all age after day thirty. local duration=now-planted local samples=math.min(CROP_SAMPLES,math.max(1,math.ceil(duration/DAY))) local progress=0 for i=1,samples do local sample=planted+math.floor(duration*(i-.5)/samples) local climate = hearth.climate_at and hearth.climate_at(sample) local temperature = finite(climate and climate.temperature) or 12 local rate = temperature < 3 and 0 or (temperature < 10 and 0.5 or 1) progress = progress + duration/samples/3600*rate end return math.min(CROP_MATURE_HOURS,progress,duration/3600) end local function crop_stage(planted, now) local growth = climate_progress(planted, now) if growth >= CROP_MATURE_HOURS then return 4 end if growth >= 72*24 then return 3 end if growth >= 24*24 then return 2 end return 1 end local function update_crop(pos) local current = node(pos) if not current or not current.name:match("^hearth:crop_[1234]$") then return false end local meta, now = api.get_meta(pos), safe_now() local planted = tonumber(meta:get_string("planted_at")) or 0 if planted <= 0 or planted > now then return true end local observed=finite(meta:get_string("crop_last_observed_at")) or planted if observed>now then return true end meta:set_string("crop_last_observed_at",tostring(now)) local stage = crop_stage(planted, now) -- Quadrature refinement is approximate, so an established stage never -- regresses when a different sample partition straddles a cold front. stage=math.max(stage,tonumber(current.name:match("(%d)$"))) local target = "hearth:crop_" .. stage if current.name ~= target then api.swap_node(pos, {name = target, param2 = current.param2}) end return stage < 4 end local function reconcile_crop(pos) if update_crop(pos) then api.get_node_timer(pos):start(CROP_STEP) end end local function plant_crop(stack, player, pointed) if not pointed or pointed.type ~= "node" or not player or not player:is_player() then return stack end local pos = pointed.above if not free_crop(pos, player) then return stack end api.set_node(pos, {name = "hearth:crop_1"}) api.get_meta(pos):set_string("planted_at", tostring(safe_now())) api.get_meta(pos):set_string("planter", player:get_player_name()) api.get_node_timer(pos):start(CROP_STEP) stack:take_item(1) return stack end local function harvest_crop(pos, player) if not construction.allowed(pos, player, "use") then return end local n = node(pos) if not n or n.name ~= "hearth:crop_4" then hearth.notify(player, "The crop is still growing; seasons and temperature affect its progress.") return end local output = ItemStack("hearth:grain 4") local inv = player:get_inventory() if not inv or not inv:room_for_item("main", output) then hearth.notify(player, "Make room for grain before harvesting.") return end local requested = output:get_count() local leftover = inv:add_item("main", output) if not leftover or leftover:get_count() >= requested then hearth.notify(player, "Harvest stopped; make inventory space and try again.") return end api.set_node(pos, {name = "hearth:soil"}) -- Harvested farmland is player-created terrain, not a fresh native meadow. api.get_meta(pos):set_string("player_placed", "1") end local function harvest_berries(pos, player) if not construction.allowed(pos, player, "use") then return false end local n = node(pos) if not n or (n.name~="hearth:berry_bush" and n.name~="hearth:berry_bush_bare") then return false end if n.name == "hearth:berry_bush_bare" then hearth.notify(player, "The berry bush has spent this crop; check a future Summer after its annual rest.") return false end local meta,now=api.get_meta(pos),safe_now() local previous=meta:get_string("ready_at") local due=previous=="" and 0 or finite(previous) if not due or due<0 or due>now or now>9007199254740991-YEAR then return false end local inv = player:get_inventory() local output = ItemStack("hearth:berries 3") if not inv or not inv:room_for_item("main", output) then hearth.notify(player, "Make inventory space before picking these berries.") return false end local requested = output:get_count() -- Shared durable reservation before inventory callbacks, including -- reentrant picking. A rejected transfer restores exactly the old field. local ready = now + YEAR meta:set_string("ready_at",tostring(ready)) local leftover = inv:add_item("main", output) local count=leftover and finite(leftover:get_count()) if not count or count<0 or count>requested then return false end -- uncertain delivery stays locked if count==requested then meta:set_string("ready_at",previous); return false end api.swap_node(pos, {name = "hearth:berry_bush_bare", param2 = n.param2}) meta:set_string("infotext", "Berry bush: annual rest; next eligible Summer") api.get_node_timer(pos):start(DAY) if hearth.invalidate_environment then hearth.invalidate_environment(pos) end return true end local function harvest_seasonal(pos, player) if not construction.allowed(pos, player, "use") then return false end local n = node(pos) local seasons = hearth.seasons local kind = n and seasons and seasons.kind(n) local item = ({spring_greens_patch="hearth:spring_greens", autumn_mushrooms_patch="hearth:autumn_mushrooms"})[kind] if not item then return false end local now, meta = safe_now(), api.get_meta(pos) local previous=meta:get_string("succ_harvest_ready_at") local ready = previous=="" and 0 or finite(previous) if not ready or ready<0 or not seasons.available(kind, now) or ready > now then hearth.notify(player, "This patch rests outside its season or until one real day after picking.") return false end local inv, output = player:get_inventory(), ItemStack(item) if not inv or not inv:room_for_item("main", output) then return false end -- One item only: no partial multi-item harvest can lose or duplicate food. meta:set_string("succ_harvest_ready_at", tostring(now + DAY)) local leftover = inv:add_item("main", output) local count=leftover and finite(leftover:get_count()) if count==1 then meta:set_string("succ_harvest_ready_at",previous); return false end if count~=0 then return false end return true end local function refresh_bush(pos) local n = node(pos) if not n or n.name ~= "hearth:berry_bush_bare" then return false end local meta, now = api.get_meta(pos), safe_now() local ready = finite(meta:get_string("ready_at")) if not ready or ready<0 then return false end local climate=hearth.climate_at and hearth.climate_at(now) if ready <= now and climate and climate.season=="Summer" then api.swap_node(pos, {name = "hearth:berry_bush", param2 = n.param2}) meta:set_string("ready_at", "0"); meta:set_string("infotext", "") api.get_node_timer(pos):stop() return false end return true end local grow_sapling local function reconcile_sapling(pos) if grow_sapling(pos) then api.get_node_timer(pos):start(DAY) end end local function reconcile_bush(pos) if refresh_bush(pos) then local ready = tonumber(api.get_meta(pos):get_string("ready_at")) or safe_now() + DAY api.get_node_timer(pos):start(math.max(1, math.min(DAY, ready - safe_now()))) end end local function tree_clear(pos, owner) local limit = tonumber(api.settings and api.settings:get("mapgen_limit")) or 512 if math.abs(pos.x) > limit - 4 or math.abs(pos.z) > limit - 4 or pos.y < -31000 or pos.y > limit - 6 then return false end for y = 0, 5 do for x = -2, 2 do for z = -2, 2 do local p = {x=pos.x+x,y=pos.y+y,z=pos.z+z} local n = node(p) if not n then return false end local kind = hearth.seasons and hearth.seasons.kind(n) or n.name:match("^hearth:(.+)$") if not (x == 0 and y == 0 and z == 0) and n.name ~= "air" and kind ~= "leaves" then return false end if not hearth.claim_allowed(p, owner or "", "build") or (hearth.external_protected and hearth.external_protected(p, owner or "")) then return false end end end end return true end grow_sapling = function(pos) local n = node(pos) if not n or n.name ~= "hearth:sapling" then return false end local meta, now = api.get_meta(pos), safe_now() local planted = tonumber(meta:get_string("planted_at")) or 0 if planted <= 0 or planted > now then return true end local previous=finite(meta:get_string("succ_last_observed_at")) or planted if previous>now then return true end meta:set_string("succ_birth_at",tostring(planted)) meta:set_string("succ_last_observed_at",tostring(now)) meta:set_string("succ_lifecycle_version","1") meta:set_string("succ_lifecycle_origin","player_planted") meta:set_string("succ_lifecycle_profile","generic_tree") meta:set_string("succ_lifecycle_policy","generic_1y_young_10y_mature_unknown_lifespan") local owner = meta:get_string("planter") if now - planted < YEAR or not tree_clear(pos, owner) then return true end local function plant_wood(q,name) api.set_node(q,{name=name}); api.get_meta(q):set_string("player_placed","1") end for y = 0, 3 do plant_wood({x=pos.x,y=pos.y+y,z=pos.z},"hearth:log") end for y = 3, 5 do for x = -2, 2 do for z = -2, 2 do local radius = y == 5 and 1 or 2 if math.abs(x) + math.abs(z) <= radius and not (x == 0 and z == 0 and y < 5) then local p = {x=pos.x+x,y=pos.y+y,z=pos.z+z} if api.get_node(p).name == "air" then plant_wood(p,"hearth:leaves") end end end end end -- These are player trees, not a newly generated natural forest. Mark -- every written cell so later worldgen adoption cannot erase provenance. local root=api.get_meta(pos) root:set_string("planted_at",tostring(planted)); root:set_string("planter",owner) root:set_string("succ_birth_at",tostring(planted)); root:set_string("succ_last_observed_at",tostring(now)) root:set_string("succ_lifecycle_version","1"); root:set_string("succ_lifecycle_origin","player_planted") root:set_string("succ_lifecycle_profile","generic_tree") root:set_string("succ_lifecycle_policy","generic_1y_young_10y_mature_unknown_lifespan") return false end local function plant_sapling(stack, player, pointed) if not pointed or pointed.type ~= "node" or not player or not player:is_player() then return stack end local pos = pointed.above if not free_crop(pos, player) then return stack end api.set_node(pos, {name = "hearth:sapling"}) local meta = api.get_meta(pos) meta:set_string("planted_at", tostring(safe_now())); meta:set_string("planter", player:get_player_name()) api.get_node_timer(pos):start(DAY) stack:take_item(1) return stack end local function berry_dig(pos, node, digger) if digger and digger:is_player() then harvest_berries(pos, digger) end end local function crop_dig(pos, node, digger) if digger and digger:is_player() then harvest_crop(pos, digger) end end return {plant_crop = plant_crop, update_crop = update_crop, crop_stage = crop_stage, climate_progress = climate_progress, harvest_crop = harvest_crop, crop_dig = crop_dig, harvest_berries = harvest_berries, berry_dig = berry_dig, harvest_seasonal = harvest_seasonal, refresh_bush = refresh_bush, reconcile_bush = reconcile_bush, reconcile_crop = reconcile_crop, plant_sapling = plant_sapling, grow_sapling = grow_sapling, reconcile_sapling = reconcile_sapling, CROP_SAMPLE_LIMIT = CROP_SAMPLES, CROP_MATURE_DAYS = 120, CROP_MATURE_HOURS = CROP_MATURE_HOURS} end