Farming: Convert plants to use node timers
authorAuke Kok <sofar@foo-projects.org>
Tue, 23 Feb 2016 06:28:17 +0000 (22:28 -0800)
committerparamat <mat.gregory@virginmedia.com>
Wed, 25 May 2016 02:26:53 +0000 (03:26 +0100)
This PR requires @minetest/minetest#3677

Farming and plant growth has traditionally in minetest been
implemented using ABM's. These ABM's periodically tick and cause
plants to grow. The way these ABM's work has several side effects
that can be considered harmful.

Not to mention a comprehensive list of downsides here, but ABM's
are chance-dependent. That results in the chance that some nodes
potentially never get processed by the ABM action, and others get
processed always. One can easily find this effect by planting a large
field of crops, and seeing that some nodes are fully grown really
fast, and some just won't make it to fully grown status even after
hours or play time.

One could solve the problem by making the ABM's slower, and giving them
a 100% of action, but this would cause the entire field to grow a step
instantly at ABM intervals, and is both ugly, and a large number of
node updates that needs to be sent out to each client. Very un-ideal.

With NodeTimers though, each node will see a separate node timer event,
and they will likely not coalesce. This means that we can stop relying
on chance to distribute plant growth, and assign a single timer event
to grow the plant to the next phase.  Due to the timer implementation,
we won't ever miss a growth event, and we can re-scehdule them until
the plant has reached full size.

Previously, plants would attempt to grow every 9 seconds, with a
chance of 1/20. This means typically, a plant would need 9*20 seconds
to grow 1 phase, and since there are 8 steps, a typical plant growth
would require 9*20*8 ABM node events. (spread out over 9*8 ABM actual
underlying events per block, roughly).

