--=======-- -- Setup -- --=======-- -- Translation wrapper for descriptive strings local S = minetest.get_translator(minetest.get_current_modname()) -- Explicit local references to globals we use local core, vector = core, vector local anarchy = anarchy -- Locals we'll use a lot local get_node = core.get_node local set_node = core.set_node local swap_node = core.swap_node local remove_node = core.remove_node -- sets node to air faster local get_biome_data = core.get_biome_data local get_biome_name = core.get_biome_name local get_natural_light = core.get_natural_light local floor = math.floor local abs = math.abs local max = math.max local min = math.min local sin = math.sin local cos = math.cos local log10 = math.log10 local pi = math.pi local random = math.random local offset = vector.offset local band = bit.band -- Optional dependency on seasons_clock local seasons_clock = seasons_clock -- might be nil -- global object we can attach API functions to -- The 'grasses' and 'growables' fields are tables holding registered -- grasses/growables. They're re-consulted every time growth or -- grass conversion triggers, so other mods may modify them. The -- 'by_substrate' and 'by_soil' tables contain duplicate references to -- the grasses and growables tables, using substrate+matrix or -- soil+matrix node names as keys and label -> definition tables as -- values. These are filled in automatically when `botany.sprout` is -- called. -- -- Note that the `_cache` field is a custom cache table to speed up ABM -- work; don't mess with it. botany = { grasses = {}, growables = {}, growth_plans = {}, rares = {}, by_substrate = {}, by_soil = {}, death_callbacks = {}, _cache = {}, } -- Locals for quick access from the main table local by_substrate = botany.by_substrate local by_soil = botany.by_soil local growth_plans = botany.growth_plans local death_callbacks = botany.death_callbacks local rares = botany.rares local _cache = botany._cache _cache.grasses = {} local _grass_cache = _cache.grasses _cache.seeds = {} local _seed_cache = _cache.seeds -- Whether to enable seasonal plant phases instead of lifespan based phase -- cycles. This will generate a warning if set to true when the -- 'seasons_clock' mod is not available, as that's necessary for seasons -- to work at all. This can be changed at any point but may have weird -- effects on plant growth. -- TODO: Make this a server setting! botany.enable_seasons = true if botany.enable_seasons and seasons_clock == nil then core.log( "warning", ( "Seasons are enabled but the 'seasons_clock' mod is not" .. " available. Seasons will be disabled. Install the" .. " 'seasons_clock' mod to enable them." ) ) botany.enable_seasons = false end -- Constants that identify top/bottom/sides or combinations. Use `band` -- to identify overlaps. botany.NO_SIDES = 0 local NO_SIDES = botany.NO_SIDES botany.TOP = 1 local TOP = botany.TOP botany.BOTTOM = 2 local BOTTOM = botany.BOTTOM botany.SIDES = 4 local SIDES = botany.SIDES botany.TOP_AND_SIDES = 5 local TOP_AND_SIDES = botany.TOP_AND_SIDES botany.BOTTOM_AND_SIDES = 6 local BOTTOM_AND_SIDES = botany.BOTTOM_AND_SIDES botany.TOP_AND_BOTTOM = 3 local TOP_AND_BOTTOM = botany.TOP_AND_BOTTOM botany.ALL_SIDES = 7 local ALL_SIDES = botany.ALL_SIDES --- Names for each of the constants that identify one or a combination -- of sides where roots might be allowed to sprout. botany.SIDES = { none = NO_SIDES, top = TOP, bottom = BOTTOM, sides = SIDES, ["top/sides"] = TOP_AND_SIDES, ["bottom/sides"] = BOTTOM_AND_SIDES, ["top/bottom"] = TOP_AND_BOTTOM, all = ALL_SIDES } local SIDES = botany.SIDES --- Using one of the sides constants as a key, this table allows us to -- look up an array of 3-element x/y/z offset arrays that identify the -- offsets to sides that we should check. For example, if we access -- `botany.RELEVANT_SIDE_OFFSETS[botany.TOP]` we'll get the array -- `{ {0, 1, 0} }` which just includes the offset value for the node -- above. botany.RELEVANT_SIDE_OFFSETS = { [NO_SIDES] = {}, [TOP] = { {0, 1, 0} }, [BOTTOM] = { {0, -1, 0} }, [SIDES] = { {1, 0, 0}, {-1, 0, 0}, {0, 0, 1}, {0, 0, -1} }, [TOP_AND_SIDES] = { {0, 1, 0}, {1, 0, 0}, {-1, 0, 0}, {0, 0, 1}, {0, 0, -1} }, [BOTTOM_AND_SIDES] = { {0, -1, 0}, {1, 0, 0}, {-1, 0, 0}, {0, 0, 1}, {0, 0, -1} }, [TOP_AND_BOTTOM] = { {0, 1, 0}, {0, -1, 0} }, [ALL_SIDES] = { {0, 1, 0}, {0, -1, 0}, {1, 0, 0}, {-1, 0, 0}, {0, 0, 1}, {0, 0, -1} } } local RELEVANT_SIDE_OFFSETS = botany.RELEVANT_SIDE_OFFSETS --- Translatable one-word descriptions for each plant life phase. Annual -- plants go through each phase (except "blighted") once before dying. -- Perennial plants go through each phase (except "blighted" and "dead") -- every season. When the seasons clock mod isn't available, perennial -- plans use a pre-determined number of season cycles throughout their -- lifetime (but often can't do one season-cycle per in-game year of life -- due to age bin resolution limitations when lifespans are long). botany.PLANT_PHASES = { bud=S("bud"), flower=S("flower"), fruit=S("fruit"), bare=S("bare"), shedding=S("shedding"), dormant=S("dormant"), blighted=S("blighted"), dead=S("dead") } local PLANT_PHASES = botany.PLANT_PHASES --- An array containing the phase names for the plant phases that are in -- the normal seasonal cycle, in order starting with 'bud'. botany.PLANT_PHASE_CYCLE = { "bud", "flower", "fruit", "bare", "shedding", "dormant" } local PLANT_PHASE_CYCLE = botany.PLANT_PHASE_CYCLE --- Cache bin sizing sets the resolution at which min/max heat/humidity -- are checked for conversion constraints (but not for growth) as well -- as the resolution at which ideal heat/humidity weight multipliers -- are computed. Do NOT change this (it won't work correctly). botany.CACHE_BIN_SIZE = 4 local CACHE_BIN_SIZE = botany.CACHE_BIN_SIZE --- Bias during germination towards or away from failure. Applies once for -- every age index in `grow_after`. This can be changed and will affect -- germination going forward. This value is multiplied by the failure -- probability, so smaller numbers indicate a stronger bias away from -- failure. botany.GERMINATION_FAILURE_BIAS = 0.998 --- Not always, but in certain cases where it affects performance (such as -- accumulating failure chances), we may ignore probabilities lower than -- this, treating them as 0. This can be changed. botany.IGNORABLE_PROBABILITY = 0.000001 --- Probability that when a seed fails to sprout because of an unsuitable -- environment, it will immediately die (vs. trying again later). This -- probability compounds every tick. You can change this value and it will -- affect future sprouts. botany.SPROUT_FAILURE_DEATH = 0.65 --=======================-- -- Configuration options -- --=======================-- -- TODO: Make these server-configurable --- Note on time: The default server time parameter is 72. As I understand -- this it's a time multiplier, so that time runs 72x as fast in-game as in -- the real world. This means that in one real-world hour, there are 72 -- in-game hours, or 3 days, so that each in-game day is 1/3 of a -- real-world hour, or 20 minutes. Also as I understand things, setting the -- server time speed parameter to a different value affects the day/night -- cycle, but will NOT affect the timing of ABM callbacks, and so it will -- NOT affect plant growth speed. -- TODO: Add a setting or otherwise make plant growth stuff depend on -- server time setting. -- TODO botany.DEBUG_SPEEDUP = true local DEBUG_SPEEDUP = botany.DEBUG_SPEEDUP --- Grass sprout checks happen this often (in seconds). We have grass -- sprout checks happen fairly quickly but we only apply them to some of -- the blocks (see `grass_sprout_chance`) so the expected number of seconds -- before a given dirt block sprouts grass is `grass_interval` times -- `grass_sprout_chance`, or by default 272 seconds ~= 4.5 minutes or -- slightly less than 1/4 default in-game day. Grass sprout checks -- eventually shouldn't consume much CPU usage (beyond baseline ABM -- filtering) because there won't be any air-exposed dirt left to convert. -- However, in dark areas where lighting checks fail but there is -- air-exposed dirt, the grass-sprouting ABM will continue to fire a lot. I -- haven't profiled whether this is a significant amount of CPU usage; if -- it is, one strategy would be to add conversions for cave-growing lichen -- or mushrooms or the like to further reduce the prevalence of air-exposed -- dirt. -- Changes after botany.sprout is called will NOT affect behavior. botany.grass_interval = 17 --- Chance to sprout grass (probability is 1/chance). Setting this to a -- higher number (lower probability) helps prevent too much CPU usage and -- makes it so that grass sprouts bit by bit instead of all at once. -- Changes after botany.sprout is called will NOT affect behavior. botany.grass_sprout_chance = 16 --- Seed checks happen this often (in seconds). We can be quite slow about -- adding seeds to grass, since in the steady-state almost every grass -- block will have seeds in it, and we're not really in a hurry to get -- there (mapgen could always add seeds and/or roots/plants ahead of time -- if it wanted to). Setting this to a large value helps reduce CPU usage -- from seed checks. -- Changes after botany.sprout is called will NOT affect behavior. botany.seed_interval = 52 --- Chance to seed a grass block with a seedling (probability is -- 1/chance, but sky light is also required for most seeds) -- Changes after botany.sprout is called will NOT affect behavior. botany.seed_chance = 8 --- Growth checks happen this often (in seconds). This is set so low because -- it places a limit on the maximum rate of growth of fast-growing things, -- otherwise we might make it higher since most things grow slowly. -- At the default base speed of 72, one day is 20 minutes, so 30 seconds is -- 1/40 of an in-game day, or about 1/2 in-game hour. Our chance of firing -- the growth callback for a given plant is only 2/3 (see `growth_chance`) -- so each plant on average will get about 4/3 of a tick per in-game hour, -- or about 32 expected ticks-per-in-game-day. Another way of normalizing -- this is that from the perspective of a single node, each growth tick -- takes on average 45 seconds to happen. -- Changes after botany.sprout is called will NOT affect behavior. botany.growth_interval = 30 --- Chance to do growth (probability is 1/chance) This is set so high -- because for many plants growth may involve probabilistic counting of -- cycles, and we don't want to add too much randomness on top of that, -- although we *do* want to avoid triggering growth on *every* growing node -- every cycle to save CPU. -- Changes after botany.sprout is called will NOT affect behavior. botany.growth_chance = 1.5 -- This translates to a 2/3 chance --- Rare checks happen this often (in seconds). Taking the `rare_chance` -- into account, we expect to trigger every 8769 seconds per node with a -- rare rule, or every 2.43 real-time hours. That translates to about once -- every 8 in-game days. -- Changes after botany.sprout is called will NOT affect behavior. botany.rare_interval = 137 --- Chance to fire a rare check on a matching block. Probability is -- 1/chance, but rare rules affect what actually happens too. -- This default along with the `rare_interval` default means that rare -- rules fire on each target node about once every 2.5 hours. -- Changes after botany.sprout is called will NOT affect behavior. botany.rare_chance = 64 -- If DEBUG_SPEEDUP is on, make things happen every second and increase -- chances to 1. if DEBUG_SPEEDUP then botany.grass_interval = 1 botany.grass_sprout_chance = 2 botany.seed_interval = 1 botany.seed_chance = 2 botany.growth_interval = 1 botany.growth_chance = 1.5 -- unchanged botany.rare_interval = 5 botany.rare_chance = 2 end --==================-- -- Helper Functions -- --==================-- --- Strips the mod prefix from the beginning of a node name. Returns -- the same string minus everything up to and including the first -- colon, or the same string unmodified if there is no colon in it. -- @tparam string node_name The name to strip. -- @treturn string The stripped name. botany.strip_mod_prefix = function(node_name) local result = "" local colon_pos = node_name:find(":") if colon_pos == nil then -- No colon: return unmodified return node_name else -- Return everything after the colon return node_name:sub(colon_pos+1) end end local strip_mod_prefix = botany.strip_mod_prefix --- Returns a string to be used as a lookup key for the given -- substrate/grows_through values. -- @tparam substrate string The node name that a grass type grows on -- or a seed type can take root in. -- @tparam grows_through string The node name (or group:group_name) -- string that a grass type grows into or a seed type can take -- root next to. If this is nil, we use the string "air" instead. -- @treturn string A string to be used as a table key for -- grasses/growables that use the given substrate/grows_through -- nodes/groups. botany.matrix_key = function (substrate, grows_through) if grows_through == nil then grows_through = "air" end return ( substrate .. "%" .. grows_through ) end local matrix_key = botany.matrix_key --- Returns a string to be used as a cache lookup key for the given -- substrate/grows_through and biome data. We bin heat/humidity in -- ranges of `CACHE_BIN_SIZE` units, so e.g., if `CACHE_BIN_SIZE` is 4 -- (the default) heat = 0 / humidity = 17 will have the same key string -- as heat = 3 / humidity = 16. -- -- @tparam substrate string The node name that a grass type grows on -- or a seed type can take root in. -- @tparam grows_through string The node name (or group:group_name) -- string that a grass type grows into or a seed type can take -- root next to. If this is nil, we use the string "air" instead. -- @tparam biome_data table The biome data table with 'heat' and -- 'humidity' fields each of which are numbers, as well as a -- 'biome' field which is a biome ID (get this from -- `core.get_biome_data`). -- @treturn string A string to be used as a cache key for -- grasses/growables that can grow/take root using the provided -- environment. botany.climate_key = function (substrate, grows_through, biome_data) if grows_through == nil then grows_through = "air" end return ( substrate .. "%" .. grows_through .. "/" .. floor(biome_data.heat / CACHE_BIN_SIZE) .. "/" .. floor(biome_data.humidity / CACHE_BIN_SIZE) .. "/" .. biome_data.biome ) end local climate_key = botany.climate_key --- Given a position, a sides restriction, and a filter function, -- returns an array of x/y/z position vectors representing the -- position(s) of neighbor(s) of the node at the given position on one -- of the indicated sides where the given filter function matches (or -- all neighbors indicated by the given sides value). -- -- @tparam table pos An x/y/z vector indicating the position we want to -- filter neighbors of. -- @tparam number sides A value from the `botany.SIDES` table like -- `botany.TOP` which indicates which side(s) we want to include in -- results. -- @tparam function filter A function which, given a position vector, -- returns true if that neighbor should be included and false if -- not. If omitted, all neighbors on specified sides will be -- returned. -- @treturn table An array of x/y/z vector tables, one for each -- neighbor that is on one of the specified sides and for which the -- filter function returns true. botany.neighbors = function (pos, sides, filter) local result = {} for _, nb_offset in ipairs(RELEVANT_SIDE_OFFSETS[sides]) do local nb = offset(pos, unpack(nb_offset)) if filter == nil or filter(nb) then result[#result + 1] = nb end end return result end local neighbors = botany.neighbors --- For the point (x, y), returns how far above the specified parabola -- it is, where the parabola's lowed point is at the given nadir x/y -- values and its multiplier is the given coefficient. Specifically, -- the formula for our parabola is: -- -- nadir_y + coefficient * (x - nadir_x)^2 -- -- This function returns 0 or a negative number if the given point is -- on or below the parabola. If the coefficient is 0, the "parabola" is -- a flat line at height `nadir_y`. -- -- @tparam number nadir_x The x value where the parabola is lowest (or -- highest if the coefficient is negative). -- @tparam number nadir_y The y value of the lowest point of the -- parabola (or highest point of if the coefficient is negative). -- @tparam number coefficient The multiplier on the squared term of the -- parabola. This determines how quickly the parabola shoots up (or -- if negative, curves down) as it moves away from the `nadir_x` x -- value in both directions. -- @tparam number x The x coordinate you want to check on. -- @tparam number y The y coordinate you want to check on. -- @treturn number The distance upwards from the parabola at the given -- coordinates' x value to the given y coordinate. Will be 0 or -- negative if the point specified is on or below the parabola. botany.distance_above_parabola = function( nadir_x, nadir_y, coefficient, x, y ) local parabola_value = nadir_y + coefficient * (x - nadir_x)^2 return y - parabola_value end local distance_above_parabola = botany.distance_above_parabola --- Given x/y origin coordinates and an angle (in radians), this -- function takes an arbitrary x/y point and returns two values: the -- new x and y values for the axes centered at the given alternate -- origin and tilted at the given angle relative to x=right/east and -- y=up/north. -- -- @tparam number origin_x The x coordinate of the origin of the new -- reference frame. -- @tparam number origin_y The y coordinate of the new origin. -- @tparam number angle The tilt of the new coordinate frame in -- radians. When this is 0, the x-axis will point due east and the -- y-axis will point due north. As this increases, the axes rotate -- counterclockwise, coming back to the original orientation when -- the angle is equal to 2π. -- @tparam number x The x-value of the point to transform. -- @tparam number y The y-value of the point to transform. -- @treturn multiple Two numbers: the x and y values for the same point -- relative to the specified alternate axes. botany.coordinate_transform = function( origin_x, origin_y, angle, x, y ) local vx = x - origin_x local vy = y - origin_y -- Negative angle here because we're rotating the axes, not the -- vector, so the vector rotates in the opposite direction local co = cos(-angle) local si = sin(-angle) return (co * vx - si * vy), (si * vx + co * vy) end local coordinate_transform = botany.coordinate_transform --- Given a table of parabola definitions, returns a value that's -- created by looking for where the area "above" those parabolas -- intersects (but each parabola can have a different direction value). -- -- Each parabola is defined as an array of 6 numbers, which are: -- -- x, y, angle (in radians), shape, strength, edge -- -- The x and y values specify the coordinates where the parabola -- starts, this will be its lowest point after accounting for the -- angle. The angle value specifies what direction the parabola points -- in (for positive coefficients). The shape value specifies how steep -- the parabola is, with 0 turning it into a line and negative -- shape values bending it downwards instead of upwards. The strength -- value specifies the "full" strength of the parabola's contribution, -- while the edge value specifies how far from the parabola it reaches -- full strength (strength values continue to slowly increase after -- this point). -- -- To combine the parabolas, for the given x and y values, we determine -- a value for each parabola by setting the value to 0 on the parabola -- itself and below it, and to full-strength once we reach the edge -- distance above the parabola. The formula for strength as we move -- away from the parabola itself by distance d is: -- -- strength * log10(1 + (d/edge * 9)) -- -- This formula means that when d is 0, the strength is 0, when d -- reaches the edge value, the strength is equal to the full 'strength' -- value, and for every additional multiple of 10 times the edge -- distance d reaches, another 'strength' is added in. For example, if -- 'edge' is 4, then when the distance is 4 we're at full strength, -- when the distance is roughly 40 we're at double strength, at 400 -- we're at triple strength, etc. -- -- If the given point is on or below a parabola, we set its strength -- value to 0, regardless of how far below the parabola it is. -- -- To combine these computed strength values from each parabola -- definition, we first we find the minimum strength among them. If -- this minimum is 0, we return 0 as our result. Otherwise, all of the -- values are non-zero, and we use the *geometric* average as our -- result (we multiply them all and take the nth root where n is the -- number of values). -- -- The effect of this algorithm is that for points that are not in the -- area where *all* of the specified parabolas intersect, we get 0 as -- the result. For points where *all* of the specified parabolas -- overlap, we get the geometric average of their individual strengths -- at that point, which will trend towards 0 at all the edges and -- towards an average of their strengths at places where they're all -- full strength. -- -- Note: To avoid overflow issues, use small numbers for the strength -- values, especially if you have many parabolas. You can multiply the -- final result by a larger scale value afterwards if you need to. -- -- Note: especially with many parabolas, this is a fairly expensive -- function, It's probably better to compute it once and cache the -- result than to try to compute it inside an ABM every time it's -- triggered. -- -- @tparam table parabolas An array of parabola definitions, each of -- which is an array of six numbers as described above. -- @tparam number x The x coordinate of interest. -- @tparam number y The y coordinate of interest. -- @treturn number The combined overall strength value for the given -- (x, y) point. botany.parabolas_region_value = function(parabolas, x, y) local product = 1 assert(#parabolas ~= 0, "Can't compute a region value with 0 parabolas.") for _, parabola in ipairs(parabolas) do local ox = parabola[1] local oy = parabola[2] local angle = parabola[3] local shape = parabola[4] local strength = parabola[5] local edge = parabola[6] local tx, ty = coordinate_transform(ox, oy, angle, x, y) local d = distance_above_parabola(0, 0, shape, tx, ty) local s = strength * log10(1 + (d/edge * 9)) -- If s is <= 0 or not a number, return 0 if s <= 0 or s ~= s then return 0 end product = product * s end -- Return the geometric mean return product ^ (1 / #parabolas) end local parabolas_region_value = botany.parabolas_region_value --- Computes a boost value for the given humidity and heat values -- based on the 'boost' and 'humidity' and/or 'heat' values in -- the given ideal climate table. 'humidity_range' and 'heat_range' are -- taken into account as well if provided. If 'boost' is not specified, -- we use 1.5, but if neither an ideal heat nor an ideal humidity is -- provided, we return 1 as the multiplier.. -- -- The boost is calculated as follows: -- -- 1. For each of heat and humidity, compute a strength value: -- 1. If no ideal is specified for this property, boost strength -- is undefined/nil. -- 2. Compute the boost range, either from the -- 'humidity_range'/'heat_range' slot of the growth -- definition, or by finding the distances from the -- 'ideal_humidity'/'ideal_heat' to 0 and 100 and averaging -- those distances. -- 3. If beyond the boost range, strength is 0. -- 4. Within the boost range, strength scales linearly from 0 at -- either edge to 1 at the ideal value. -- 2. Take the minimum of the heat or humidity strengths, but just use -- the other strength as-is if one is undefined/nil. This is the -- boost strength. If both strengths are nil, return the original -- weight unchanged. -- 3. Multiply the boost strength by the 'boost' value minus -- 1, and then add 1 to the result to get the final multiplier. -- -- @tparam number humidity The humidity value to adjust for. -- @tparam number heat The heat value to adjust for. -- @tparam table ideal The 'ideal' sub-table from a climate -- specification, with 'boost', 'heat', 'humidity', 'heat_range', -- and/or 'humidity_range' keys. -- @treturn number The boost value to apply for these heat/humidity -- values. botany.boost_multiplier = function(humidity, heat, ideal) local boost = ideal.boost or 1.5 -- Return original value if there is no boost if boost == 1 then return 1 end -- Compute humidity boost strength local humidity_strength = nil local ideal_humidity = ideal.humidity if ideal_humidity ~= nil then -- Compute humidity boost range local humidity_range = ideal.humidity_range if humidity_range == nil then local min_dist = abs(ideal_humidity - 0) local max_dist = abs(100 - ideal_humidity) humidity_range = (min_dist + max_dist) / 2 end -- Normalize distance onto range local dist_from_ideal = abs(humidity - ideal_humidity) humidity_strength = max(0, 1 - dist_from_ideal / humidity_range) end -- Compute heat boost strength local heat_strength = nil local ideal_heat = ideal.heat if ideal_heat ~= nil then -- Compute heat boost range local heat_range = ideal.heat_range if heat_range == nil then local min_dist = abs(ideal_heat - 0) local max_dist = abs(100 - ideal_heat) heat_range = (min_dist + max_dist) / 2 end -- Normalize distance onto range local dist_from_ideal = abs(heat - ideal_heat) heat_strength = max(0, 1 - dist_from_ideal / heat_range) end -- Boost strength is minimum of non-nil strengths -- This gives us a pyramid shape in the heat/humidity/strength space local strength = nil if heat_strength == nil then strength = humidity_strength elseif humidity_strength == nil then strength = humidity_strength else strength = min(humidity_strength, heat_strength) end -- Return multiplier of 1 if no boost strength if strength == nil then return 1 else -- Scale boost based on strength boost = 1 + (boost - 1) * strength -- Return adjusted weight return boost end end local boost_multiplier = botany.boost_multiplier --- A table containing multiple parabola-group arrays for use with -- `parabolas_region_value` which define the climate regions within -- which different kinds of plants thrive using heat as the x-axis and -- humidity as the y-axis, based on the standard Minecraft-inspired -- biomes shown here: -- -- https://github.com/Treer/Amidst-for-Minetest?tab=readme-ov-file botany.CLIMATE_PARABOLAS = { dirt_grows_grass={ -- ALL regions in which dirt can turn into grass { 90, 16, -- anchored in desert near savanna/red savanna border pi/12, -- y-axis tilted slightly counterclockwise (+y left) -7/(30^2), -- bends downards to avoid hitting savanna biomes 1, -- strength of 1 5 -- reaches max strength pretty quickly }, { 50, 3, pi/10, 3/(70^2), 1, 9 }, -- slightly curved humidity floor { 25, 10, 0, -5/(15^2), 1, 8 }, -- cut out gravel/cold deserts { -10, 30, -pi/2 + pi/16, 3/(40^2), 1, 20 }, -- cut glaciers 1 & 2 { -12, 70, -pi/2 - pi/16, -4/(50^2), 1, 16 }, -- cut glacier 3 }, snow_falls={ { 24, 50, pi/2, 0, 1, 14 }, -- snow under 24 heat; full under 14 }, arid_grasses={ { 46, 6, -- anchored near bottom of sandstone grasslands 0, -- not tilted 9/(30^2), -- reaches 9 when sideways distance is 30 1, -- strength of 1 7 -- reaches max strength fairly quickly }, { 115, 45, pi + pi/10, 50/(20^2), 0.8, 10 }, -- down left from right { 15, 25, -pi/2, 5/(15^2), 0.8, 18 }, -- to right from tundra edge }, temperate_grasses={ { 73, 21, pi/6, 20/(20^2), 0.8, 17 }, -- up-left from scrub edge { 70, 97, pi - pi/12, 10/(70^2), 1, 14 }, -- below swamps { 10, 50, -pi/2 + pi/12, 10/(40^2), 0.8, 12 }, -- to right from mixed forest edge -- Amit ? - downtown LA - concrete apartment building }, humid_grasses={ { 50, 90, -pi/6, -16/(20^2), 1, 9 }, -- bending back from deciduous { 15, 90, -pi/2 + pi/10, 17/(30^2), 1, 8 }, -- cut off icy swamp { 90, 50, pi/6, -5/(30^2), 0.8, 9 }, -- along bottom of rainforest { 50, 55, -pi/6, -7/(70^2), 0.8, 10 }, -- limit extension into middle }, deciduous_trees={ { 34, 28, 0, 20/(40^2), 1, 7 }, -- up from bottom of orchard { 30, 70, -pi/2 - pi/6, -10/(20^2), 0.8, 8 }, -- through coniferous { 8, 50, -pi/2, 0, 0.8, 12 }, -- hard temp limit { 80, 70, pi/2 + pi/8, 3/(30^2), 0.8, 18 }, -- left from rainforest }, tropical_trees={ { 50, 80, -pi/2 + 0.1, 5/(50^2), 0.8, 23 }, -- right from deciduous { 90, 56, pi/6, -5/(30^2), 0.8, 9 }, -- along bottom of rainforest }, savanna_trees={ { 60, 20, -pi/3, -5/(20^2), 1, 19 }, -- left of savanna/hot pines { 75, 50, -pi/3, 5/(20^2), 1, 7 }, -- through hot pine forest { 90, 63, pi, 0, 0.8, 12 }, -- humidity cap ~below rainforest { 90, 17, pi/8, -8/(20^2), 0.8, 5 }, -- exclude desert }, cold_connifers={ { -10, 60, -pi/2, 5/(25^2), 1, 6 }, -- right from glaciers { 15, 40, 0, 15/(10^2), 1, 8 }, -- up from bottom of mixed forest { 40, 70, pi/2 - pi/12, -4/(20^2), 1, 7 }, -- left of deciduous { 40, 110, pi - pi/6, 5/(30^2), 1, 19 }, -- not too much swamp }, temperate_swamp_plants={ { 50, 87, 0, 0, 1, 10 }, -- humidity floor { 10, 100, -pi/2, 0, 1, 6 }, -- heat floor { 60, 100, pi/2, 0, 1, 6 }, -- heat ceiling }, hot_swamp_plants={ { 50, 83, 0, 0, 1, 22 }, -- humidity floor { 53, 100, -pi/2, 0, 1, 7 }, -- heat floor }, } local CLIMATE_PARABOLAS=botany.CLIMATE_PARABOLAS --- Given a climate preferences specification and biome data, computes -- a weight value for the biome/heat/humidity values stored in the -- biome data table. -- -- @tparam number base_weight The base weight value to use if neither -- 'regions' nor 'custom' is specified. -- @tparam table climate_spec The climate specification table has the -- following sub-tables (each optional): -- -- - 'regions' is a table that uses keys from the -- `CLIMATE_PARABOLAS` table and has numeric values. The values -- define a base weight associated with each region. We use -- `parabolas_region_value` to compute a strength for each -- relevant region, multiply by the weight value listed in this -- table, and then take the max of those values as the base -- weight. If 'regions' is not provided, we use the specified base -- weight. The string "any" may be used as a special region -- name to indicate a base weight that applies everywhere; -- without this the base weight and thus result will be 0 -- outside of the listed regions. -- - 'custom' is an array whose entries are 2-element arrays. Those -- entries have a weight number as their first entry, and array -- of 6-number parabola arrays as their second entry. These are -- custom regions, and if the best custom region value (weight -- times region strength) is better than the value from the -- 'regions' table (or 0 if there is no regions table) then -- that becomes the base weight. The 'base_weight' value is -- only used if neither 'regions' nor 'custom' is provided. -- - 'biomes' is a table that uses biome names as keys and has -- numeric values. When in a listed biome, the base weight gets -- multiplied by the value for that biome. Biomes not listed -- have an effective multiplier of 1. You can set this to 0 to -- ensure the result will be 0 in a given biome. -- - 'ideal' is a table with 'boost', 'heat', 'humidity', and -- possible 'heat_range' and/or 'humidity_range' keys. This -- calculates an additional multiplier when near the specified -- ideal heat and/or humidity. See `boost_multiplier` for -- details of how these values are used. -- @tparam table bdata The biome data to use to compute a -- climate-specific weight, which you can get using -- `get_biome_data`. Must have 'heat', 'humidity', and 'biome' -- keys. -- @treturn number A weight value specific to the specified -- biome/heat/humidity. botany.climate_weight = function(base_weight, climate_spec, bdata) local humidity = bdata.humidity local heat = bdata.heat local result = base_weight -- Set base to 0 if regions and/or custom is provided if climate_spec.regions or climate_spec.custom then result = 0 end -- First compute region-based base weight if climate_spec.regions then -- Find max among regions for region_name, region_weight in pairs(climate_spec.regions) do if region_name == "any" then if region_weight > result then result = region_weight end else local parabolas = CLIMATE_PARABOLAS[region_name] assert( parabolas ~= nil, ( "Bad region name '" .. region_name .. "' in climate spec." ) ) local here = ( region_weight * parabolas_region_value(parabolas, heat, humidity) ) -- Replace max if this is better if here > result then result = here end end end end -- Account for custom regions if climate_spec.custom then for _, custom in ipairs(climate_spec.custom) do local custom_weight = custom[1] local custom_parabolas = custom[2] local here = ( custom_weight * parabolas_region_value( custom_parabolas, heat, humidity ) ) -- Replace max if this is better if here > result then result = here end end end -- Check for a biome-specific multiplier local biome_multiplier = 1 local bname = get_biome_name(bdata.biome) if climate_spec.biomes then biome_multiplier = climate_spec.biomes[bname] or 1 end result = result * biome_multiplier -- Now compute ideal boost multiplier local boost = 1 if climate_spec.ideal then boost = boost_multiplier(humidity, heat, climate_spec.ideal) end result = result * boost -- We're done; return our result return result end local climate_weight = botany.climate_weight --- Given a cache bin containing grass and/or growable definitions -- which have sides and/or light constraints about where they can grow, -- returns a "drop list" which is an array that contains indices into -- the cache bin which should be ignored when selecting one to apply, -- because the entries at those indices aren't compatible with the -- given position. This array will also have a 'total' field holding -- the total weight of all entries that should be dropped. -- -- @tparam table cache_bin An array of grass definitions (see -- `register_grass`) or growable definitions (see -- `register_growable`) which are relevant to a specific -- substrate/grows_through/heat/humidity/biome. This function will -- further figure out which entries on that list are excluded due -- to light/sides requirements. -- @tparam table pos An x/y/z table or real vector indicating the -- position at which we're considering placing something. Use to -- access neighboring nodes + light levels. -- @tparam function can_grow A function which, given the name of a -- node, will return true or false depending on whether it is -- suitable for a plant to grow through. This function is assumed -- to apply to all definitions in the cache bin; we don't consult -- their individual grows_through value. -- @treturn table An array of indices within the given cache_bin which -- do not qualify for placement here due to restrictions on light -- levels and/or which sides must have an appropriate substrate. botany.filter_cache_bin = function (cache_bin, pos, can_grow) local result = {} -- We keep track of the cumulative weight of dropped entries result.total = 0 -- A few variables we'll fill in once requested local above = nil local below = nil local node_above = nil local node_below = nil local nodes_beside = nil local allowed_above = nil local allowed_below = nil local allowed_beside = nil local allowed_neighbors = nil local light_above = nil local light_below = nil local light_beside = nil -- Go through each definition from our cache bin for i, weighted_def in ipairs(cache_bin) do local local_weight = weighted_def.weight local grow_def = weighted_def.def local allowed = grow_def.check_sides or TOP local cares_light = ( grow_def.min_light ~= nil or grow_def.max_light ~= nil ) local has_space = false local max_light = nil if band(allowed, TOP) ~= 0 then -- Compute stuff above if we care about it, or use -- computed values from previous iteration if node_above == nil then above = offset(pos, 0, 1, 0) node_above = get_node(above) allowed_above = can_grow(node_above.name) end -- If allowed above, we have space if allowed_above then has_space = true end -- Only compute light if we care about that and -- haven't already if cares_light and allowed_above then if light_above == nil then light_above = get_natural_light(above, 0.5) end if max_light == nil or max_light < light_above then max_light = light_above end end end if ( -- Skip this check if we don't care about light and already -- know we have space to grow (cares_light or not has_space) and band(allowed, BOTTOM) ~= 0 ) then -- Compute stuff below if we care about it, or use -- computed values from previous iteration if node_below == nil then below = offset(pos, 0, 1, 0) node_below = get_node(below) allowed_below = can_grow(node_below.name) end -- If allowed below, we have space if allowed_below then has_space = true end -- Only compute light if we care about that and -- haven't already if cares_light and allowed_below then if light_below == nil then light_below = get_natural_light(below, 0.5) end if max_light == nil or max_light < light_below then max_light = light_below end end end if ( -- Skip this check if we don't care about light and already -- know we have space to grow (cares_light or not has_space) and band(allowed, SIDES) ~= 0 ) then -- Compute stuff on the sides if we care about it, or use -- computed values from previous iteration allowed_beside = false if nodes_beside == nil then nodes_beside = {} allowed_neighbors = {} local side_offsets = RELEVANT_SIDE_OFFSETS[SIDES] for i, offsets in ipairs(side_offsets) do local this_pos = offset( pos, unpack(offsets) ) local this_node = get_node(this_pos) nodes_beside[#nodes_beside + 1] = this_node if can_grow(this_node.name) then allowed_beside = true allowed_neighbors[ #allowed_neighbors + 1 ] = this_pos end end end if allowed_beside then has_space = true end -- Only compute light if we care about that and -- haven't already if cares_light and #allowed_neighbors > 0 then if light_beside == nil then for _, nb_pos in ipairs(allowed_neighbors) do if light_beside == nil then light_beside = get_natural_light( nb_pos, 0.5 ) else local light_here = get_natural_light( nb_pos, 0.5 ) if light_beside < light_here then light_beside = light_here end end end end if max_light == nil or max_light < light_beside then max_light = light_beside end end end -- At this point, has_space and max_light have been set -- according to the appropriate info from sides that this -- definition cares about. That info was extracted in this -- iteration only if it hadn't been previously requested. local min_light_allowed = grow_def.min_light local max_light_allowed = grow_def.max_light if ( -- no space not has_space -- not enough light or (min_light_allowed ~= nil and max_light < min_light_allowed) -- too much light or (max_light_allowed ~= nil and max_light > max_light_allowed) ) then result[#result + 1] = i result.total = result.total + local_weight end end return result end local filter_cache_bin = botany.filter_cache_bin --- Given an array of items which are each tables that have a 'weight' -- key, plus optionally an array of indices to ignore from the first -- array, randomly selects and returns one of the tables in the first -- array. Each item not ignored has a chance to be selected -- proportional to it weight value. If the items array has a 'sum' -- field, that value will be used instead of computing the sum by -- adding up the weights. Similarly, if the `drop` array has a 'total' -- key, that will be used as the total weight of dropped items. -- The dropped array must be sorted in ascending order. If 'sum' and -- 'total' are provided, we only iterate over (expected half of) the -- items once. If not, we may need to iterate over the items and/or -- drop lists first to sum weights. Not providing 'sum' makes things -- more than twice as expensive. -- -- @tparam items table An array whose entries are tables that have -- 'weight' fields. One of these entries will be returned. If a -- 'sum' field is present in this table, it's assumed to be the -- pre-computed sum of the items' weights. -- @tparam drop tale An array of indices to skip. Must be sorted in -- ascending order; each must be a valid index of the items array. -- If a 'total' field is present, it's assumed to be a pre-computed -- total of the weight of all skipped items. -- -- @treturn table One of the items in the items array, selected at -- random with probabilities proportional to their weights. botany.select_weighted = function(items, drop) if drop == nil then drop = {total = 0} end -- Compute sum of weights or fetch stored sum local sum = items.sum if sum == nil then -- need to re-compute sum, respecting drops sum = 0 local di = 1 local nextDrop = drop[di] for i, item in ipairs(items) do if i == nextDrop then -- won't match if nextDrop is nil -- This item is dropped; di = di + 1 nextDrop = drop[di] else sum = sum + item.weight end end else -- need to subtract dropped total from stored sum local dTotal = drop.total if dTotal == nil then dTotal = 0 for _, di in ipairs(drop) do dTotal = dTotal + items[di].weight end end -- Subtract weight of dropped items from sum sum = sum - dTotal end -- Now generate a random number between zero and the sum of weights local target = random() * sum -- TODO: Seeding for this choice? -- Now iterate through the items until the target value falls into -- the weight bin for one of them and stop there local which = 0 -- will increment as we enter the loop local di = 1 local nextDrop = drop[di] -- might be nil while target > 0 do -- consider next item which = which + 1 -- Skip over dropped entries if which == nextDrop then -- won't be true if nextDrop is nil di = di + 1 nextDrop = drop[di] -- might be nil else target = target - items[which].weight end end -- Return the item we selected return items[which] end local select_weighted = botany.select_weighted --- Given a growth plan table, returns a function that can be used as a -- filter function for `neighbors` which accepts a position and returns -- true if that position is a 'grows_through' node for this plan which -- has a light level between the plan's 'min_light' and 'max_light' -- values inclusive. If min/max heat/humidity values and/or allowed -- biomes are specified in the growth plan these are also checked. -- Caches that plan in the '_can_grow' field of the plan, so we're not -- constantly re-building this function every ABM call. Note that this -- function does NOT take `plan.sides` into account; pass that as the -- second parameter to `botany.neighbors`. -- -- @tparam table plan The growth plan table to use (and also where -- we'll cache the result if we need to build one). -- @treturn function A function that takes a position x/y/z vector -- table and returns true or false to indicate if the node at that -- position matches the plan's growth constraints. botany.plan_growth_filter = function(plan) -- Grab cached filter function, or build one and store it local can_grow = plan._can_grow if can_grow == nil then -- Build function to check node name for growability local gt = plan.grows_through or "air" local check_grows_through if gt.sub(1, 6) == "group:" then local group_name = gt.sub(7) check_grows_through = function(name) local group_rating = get_item_group(name, group_name) or 0 return group_rating ~= 0 end else check_grows_through = function(name) return name == gt end end -- Build can_grow function local cares_light = plan.min_light ~= nil or plan.max_light ~= nil local cares_biome = plan.climate ~= nil can_grow = function(pos) local nb_node = get_node(pos) -- Check node name against 'grows_through' group or name if not check_grows_through(nb_node.name) then return false end -- Check min/max light if cares_light then local light_level = get_natural_light(pos, 0.5) if ( plan.min_light ~= nil and light_level < plan.min_light ) then return false end if ( plan.max_light ~= nil and light_level > plan.max_light ) then return false end end -- Check climate if cares_biome then -- Get biome data local bdata = get_biome_data(pos) -- Compute local weight value local weight_here = climate_weight( plan.weight or 1, plan.climate, bdata ) if weight_here <= 0 then return false end else -- Even if we don't care about the biome (implies -- plan.climate is nil) if the weight is <= 0 it can't -- grow (setting weights to 0 could be used to -- temporarily or conditionally disable certain growth -- definitions). if plan.weight <= 0 then -- No need to check climate weights because if -- plan.climate were not nil, we wouldn't be in -- this branch. return false end end -- Return true if none of the previous checks failed return true end -- Stash our filter function for future use plan._can_grow = can_grow end -- Return cached or newly-built can_grow function return can_grow end local plan_growth_filter = botany.plan_growth_filter --- Given an age bin index and an age parameters table, returns the number -- of ages within the age bin at the given index, to be used in -- `increment_age`. `age_params.max_age` defines how many ages we want to -- sort-of count, but we only have 8 bits to count them. If the max age is -- <= 255, then the size of each age bin is 1. Otherwise, we have fewer -- bits than necessary to count each age precisely, so we divide ages we -- want to count up into bins, and decide randomly when to move from one -- bin to the next. -- -- Age bin 0 always has size 1. After that, up to the `age_params.infancy` -- value, each bin has size 1. After that, up to the `age_params.childhood` -- value each bin has size 2. Then up to `age_params.youth` we have size-4 -- bins, and finally we come to the middle of the age bins. First, we shave -- off `age_params.decline` of the remaining age bins to cover 20% of the -- remaining ages at the end (unless `age_params` doesn't define a -- 'decline' value). In the remainder of the middle ages, we take -- the remaining age bins and divide the ages up between them. We have -- -- Remaining bins: -- 255 - `age_prams.youth` - `age_params.decline` -- -- Remaining ages: -- 0.8 * (max_age - ( -- infancy -- + 2 * (childhood - infancy) -- + 4 * (youth - childhood) -- )) -- -- We divide the remaining ages evenly among the remaining bins (rounding -- down), and put a larger bin at the end to account for leftovers from -- rounding if necessary. -- -- @tparam number age_bin_index The age bin that we're interested in, -- indexed from 0. -- @tparam table age_params The age parameters table, with 'max_age' -- specifying the max age we're interested in counting to. May also -- have 'infancy', 'childhood', 'youth', and/or 'decline' values as -- described above. botany.age_bin_size = function(age_bin_index, age_params) -- Extract age parameters from table local max_age = age_params.max_age assert(max_age ~= nil, "Age params must include max age.") local infancy = age_params.infancy or 0 local childhood = age_params.childhood or 0 local youth = age_params.youth or 0 local decline = age_params.decline or 0 -- If we can just count all the ages, no need for bins if max_age <= 255 then return 1 end -- Decide which bucket we fall into if age_bin_index <= infancy then return 1 elseif age_bin_index <= childhood then return 2 elseif age_bin_index <= youth then return 4 else local remaining_ages = max_age - infancy - 2 * childhood - 4 * youth local middle_ages if decline > 0 then -- Check for decline part & adjust middle local decline_ages = floor(remaining_ages / 5) local decline_rev_zero_index = 255 - age_bin_index -- we're in the decline part if decline_rev_zero_index < decline then local decline_bin_size = floor(decline_ages / decline) if decline_rev_zero_index == 0 then -- bigger bin at end scoops up leftovers from rounding return decline_ages - (decline_bin_size * (decline - 1)) else return decline_bin_size end else -- Not in decline part; adjust middle & continue below middle_ages = remaining_ages - decline_ages end else -- No decline part; set middle_ages to remaining_ages middle_ages = remaining_ages end -- Divide middle ages among remaining bins local remaining_bins = 255 - youth - decline local middle_bin_size = floor(middle_ages / remaining_bins) if age_bin_index == 255 - decline then -- Larger last bin to account for rounding return middle_ages - (remaining_bins - 1) * middle_bin_size else return middle_bin_size end end end local age_bin_size = botany.age_bin_size botany.AGE_ZERO = 0 local AGE_ZERO = botany.AGE_ZERO botany.AGE_INFANT = 1 local AGE_INFANT = botany.AGE_INFANT botany.AGE_CHILD = 2 local AGE_CHILD = botany.AGE_CHILD botany.AGE_YOUTH = 3 local AGE_YOUTH = botany.AGE_YOUTH botany.AGE_MIDDLE = 4 local AGE_MIDDLE = botany.AGE_MIDDLE botany.AGE_OLD = 5 local AGE_OLD = botany.AGE_OLD botany.AGE_MAX = 6 local AGE_MAX = botany.AGE_MAX botany.AGE_CATEGORIES = { [AGE_ZERO] = "zero", [AGE_INFANT] = "infant", [AGE_CHILD] = "child", [AGE_YOUTH] = "youth", [AGE_MIDDLE] = "middle", [AGE_OLD] = "old", [AGE_MAX] = "max", } --- Given an age bin index, and an age parameters table (see -- `age_bin_size`) indicates which age category that index is in, returning -- one of the `botany.AGE_CATEGORIES` numeric keys. If the age parameters -- don't include a value for a given category, that category won't be -- returned. -- -- @tparam age_bin_index The age bin index, starting from 0, which we want -- to categorize. -- @tparam table age_params The age parameters table (see `age_bin_size`). -- @treturn number The age category constant as described above. The -- meanings are: -- -- * `botany.AGE_ZERO`: Age 0. -- * `botany.AGE_INFANT`: Age bin is <= the `age_params.infant` value. -- At these ages, every cycle moves you to the next age and age is -- tracked exactly. -- * `botany.AGE_CHILD`: Age bin is <= the `age_params.child` value. At -- these ages, each bin has 2 ages, and we increment with -- probability 1/2 each cycle. In theory it is possible to get -- stuck in these bins for a long time. -- * `botany.AGE_YOUTH`: Age bin is <= the `age_params.youth` value. At -- these ages, each bin has 4 ages, and we increment with -- probability 1/4. It's easier to get stuck in these ages. -- * `botany.AGE_MIDDLE`: Age bin is > `age_params.youth` but less than -- 20% of `age_params.max_age`. These age bins are variable-sized -- depending on the max age they may be quite big and thus have -- very small probabilities of incrementing. -- * `botany.AGE_OLD`: Age bin is one of the last `age_params.decline` -- bins, but not the very last one. Here the expected age is >= 80% -- of `age_params.max_age`. We track progress through these last -- 20% of ages in `age_params.decline` steps, so these bins are -- variable size. Note that this value will never be the result if -- `age_params.decline` is missing, 0 or 1. If it's 1, we won't -- return this because the single decline bin will be the oldest -- possible age, so we'll return `botany.MAX` instead. -- * `botany.AGE_MAX`: We're in the oldest possible age bin. Note that the -- expected age here is NOT `age_params.max_age` because this age -- bin has multiple values in it. Age won't go up past this point. botany.age_category = function(age_bin_index, age_params) -- Extract age parameters from table local infancy = age_params.infancy or 0 local childhood = age_params.childhood or 0 local youth = age_params.youth or 0 local decline = age_params.decline or 0 local max_index = min(255, age_params.max_age) if age_bin_index == 0 then return AGE_ZERO elseif age_bin_index >= max_index then return AGE_MAX elseif age_bin_index <= infancy then return AGE_INFANT elseif age_bin_index <= childhood then return AGE_CHILD elseif age_bin_index <= youth then return AGE_YOUTH elseif age_bin_index > max_index - decline then return AGE_OLD else return AGE_MIDDLE end end local age_category = botany.age_category --- Given an age bin index and an age parameters table, returns the best -- estimate of the current actual age. See `age_bin_size` for an -- explanation of how age bins work. -- -- @tparam number age_bin_index The index of the age bin we're in, -- counting from 0. -- @tparam table age_params The age parameters table (see `age_bin_size`). -- @treturn number The age in counted cycles that we expect given the -- `age_bin_index` we are at. This is always halfway through the age -- bin we're in, except it will be 0 at age 0. It will never be -- `age_params.max_age` because the last bin spans multiple ages. botany.expected_age = function(age_bin_index, age_params) -- Extract age parameters from table local max_age = age_params.max_age assert(max_age ~= nil, "Age params must include max age.") local infancy = age_params.infancy or 0 local childhood = age_params.childhood or 0 local youth = age_params.youth or 0 local decline = age_params.decline or 0 -- If we can just count all the ages, no need for bins if max_age <= 255 then return age_bin_index end -- Decide which bucket we fall into if age_bin_index <= infancy then return age_bin_index elseif age_bin_index <= childhood then return infancy + 2 * (age_bin_index - infancy) + 0.5 elseif age_bin_index <= youth then return infancy + 2 * childhood + 4 * (age_bin_index - childhood) + 1.5 else local early_ages = infancy + 2 * childhood + 4 * youth local remaining_ages = max_age - early_ages local middle_ages if decline > 0 then -- Check for decline part & adjust middle local decline_ages = floor(remaining_ages / 5) local decline_rev_zero_index = 255 - age_bin_index -- we're in the decline part if decline_rev_zero_index < decline then local decline_bin_size = floor(decline_ages / decline) local leftovers_size = ( decline_ages - (decline_bin_size * (decline - 1)) ) if decline_rev_zero_index == 0 then -- bigger bin at end scoops up leftovers from rounding return max_age - (leftovers_size / 2) else local after = ( leftovers_size + (decline_rev_zero_index - 1) * decline_bin_size ) return max_age - after - decline_bin_size / 2 end else -- Not in decline part; adjust middle & continue below middle_ages = remaining_ages - decline_ages end else -- No decline part; set middle_ages to remaining_ages middle_ages = remaining_ages end -- Divide middle ages among remaining bins local remaining_bins = 255 - youth - decline local middle_bin_size = floor(middle_ages / remaining_bins) local middle_most = (remaining_bins - 1) * middle_bin_size if age_bin_index == 255 - decline then local middle_leftovers = middle_ages - middle_most return middle_most + middle_leftovers / 2 else local middle_index = age_bin_index - youth return ( early_ages + middle_bin_size * (middle_index - 1) + (middle_bin_size / 2) ) end end end local expected_age = botany.expected_age --- Keeps track of an approximate age value using the 8 bits available in -- e.g., param1. Since our callbacks happen on ~30 second scales and we -- can only count to 255 with 8 bits, we'd only be able to track age up to -- ~2 hours of game time. If we want trees that eventually die of old age -- after 1000 game-years, since each day is ~10 minutes, we need to count -- to ~3.6 million seconds, or ~120,000 per-30-second events. So we use a -- probabilistic counting scheme (see `age_bin_size`) that splits the -- indices we do have (0-255) among different age buckets, using smaller -- buckets at the start and end for more fine-grained tracking of -- important early/late ages. -- -- @tparam number current_age_index The index of the age bin we're in, -- counting from 0. -- @tparam table age_params The age parameters table (see `age_bin_size`). -- @treturn number The age bin index after counting a single cycle. -- Will be one greater than the `current_age` with probability -- 1/bin_size, and otherwise it will be the same as the current age. -- This ensures that the expected value of the number of cycles -- required to get to the next bin is equal to the bin size. See: -- -- https://math.stackexchange.com/questions/1196452/expected-value-of-the-number-of-flips-until-the-first-head -- -- Use `expected_age` to get the expected age value in # of cycles -- given an age bin index, and/or use `age_category` to get the rough -- category of age we're at, which is both much faster and usually more -- useful for influencing growth. botany.increment_age = function(current_age_index, age_params) local bin_size = age_bin_size(current_age_index, age_params) local max_age_index = min(age_params.max_age, 255) -- Don't increment once we reach max age (in exact-counting regime) if current_age_index == max_age_index then return current_age_index end -- Assume bin_size >= 1 if bin_size == 1 then -- bin size <= 1: always increment until max age return current_age_index + 1 else -- bin size > 1: increment if we are lucky, with expected total -- number of tries necessary equal to bin size if random() < (1 / bin_size) then -- Lucky! Go to next bin return current_age_index + 1 else -- Stay in this bin and keep trying to flip out return current_age_index end end end local increment_age = botany.increment_age --- Given a number of in-game days and/or hours/minutes/seconds (only 1 -- argument is necessary and the rest are optional) this function returns -- the number of real-world seconds that would pass during that much -- in-game time, based on the current "time_speed" value set in -- minetest.conf. You can pass `nil` for `in_game_days` and it will be -- treated as 0. `time_speed` is just a multiplier, so the default (72) -- implies that 72 in-game days pass for each real-world day, which -- translates to 20 real-world minutes per in-game day. -- -- @tparam number in_game_days The number of in-game days. Default 0. -- @tparam number in_game_hours The number of in-game hours. Default 0. -- @tparam number in_game_minutes The number of in-game minutes. Default 0. -- @tparam number in_game_seconds The number of in-game seconds. Default 0. -- @treturn number The corresponding number of real-world seconds based on -- the current "time_speed" setting. botany.real_seconds = function( in_game_days, in_game_hours, in_game_minutes, in_game_seconds ) local speed = core.settings:get("time_speed") or 72 local days = ( (in_game_days or 0) + (in_game_hours or 0)/24 + (in_game_minutes or 0)/1440 + (in_game_seconds or 0)/86400 ) return 86400 * (days / speed) end local real_seconds = botany.real_seconds --- Converts a number of real-world seconds into the expected number of -- growth ticks that will happen during those seconds for a particular -- growable node. botany.growth_ticks = function(seconds) local seconds_per_tick = botany.growth_interval * botany.growth_chance return seconds / seconds_per_tick end local growth_ticks = botany.growth_ticks --- Given a number of growth ticks that have been counted, returns the -- corresponding number of (expected) in-game days elapsed. Uses the -- current time_speed setting. -- -- @tparam number growth_ticks The number of growth ticks measured. -- @treturn number An estimate of the number of in-game days elapsed. Since -- growth ticks happen randomly, this can't be exact. Will include a -- fractional part. botany.growth_days = function(growth_ticks) local seconds_per_tick = botany.growth_interval * botany.growth_chance local speed = core.settings:get("time_speed") or 72 local real_seconds = seconds_per_tick * growth_ticks return real_seconds * (speed / 86400) end local growth_days = botany.growth_days --- Returns an effective parameter value (usually param1 or param2) by -- looking up the given parameter name in the given plan, using the given -- default if that field is not present, and then if either the value or -- default is a function, calling that function on the base table, with -- any additional arguments to this function passed through. -- -- @tparam table table The table to look in. -- @tparam string param_name The field name to look up in the table. -- @param default The default value to use if the param_name slot is not -- present in the table. If this is a function, it will be called with -- the table and position just like a field value function would have -- been. -- -- Additional parameters are passed as additional arguments to the -- parameter value if it's a function we need to convert to a final value. -- -- @return The value for the specified parameter in the given table. botany.effective_param = function(table, param_name, default, ...) local result = table[param_name] if result == nil then result = default end if type(result) == "function" then result = result(table, ...) end return result end local effective_param = botany.effective_param --- This function triggers any registered death callbacks for nodes with -- the given node's name, at the given position. You should call this -- *after* setting that node to the default death result, so that the -- callback can decide whether to further modify that node or not. The -- callback will receive the *original* node table and can use get_node if -- it wants to check what the death result was. -- -- This does nothing if there is no registered death callback for the node -- name of the node that died. -- -- @tparam table pos An x/y/z vector indicating the position where a plant -- died of natural causes. -- @tparam table node The name/param1/param2 table for the node that died. -- The node in the specified position should already have been updated -- to a new node before this function is called. botany.notify_death = function(pos, node) local cb = botany.death_callbacks[node.name] if cb then cb(pos, node) end end local notify_death = botany.notify_death --- A function that checks for avoiding a terminal fate (like death) over -- the course of a lifetime of an aging plant, with a specified -- whole-lifetime chance to avoid that fate, distributing the chance that -- it happens across age categories. To control probability distribution, -- unless 'shape' is set to `nil`, we only perform a real check when -- advancing to the next age category. The nature of age categories -- (unless a plant has a very low max age) is that these checks will -- happen less frequently after the plant reaches maturity (after the -- infant/child/youth phases) and then will typically accelerate slightly -- during the last 20% of the plant's lifespan (see `age_bin_size`). -- -- This function assumes (but does not enforce) that if it returns true, -- the plant will die or otherwise be permanently changed and there won't -- be further chances for the fate to occur. If you keep checking after it -- returns true once, it may in fact return true again and the lifetime -- probability won't be correct, since probability-wise it assumes on each -- check that all previous checks must have returned false. -- -- @tparam number avoid_chance The chance (inverse of probability) that -- the event will never occur during the entire lifespan of he plant. -- if this is <= 1, then this function will always return false. -- @tparam table age_params The age parameters used by the underlying -- aging process (see `age_bin_size` and `increment_age`). -- @tparam number age_index The current age index of the plant. -- @tparam number next_age_index The next age index of the plant. -- @tparam number shape (optional) The shape of the distribution of -- probability. If this is nil, we'll divide the probability evenly -- over the lifetime of the plant and check at every step. If not, -- we'll use this fraction of the probability in the first age index, -- and this fraction of the remaining probability in the second age -- index, etc. If this is set to 1 or higher, we only do a single -- check at age 0 using the entire lifetime chance and always return -- false thereafter. Set this to higher fractions like .5 or .7 to -- create a type-III survivorship curve where the fate is most likely -- to occur young. To create a type-II survivorship curve where -- probability is evenly distributed by age set this to `nil`. Set it -- to a small fraction (close to or less than 1/255) to create a -- type-I survivorship curve where the event is more likely to happen -- near the end of the lifespan. We always increase the -- fraction-of-remaining-probability checked for each step of the -- decline phase so we don't end up with just one big check at the -- very end. -- @tparam number bias (optional) A multiplier on the remaining -- probability-of-meeting-the-fate after each check (note that checks -- happen more frequently when 'shape' is `nil`). Set this to -- something like 0.995 or 1.005 to slightly bias the odds over the -- lifetime. If omitted defaults to 1, which preserves the overall -- avoid-chance; if set to something other than 1 the specified avoid -- chance will not be exact. Lower values increase the chance of -- avoiding the fate and higher values decrease it. This compounds -- with every age index, so by the end this number raised to the 254th -- power is the total bias factor on the final check, and half the -- checks are biased by at least this to the 128th power, etc, which -- is why it usually needs to be so close to 1. -- @tparam boolean use_trigger (optional) Use the 'trigger_age' and -- 'trigger_index' fields of the 'age_params' table instead of using -- the 'max_age' field and 255. This compresses all of the possibility -- of meeting the fate into ages/age-indices before the trigger. -- @tparam number final_chance (optional) By default, once the max age -- index is reached this function will always return false. However, -- if you specify a 'final_chance', then this function will check -- against that chance (inverse of probability) on each tick once the -- max age index is reached, effectively ensuring that the fate will -- eventually be met. Leave this out or set it to `nil` explicitly to -- use the default behavior. Note that this only triggers once the max -- age index is reached, so if a custom aging scheme prevents that, -- this behavior won't kick in. -- -- @treturn boolean True if the fate should occur now, and false if not. -- Assumes checking will stop once it returns true, and therefore can -- return true multiple times if checking continues. -- -- Note: If the probability of meeting the fate falls below -- `botany.IGNORABLE_PROBABILITY`, then we ignore it and always return -- false. botany.check_fate = function( avoid_chance, age_params, age_index, next_age_index, shape, bias, use_trigger, final_chance ) -- If we definitely avoid the fate, we don't need to do any math. if avoid_chance <= 1 then return false end local avoid_probability = 1 / avoid_chance local fate_probability = 1 - avoid_probability -- Check if the fate probability is low enough to be ignorable -- Don't ignore avoid probability as we still want to distribute fate -- timing across age indices. local ignorable = botany.IGNORABLE_PROBABILITY if fate_probability <= ignorable then return false end local ticks_expected local final_index if use_trigger then ticks_expected = age_params.trigger_age or 1 final_index = age_params.trigger_index or 1 else ticks_expected = age_params.max_age or 1 final_index = 255 end -- With just 1 (or fewer?) ticks, we check only when the age_index is 0 -- and then never check again. We also use this behavior when shape -- is specified at is >= 1. if ticks_expected <= 1 or shape and shape >= 1 then -- Note: even when using age bins, we always tick out of age 0 on -- the first tick. if age_index == 0 then -- Single check against entire chance if random() < fate_probability then return true -- unlucky else return false -- fate avoided end else return false -- fate already avoided end elseif next_age_index > age_index then -- If we're aging up, we do another fate check -- We want to use up some fraction the fate probability on the -- first age index, another portion of the remainder on the -- second, etc. But the overall failure probability is the inverse -- of the product of all the success chances, NOT the sum of the -- individual failure probabilities. We go by age indices and not -- ages because otherwise this loop might have to run for *many* -- iterations and the way that expected ages jump around might get -- messy. local p_survived = 1 -- fraction of remaining probability mass to use up in this check local use_now = shape -- Get decline parameters for altering use_now local decline_indices = age_params.decline or 1 -- minimum 1 index so age 254 is always max decline if decline_indices == 0 then decline_indices = 1 end local decline_fraction = 0 -- smooth fraction up to 1 at end of decline phase if age_category(age_index, age_params) == AGE_OLD then decline_fraction = ( (age_index - (254 - decline_indices)) / decline_indices ) -- Average from shape towards "use it all" as we decline use_now = decline_fraction * 1 + (1 - decline_fraction) * shape end -- Use up desired fraction of failure probability immediately local p_fate_now = use_now * fate_probability -- Factor in chance of avoiding on this step local p_survived = p_survived * (1 - p_fate_now) -- Note that p_fate_later is NOT fate_probability - p_fate_now local p_fate_later = 1 - (avoid_probability / p_survived) -- Note that we have to re-do this loop at each age index, which -- might get expensive at later indices a bit (haven't profiled -- this yet). for t = 1, age_index do -- Bias towards success p_fate_later = p_fate_later * bias -- Cut off checks once probability falls too low if p_fate_later <= ignorable then return false -- fate avoided at this age & later end -- Compute fraction of probability to use up next check -- smooth fraction up to 1 at end of decline phase if age_category(t, age_params) == AGE_OLD then decline_fraction = ( (t - (254 - decline_indices)) / decline_indices ) -- Average from shape towards "use it all" as we decline use_now = decline_fraction * 1 + (1 - decline_fraction) * shape end -- Update p_fate_now for next check p_fate_now = p_fate_later * use_now -- Update probability of surviving next age bin p_survived = p_survived * (1 - p_fate_now) -- Update probability of failing after next check later p_fate_later = 1 - (success_probability / p_survived) end -- Check if we're aging into the final age index if next_age_index == final_index then -- We need to use up any remaining failure probability here -- Divide by final use_now value to back out to total -- probability of failure now + later return random() < (p_fate_now / use_now) else -- Just use the local failure chance return random() < p_fate_now end else if final_chance and age_index == 255 then -- Check against our final chance each tick at max age index return random() < 1 / final_chance else -- This is either an intermediary tick where age index didn't -- increase, or we're at final age index but final_chance is -- nil. return false end end end local check_fate = botany.check_fate --- Given a 'rule' string that's either a node name or a -- 'group:' string, returns true if the given node name either -- is that node name or is in that group. -- -- @tparam string rule The node name OR group name to match. -- @tparam string name The node name of the node to check. botany.matches_node_or_group = function(rule, name) if rule.sub(1, 6) == "group:" then local group_name = rule.sub(7) return (get_item_group(name, group_name) or 0) ~= 0 else return name == rule end end local matches_node_or_group = botany.matches_node_or_group --- Given a position and a node name/param1/param2 table, uses the given -- age parameters table (see `age_bin_size`) to compute the next age for -- the given node, and edits the param1 value for the node (or param2 if -- 'use_param2' is true) to set the new age. Note that this is NOT a growth -- function; see `grow_after` and `grow_as_it_ages` for examples of growth -- functions that will age heartroot nodes and also handle various growth -- callbacks. Use this function to age individual parts of a plant that -- also want to track their own ages. -- -- Returns two values: first is the new age index, and second is true if -- the age index increased and false otherwise. -- -- If 'sub_param_name' is set, we look up that sub-table within the age -- parameters and use those parameters instead. If that sub-table doesn't -- exist but 'use_max_age' is set, we generate a new sub-table under the -- 'sub_param_name' key with just 'max_age' equal to that max age. -- Otherwise it's an error to call this with a specified 'sub_param_name' -- where that sub-table doesn't exist on the age_params. -- -- Calls the 'max_age_callback' once upon reaching maximum age if one is -- provided. -- -- @tparam table age_params The age parameters table that defines aging -- properties. See `age_bin_size` and `increment_age`. Needs at minimum -- a 'max_age' key. -- @tparam table pos The x/y/z vector position of the node we're aging. -- @tparam table node The name/param1/param2 table for the aging node. -- @tparam boolean use_param2 (optional) By default or when this is -- `nil`/`false`, we read the current age index from param1 and write -- to param1 if it changes. If this is set to `true`, we instead read -- from and possibly write to param2. -- @tparam string sub_param_name (optional) The name of a key in the -- 'age_params' table which holds specialized age parameters to use -- instead of the base parameters. If this is `nil` or omitted, we just -- use the base `age_params`. If this is specified but the key doesn't -- exist we get an error, unless 'use_max_age' is provided, in which -- case we instead generate a new table with 'max_age' set to the -- provided value and place it into the 'age_params' table. -- @tparam number use_max_age (optional) The maximum age (in ticks) to use -- for the aging process IF we're required to create a new age -- parameters sub-table because 'sub_param_name' was set but the -- specified sub-table doesn't exist yet. Not relevant in any other -- circumstance. -- @tparam function max_age_callback (optional) If provided, this function -- is called when the aging process first reaches the max age index -- (note that this isn't quite the same as reaching max age; see -- `age_bin_size`). Subsequent calls to `age_up_node` while already at -- the max age index will not trigger this (but will also never result -- in aging up). The function will receive the following parameters: -- * 'pos' - The x/y/z/ position table. -- * 'node' - The name/param1/param2 node table (with the new age) -- * 'age_index' - The new age index (which will be the max age index -- for the specified age parameters). -- -- @treturn multiple Returns the new age index (which might be the same as -- the old one) and also returns a boolean indicating whether that age -- index changed or not (old index will always be new - 1 if this is -- true. botany.age_up_node = function( age_params, pos, node, use_param2, sub_param_name, use_max_age, max_age_callback ) local my_age_params = age_params -- Get age params sub-table if requested if sub_param_name then my_age_params = age_params[sub_param_name] -- Generate a new sub-table if we can, or error out if my_age_params == nil then -- Check that we have info to generate new sub-params assert( use_max_age ~= nil, ( "In 'age_up_node' sub_param_name was set to '" .. tostring(sub_param_name) .. "' but no sub-table with" .. " that value was found and no 'use_max_age' value was" .. " provided." ) ) my_age_params = {max_age=use_max_age} age_params[sub_param_name] = my_age_params end end -- Figure out which node parameter we're using: local param = 'param1' if use_param2 then param = 'param2' end -- Check age index increment local current_age = node[param] local next_age = increment_age(current_age, my_age_params) -- If we aged up, swap the node and return (age, true) if next_age > current_age then local new_node = {name=node.name, param1=node.param1, param2=node.param2} new_node[param] = next_age swap_node(pos, new_node) if age_category(next_age, my_age_params) == AGE_MAX then max_age_callback(pos, new_node) end return next_age, true else -- Otherwise return (age, false) return next_age, false end end local age_up_node = botany.age_up_node --- Gets a phase-specific node name from the given plan under the given -- key, where that key can be either just a string (in which case that's -- the name we'll return for all phases) or a table of per-phase entries -- (in which case we use the appropriate entry, defaulting to the 'bare' -- entry if one is missing and raising an error if 'bare' is missing when -- we need a default. -- -- @tparam table plan A growth plan with the listed key that's either a -- string or a table. See the 'tip' entry under the 'plan' param in -- `register_simple_herb` for an example. -- @tparam string key The plan key we want to use. Its value must be either -- a string or a table. -- @tparam string phase Which one of the `botany.PLANT_PHASES` we're -- interested in. botany.plan_phase_node = function(plan, key, phase) local result = plan[key] local t = type(result) if t == "string" then return result else assert( t == "table", ( "Growth plan for heartroot '" .. tostring(plan.heartroot) .. "' has a value for key '" .. tostring(key) .. "' which is" .. " neither a string nor a table, so we can't get a per-phase" .. " node name from it for phase '" .. phase .. "'." ) ) local per_phase = result result = per_phase[phase] if result == nil then result = per_phase.bare end assert( result ~= nil, ( "Growth plan for heartroot '" .. tostring(plan.heartroot) .. "' has a table for key '" .. tostring(key) .. "' which" .. " contains entries for neither the desired phase '" .. phase .. "' nor the default phase 'bare'. Table is:\n" .. core.serialize(per_phase) ) ) return result end end --- Gets a cached table which lists all of the per-phase node names for a -- given slot as keys and has just 'true' as the value for each. This table -- can then be used to check for the presence of any of those nodes by -- checking if a node name from the map is in the table or not. If the -- cached table doesn't exit yet, it will be generated and stored. The key -- for the cache within the plan will be '__phase_names' where -- '' is the base key value provided here (e.g., '_tip_phase_names'). -- -- @tparam table plan The growth plan table where the cache is stored (and which -- defines a node name or per-phase table for the given key if no cache -- is available yet). -- @tparam string key The key in the plan table that holds either a single -- node name or a per-phase table of node names (e.g., 'tip' in simple -- herbs; see `register_simple_herb`). These name(s) are collected as -- keys of the resulting table. -- -- @treturn table A table with node names as keys and 'true' as each value, -- where all of the various per-phase node names for the given plan key -- are included as keys. botany.get_or_cache_phase_nodes_table = function(plan, key) local cache_key = "_" .. key .. "_phase_names" local result = plan[cache_key] if result == nil then result = {} local value = plan[key] local t = type(value) -- Table of tip nodes: collect all names into keys of tip_nodes if t == "table" then for _, v in pairs(value) do result[v] = true end else -- Single string stem node serves for all seasons assert( t == "string", ( "Plan '" .. key .. "' specification for heartroot '" .. plan.heartroot .. "' was " .. tostring(value) .. " which is neither a table nor a string." ) ) result[value] = true end plan[cache_key] = result end return result end get_or_cache_phase_nodes_table = botany.get_or_cache_phase_nodes_table --- Given a phases list and a number of days (possibly within a relevant -- period like a season), this function returns which phase the plant -- should be in. If `cyclic` is true (default if omitted is false) then -- the botany.select_phase = function(phases_list, days, cyclic) local select_phase = botany.select_phase --=======================-- -- Growth Plan Functions -- --=======================-- --- Builds and returns a growth function which counts cycles using the -- param1 value of the heartroot node and tries to trigger the indicated -- trigger function (with the usual growth function parameters) after the -- given number of (real-world) seconds has elapsed (but there will be -- significant variance). If a 'tick' function is provided, it will be -- called every tick until the trigger function is called (it won't be -- called on that tick). It will get age_index and next_age_index arguments -- like an aging function does (see `grow_as_it_ages`). If a `maintain` -- function is provided, it will be called every tick starting with the -- tick after the trigger function is called. -- -- @tparam number seconds The number of seconds after which the trigger -- function should be called. -- @tparam function trigger The trigger function to call when we hit -- approximately the desired number of seconds elapsed. It will get the -- plan, the position, the node, and any additional arguments that the -- callback gets (typically active object counts). If this function -- returns true, we cancel the normal tick-up-to-end update which would -- swap out the target node. Use this when the trigger function has -- already modified that node. In that case, if the node retains the -- name node name, its param1 value should be set to 255 unless you -- want the trigger function to potentially be called again (but not -- necessarily in the next step if age buckets are used; see below). -- @tparam function tick A function to call on each tick while waiting for -- the trigger. This is called before the age of the node is -- incremented, and if it returns true then the age increment is -- skipped (use this in instances where it changes the node itself). It -- will get the same parameters as the trigger function, plus the -- current age index and next age index as extra arguments between the -- plan and the position. -- @tparam function maintain A function to call each time the callback is -- called after the trigger function has been called. Note that using -- the trigger function to transform the node into a different node -- with a different callback setup might be a better option, especially -- if you want the callback frequency to change. -- @treturn multiple Both a growth function set up according to the -- provided parameters, and an age parameters table. This age -- parameters table will include a special 'trigger_age' field holding -- the desired number of ticks to count before calling the trigger -- function and a 'trigger_index' field holding the age index at which -- the trigger function will actually be called (expected age at this -- index may be greater than the trigger_age because of bucket -- sizing). -- -- Notes: -- -- * Make sure to set param1 to zero when the heartroot node is created. -- * Use a node timer instead if you need exact timing. -- * You can use the `botany.real_seconds` helper function to compute the -- desired number of real-world seconds from a desired in-game -- duration. -- * Use `make_age_stages_callback` if you want to have a function that -- fires throughout the plant's aging process. -- -- Details: -- -- The param1 value only counts up to 255, and with default settings -- giving one expected-tick every 45 seconds (for growth ticks), this -- means we can only count up to 11475 seconds, or 3 hours and 10 minutes -- of wall-clock time, corresponding to ~9.5 default in-game days (each 20 -- minutes of real time). If the provided seconds value (which is -- specified in real-world time) is less than or equal to the max we can -- count to in 8 bits, we just count the expected number of seconds each -- tick and trigger when that value is reached. Note that there is a good -- deal of variation possible, since not every growable ticks every time -- (see `botany.growth_chance`). Also note that the minimum number of -- seconds before a trigger is one growth tick, specified by -- `botany.growth_interval`. If you need precise timing, use a node timer -- instead (although that's expensive if many nodes are using them). -- -- If the desired number of seconds is larger than what will fit in 8 -- bits, we use an even less precise method where we don't count every -- tick but instead increment our counter probabilistically (see -- `increment_age` and `age_bin_size`). This allows us to "count" to -- arbitrarily high numbers, with variance increasing as the target number -- increases (often ±20% or more). This function uses flat age bins with 20 -- reserved for decline so we can catch the end of the ageing process; it -- assumes we just want to trigger our function when the target time is -- reached, and we don't care about doing anything along the way (see -- `make_age_stages_callback`). botany.grow_after = function(seconds, trigger, tick, maintain) local seconds_per_tick = botany.growth_interval * botany.growth_chance local ticks_to_count = seconds / seconds_per_tick local age_params = { max_age=ticks_to_count + 1, trigger_age=ticks_to_count, trigger_index=ticks_to_count } local trigger_at = ticks_to_count -- If we can't count things in 255 steps, count imprecisely if age_params.max_age > 255 then age_params.max_age = ticks_to_count * 1.2 -- extra 20% age_params.decline = 2 -- last 20% in 2 ticks, so 10% size each trigger_at = 254 -- trigger on entering second-to-last bin age_params.trigger_index = 254 end -- Build tick callback function local growth_callback = function(plan, pos, node, ...) local age_index = node.param1 -- If we've reached max age, we've already triggered our callback -- and want to return without doing any work. We do call a -- maintenance function here if there is one. if age_index == 255 then if maintain then maintain(plan, pos, node, ...) end return end -- Increment our age index local next_age_index = increment_age(age_index, age_params) if next_age_index == trigger_at then -- We reached max index - 1; call the trigger function if not trigger(plan, pos, node, ...) then -- Tick up to max index ourselves so this only triggers once -- even if the second-to-last bin is large or the number of -- ticks to count is < 255. swap_node( pos, {name=node.name, param1=255, param2=node.param2} ) end else -- If there's a tick function, call it and only age up if it -- says not to cancel if tick then if not tick( plan, age_index, next_age_index, pos, node, ... ) then if next_age_index ~= age_index then swap_node( pos, { name=node.name, param1=next_age_index, param2=node.param2 } ) end end else -- Otherwise always age up if next_age_index ~= age_index then swap_node( pos, { name=node.name, param1=next_age_index, param2=node.param2 } ) end end end end -- Return both the callback and the age parameters table return growth_callback, age_params end local grow_after = botany.grow_after --- A growth function that uses the 'age_function' and 'age_params' fields -- of the growth plan to keep track of plant age using the heartroot's -- 'param1' value and call the 'age_function' on every tick with the -- current age index and next age index as additional arguments after the -- plan and before the position. The arguments to the age_function will -- thus be: -- -- * plan: the growth plan table -- * age_index: the current age index (use `expected_age` and -- `growth_days` if you want to convert this to an in-game time value; -- use `age_category` to categorize it). -- * next_age_index: the next age index that we're going to update to after -- this update. This can be used to have something happen only once when -- moving between age buckets. -- * pos: The x/y/z vector table for the node's position. -- * node: The name/param1/param2 table for the node. -- * active_object_count, active_object_count_wider: These are numbers of -- active objects in this map block and in this plus the surrounding 27 -- map blocks. -- -- We increment the age *after* calling the age function, to ensure that -- it gets called with age 0 to start in case it wants to do setup stuff -- (but note that setup stuff is better done when setting up the node in -- the sprout function). This increment only happens if the 'age_function' -- returns nil or false; if it returns true (or some other truthy value), -- we skip the age increment. If the age function does something like -- change the age itself or swap out the node for a different one, it -- should return true, since we increment the age based on the age value -- that existed before the 'age_function' was called, and we also use the -- old node name and param2 values. botany.grow_as_it_ages = function(plan, pos, node, ...) local age_index = node.param1 local age_params = plan.age_params -- Figure out whether we're going to age up or not local next_age = increment_age(age_index, age_params) -- Call age function if not plan.age_function(plan, age_index, next_age, pos, node, ...) then -- Only if age function returned true do we age up if next_age ~= age_index then swap_node( pos, {name=node.name, param1=next_age, param2=node.param2} ) end end end local grow_as_it_ages = botany.grow_as_it_ages --- An aging function used to check for germination failure as a seed sits -- in the soil waiting to sprout. See `grow_after` (this will be a 'tick' -- function in that context). Uses the plan's 'germination_chance' slot to -- determine the chance for a seed node to sprout, uses the 'climate' slot -- to adjust for local climate, and uses the 'germination_age_params' slot -- to determine how many times its likely to be called before the plan -- sprouts (this is not exact). If 'germination_age_params' is missing (or -- the expected ticks in its 'trigger_age' field <= 1), we just check for -- failure on the first tick using the entire probability budget and then -- never check again. Otherwise we distribute the inverse germination -- probability (the chance of failure) over the number of expected ticks, -- putting most of the failure probability early (type-III survivorship -- curve, both realistic and convenient) and biasing towards success -- against the not-fully-known number of ticks before germination. -- -- @tparam table plan The growth plan table for this node. -- @tparam number age_index The current age index of the node. -- @tparam number next_age_index The next age index we'll move to by -- default. The probability of aging has already been checked. -- @tparam table pos The x/y/z position vector for the node being checked. -- @tparam table node The name/param1/param2 table for the node being -- checked. -- @treturn boolean Whether to cancel the normal age update or not. If -- true, we cancel the age update, otherwise we proceed. This function -- returns true only when it kills the seed. -- -- Details: The chance to instead die off and return to being an unseeded -- grass node (whatever the 'soil' slot of the plan is) is distributed -- over the germination period, with a bias towards the start of the -- period, and isn't 100% exact for long germination periods. Note that a -- "seed node" already represents having out-competed other local flora -- for this spot and averaging over many seeds in the environment, so this -- number should usually be fairly high; much higher than the chance of a -- single seed germinating. Think of it as: if the player plants a field -- of a crop, how many entire nodes should fail to come up? The -- probability derived from this chance is affected by the -- climate-specific weight derived from the 'climate' table if one is -- present (this weight is often > 1) However, it's not a simple -- multiplication, since that weight has already been factored into the -- probability of choosing this seed in this location, and the weights are -- often much greater than 1. Instead, we cap the weight value at 2, then -- compute base chance divided by the climate weight (dividing chances -- makes things more likely), and then average that with the unaltered -- chance, using a weight of 80% for the scaled probability and 20% for -- the base probability. So the final probability is between 20% and 180% -- of the base probability. Near the ideal heat & humidity for a plant, -- you'll usually have nearly double this base rate, and for some plants -- like grasses, you'll have an increased rate throughout a lot of their -- range. Near the edges of the tolerable climate for a plant this will -- fall towards 20% of the base value. botany.check_germination = function(plan, age_index, next_age_index, pos, node) local chance = plan.germination_chance or 1 local climate = plan.climate if climate then local bdata = get_biome_data(pos) local weight = min(2, climate_weight(1, climate, bdata)) chance = chance * 0.2 + (chance / weight) * 0.8 end -- Check for failure to germinate if check_fate( chance, plan.age_params or {}, age_index, next_age_index, 0.7, botany.GERMINATION_FAILURE_BIAS, true, -- use trigger values nil -- no chance at final age; irrelevant anyways as we'll sprout ) then -- Seed failed germination check; return to soil local p1 = effective_param(plan, 'soil_param1', 0, pos) local p2 = effective_param(plan, 'soil_param2', 0, pos) set_node(pos, {name=plan.soil, param1=p1, param2=p2}) notify_death(pos, node) -- trigger any death callback return true -- don't update age; we already replaced node else return false -- age up normally end end --- A growth function to apply to a seed-type heartroot node which will -- find a side to sprout on and then create a single sprout block on -- that side, transforming the root block into a seedling heartroot at -- that point. This step makes it so that if you cut down a seedling, -- it won't re-sprout. -- -- @tparam table plan The growth plan table for this seed type. We use -- the 'sides', 'grows_through', 'min_light', and 'max_light' -- fields to determine where to generate a sprout block. The -- unique 'sprout' field determines what node we place. We replace -- the heartroot with the 'matures_into' node when it sprouts. -- Both `sprout` and `matures_into` may be tables with -- name/param1/param2 values or just strings in which case params -- will be set to 0. The param1/2 values of these tables may be -- functions, in which case they'll be called with the plan and the -- position to get the param value to use. If the plan has 'param1' -- and/or 'param2' fields, these values override the sprout param -- values (but are not used for the mature root). The 'sprout_any' -- field can be set to `true` to always pick a random sprout side -- when multiple are available; by default sprouting upwards is -- always preferred. -- @tparam table pos The x/y/z position of the seed node to sprout. -- @tparam table node The node table (w/ name, param1, and param2) of -- the heartroot node being sprouted. botany.sprout_seed = function(plan, pos, node) -- Because we're still a seed block, we assume there's no existing -- plant structure to contribute to. -- find neighbors we can sprout into local can_grow = plan_growth_filter(plan) local candidates = neighbors(pos, plan.sides or TOP, can_grow) -- If there are no candidates, either die or try again later if #candidates == 0 then -- TODO: DEBUG -- local bdata = get_biome_data(pos) -- local bname = get_biome_name(bdata.biome) -- core.chat_send_all("Biome '" .. bname .. "' w/ heat " .. bdata.heat .. " and humidity " .. bdata.humidity) -- If there's no place to grow right now (possibly a light issue; -- possibly somehow placed in an unsuitable climate) then we have -- a chance to die and return to a soil block. If we don't die, we -- will remain at the current age and probably trigger again the -- next tick (unless age buckets are in play). if random() < botany.SPROUT_FAILURE_DEATH then local p1 = effective_param(plan, 'soil_param1', 0, pos) local p2 = effective_param(plan, 'soil_param2', 0, pos) set_node(pos, {name=plan.soil, param1=p1, param2=p2}) notify_death(pos, node) -- trigger any death callback end -- Either way prevent age update: Either we modified the block -- already or we want to stay at this age to try to trigger again return true end -- If we only matched one location (typical if we only had TOP -- allowed) use that candidate. If we have multiple candidates, we -- still prefer the TOP candidate unless sprout_any is set: -- Neighbor above will always be first candidate if it matched so -- if first candidate is above, use it. local chosen local first = candidates[1] if ( #candidates == 1 or ( not plan.sprout_any and first.x == pos.x and first.y == pos.y + 1 and first.z == pos.z ) ) then chosen = first else -- Pick a random candidate chosen = candidates[1 + floor(random() * #candidates)] end -- Replace chosen candidate with sprout node and ourselves with -- mature_into node. local place_sprout = plan.sprout assert( place_sprout ~= nil, ( "Nil 'sprout' value in sprout_seed growth plan's plan" .. " table for heartroot '" .. plan.heartroot .. "'." ) ) local matures = plan.matures_into assert( matures ~= nil, ( "Nil 'matures_into' value in sprout_seed growth plan's plan" .. "table for heartroot '" .. plan.heartroot .. "'." ) ) -- Convert to full table for placement if type(place_sprout) == "string" then place_sprout = {name=place_sprout} end local p1 = effective_param(place_sprout, 'param1', plan.param1 or 0, chosen) local p2 = effective_param(place_sprout, 'param2', plan.param2 or 0, chosen) place_sprout.param1 = p1 place_sprout.param2 = p2 set_node(chosen, place_sprout) -- Convert to full table for placement if type(matures) == "string" then matures = {name=matures, param1=node.param1, param2=node.param1} end -- Get param values -- Note that plan.param1/param2 are NOT used as backups here matures.param1 = effective_param(matures, 'param1', 0, pos) matures.param2 = effective_param(matures, 'param2', 0, pos) set_node(pos, matures) return true -- stifle age update; we already swapped seed node for roots end local sprout_seed = botany.sprout_seed --- A grow_function for herbs that grow upwards and which have -- flowers/tufts (possibly at the top of one or more blocks of stem). -- This function tracks age through an initial sprouting period and then -- death after a final decline at max age, with optional alternate aged -- stem/flower nodes. If seasons are activated it will use per-phase nodes -- according to the season (see `botany.PLANT_PHASES`); otherwise these -- occur on a fixed cycle throughout the plant's lifespan when they're -- available. When cut down, regrowth via an (accelerated) sprout is an -- option. Simple herbs only grow straight upwards and only generate -- additional roots straight downwards (and only if 'root_depth' is > 1). -- Simple herbs do not mature into another heartroot type. If a stem block -- is used with 'max_height' greater than 1, it should be a node which is -- set up to auto-destroy stem/flower nodes above it (but not necessarily -- below it). Depending on the 'reflower_chance' setting, cutting off the -- flowering tip of an herb (or one of its above-ground stem blocks) may -- permanently limit its height, or it may re-sprout a new flower block -- (re-sprouting from the roots is controlled by 'resprout_chance'). -- -- @tparam table plan The growth plan table. We use the following -- entries: -- * 'sprout' to identify our sprout node (a node name). -- * 'tip' is the node name of the node the sprout matures into, and -- will always be used as the top node if we grow higher. It can -- also be a sub-table mapping `botany.PLANT_PHASES` names to node -- names for each phase. Phases not listed will default to the -- value given for the 'bare' phase, which must be included. -- * 'stem' (optional if max_height is 1 or omitted) is the node name -- of the stem node we use below the tip node when growing to -- height 2 or more. At heights above 2, multiple stem nodes are -- used with a flower node at the top. As with 'tip', it can be a -- mapping from phases to per-phase nodes including at least -- 'bare'. -- * 'dirt' as the dirt node to convert into if/when we die. -- * 'max_height' (optional) to allow growth above height 1 -- (default is 1 for just-flowers). 'max_height' may also be a -- function, in which case it will get the x/y/z/ position vector -- for the node above the root and whatever number it returns will -- be used as the max height for that specific plant. This is -- somewhat expensive though as the function needs to be called -- every time the plant tries to grow. Consider instead registering -- multiple variants with different fixed max heights and possibly -- climate weights. -- * 'root_depth' (optional) to allow root growth below the heartroot -- (default is 1 for just-heartroot). -- * 'roots_required' (optional) By default when both root_depth and -- max_height are greater than 1, growth aboveground is limited by -- root depth below ground as a proportion of the maxima. So for -- example, if 'root_depth' is 3 and 'max_height' is 5, when no -- roots have grown the effective max_height will be 1/3 of 5 = 1. -- When 1 root has grown, the plant can then grow up to 2/3 of -- max_height (3) and only when all 2 roots have grown (plus -- heartroot = 3 depth) can the plant reach the full height of 5. -- Set this to false to disable this limitation. Note that shallow -- soils will limit root depth. -- * 'buried_root' As the root node for roots below the top (only when -- 'root_depth' > 1). -- * 'roots_through As the soil node or "group:" string to -- grow roots down through (if 'root_depth' > 1). -- * 'resprout_chance' (optional) to allow re-sprouting from the roots -- if cut down (otherwise the roots will die when the plant is cut -- down. This should be a number representing the chance of -- successfully resprouting (where failure means the heartroot -- dies; chance is inverse of probability so 2 -> 1/2 probability). -- If omitted, the plant will always die when cut back to the -- roots. -- * 'cut_stem' (optional) a node to use at the top of the stem once -- the flower node has been cut off. If omitted, a normal stem node -- is used. This doesn't affect growth properties. -- * 'reflower_chance' (optional) to allow re-growing the flower node -- at the stem tip if it's cut off leaving at least one stem block. -- Only relevant when max_height > 1. If a 'cut_stem' node is -- provided that's different from the 'stem' node, this reflower -- chance applies only once. If there is no 'cut_stem' node, then -- the reflower chance is checked every growth tick and so flowers -- will almost certainly eventually resprout (unless this is -- omitted). -- * 'sprout_days' -- @tparam number age_index The age index for the plant at this point. -- @tparam number next_age_index The age index that the plant will update -- to (unless we return true). -- @tparam table pos The x/y/z position of the seed node to sprout. -- @tparam table node The node table (w/ name, param1, and param2) of -- the heartroot node being sprouted. -- @treturn boolean Should we cancel the normal age index update? True = -- cancel, false = proceed. botany.grow_simple_herb = function(plan, age_index, next_age_index, pos, node) -- Get info on the node directly above the heartroot node local above = offset(pos, 0, 1, 0) local base_node = get_node(above) local base_name = base_node.name -- First, if the heartroot is still in its infancy, we don't grow at -- all until sprouting our first aboveground tip when we are old -- enough. local age_params = plan.age_params local expected_ticks_age = expected_age(age_index, age_params) local expected_game_age = growth_days(expected_age(age_index, age_params)) -- If the root node is still young enough to remain in sprout form, we -- just age up the sprout node; no other growth happens local sprout_days = age_params.sprout_days local is_mature = expected_game_age >= sprout_days -- Default root fraction and depth local root_depth = 1 -- counts heartroot local max_root_depth = plan.root_depth local root_fraction = root_depth / max_root_depth -- Tracks whether we already grew something local grew_already = false -- If an adult, check for roots below and grow more downwards as -- appropriate. if is_mature then local root_growth_days = age_params.root_growth_days local below = offset(pos, 0, -1, 0) local roots_tip_node = nil local node_below = get_node(below) local name_below = node_below.name local roots_name = plan.buried_root -- Scan downwards for root tip (don't scan beyond max depth) while name_below == roots_name and root_depth < max_root_depth do root_depth = root_depth + 1 roots_tip_node = node_below below = offset(below, 0, -1, 0) node_below = get_node(below) name_below = node_below.name end local roots_tip = offset(below, 0, 1, 0) -- Compute real root fraction root_fraction = root_depth / max_root_depth -- Grab cached root tip age params (or generate + cache them) local roots_age_params = age_params._roots_params if roots_age_params == nil then roots_age_params = { max_age=growth_ticks(real_seconds(root_growth_days)) } age_params._roots_params = roots_age_params end -- If we can grow farther down, let's do that (& leave other growth -- for another tick) if ( -- not at max depth root_depth < max_root_depth -- room to grow further down and matches_node_or_group(plan.roots_through, name_below) -- root tip is mature enough and ( -- EITHER root tip is heartroot and it's old enough ( root_depth == 1 and expected_game_age >= (sprout_days + root_growth_days) ) -- OR root tip is buried roots and they're fully aged or ( root_depth > 1 and ( age_category(roots_tip_node.param2, roots_age_params) == AGE_MAX ) ) ) ) then -- Generate new root tip below old tip swap_node( below, { name=roots_name, param1=0, -- 0 age index param2=effective_param(plan, 'param2', 0, below) } ) -- This uses up our growth for this tick, but we still need to -- check for predation, which happens last since it uses height -- info. grew_already = true elseif root_depth > 1 then -- If root tip is buried roots we need to age it up age_up_node( roots_age_params, roots_tip, roots_tip_node, false -- use param1 ) -- Aging our root tip doesn't cost growth chance, so we -- continue with the code below end -- Else no new roots continue checking for other kinds of growth end -- TODO: Account for aging, etc. -- -- Now we check the node above: -- -- 1. Sprout -> age up and eventually replace w/ buds -- 2. Dormant/buds/flowers/fruit/bare -> age up and swap to next -- category in cycle. Grow taller if old enough. -- 3. Dormant/budding/flowering/fruiting/bare stem -> look up to find -- tip, aging each as we go; then do as in case 2 -- * If there is no tip (ends in stem) then check reflower chance -- and either resprout tip node, persist as shaved, or die -- entirely. -- 4. Empty growable spot -> if resprouting allowed, replace with -- sprout, otherwise die and turn into dirt. -- 5. Anything else -> die & turn back into dirt. -- If the node above is a sprout, then regardless of heartroot age we -- age it up and when it matures, convert it into a proper tip node if base_name == plan.sprout then local next_age, age_changed = age_up_node( age_params, above, base_node, true, -- param1 is used for lighting so we store age in param2 -- TODO: Set sprout blocks to stretch upwards as they age! "_sprout_params", -- If we reach max age, replace sprout with buds function (pos, node, age) -- Grow sprout into buds local bud_node = plan_phase_node(plan, "tip", "bud") local p1 = effective_param(plan, 'param1', 0, above) local p2 = effective_param(plan, 'param2', 0, above) set_node(above, {name=bud_node, param1=p1, param2=p2}) end ) -- continue here to check for predation else -- Get growth filter & max height from the plan local can_grow = plan_growth_filter(plan) local max_height = plan.max_height or 1 if type(max_height) == "function" then max_height = max_height(above) end -- Collect dormant/budding/flowering/fruiting/bare tip node names local tip_nodes = get_or_cache_phase_nodes_table(plan, 'tip') -- Collect dormant/budding/flowering/fruiting/bare stem node names -- (only if max_height > 1) local stem_nodes = {} if max_height > 1 then stem_nodes = get_or_cache_phase_nodes_table(plan, 'stem') end -- Figure out which phase we're in based on either age or season local phase if botany.enable_seasons then -- TODO: SEASONS local season, season_days = seasons_clock.current_season() phase = select_phase( age_params.seasons[season], season_days ) else phase = select_phase( age_params.phases, expected_game_age, true ) end -- Figure out what to do based on block above -- TODO: Add aging logic here if tip_nodes[base_name] or stem_nodes[base_name] then -- Plant is growing here; grow taller if we can local tip = above local tip_height = 1 local tip_node = base_node local tip_name = base_name -- Scan upwards until we hit a non-stem or max out on -- height (might scan 0 times) while tip_height < max_height and tip_nodes[tip_name] do -- Age up node we're scanning past local phase = growth_phase(tip_name) local stem_aged, age_changed = age_up_node( age_params, tip, tip_node, true, -- param1 is used for light "_phase_parms" tip = offset(tip, 0, 1, 0) tip_height = tip_height + 1 tip_node = get_node(tip) tip_name = tip_node.name end -- If we found a tip and it's below the max height, and the -- cell above the tip is one we can grow into, grow by -- replacing tip with stem and block above w/ tip. local above_tip = offset(tip, 0, 1, 0) if ( tip_height < max_height and tip_nodes[tip_name] and can_grow(above_tip) ) then -- We use swap here to avoid callbacks swap_node( tip, { name=plan.stem, param1=tip_node.param1, param2=tip_node.param2 } ) set_node( above_tip, { name=plan.flower, -- TODO param1=tip_node.param1, param2=tip_node.param2 } -- copy params from old tip ) end elseif can_grow(above) then -- Open spot above we can grow into if plan.resprout then -- We can sprout again; set the open spot to a sprout local p1 = effective_param(plan, "param1", 0, above) local p2 = effective_param(plan, "param2", 0, above) set_node(above, {name=plan.sprout, param1=p1, param2=p2}) else -- We got cut down; can't resprout so we die local p1 = effective_param(plan, "soil_param1", 0, above) local p2 = effective_param(plan, "soil_param2", 0, above) set_node(pos, {name=plan.dirt, param1=p1, param2=p2}) notify_death(pos, node) -- trigger any death callback end else -- isn't growing and can't grow above, so we die local p1 = effective_param(plan, "soil_param1", 0, above) local p2 = effective_param(plan, "soil_param2", 0, above) set_node(pos, {name=plan.dirt, param1=p1, param2=p2}) notify_death(pos, node) -- trigger any death callback end end end local grow_simple_herb = botany.grow_simple_herb botany.grow_tree = function(pos, node) -- First, look for an existing sprouted block on relevant sides. If -- we find one, we'll contribute growth to that plant structure, -- otherwise we'll try to sprout -- If we did NOT find an existing sprouted block, try to sprout. -- TODO -- If we DID find an existing sprouted block, collect growth -- energy from roots and send it to build more plant blocks -- TODO -- First, search for all connected roots to figure out how much -- growth energy we have -- TODO -- Next, search out the shape of the existing growth and collect -- growth candidates -- TODO -- Pick one or more growth candidates -- TODO -- Replace growth candidates with new nodes -- TODO end local grow_tree = botany.grow_tree --=========================-- -- API: Basic Registration -- --=========================-- --- Registers a type of grass, including the base node that may -- get converted into this type of grass, required biome conditions, -- and relative weight. If multiple grass types are registered for -- conversion from the same base block, their weights will determine -- the probabilities with which each will result. -- -- All grass types should be registered before `botany.sprout` is -- called, since otherwise it will have to be called multiple times and -- may register multiple ABMs, which is less efficient. -- -- @tparam table A definition table for grass growth. This table will -- be incorporated into the `botany.grasses` registration table -- directly; you can retain a reference to it to change things -- later, but you shouldn't change the base node. Other fields may -- be added or changed by the internals of this mod. The fields -- allowed are: -- -- 'label' (required) - A label for this conversion. Must be unique, -- since a second registration using the same label will override -- the first. Should start with ":" to help identify which -- mods are adding which conversions. -- 'substrate' (required) - Which node will be converted into this -- type of grass. Must be a node name, NOT a group. -- 'grows_through' - If omitted we consider "air" to be what the grass -- grows in allowing grass to be placed when there's an air node -- adjacent. If given, this should be a node name or "group:" plus -- group name and that node/those nodes will need to be adjacent -- to the substrate for the conversion to happen. -- 'result' (required) - What type of grass will be used to replace the -- base node (a node name string). -- 'param1' - The param1 value for grass nodes once converted. Defaults -- to 0 if not provided. If it's a function, it will be called with -- the definition table and the x/y/z node position vector as -- arguments and the value it returns will be used for the param1 -- of the new node. -- 'param2' - The param2 value for grass nodes once converted. Defaults -- to 0 if not provided. If it's a function, it will be called with -- the definition table and the x/y/z node position vector as -- arguments and the value it returns will be used for the param2 -- of the new node. -- 'weight' - A relative number indicating how likely this conversion -- is when multiple conversions are possible. If omitted it will -- default to 1. You should NOT change this after registration. -- 'min_light' - The minimum light level required to grow (inclusive). -- The conversion will be allowed as long as there is *any* -- adjacent grows_through node on an appropriate side (see -- 'check_sides') with at least this much light, even if some -- adjacent grows_through blocks are darker. -- 'max_light' - The maximum light level allowed to grow (inclusive). -- Conversion is only allowed if there are NO adjacent -- grows_through nodes on relevant sides (see 'check_sides') with -- light levels above this level. -- 'check_sides' - If not omitted, should be one of the values in the -- `botany.SIDES` table, like `botany.TOP`. This controls which -- sides a grows_through node must appear on to enable conversion. -- When left out, the grows_through node must appear above, so it's -- the same as specifying `botany.TOP`. -- 'climate' - A table defining how climate-based weight is calculated. -- See `climate_weight`. -- 'overwrite' - Set this to true to suppress the warning about -- re-registration if you are intentionally re-registering with the -- same label. Note that not all properties of the original -- definition can be changed when re-registering. -- -- Fields added to this registration table by this mod will all have -- names that start with an underscore, so avoid using such names for -- your own custom fields. botany.register_grass = function (grass_definition) grass_definition._sprouted = false if ( botany.grasses[grass_definition.label] ~= nil and not grass_definition.overwrite ) then core.log( "warning", ( "Re-registration of grass conversion with label '" .. tostring(grass_definition.label) .. "'.\nSet 'overwrite' to true in the new definition to" .. " suppress this warning." ) ) end botany.grasses[grass_definition.label] = grass_definition end local register_grass = botany.register_grass --- Registers a growable plant. You'll specify the kind of soil it -- can grow in, biome restrictions, and a weight that gets compared -- with the weights of competing growables to determine the probability -- of each kind of seed taking root in an unoccupied block. -- -- All growables should be registered before `botany.sprout` is called, -- called, since otherwise it will have to be called multiple times and -- may register multiple ABMs, which is less efficient. -- -- @tparam table A definition table for seeding. This table will be -- incorporated into the `botany.growables` registration table -- directly; you can retain a reference to it to change things -- later, but you shouldn't change the soil node. Other fields may -- be added or changed by the internals of this mod. The fields -- allowed are: -- -- 'label' (required) - A label for this growable. Must be unique, -- since a second registration using the same label will override -- the first. Should start with ":" to help identify which -- mods are adding which conversions. -- 'soil' (required) - Which node can this growable take root in (a -- node name string). If you want to register a single plant that -- grows in multiple soils, re-register the same plant multiple -- times with different soil values. You may NOT use a group here. -- 'grows_through' - Which node needs to be adjacent to the soil for a -- seed to take root. If omitted, 'air' is used, but this might be -- water for aquatic plants, for example. A 'group:' + group name -- string can be used instead of a node ID. -- 'result' (required) - The heart root node that the substrate will -- be replaced with if this growable sprouts. You should call -- `register_growth_plan` with this heartroot to make sure that it -- can grow and sprout once placed. -- 'param1' - The param1 value for heartroot nodes once converted. -- Defaults to 0 if not provided. Heartroot nodes usually use param1 -- as an age value, so leaving this as 0 is usually correct. -- 'param2' - The param2 value for heartroot nodes once converted. -- Defaults to 0 if not provided. -- 'weight' - A relative number indicating how likely this growable is -- to take root when an empty space is being considered for -- sprouting. If omitted it will default to 1. You should NOT -- change this after registration. -- 'min_light' - The minimum light level required to grow (inclusive). -- If `check_sides` is set and air is not available above a block, -- roots may be established as long as there is *any* adjacent -- grows_through block on an allowed side with at least this much -- light, even if some adjacent grows_through blocks are darker. -- This does NOT affect growth after roots are established. -- 'max_light' - The maximum light level allowed to grow (inclusive). -- If `check_sides` is set and a grows_through node is not -- available above a block, roots may be established only if there -- are NO adjacent grows_through blocks with light levels greater -- than this. -- 'check_sides' - If not omitted, should be one of the values in the -- `botany.SIDES` table, like `botany.TOP`. This controls which -- sides a grows_through node must appear on to enable roots to be -- established. When left out, the grows_through node must appear -- above, so it's the same as specifying `botany.TOP`. -- 'climate' - A table defining how climate-based weight is calculated. -- See `climate_weight`. -- 'overwrite' - Set this to true to suppress the warning about -- re-registration if you are intentionally re-registering with the -- same label. Note that not all properties of the original -- definition can be changed when re-registering. -- -- Fields added to this registration table by this mod will all have -- names that start with an underscore, so avoid using such names for -- your own custom fields. botany.register_growable = function (growable_definition) growable_definition._sprouted = false if ( botany.growables[growable_definition.label] ~= nil and not growable_definition.overwrite ) then core.log( "warning", ( "Re-registration of growable conversion with label '" .. tostring(growable_definition.label) .. "'.\nSet 'overwrite' to true in the new definition to" .. " suppress this warning." ) ) end botany.growables[growable_definition.label] = growable_definition end local register_growable = botany.register_growable --- Registers a growth plan for a specific type of heartroot. You -- should call `register_growable` as well if you want this kind of -- plant to automatically take root in the world. -- -- Growth plans used for growable heartroots should be registered -- before `botany.sprout` is called, and if registered afterwards -- they won't be active until `botany.sprout` is called later (but you -- should only call `botany.sprout` once if possible). A single -- heartroot node type can only have one registered growth plan, and if -- a heartroot block does not have a registered growth plan, it won't -- grow. -- -- @tparam table A definition table for growth. This table will be -- incorporated into the `botany.growth_plans` registration -- table directly; you can retain a reference to it to change -- things later, but you shouldn't change the heartroot node. Other -- fields may be added or changed by the internals of this mod. The -- required fields are: -- -- 'heartroot' (required) - The heart root node that this plan will -- apply to. Replaces any old plan for this kind of heartroot if -- called again with the same 'heartroot' value. -- 'grow_function' (requried) - The function to be called for growth. -- Depending on which grow function is used, different sets of -- additional keys will be needed. Some keys common to multiple -- grow functions are described here, but see the documentation for -- specific grow functions (like `botany.sprout_seed`) for details -- about which keys they require/use. -- -- Extra optional fields used by some or all growth plans include: -- -- 'sides' - Used to determine which directions growth may happen in, -- and should be one of the `botany.SIDES` table values like -- `botany.TOP`. Exact meaning depends on the grow function. -- 'grows_through' - A node name or "group:" plus group name -- indicating what kind of node must be present for the plant to -- grow into. Exact use is up to the grow function. The default is -- 'air', but some plants might grow through water, for example. -- Some plants like a lily pad might actually grow through multiple -- kinds of nodes. -- 'param1' - A value or function used to set the param1 value of new -- nodes generated. If it's a function, it will get the growth plan -- table and the x/y/z node position of the newly-generated node as -- arguments, and should return a number to use as the param1 -- value. The usual default is 0 if this is not supplied (but each -- grow function can do whatever it wants). -- 'param2' - Same as 'param1' but for 'param2' values. Some growth -- plans set param2 values via other logic (e.g., to grass palette -- value). -- 'min_light' - The minimum light level required for growth. May be -- checked adjacent to the heartroot or at different parts of the -- plant, depending on the grow function. Only natural light -- counts, not artificial light. -- 'max_light' - The maximum light level allowed (inclusive). Again -- only natural light is considered, and the exact meaning is up to -- the grow function. -- 'mature_age' - A number that represents how many growth ticks must -- happen before the plant is considered mature. Different growth -- rules interpret this differently. Some convert to another -- heartroot type when this age is reached (see 'matures_into') while -- others change their growth pattern, etc. The maximum possible age -- is 255, since age is stored in param1 which is an 8-bit integer. -- 'matures_into' - The node name for a new node that will replace this -- heartroot node when it matures. Not all growth plans use this, -- but when it is used, a growth plan for this new heartroot node -- should be registered (unless it represents the death of the -- plant or something). Typically it can also be a name/param1/param2 -- table, with the param values allowed to be functions. Also -- typically, if it's a name or params are left off, the param values -- from the old node will be retained. -- 'age_function' - When the 'grow_function' is set to `grow_as_it_ages`, -- this age function will be called every tick, and the plant will age -- according to its 'age_params'. See `grow_as_it_ages` for details. -- 'age_params' - Set 'grow_function' to `grow_as_it_ages` and this table -- will be used with `increment_age` to track plant age and call the -- 'age_function'. -- 'overwrite' - Set this to true to suppress the warning about -- re-registration if you are intentionally re-registering with the -- same heartroot node. Note that not all properties of the -- original definition can be changed when re-registering. -- TODO -- -- Min/max heat and humidity values and/or 'allowed biomes' are also -- allowed and many growth functions will take them into account. -- -- Fields added to this registration table by this mod will all have -- names that start with an underscore, so avoid using such names for -- your own custom fields. botany.register_growth_plan = function (plan) assert( plan.heartroot ~= nil, "Growth plan has no 'heartroot' value." ) local old_plan = growth_plans[plan.heartroot] -- Set _sprouted to true if an older plan exists and was already -- sprouted. In that case, no need to add a new ABM to cover this -- heartroot type. plan._sprouted = old_plan ~= nil and old_plan._sprouted if growth_plans[plan.heartroot] ~= nil and not plan.overwrite then core.log( "warning", ( "Re-registration of heartroot growth plan for node '" .. tostring(plan.heartroot) .. "'.\nSet 'overwrite' to true in the new growth plan to" .. " suppress this warning." ) ) end growth_plans[plan.heartroot] = plan end local register_growth_plan = botany.register_growth_plan --- Registers a rare transformation rule for a specific node type -- (usually a heartroot). Rare rules happen much less often than -- other events, so but note that we use a single ABM to handle each -- event type, so unless you're defining a rule that applies to a new -- node type, defining something as a rare rule vs. another event type -- isn't necessarily much more efficient assuming the nodes it applies to -- are themselves rare and/or the processing cost of a single callback is -- low so that the searching process outweighs the processing cost. There -- can only be one rare rule for each node type; if you register a new -- one the old one will be replaced and a warning will be issued unless -- you set 'overwrite' to true. -- -- @tparam table plan The plan table for the rare rule. It uses the -- following keys: -- -- * 'target' - The node name that the rule will apply to. -- * 'action' - The function to call when the rule applies. It will -- be given six arguments: the plan table for its own rule, the -- x/y/z position vector at which the rule triggered, the -- name/param1/param2 table for the node that triggered the rule, -- the count of active objects in the map block where that node -- is, and the count of active objects in that block plus the -- surrounding 26 map block, and the climate weight value -- computed for the target node (see 'climate'). -- * 'climate' (optional) - A climate definition table like the ones -- for grasses or growables, with 'regions', 'custom', 'biomes', -- and/or 'ideal' sub-tables. See the `climate_weight` function -- documentation for details on what these mean. If present, the -- `climate_weight` function will be called with 1 as the base -- weight along with this table and biome data from the target -- node, and the result will be fed into the action function as -- the 5th argument. If there is not 'climate' sub-table, then -- the climate check is skipped and the 5th argument will be nil. -- * 'overwrite' - Set this to true to suppress the warning about -- re-registration if you are intentionally re-registering with -- the same target node. Note that not all properties of the -- original definition can be changed when re-registering. botany.register_rare_rule = function (plan) assert( plan.target ~= nil, "Rare rule has no 'target' value." ) assert( plan.action ~= nil, "Rare rule has no 'action' value." ) local old_plan = rares[plan.target] -- Set _sprouted to true if an older plan exists and was already -- sprouted. In that case, no need to add a new ABM to cover this -- target type. plan._sprouted = old_plan ~= nil and old_plan._sprouted if rares[plan.target] ~= nil and not plan.overwrite then core.log( "warning", ( "Re-registration of rare rule for node '" .. tostring(plan.target) .. "'.\nSet 'overwrite' to true in the new plan to" .. " suppress this warning." ) ) end rares[plan.target] = plan end local register_rare_rule = botany.register_rare_rule --- Registers a plant death callback. You'll specify the seed or heartroot -- node name that you want the callback to be triggered by, and when one of -- those nodes dies naturally (as opposed to being dug up or otherwise -- artificially replaced) the callback will be called. The callback will -- get the node's position and name/param1/param2 table as arguments. -- -- @tparam string name The node name that the callback applies to. -- Register the same callback multiple times if you want it to apply -- to multiple kinds of nodes. -- @tparam function callback The function to call when one of those nodes -- dies naturally. -- @tparam boolean overwrite (optional) Set this to true to suppress the -- warning issued when an old callback is overwritten. -- -- Note: Only one callback can be registered per node type, but you can -- define a new callback which calls the old one and set overwrite = true -- to combine them. The `botany.death_callbacks` table stores registered -- callbacks using node names as the keys. botany.register_death_callback = function (name, callback, overwrite) if ( death_callbacks[name] ~= nil and not overwrite ) then core.log( "warning", ( "Re-registration of death callback with for node '" .. tostring(name) .. "'.\nSet 'overwrite' to true to suppress this warning." ) ) end botany.death_callbacks[name] = callback end local register_death_callback = botany.register_death_callback --===========================-- -- API: Registration Helpers -- --===========================-- --- Registers a simple herb-type growable which turns grass into seeds, -- then sprouts seeds into a root ball + sprout, and then grows the -- sprout into flowers and, depending on the height limit, stems with -- flowers on top. Registers a growable for the seeds to be placed in -- grass, plus two growth plans: one for sprouting the seeds and -- another for growing once sprouted. -- -- @tparam table plan The growth plan for the herb. Core slots used are: -- * 'soil' (required) - Node name for the soil that can be converted -- into seeds (and which the seeds or roots will revert to if the -- plant dies). -- * 'grows_through' - What kind of nodes this plant can grow -- through (default "air"; can be a "group:" -- string). -- * 'tip' (required) - Node name for tip node. Sprout converts into -- tip, and tip grows upwards if allowed. This can also be a table -- with a 'bare' field plus any of 'dormant', 'bud', 'flower', -- 'fruit', and/or 'shedding', each with a node name as the value. If -- any key is not provided, it will default to the 'bare' value; -- specifying just 'bare' is the same as specifying a string instead -- of a table. -- * 'seed' - Node name for seeds node. If not provided, a new -- specific seed node will be registered using -- `register_seeds_and_roots` for the 'soil' block followed by -- `register_alternate_node` to make a version based on the -- tip node name. -- * 'root' - Node name for root node. If not provided, a new -- specific root node will be registered using -- `register_seeds_and_roots` for the 'soil' block followed by -- `register_alternate_node` based on the tip node name. -- * 'roots_through' - Node name (or "group:" string) -- specifying what stuff roots will grow down through. -- * 'buried_root' - Node name for buried roots. Only used when -- 'root_depth' is > 1. If root depth is > 1 and it's not provided, -- a new node will be registered by using `register_alternate_node` -- with the 'roots_through' node, although if 'roots_through' is a -- group you'll get an error. -- * 'buried_root_base' - When 'roots_through' is a group but you want -- to have the buried root node registered for you automatically, -- you can provide 'buried_root_base' as the single node name to use -- as the base node for an auto-generated buried roots node. -- * 'sprout' - Node name for sprout node, placed above seeds when -- they convert to a root. If not provided, the generic sprout -- node will be used. -- * 'stem' - Node name for stem node, placed between root and -- tip as the plant grows. Only required when 'max_height' is -- greater than 1. Unlike seed, root, and sprout blocks, this -- cannot be automatically generated. Like the 'tip', this can -- instead be a sub-table with 'bare' plus any of 'dormant', 'bud', -- 'flower', 'fruit', and/or 'shedding' keys. -- * 'max_height' - (optional) Max height to grow above root. Default -- (and minimum) is 1. -- * 'root_depth' - (optional) Max depth of roots (defaults to and will -- effectively be at least 1 for the heartroot block). -- * 'roots_required' - (optional) Height as a fraction of max height -- may not exceed root depth as a fraction of root depth limit -- unless this is set to false. -- * 'weight' - (optional) How likely this plant is to be placed as a -- seed relative to other plants that could inhabit the same node. -- Default is 1. Overridden by climate-specific weights if they're -- provided in the 'climate' slot. -- * 'germination_days' - Number of in-game days it takes for a seed of -- the plant to sprout and turn into a root + sprout. There's a bias -- towards germinating earlier than this, so that by this number of -- days since planting, a majority of seeds will have sprouted. May -- be fractional. -- * 'germination_chance' - Chance that a seed node will successfully -- sprout into a root node + sprout node. See `check_germination`. -- * 'age_params' - Aging parameters for the plant once it sprouts from -- a seed. This controls aging for the heartroot node, and includes -- sub-tables or special entries for other parts of the plant. These -- include: -- + 'max_days' is the number of days the plant can stay alive -- before dying of old age (approximately). Expect at minimum -- 10-20% variation unless the number is very small due to -- `age_bin_size` limitations. Note that 'max_age' can be -- specified directly instead, but it's usually easier to -- specify this in which case 'max_age' will be calculated from -- it based on the current conversion form in-game-days to -- real-world seconds and from real-world-seconds to -- in-game-growth-ticks. This value will only be used if -- 'max_age' is not present. -- + 'sprout_days' is the number of days it takes a sprout to mature -- into a budding tip node. Defaults to 2; can be a fraction. -- + 'root_growth_days' is the number of days it takes a root node -- to mature before the next root node will grow beneath it; -- also the number of days after growing up from a sprout before -- we are ready to generate our first extra root node. Only -- relevant when 'root_depth' is > 1. Defaults to 2; can be a -- fraction. -- + 'phases' is a table with `botany.PLANT_PHASES` values as keys -- and numbers as values. It indicates how many days each phase -- lasts for that plant (special phases like 'blighted' and -- 'dead' are ignored and may be omitted). If the total adds up -- to less than the max age of the plant, -- numbers should normally add up to 1; they'll be multiplied -- by ticks-per-phase to get tick lengths for each phase. These -- fractions should not be less than about 1/128 (and probably -- should be much larger for safety) to avoid age bin -- resolution issues from skipping over phases (see -- `age_bin_size`). Setting a phase value to 0 disables that -- phase and it will be skipped (but also, providing the same -- node for multiple phases in the 'tip' and 'stem' phase -- tables will mean that the corresponding part of the plant -- won't change between those phases. If you want a plant that -- only flowers at the tip, for example, you could set the -- 'flower' entry of the 'stem' phase table to the same value -- as the 'bare' entry (or leave it out entirely) while setting -- the 'flower' entry of the 'tip' table to a nice flower node; -- then the stems will retain their bare node identity during -- the flower phase while the tips change. -- + 'phase_cycles' is a number which indicates how many phase -- cycles the plant will go through over its lifetime (default -- is 1). When seasons are enabled (see -- `botany.enable_seasons`) phases are instead dictated by the -- season and the 'seasons' value will take precedence. -- + 'seasons' TODO -- * 'param1' and/or 'param2' - These set the parameter values for both -- the seeds/root nodes and the grown nodes. These can be functions -- in which case they'll be called with the plan and the node -- position as arguments. -- * 'soil_param1' and/or `soil_param2' - These set the parameter -- values for the soil block if we convert back into one when the -- plant dies. -- -- Additional optional slots used to limit where the herb can be -- seeded and/or grow: -- * 'min_light' - Light level required to settle seeds, sprout, and -- grow (inclusive). -- * 'max_light' - Light level above which seeds won't be placed, -- sprout, or grow (light at this level is okay). -- * 'seed_climate' - Alternate biome/climate weights for seed -- placement. Doesn't affect growth once seeds are placed (use -- 'climate' for that). Often fine to be more restrictive here than -- in the full climate table. See `climate_weight` for details. -- * 'climate' - A table defining how climate-based weight is -- calculated. See `climate_weight`. -- -- Any additional slots that affect growables or growth plans in -- general may be included. botany.register_simple_herb = function(plan) -- Assert required nodes assert( core.registered_nodes[plan.soil] ~= nil, ( "Simple herb has soil node '" .. tostring(plan.soil) .. "' but that node isn't registered yet." ) ) local tip_name local tip_info = plan.tip local bare_def if type(tip_info) == "string" then tip_name = strip_mod_prefix(tip_info) bare_def = core.registered_nodes[tip_name] assert( bare_def ~= nil, ( "Simple herb has tip node '" .. tostring(tip_info.bare) .. "' but that node isn't registered yet." ) ) else assert( type(tip_info) == "table", ( "Simple herb has tip info " .. tostring(tip_info) .. " but that's neither a string nor a table." ) ) local bare_tip = tip_info.bare bare_def = core.registered_nodes assert( bare_def ~= nil, ( "Simple herb has bare tip node '" .. tostring(tip_info.bare) .. "' but that node isn't registered yet." ) ) -- Fill out defaults; check registration tip_name = strip_mod_prefix(bare_tip) for _, k in ipairs({"dormant", "bud", "flower", "fruit", "shedding"}) do -- Add default if missing if tip_info[k] == nil then tip_info[k] = bare_tip end -- Node must be registered assert( core.registered_nodes[tip_info[k]] ~= nil, ( "Simple herb has " .. k .. " tip node '" .. tostring(tip_info[k]) .. "' but that node isn't registered yet." ) ) end end -- If max height is > 1; check stem info if plan.max_height ~= nil and plan.max_height > 1 then local stem_info = plan.stem if type(stem_info) == "string" then assert( core.registered_nodes[stem_info] ~= nil, ( "Simple herb has max height > 1 and stem node '" .. tostring(stem_info) .. "' but that node isn't registered yet." ) ) else assert( type(stem_info) == "table", ( "Simple herb with max height > 1 has stem info " .. tostring(stem_info) .. " but that's neither a string nor a table." ) ) local bare_stem = stem_info.bare assert( core.registered_nodes[bare_stem] ~= nil, ( "Simple herb has bare stem node '" .. tostring(stem_info.bare) .. "' but that node isn't registered yet." ) ) -- Fill out defaults; check registration for _, k in ipairs({"dormant", "bud", "flower", "fruit"}) do -- Add default if missing if stem_info[k] == nil then stem_info[k] = bare_stem end -- Node must be registered assert( core.registered_nodes[stem_info[k]] ~= nil, ( "Simple herb has " .. k .. " stem node '" .. tostring(stem_info[k]) .. "' but that node isn't registered yet." ) ) end end end -- Check for growth params local plan_age_params = plan.age_params assert(plan_age_params ~= nil, "Simple herb missing age params.") -- Create three definition tables to fill out based on original plan local growable = {} local sprout_plan = {} local grow_plan = {} -- Shallow-copy the entire table in triplicate -- Note that age_params in particular are shared! Only the growth plan -- should edit them though... for key, value in pairs(plan) do growable[key] = value sprout_plan[key] = value grow_plan[key] = value end -- Get current mod name & tip name base local modname = core.get_current_modname() -- Fill in missing seed/root/sprout nodes if plan.seed == nil or core.registered_nodes[plan.seed] == nil then -- Warning if a non-existent seed node was specified if plan.seed ~= nil then core.log( "warning", ( "Simple herb has seed node '" .. tostring(plan.seed) .. "' but that node isn't registered yet." .. " A default seed node will be used instead." ) ) end -- Ensure generic seed node for this soil type local gen_seeds_name, gen_roots_name, _ = register_seeds_and_roots( plan.soil ) -- Derive same-appearance variant with unique name local seed_name = modname .. ":" .. tip_name .. "__seeds" register_alternate_node( gen_seeds_name, nil, seed_name, bare_def.description .. S(" (seeds)") ) plan.seed = seed_name sprout_plan.seed = seed_name grow_plan.seed = seed_name end if plan.root == nil or core.registered_nodes[plan.root] == nil then -- Warning if a non-existent root node was specified if plan.root ~= nil then core.log( "warning", ( "Simple herb has root node '" .. tostring(plan.root) .. "' but that node isn't registered yet." .. " A default root node will be used instead." ) ) end -- Ensure generic root node for this soil type local gen_seeds_name, gen_roots_name, _ = register_seeds_and_roots( plan.soil ) -- Derive same-appearance variant with unique name local roots_name = modname .. ":" .. tip_name .. "__roots" register_alternate_node( gen_roots_name, nil, roots_name, bare_def.description .. S(" (roots)") ) plan.root = roots_name sprout_plan.root = roots_name grow_plan.root = roots_name end if ( plan.buried_root == nil or core.registered_nodes[plan.buried_root] == nil ) then -- Warning if a non-existent buried root node was specified if plan.buried_root ~= nil then core.log( "warning", ( "Simple herb has buried root node '" .. tostring(plan.buried_root) .. "' but that node isn't registered yet." .. " A default buried root node will be used instead." ) ) end assert( ( plan.roots_through.sub(1, 6) ~= "group:" or plan.buried_root_base ~= nil ), ( "Buried roots node not specified for plan with roots " .. tostring(plan.roots) .. " but roots_through is a" .. "group, and buried_root_base was not provided." ) ) local buried_roots_base = plan.buried_roots_base or plan.roots_through -- Ensure generic root node for this soil type local gen_buried_roots_name, _ = register_underground_roots( buried_roots_base ) -- Derive same-appearance variant with unique name local buried_roots_name = ( modname .. ":" .. tip_name .. "__buried_roots" ) register_alternate_node( gen_buried_roots_name, nil, buried_roots_name, bare_def.description .. S(" (buried roots)") ) plan.buried_root = buried_roots_name grow_plan.buried_root = buried_roots_name end if plan.sprout == nil or core.registered_nodes[plan.sprout] == nil then -- Warning if a non-existent sprout node was specified if plan.sprout ~= nil then core.log( "warning", ( "Simple herb has sprout node '" .. tostring(plan.sprout) .. "' but that node isn't registered yet." .. " The default sprout node will be used instead." ) ) end local sprout_name = register_sprout() plan.sprout = sprout_name sprout_plan.sprout = sprout_name grow_plan.sprout = sprout_name assert(sprout_plan.sprout ~= nil) end -- Adjust slots for growable definition growable.label = modname .. ":herb:" .. tip_name -- TODO: Dedup this? growable.result = plan.seed if growable.seed_climate then growable.climate = growble.seed_climate end -- Adjust slots for sprout plan sprout_plan.heartroot = plan.seed local germination_function, germination_age_params = grow_after( real_seconds(plan.germination_days or 1) * 0.8, -- bias towards earlier sprout_seed, -- triggered function check_germination -- called every tick until trigger ) sprout_plan.grow_function = germination_function -- Store germination age params on the plan for check_germination sprout_plan.germination_age_params = germination_age_params sprout_plan.matures_into = { name=plan.root, param1=0, -- reserved for light param2=0 -- start off at age index 0 } sprout_plan.dirt = plan.soil assert(sprout_plan.sprout ~= nil) -- Adjust slots for growth plan grow_plan.heartroot = plan.root grow_plan.grow_function = grow_as_it_ages grow_plan.age_function = grow_simple_herb grow_plan.dirt = plan.soil assert(grow_plan.sprout ~= nil) -- Further aging defaults & adjustments (user supplies age_params) if plan_age_params.max_age == nil then -- Calculate max age from max_days or 1 day default plan_age_params.max_age = growth_ticks( real_seconds(plan_age_params.max_days or 1) ) end -- Default to 2 sprout days if plan_age_params.sprout_days == nil then plan_age_params.sprout_days = 2 end -- Default to 2 root growth days if plan_age_params.root_growth_days == nil then plan_age_params.root_growth_days = 2 end -- Default phase fractions if plan_age_params.phases == nil then plan_age_params.phases = {} end for phase, fraction in pairs({ dormant=0.1, bud=0.1, flower=0.3, fruit=0.2, bare=0.25, shedding=0.05, -- blighted and dead aren't part of the normal cycle }) do if plan_age_params.phases[phase] == nil then plan_age_params.phases[phase] = fraction end end -- Default just one phase cycle if plan_age_params.phase_cycles == nil then plan_age_params.phase_cycles = 1 end -- Set up per-phase tick counts based on phase fractions and cycles local phase_ticks = {} plan_age_params._phase_ticks = phase_ticks local ticks_per_cycle = ( plan_age_params.max_age / plan_age_params.phase_cycles ) for phase, _ in pairs(PLANT_PHASES) do local phase_fraction = plan_age_params.phases[phase] if phase_fraction == nil then phase_ticks[phase] = 0 else phase_ticks[phase] = ticks_per_cycle * phase_fraction end end -- TODO: SEASONS -- Register everything register_growable(growable) register_growth_plan(sprout_plan) register_growth_plan(grow_plan) end local register_simple_herb = botany.register_simple_herb --===========-- -- Callbacks -- --===========-- --- Builds a node-conversion ABM callback function that converts nodes -- of a particular substrate next to a particular grows_through node -- into one of a random selection of possible results selected -- proportionally to their weights but first filtered by biome, -- humidity, heat, light, and side-on-which-grows_through-is-available -- constraints. -- -- @tparam conversions_by_substrate table A table where keys are node -- names for different substrates and values are tables holding all -- possible conversion rules that apply to that substrate. -- @tparam cache table A cache table holding arrays of pre-filtered -- conversions tables binned by cache keys. This cache will be -- updated and added to if the relevant cache key is missing. -- @tparam grows_through string The node name (or "group:" .. -- group name) string indicating what kind of node(s) are required -- as a neighbor for a conversion to take place. *All* of the -- conversion definitions in the `conversions_by_substrate` table -- and/or cache that will possibly activate on nodes for which -- this callback will be triggered must have the same -- grows_through value, which must match this value. botany.node_converter = function( conversions_by_substrate, cache, grows_through ) -- First create local can_grow function specific to our specified -- grows_through node name or group name local can_grow if grows_through.sub(1, 6) == "group:" then local group_name = grows_through.sub(7) can_grow = function(node_name) local group_rating = get_item_group(node_name, group_name) or 0 return group_rating ~= 0 end else can_grow = function(node_name) return node_name == grows_through end end -- Now define and return our function for growing grass return function( pos, node, active_object_count, active_object_count_wider ) -- Look up cached grass types for this -- substrate/humidity/heat/biome, or generate & save those -- values if necessary. local bdata = get_biome_data(pos) local bname = get_biome_name(bdata.biome) local sub_key = matrix_key(node.name, grows_through) local conversions = conversions_by_substrate[sub_key] local cache_key = climate_key(node.name, grows_through, bdata) local cache_bin = cache[cache_key] if cache_bin == nil then cache_bin = {} cache[cache_key] = cache_bin -- Fill in this new cache bin local bin_min_heat = ( CACHE_BIN_SIZE * floor(bdata.heat / CACHE_BIN_SIZE) ) local bin_max_heat = bin_min_heat + CACHE_BIN_SIZE local bin_min_humidity = ( CACHE_BIN_SIZE * floor(bdata.humidity / CACHE_BIN_SIZE) ) local bin_max_humidity = bin_min_humidity + CACHE_BIN_SIZE local bin_sum = 0 for label, grow_def in pairs(conversions) do local weight_here = grow_def.weight -- Heat/humidity constraints handled by climate def -- Adjust bdata to middle of bin bdata.heat = bin_min_heat + CACHE_BIN_SIZE/2 bdata.humidity = bin_min_humidity + CACHE_BIN_SIZE/2 if grow_def.climate then weight_here = climate_weight( grow_def.weight or 1, grow_def.climate, bdata ) end if weight_here > 0 then -- In this case, we know that the substrate, and -- climate constraints all fit this cache bin, so -- this definition belongs here and we can append -- to this cache bin cache_bin[#cache_bin + 1] = { def=grow_def, weight=weight_here } -- Add to bin sum bin_sum = bin_sum + weight_here end cache_bin.sum = bin_sum end end -- Next, filter cached grass definitions applicable to this -- substrate/biome/heat/humidity according to available -- grows_through neighbors and light levels in those -- neighbors. -- From the cached list appropriate for this -- biome/heat/humidity, figure out which definitions have -- their light & growth-medium-direction requirements -- satisfied and make an array of the indices of those that we -- need to drop from the selection process. local drop = filter_cache_bin(cache_bin, pos, can_grow) -- If nothing grows here due to constraints, return if #drop == #cache_bin then return end -- Now randomly select one of the possible conversions -- according to their weights local grow_here = select_weighted(cache_bin, drop).def -- TODO: DEBUG -- core.chat_send_all("NC sel (" .. cache_bin.sum .. "):") -- for i, item in ipairs(cache_bin) do -- local dropped = false -- for _, di in ipairs(drop) do -- if i == di then -- dropped = true -- break -- end -- end -- local prefix = " " -- if dropped then -- prefix = "X " -- end -- local def = item.def -- core.chat_send_all(prefix .. item.weight .. " " .. tostring(def.label)) -- end -- core.chat_send_all("NC regions (" .. bdata.heat .. ", " .. bdata.humidity .. "):") -- for name, parabolas in pairs(CLIMATE_PARABOLAS) do -- core.chat_send_all(" " .. name .. ": " .. parabolas_region_value(CLIMATE_PARABOLAS[name], bdata.heat, bdata.humidity)) -- if name == "temperate_grasses" then -- local parabs = CLIMATE_PARABOLAS[name] -- for i, p in ipairs(parabs) do -- local ox = p[1] -- local oy = p[2] -- local angle = p[3] -- local shape = p[4] -- local strength = p[5] -- local edge = p[6] -- local x = bdata.heat -- local y = bdata.humidity -- local tx, ty = coordinate_transform(ox, oy, angle, x, y) -- local d = distance_above_parabola(0, 0, shape, tx, ty) -- local s = strength * log10(1 + (d/edge * 9)) -- core.chat_send_all(" p" .. i .. ": " .. d .. " -> " .. s) -- end -- end -- end -- Replace substrate with the selected result local p1 = effective_param(grow_here, 'param1', 0, pos) local p2 = effective_param(grow_here, 'param2', 0, pos) set_node(pos, {name = grow_here.result, param1=p1, param2=p2}) end end local node_converter = botany.node_converter --- Looks up and calls the 'grow_function' from the growth plan for the -- targeted heartroot node. That function gets the plan table plus all -- the same arguments as this function. -- -- @tparam table pos The x/y/z position of the target node. -- @tparam table node The node table (w/ name, param1, and param2) of -- the target node. -- @tparam number active_object_count The count of active objects near -- the target node (in the same map block). -- @tparam number active_object_count_wider The count of active objects -- somewhat near the target node (in the same chunk or in one of the -- 26 orthogonally- or diagonally-neighboring chunks). botany.grow_plant = function( pos, node, active_object_count, active_object_count_wider ) local plan = growth_plans[node.name] assert( plan ~= nil, ( "Growth triggered on heartroot node '" .. node.name .. "' at (" .. pos.x .. ", " .. pos.y .. ", " .. pos.z .. ") with no associated growth plan!" ) ) plan.grow_function( plan, pos, node, active_object_count, active_object_count_wider ) end local grow_plant = botany.grow_plant --- Looks up and calls the 'action' from the rare rule for the -- targeted node. That function gets the plan table plus all the same -- arguments as this function, plus a climate weight argument if the plan -- table has a 'climate' entry. -- -- @tparam table pos The x/y/z position of the target node. -- @tparam table node The node table (w/ name, param1, and param2) of -- the target node. -- @tparam number active_object_count The count of active objects near -- the target node (in the same map block). -- @tparam number active_object_count_wider The count of active objects -- somewhat near the target node (in the same chunk or in one of the -- 26 orthogonally- or diagonally-neighboring chunks). botany.trigger_rare_rule = function( pos, node, active_object_count, active_object_count_wider ) local plan = rares[node.name] assert( plan ~= nil, ( "Rare rule triggered on node '" .. node.name .. "' at (" .. pos.x .. ", " .. pos.y .. ", " .. pos.z .. ") with no associated plan!" ) ) local weight = nil if plan.climate then local bdata = get_biome_data(pos) weight = climate_weight(1, plan.climate, bdata) end plan.action( plan, pos, node, active_object_count, active_object_count_wider, weight ) end local trigger_rare_rule = botany.trigger_rare_rule --===================-- -- Node Registration -- --===================-- --- Registers an alternate version of an existing node. -- -- @tparam string orig_name The name of the node you're copying. -- @tparam table orig_def The definition table for the node you're -- copying. If `nil` we'll look it up in `core.registered_nodes`. -- @tparam string new_name The new node name to use. Before calling -- this ensure that this name isn't already in use. Will be used -- with `core.register_node` so it must start with ":" and -- only use allowed characters (letters, numbers, and underscores; -- no colons) after that. -- @tparam string description The node description (shows in inventory -- tooltip; use a translated string). -- @tparam table overlay An array of texture names of textures to -- overlay on each face of the original block, up to 6 elements. -- Faces beyond the end of this array will use the last entry, so -- it can be shortened. Use an empty string so skip overlay on a -- particular face. So for example, { "", "botany_seeds.png" } will -- overlay "botany_seeds.png" on all faces except the top. If -- omitted no texture changes are made. -- @tparam string mode The overlay mode which controls how the overlay -- texture(s) is/are applied: -- * "impose" will superimpose the overlay textures. This is the -- default if 'nil' is given. -- * "overlay" will overlay textures using the "[overlay:" texture -- operation. -- * "mask" will use textures as a mask applied to the original -- using the "[mask:" texture operation (binary AND). -- @tparam table groups Additional groups to add to the new node, a -- table with group names as keys and group strengths (integers) as -- values. May be omitted if no new groups are needed. botany.register_alternate_node = function( orig_name, orig_def, new_name, description, overlays, mode, groups ) -- Look up definition if not provided if orig_def == nil then orig_def = core.registered_nodes[orig_name] end assert( orig_def ~= nil, ( "Registering alternate for node '" .. orig_name .. "' but that node hasn't been registered yet." ) ) -- Make a clean copy of the old definition -- Note: Is this clean enough? Are there bookkeeping fields we -- need to remove? local new_def = {} for key, value in pairs(orig_def) do new_def[key] = value end -- Update description new_def.description = description new_def.short_description = short_description -- if original node didn't have a custom drop, we drop -- the original node when mined. if new_def.drop == nil then new_def.drop = orig_name end -- TODO: Define alternate drop w/ hoe? -- Apply new groups if groups ~= nil then for group, strength in pairs(groups) do new_def.groups[group] = strength end end -- Override textures if overlays ~= nil then local orig_tiles = new_def.tiles new_def.tiles = {} local side = 1 -- Variables to store last orig/overlay tile def/texture local last_orig = orig_tiles[1] local last_overlay if #overlays == 0 then last_overlay = "" else last_overlay = overlays[1] end local orig_len = #orig_tiles local over_len = #overlays local stop = min(orig_len, over_len) local orig_texture, over local op = "^" -- default and/or "impose" if mode == "overlay" then op = "^[overlay:" elseif mode == "mask" then op = "^[mask:" end -- Apply overlays to each side while side <= stop do if side <= orig_len then orig_texture = orig_tiles[side] else orig_texture = last_orig end if side <= over_len then over = overlays[side] else over = last_overlay end if over ~= "" then over = op .. over end local new_texture if type(orig_texture) == "table" then new_texture = {} for key, value in pairs(orig_texture) do new_texture[key] = value end new_texture.name = new_texture.name .. over else new_texture = orig_texture .. over end new_def.tiles[side] = new_texture side = side + 1 -- TODO... end end -- Now register our modified definition core.register_node(new_name, new_def) end register_alternate_node = botany.register_alternate_node --- Registers a few generic nodes that can be used by themselves or -- used to derive plant-specific clones. Because of registration -- restrictions, they get prefixed with the name of the mod that's -- loading when this is called (and so if multiple mods call this, each -- will set up its own set of generic blocks). We set up: -- -- * ":__seeds" A node based on a grass block that -- overlays a generic seeds texture on the non-top faces. -- * ":__roots" A node based on a grass block that -- overlays a generic thin roots texture on the non-top faces. -- * ":__thick_roots" A node based on a grass block -- that overlays a generic thick roots texture on the non-top -- faces. -- -- This function does nothing if the names it is supposed to use are -- already registered. -- -- @tparam string base The node name for the base block to use as the -- basis for seed & root blocks. -- -- @treturn multiple Multiple strings: the node names for the seed, -- root, and thick root nodes that this function registers botany.register_seeds_and_roots = function (base) -- Build new node names local modname = core.get_current_modname() local grass_suffix = strip_mod_prefix(base) local seeds_name = modname .. ":" .. base_suffix .. "__seeds" local roots_name = modname .. ":" .. base_suffix .. "__roots" local thick_roots_name = modname .. ":" .. base_suffix .. "__thick_roots" local base_definition = core.registered_nodes[base] assert( base_definition ~= nil, "Grass node '" .. base .. "' has not been registered yet." ) -- Register seeds node if it's not already registered if core.registered_nodes[seeds_name] == nil then register_alternate_node( base, base_definition, seeds_name, S("Seeds"), {"", "botany_seeds.png"}, "impose", {plant=1,seeds=1} ) end -- Register roots node if it's not already registered if core.registered_nodes[roots_name] == nil then register_alternate_node( base, base_definition, roots_name, S("Roots"), {"", "botany_roots.png"}, "impose", {plant=1,roots=1} ) end -- Register thick roots node if it's not already registered if core.registered_nodes[thick_roots_name] == nil then register_alternate_node( base, base_definition, thick_roots_name, S("Thick Roots"), {"", "botany_thick_roots.png"}, "impose", {plant=1,roots=2} ) end return seeds_name, roots_name, thick_roots_name end register_seeds_and_roots = botany.register_seeds_and_roots --- Registers a few generic nodes that can be used by themselves or -- used to derive plant-specific clones. We set up: -- -- * "::roots" A node based on a dirt block that -- overlays a generic thin roots texture on all faces. -- * "::thick_roots" A node based on a dirt block -- that overlays a generic thick roots texture on all faces. -- -- This function does nothing if the names it is supposed to use are -- already registered. It returns the roots and thick roots node names -- that it registered (or which were previously registered). -- -- @tparam string dirt The node name for the dirt block to use as the -- basis for root blocks. -- @treturn multiple The node name strings for the roots node and thick -- roots node that this function registered (or verified). botany.register_underground_roots = function (dirt) -- Build new node names local modname = core.get_current_modname() local dirt_suffix = strip_mod_prefix(dirt) local roots_name = modname .. ":" .. dirt_suffix .. "__roots" local thick_roots_name = modname .. ":" .. dirt_suffix .. "__thick_roots" local dirt_definition = core.registered_nodes[dirt] assert( dirt_definition ~= nil, "Dirt node '" .. dirt .. "' has not been registered yet." ) -- Register roots node if it's not already registered if core.registered_nodes[roots_name] == nil then register_alternate_node( dirt, dirt_definition, roots_name, S("Underground Roots"), {"", "botany_roots.png"}, "impose", {plant=1,roots=1} ) end -- Register thick roots node if it's not already registered if core.registered_nodes[thick_roots_name] == nil then register_alternate_node( dirt, dirt_definition, thick_roots_name, S("Thick Underground Roots"), {"", "botany_thick_roots.png"}, "impose", {plant=1,roots=2} ) end return roots_name, thick_roots_name end register_underground_roots = botany.register_underground_roots --- Registers a generic sprout block as ':sprout'. Does nothing -- if that name is already registered. Returns the sprout name, whether -- it was previously registered or is new. -- -- @treturn string The node name for the sprout node. -- TODO: node box for this node! botany.register_sprout = function () -- Figure out name local modname = core.get_current_modname() local sprout_name = modname .. ":sprout" -- Do nothing if sprout node is already registered if core.registered_nodes[sprout_name] ~= nil then return sprout_name end local pixel_size = 1/8 -- 1 pixel in 16x16 texture -> 1/8 of a block core.register_node(sprout_name, { description=S("Sprout"), short_description=S("Sprout"), groups={plant=1,dig_immediate=2,snappy=3}, drawtype="plantlike", paramtype2="meshoptions", place_param2=8, -- vary placement horizontally; normal 'x' shape tiles={"botany_sprout.png"}, paramtype="light", sunlight_propagates=true, walkable=false, buildable_to=true, floodable=true, selection_box = { type = "fixed", fixed = { -pixel_size, -0.5, -pixel_size, -- min x/y/z pixel_size, -0.5 + 3*pixel_size, pixel_size -- max x/y/z }, }, waving=1, -- TODO: Sounds... drop="", -- drops nothing -- TODO: Drop sprout item w/ shears? }) return sprout_name end register_sprout = botany.register_sprout --- Registers new seeds and heartroot nodes that are copies of the -- generic seeds and thick roots node for the given grass node (will -- be created & registered if not already). Returns two results: the -- node names of the seeds and heartroot nodes registered. -- -- @tparam string base_name The base name to use. "__seeds" and -- "__heartroot" will be appended to form the node names that this -- function registers. This base name must start with the current -- mod name followed by a colon. -- @tparam string base_desc The description to use. Should be a -- translatable string. -- @tparam string grass The node name of the grass block type to base -- the seeds and root blocks on. Generic seeds & roots for this -- grass block type will be registered if they aren't already. -- @treturn multiple Multiple strings: the derived seeds and heartroot -- node names. botany.derive_seeds_and_heartroot = function (base_name, base_desc, grass) -- Ensure seeds & roots are registered local gen_seeds_name, _, gen_roots_name = register_seeds_and_roots(grass) local seeds_name = base_name .. "__seeds" local heartroot_name = base_name .. "__heartroot" register_alternate_node( gen_seeds_name, nil, seeds_name, base_desc .. S("(seeds)") ) register_alternate_node( gen_roots_name, nil, heartroot_name, base_desc .. S("(heartroot)") ) return seeds_name, heartroot_name end derive_seeds_and_heartroot = botany.derive_seeds_and_heartroot --================-- -- Callback Setup -- --================-- --- Setup function that locks in registered grasses and growables and -- sets up active block modifiers to make things grow. If called -- multiple times, each time it collects only registered grasses and -- growables that have been added since the last time it was called, -- and creates new ABMs for handling conversions that aren't already -- covered. For this reason, it's most efficient to *only* call this -- once, after all registrations have happened. botany.sprout = function () -- Gather new grass types new_grasses = {} for label, grass_def in pairs(botany.grasses) do if not grass_def._sprouted then new_grasses[#new_grasses + 1] = grass_def grass_def._sprouted = true -- mark as sprouted now grass_def.weight = grass_def.weight or 1 -- fix weight end end -- Gather new growables new_growables = {} for label, grow_def in pairs(botany.growables) do if not grow_def._sprouted then new_growables[#new_growables + 1] = grow_def grow_def._sprouted = true -- mark as sprouted now grow_def.weight = grow_def.weight or 1 -- fix weight end end -- Gather new heartroot plan types new_heartroots = {} for heartroot, plan in pairs(growth_plans) do if not plan._sprouted then new_heartroots[#new_heartroots + 1] = heartroot plan._sprouted = true -- mark as sprouted now end end -- Gather new rare rules new_rares = {} for target, plan in pairs(rares) do if not plan._sprouted then new_rares[#new_rares + 1] = target plan._sprouted = true -- mark as sprouted now end end -- Map out grasses by initial substrate + grows_through new_substrates = {} for i, grass_def in ipairs(new_grasses) do local substrate = grass_def.substrate local grows_through = grass_def.grows_through or "air" local label = grass_def.label local sub_key = matrix_key(substrate, grows_through) -- TODO: Invalidate ALL per-climate cache entries for this -- substrate/grows through... join = by_substrate[sub_key] if join == nil then join = {} by_substrate[sub_key] = join local new_join = new_substrates[grows_through] if new_join == nil then new_join = {} new_substrates[grows_through] = new_join end new_join[substrate] = true end join[label] = grass_def end -- Turn substrate node names into arrays per grows_through for grows_through, substrates_map in pairs(new_substrates) do local new_substrates_array = {} new_substrates[grows_through]["_array"] = new_substrates_array for substrate, _ in pairs(substrates_map) do new_substrates_array[#new_substrates_array + 1] = substrate end end -- Map out growables by initial soil new_soils = {} for i, grow_def in ipairs(new_growables) do local soil = grow_def.soil local grows_through = grow_def.grows_through or "air" local label = grow_def.label local soil_key = matrix_key(soil, grows_through) -- TODO: Invalidate ALL per-climate cache entries for this -- substrate/grows through... join = by_soil[soil_key] if join == nil then join = {} by_soil[soil_key] = join new_join = new_soils[grows_through] if new_join == nil then new_join = {} new_soils[grows_through] = new_join end new_join[soil] = true end join[label] = grow_def end -- Turn soil node names into arrays per grows_through for grows_through, soils_map in pairs(new_soils) do local new_soils_array = {} new_soils[grows_through]["_array"] = new_soils_array for soil, _ in pairs(soils_map) do new_soils_array[#new_soils_array + 1] = soil end end -- Growth plans and rares are already mapped out by node -- These ABMs handle substrate -> grass conversion. for grows_through, substrates_map in pairs(new_substrates) do core.register_abm({ label = "Botany::SproutGrass(" .. grows_through .. ")", nodenames = substrates_map["_array"], neighbors = {grows_through}, interval = botany.grass_interval, chance = botany.grass_sprout_chance, action = node_converter(by_substrate, _grass_cache, grows_through), catch_up = true, }) end -- These ABMs handle grass -> heartroot conversion. for grows_through, soils_map in pairs(new_soils) do core.register_abm({ label = "Botany::SproutSeeds(" .. grows_through .. ")", nodenames = soils_map["_array"], neighbors = {grows_through}, interval = botany.seed_interval, chance = botany.seed_chance, action = node_converter(by_soil, _seed_cache, grows_through), catch_up = true, }) end -- This ABM handles growth from heartroots core.register_abm({ label = "Botany::GrowPlants", nodenames = new_heartroots, -- No neighbors constraint: hopefully buried heartroots are -- rare so it's cheaper this way... interval = botany.growth_interval, chance = botany.growth_chance, action = grow_plant, catch_up = true, }) -- This ABM handles rare rules core.register_abm({ label = "Botany::RareRules", nodenames = new_rares, -- No neighbors constraint interval = botany.rare_interval, chance = botany.rare_chance, action = trigger_rare_rule, catch_up = true, }) -- Register seedling, heartroot, root, thick branch, thin branch, -- leafless branch, dead branch/sticks, flowering leaves, fallen -- petals, thin leaves, and fallen leaves blocks based on existing -- wood + leaves blocks. -- TODO: What about herbs/shrubs, do they need all these? -- TODO: Only auto-define which of these are missing & needed? -- TODO end