HandyHands - Automatic handcrafting

by Qon

Automatically start handcrafting an item that is quickbar filtered (or in logistics requests) that you don't have enough of whenever your crafting queue is empty. Prioritises items in your cursor and what you need the most. It's like having logistics deliveries for early game!

Utilities
3 years ago
0.13 - 1.1
9.21K

i [Implemented] Switch from tick based polling to crafting events

3 years ago
(updated 3 years ago)

Currently you're running an on_tick event that polls the players to see who has an empty crafting queue. That doesn't seem to be necessary, and does seem to be responsible for a lot of the "idle" cpu usage of the mod. HandyHands will only ever start crafting an item right after a player empties their crafting queue, right? You can detect that with the crafted and canceled events and then some filtering within those events. POC hacky implementation below.

3 years ago
(updated 3 years ago)
diff --git a/control.lua b/control.lua
index bee3952..cf358eb 100644
--- a/control.lua
+++ b/control.lua
@@ -21,7 +21,6 @@ local SHORTCUT_NAME = 'handyhands-toggle'
 local debugging = false

 local max_arr = {0, .2, .5, .8, 1, 2, 3, 4}
-local work_tick = 5


 local map                = require('lib.functional').map
@@ -42,7 +41,6 @@ script.on_event(defines.events.on_player_crafted_item, function(event)
     local d = global.players_data[event.player_index]
     if d.current_job == event.item_stack.name then
         d.current_job = nil
-        d.hh_request_tick = game.tick
         if p.crafting_queue and p.crafting_queue[1].count == 1 and p.mod_settings["autocraft-sound-enabled"].value then
             p.play_sound{path = 'handyhands-core-crafting_finished'--[[, volume_modifier = 1--]]}
         end
@@ -51,6 +49,11 @@ script.on_event(defines.events.on_player_crafted_item, function(event)
         p.play_sound{path = 'handyhands-core-crafting_finished'--[[, volume_modifier = 1--]]}
     end
     global.players_data[event.player_index] = d
+    do_it(event.player_index)
+end)
+
+script.on_event(defines.events.on_player_cancelled_crafting, function(event)
+    do_it(event.player_index)
 end)

 -- Init all new joining players.
@@ -62,44 +65,42 @@ script.on_event(defines.events.on_player_joined_game, function(event)
         d = init_player(event.player_index)
     end
     global.players_data[event.player_index] = d
+    do_it(event.player_index)
 end)

 -- The mod core logic
-script.on_event(defines.events.on_tick, function(event)
-    for player_index = game.tick % work_tick + 1, #game.players, work_tick do
-        local p = game.players[player_index]
-        if p.connected and p.controller_type == defines.controllers.character then
-            local d = global.players_data[player_index]
-            local canceled_autocraft = false
-            if d.current_job ~= nil and p.crafting_queue_size > 0 then
-                if d.current_job ~= p.crafting_queue[#p.crafting_queue].recipe or p.crafting_queue[#p.crafting_queue].count > 1 then
-                    d.current_job = nil
-                elseif p.crafting_queue[d.current_job] ~= nil and p.crafting_queue[d.current_job].recipe == nil then
-                    canceled_autocraft = true
-                    enabled(player_index, false)
-                    -- d.paused = true
-                end
+function do_it(player_index)
+    local p = game.players[player_index]
+    if p.connected and p.controller_type == defines.controllers.character then
+        local d = global.players_data[player_index]
+        local canceled_autocraft = false
+        if d.current_job ~= nil and p.crafting_queue_size > 0 then
+            if d.current_job ~= p.crafting_queue[#p.crafting_queue].recipe or p.crafting_queue[#p.crafting_queue].count > 1 then
+                d.current_job = nil
+            elseif p.crafting_queue[d.current_job] ~= nil and p.crafting_queue[d.current_job].recipe == nil then
+                canceled_autocraft = true
+                enabled(player_index, false)
+                -- d.paused = true
             end
-            if p.crafting_queue_size == 0 then
-                if d.current_job ~= nil then
-                    canceled_autocraft = true
-                    enabled(player_index, false)
-                    -- d.paused = true
-                end
-            -- end
-            -- if p.crafting_queue_size == 0 then
-                if not d.paused and (d.hh_request_tick + work_tick >= game.tick or game.tick % 200 == game.tick % 5) then
-                    d.hh_request_tick = d.hh_request_tick - work_tick
-                    hh_player(player_index)
-                end
+        end
+        if p.crafting_queue_size == 1 and p.crafting_queue[1].count == 1 then
+            if d.current_job ~= nil then
+                canceled_autocraft = true
+                enabled(player_index, false)
+                -- d.paused = true
             end
-            if canceled_autocraft then
-                p.print(mod_name..' is now paused until you hit increase or decrease key (Options > Controls > Mods).')
-                d.current_job = nil
+        -- end
+        -- if p.crafting_queue_size == 0 then
+            if not d.paused then
+                hh_player(player_index)
             end
         end
+        if canceled_autocraft then
+            p.print(mod_name..' is now paused until you hit increase or decrease key (Options > Controls > Mods).')
+            d.current_job = nil
+        end
     end
-end)
+end

 local stack_size_cache = {}
 function stack_size(item)
@@ -237,7 +238,7 @@ end
 function hh_player(player_index)
     local p = game.players[player_index]
     if p.connected and p.controller_type ~= defines.controllers.character then return nil end
-    if p.crafting_queue_size ~= 0 then return nil end
+    -- if p.crafting_queue_size ~= 0 then return nil end
     local d = global.players_data[player_index]
     if d.paused then return nil end
     -- local mi = p.get_main_inventory()
@@ -329,7 +330,6 @@ function change(event, positive)
     local p = game.players[event.player_index]
     -- init_player(event.player_index, false)
     local d = global.players_data[event.player_index]
-    d.hh_request_tick = game.tick
     -- global.players_data[event.player_index] = d
     if d.paused == true then
         enabled(event.player_index, true)
@@ -391,6 +391,7 @@ function change(event, positive)
         end
     end
     global.players_data[event.player_index] = d
+    do_it(event.player_index)
 end

 function printOrFly(p, text)
@@ -426,8 +427,6 @@ function init_player_settings(player_index, force)
     local wasnil = global.players_data[player_index] == nil
     if wasnil or force then
         global.players_data[player_index] = {}
-        global.players_data[player_index].hh_request_tick = 0
-        global.players_data[player_index].hh_last_exec_tick = 0
         global.players_data[player_index].personal_logistics_requests = {}
         global.players_data[player_index].settings = {}
         global.players_data[player_index].settings['Default'] = 0.2
3 years ago
(updated 3 years ago)

truncated 10 second profiling of the tick-based polling approach:

600x "anonymous" in "__HandyHands__/control.lua", line 69. Total Duration: 220.421998ms
    2470x C function "__index". Total Duration: 45.033231ms
    600x C function "__len". Total Duration: 5.065482ms
    10x "hh_player" in "__HandyHands__/control.lua", line 238. Total Duration: 129.344049ms
        110x C function "__index". Total Duration: 1.272833ms
        30x "for iterator" in "__HandyHands__/lib/functional.lua", line 83. Total Duration: 80.402465ms
3 years ago

truncated 10 second profiling of the event-based approach:

10x "anonymous" in "__HandyHands__/control.lua", line 36. Total Duration: 70.863887ms
    90x C function "__index". Total Duration: 1.073662ms
    10x C function "play_sound". Total Duration: 0.100559ms
    10x "do_it" in "__HandyHands__/control.lua", line 73. Total Duration: 68.738910ms
        60x C function "__index". Total Duration: 0.496840ms
        10x "hh_player" in "__HandyHands__/control.lua", line 239. Total Duration: 67.727200ms
            100x C function "__index". Total Duration: 0.596258ms
            30x "for iterator" in "__HandyHands__/lib/functional.lua", line 83. Total Duration: 39.882608ms
3 years ago

Both profiling runs above were with a 1-second item repeatedly crafting, hence the 10 calls in 10 seconds in the event example. I'm not sure why the time in hh_player is so different between the two.

With nothing crafting, the tick based version uses 60ms on my machine in 10s of profiling, while the event based version uses no time at all because no events are triggered.

3 years ago

HandyHands will only ever start crafting an item right after a player empties their crafting queue, right?

Or if there's nothing in the queue because all requested crafts are missing material and the player picked up some. There's an event for inventory updates also though so it could probably work. Thanks for testing.

3 years ago

I'm a bit confused by the addition of

do_it()

and removal of

if p.crafting_queue_size ~= 0 then return nil end

in hh_player().
The first one is mutual recursion, which doesn't really make sense here?
and the crafting queue check is there because there's never a reason to add auto crafts to a crafting queue that has something in it. This would just make the queue fill up with more and more things potentially.

I had to add do_it() calls in some other events to make it react and wake up when necessary. But it reacts instantly now instead of waiting until the player is chosen to be processed. So that's nice. Needs some more testing. Managed to get the longest error message in the history of Factorio and need to make sure it wakes of in all events possible.

3 years ago

do_it() is most of the old on_tick contents, and it needs a function because it's called from two (three if you're right about inventory changes) different places. it could probably be merged with hh_player().

The crafting queue size query change was annoying... during the on_player_crafted event, the queue still has one item in it. You can see above where I replaced that test with a test for just one item in the queue. Sorry for not ironing out all the details, I just wanted to show that it could work.

3 years ago

There were many more events to keep track of. I haven't really tested it all but I'm releasing some other changes too soon so I guess I'll release it all and fix any bugs if they occurr :S

You don't have to apologise. Thanks for your testing :)

3 years ago

For some events where I call do_it() I needed to call other functions which themselves could call do_it() because they did things which sometimes required the check again. When not checking things extremely carefully this lead to infinite mutual recursion.
Also one event that needed do_it() to be called is on_player_main_inventory_changed, in case they pick up new ingredients to craft with. But that can happen like every tick while scooping up things from belts.

So I solved both by registering players for a check in on_nth_tick, and this handler is unregistered when no players are registered for a new check and re-added as a handler whenever a player is registered for a check again (and of course conditionally added in on_load to avoid multiplayer desync). This makes infinite mutual recursion a bug that simply can't happen and rate-limits events from taking too much cpu time. The nth_tick handler will typically only run once and then be removed until an event requires it to run again so it isn't actually running every nth_tick, that's just the maximum rate that the mod will actually take actions.
I'll release it after a playtest.

3 years ago
(updated 3 years ago)

Event based update released :)
I've been really busy for a while and the update needed some testing before I could release it so it took a while. But here it it!

Version: 1.12.0
Date: 2020-10-03 23:00
Optimisations:
- Event based instead of tick based. Hopefully all relevant events triggers it to start auto crafting.
Thanks to sparr for suggesting and testing :>
https://mods.factorio.com/mod/HandyHands/discussion/5f4835edfc56aff0abec3b15
Bugfixes:
- On init the shortcut state is correctly toggled on now
Support:
- And thanks to imnotlaughin for suggesting I make a Patreon page (patreon.com/Qon) and for being my first patron!

3 years ago

I don't know if this is necessarily related, but i have a pymods game with a lot of technologies and a LOT of possible items unlocked, as well as like 10 bars full of items as well as a lot of logistics stuff set up.

What i'm noticing is that everytime HH is done crafting an item the game pauses for like, 200ms, a noticable frame skip, and then it starts the next item.

3 years ago

Sounds related. I have an idea for an optimisation that would help but it's fairly time consuming to implement and I don't have a lot of time atm :/

3 years ago

No hurry. :)

I also noticed that the skips appear to happen because of logistic request events, as i get frameskips when robots deliver me stuff even if handyhands isn't actively crafting at the time, and disabling HH causes the skips to disappear.

For the moment i just disable HH via the hotbar button when i know a lot of logistics things are gonna happen to me.

New response