because plants are likely not growing to full for a very long time
due to statistics working against it (5% of the crops take 20x longer
than the median to grow to full, we'd be seeing ABMs fire possibly
up to 9*20*8*20 with a 95% confidence interval (the actual math
is likely off, but the scale should be correct). That's incredibly
wasteful. We'd reach those conditions easily with 20 plant nodes.

Now, after we convert to NodeTimers, each plant node will see exactly
8 NodeTimer events, and no more. This scales lineairly per plant.

I've tuned the growth rate of crops to be mature in just under 3
whole days. That's about 1hr of game time. Previously, about half
the crops would grow to full in under 2 days, but many plants would
still not be mature by the end of day 3. This is more consistent.

An additional problem in the farming mod was that the final fully-grown
plant was also included in the ABM, causing infinite more ABM's even
after the entire field had grown to completion.

Now, we're left with the problem that none of the pre-existing plants
have actual node timers started on them, and we do not want a new ABM
to fix this issue, since that would be wasteful.  Fortunately, there
is now an LBM concept, and we can use it to assure that NodeTimers
on crop nodes are properly started, and only have to do the actual
conversion once per block, ever.

We want to provide a fairly similar growth rate after this conversion
and as such I've resorted to modelling some statistical data. For this
I created a virtual 32x32 crop field with 9 steps (8 transitions)
as is the default wheat crop. We then apply a step where 1 in 20
plants in the field grows a step (randomly chosen) and count the
number of steps needed to get to 25%, 50, 75% and 95% grown.

The resulting data looks as follows:

25% - ~120 steps * 9 sec / abm = 1080s
50% - ~152 steps               = 1368s
75% - ~194 steps               = 1746s
95% - ~255 steps               = 2295s

Next, we want to create a model where the chance that a crop grows
is 100% every node timer. Since there will only be 8 steps ever,
we want the slowest crops to grow in intervals of ~ 2300 / 8 seconds
and the fastest 1/4 of crops to grow 1080 / 8 seconds intervals.
We can roughly compare this to a normal distribution with a median
of 1400 with a stddev of ~350 (thick fingering this one here).

The rest is a bit of thick-fingering to get similar growth rates,
taking into account that ABM's fire regularly so if they're missed
it's fairly painless, but our timers are going to be 1-2 minutes
apart at minimum. I calculate the timer should be around 150s
median, and experimented with several jitter ranges.

Eventually I settled for now on [80,200] with a redo of [40,80],
meaning that each growth step at minimum takes (80 to 200) seconds,
and if a negative growth condition was found (darkness, soil not
wet, etc), then the growth step is retried every (40 to 80) seconds.

The end result is a growth period from seed to full in ~ 2.25
minetest days. This is a little bit shorter than the current
growth rate but the chances you'll miss timer ticks is a bit
larger, so in normal gameplay it should be fairly comparable.

A side effect is that fields grow to full yield fairly quickly
after crops make it to mature growth, and no crops are mature
a very long time before the majority grows to full. The spread
and view over a growing field is also fairly even, there's no
large updates with plenty of nodes. Just a node here or there
every second or so in large fields.

Ultimately, we get rid of ABM rollercoasters that cause tens of
node updates every 9 seconds. This will help multiplayer servers
likely a lot.

mods/farming/api.lua

index 68f7be752c32f7211774a731e4c2fbf7dee86832..e25d5b916125339c9e50cce91452047c8ab96551 100644 (file)
@@ -1,3 +1,4 @@
+
 -- Wear out hoes, place soil
 -- TODO Ignore group:flower
 farming.hoe_on_use = function(itemstack, user, pointed_thing, uses)
@@ -9,11 +10,11 @@ farming.hoe_on_use = function(itemstack, user, pointed_thing, uses)
        if pt.type ~= "node" then
                return
        end
-       
+
        local under = minetest.get_node(pt.under)
        local p = {x=pt.under.x, y=pt.under.y+1, z=pt.under.z}
        local above = minetest.get_node(p)
-       
+
        -- return if any of the nodes is not registered
        if not minetest.registered_nodes[under.name] then
                return
@@ -21,23 +22,23 @@ farming.hoe_on_use = function(itemstack, user, pointed_thing, uses)
        if not minetest.registered_nodes[above.name] then
                return
        end
-       
+
        -- check if the node above the pointed thing is air
        if above.name ~= "air" then
                return
        end
-       
+
        -- check if pointing at soil
        if minetest.get_item_group(under.name, "soil") ~= 1 then
                return
        end
-       
+
        -- check if (wet) soil defined
        local regN = minetest.registered_nodes
        if regN[under.name].soil == nil or regN[under.name].soil.wet == nil or regN[under.name].soil.dry == nil then
                return
        end
-       
+
        if minetest.is_protected(pt.under, user:get_player_name()) then
                minetest.record_protection_violation(pt.under, user:get_player_name())
                return
@@ -47,14 +48,13 @@ farming.hoe_on_use = function(itemstack, user, pointed_thing, uses)
                return
        end
 
-       
        -- turn the node into soil, wear out item and play sound
        minetest.set_node(pt.under, {name = regN[under.name].soil.dry})
        minetest.sound_play("default_dig_crumbly", {
                pos = pt.under,
                gain = 0.5,
        })
-       
+
        if not minetest.setting_getbool("creative_mode") then
                itemstack:add_wear(65535/(uses-1))
        end
@@ -119,6 +119,15 @@ farming.register_hoe = function(name, def)
        end
 end
 
+-- how often node timers for plants will tick, +/- some random value
+local function tick(pos)
+       minetest.get_node_timer(pos):start(math.random(166, 286))
+end
+-- how often a growth failure tick is retried (e.g. too dark)
+local function tick_again(pos)
+       minetest.get_node_timer(pos):start(math.random(40, 80))
+end
+
 -- Seed placement
 farming.place_seed = function(itemstack, placer, pointed_thing, plantname)
        local pt = pointed_thing
@@ -129,10 +138,10 @@ farming.place_seed = function(itemstack, placer, pointed_thing, plantname)
        if pt.type ~= "node" then
                return
        end
-       
+
        local under = minetest.get_node(pt.under)
        local above = minetest.get_node(pt.above)
-       
+
        if minetest.is_protected(pt.under, placer:get_player_name()) then
                minetest.record_protection_violation(pt.under, placer:get_player_name())
                return
@@ -142,7 +151,6 @@ farming.place_seed = function(itemstack, placer, pointed_thing, plantname)
                return
        end
 
-       
        -- return if any of the nodes is not registered
        if not minetest.registered_nodes[under.name] then
                return
@@ -150,30 +158,86 @@ farming.place_seed = function(itemstack, placer, pointed_thing, plantname)
        if not minetest.registered_nodes[above.name] then
                return
        end
-       
+
        -- check if pointing at the top of the node
        if pt.above.y ~= pt.under.y+1 then
                return
        end
-       
+
        -- check if you can replace the node above the pointed node
        if not minetest.registered_nodes[above.name].buildable_to then
                return
        end
-       
+
        -- check if pointing at soil
        if minetest.get_item_group(under.name, "soil") < 2 then
                return
        end
-       
+
        -- add the node and remove 1 item from the itemstack
        minetest.add_node(pt.above, {name = plantname, param2 = 1})
+       tick(pt.above)
        if not minetest.setting_getbool("creative_mode") then
                itemstack:take_item()
        end
        return itemstack
 end
 
+farming.grow_plant = function(pos, elapsed)
+       local node = minetest.get_node(pos)
+       local name = node.name
+       local def = minetest.registered_nodes[name]
+
+       if not def.next_plant then
+               -- disable timer for fully grown plant
+               return
+       end
+
+       -- grow seed
+       if minetest.get_item_group(node.name, "seed") and def.fertility then
+               local soil_node = minetest.get_node_or_nil({x = pos.x, y = pos.y - 1, z = pos.z})
+               if not soil_node then
+                       tick_again(pos)
+                       return
+               end
+               -- omitted is a check for light, we assume seeds can germinate in the dark.
+               for _, v in pairs(def.fertility) do
+                       if minetest.get_item_group(soil_node.name, v) ~= 0 then
+                               minetest.swap_node(pos, {name = def.next_plant})
+                               if minetest.registered_nodes[def.next_plant].next_plant then
+                                       tick(pos)
+                                       return
+                               end
+                       end
+               end
+
+               return
+       end
+
+       -- check if on wet soil
+       local below = minetest.get_node({x = pos.x, y = pos.y - 1, z = pos.z})
+       if minetest.get_item_group(below.name, "soil") < 3 then
+               tick_again(pos)
+               return
+       end
+
+       -- check light
+       local light = minetest.get_node_light(pos)
+       if not light or light < def.minlight or light > def.maxlight then
+               tick_again(pos)
+               return
+       end
+
+       -- grow
+       minetest.swap_node(pos, {name = def.next_plant})
+
+       -- new timer needed?
+       if minetest.registered_nodes[def.next_plant].next_plant then
+               tick(pos)
+       end
+       return
+end
+
 -- Register plants
 farming.register_plant = function(name, def)
        local mname = name:split(":")[1]
@@ -200,6 +264,7 @@ farming.register_plant = function(name, def)
        end
 
        -- Register seed
+       local lbm_nodes = {mname .. ":seed_" .. pname}
        local g = {seed = 1, snappy = 3, attached_node = 1}
        for k, v in pairs(def.fertility) do
                g[v] = 1
@@ -228,6 +293,10 @@ farming.register_plant = function(name, def)
                on_place = function(itemstack, placer, pointed_thing)
                        return farming.place_seed(itemstack, placer, pointed_thing, mname .. ":seed_" .. pname)
                end,
+               next_plant = mname .. ":" .. pname .. "_1",
+               on_timer = farming.grow_plant,
+               minlight = def.minlight,
+               maxlight = def.maxlight,
        })
 
        -- Register harvest
@@ -237,7 +306,7 @@ farming.register_plant = function(name, def)
        })
 
        -- Register growing steps
-       for i=1,def.steps do
+       for i = 1, def.steps do
                local drop = {
                        items = {
                                {items = {mname .. ":" .. pname}, rarity = 9 - i},
@@ -248,6 +317,16 @@ farming.register_plant = function(name, def)
                }
                local nodegroups = {snappy = 3, flammable = 2, plant = 1, not_in_creative_inventory = 1, attached_node = 1}
                nodegroups[pname] = i
+
+               local next_plant = nil
+               local on_timer = nil
+
+               if i < def.steps then
+                       next_plant = mname .. ":" .. pname .. "_" .. (i + 1)
+                       on_timer = farming.grow_plant
+                       lbm_nodes[#lbm_nodes + 1] = mname .. ":" .. pname .. "_" .. i
+               end
+
                minetest.register_node(mname .. ":" .. pname .. "_" .. i, {
                        drawtype = "plantlike",
                        waving = 1,
@@ -262,61 +341,20 @@ farming.register_plant = function(name, def)
                        },
                        groups = nodegroups,
                        sounds = default.node_sound_leaves_defaults(),
+                       next_plant = next_plant,
+                       on_timer = farming.grow_plant,
+                       minlight = def.minlight,
+                       maxlight = def.maxlight,
                })
        end
 
-       -- Growing ABM
-       minetest.register_abm({
-               nodenames = {"group:" .. pname, "group:seed"},
-               neighbors = {"group:soil"},
-               interval = 9,
-               chance = 20,
+       -- replacement LBM for pre-nodetimer plants
+       minetest.register_lbm({
+               name = "farming:start_nodetimer_" .. mname .. "_" .. pname,
+               nodenames = lbm_nodes,
                action = function(pos, node)
-                       local plant_height = minetest.get_item_group(node.name, pname)
-
-                       -- return if already full grown
-                       if plant_height == def.steps then
-                               return
-                       end
-
-                       local node_def = minetest.registered_items[node.name] or nil
-
-                       -- grow seed
-                       if minetest.get_item_group(node.name, "seed") and node_def.fertility then
-                               local can_grow = false
-                               local soil_node = minetest.get_node_or_nil({x = pos.x, y = pos.y - 1, z = pos.z})
-                               if not soil_node then
-                                       return
-                               end
-                               for _, v in pairs(node_def.fertility) do
-                                       if minetest.get_item_group(soil_node.name, v) ~= 0 then
-                                               can_grow = true
-                                       end
-                               end
-                               if can_grow then
-                                       minetest.set_node(pos, {name = node.name:gsub("seed_", "") .. "_1"})
-                               end
-                               return
-                       end
-
-                       -- check if on wet soil
-                       pos.y = pos.y - 1
-                       local n = minetest.get_node(pos)
-                       if minetest.get_item_group(n.name, "soil") < 3 then
-                               return
-                       end
-                       pos.y = pos.y + 1
-
-                       -- check light
-                       local ll = minetest.get_node_light(pos)
-
-                       if not ll or ll < def.minlight or ll > def.maxlight then
-                               return
-                       end
-
-                       -- grow
-                       minetest.set_node(pos, {name = mname .. ":" .. pname .. "_" .. plant_height + 1})
-               end
+                       tick_again(pos)
+               end,
        })
 
        -- Return