From 88990a1f574c9683bb34cbf5528f69286083bbe8 Mon Sep 17 00:00:00 2001 From: lex Date: Fri, 10 Jul 2026 21:34:59 +0300 Subject: [PATCH] Make loader crafting GUI persistent to avoid multiplayer latency delay The loader frame anchored to the controller GUI was created in on_gui_opened and destroyed in on_gui_closed. Script events only run once the input action has made the round trip through the server, while the vanilla crafting menu is latency-hidden and opens instantly, so on slow connections the frame appeared seconds after the menu. The controller frame is now created once per player (on create, join, force change and research finished) and left in place; anchored relative frames show and hide with their target GUI, so it now appears instantly with the crafting menu. The per-entity assembling machine frame keeps the create/destroy cycle and gets its own element name so the two no longer collide. Also adds on_register/on_unregister hooks to the QOL feature framework so runtime toggling via set_loaders draws and removes the persistent frames, and adds tests for the loader GUI. Co-Authored-By: Claude Fable 5 --- features/redmew_qol.lua | 77 +++++++++++++---- features/redmew_qol_tests.lua | 156 ++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 features/redmew_qol_tests.lua diff --git a/features/redmew_qol.lua b/features/redmew_qol.lua index 25a2a7308..e5309adad 100644 --- a/features/redmew_qol.lua +++ b/features/redmew_qol.lua @@ -34,9 +34,14 @@ local function pick_name() end local loader_frame_name = Gui.uid_name() +local loader_machine_frame_name = Gui.uid_name() local loader_button_player = Gui.uid_name() local loader_button_machine = Gui.uid_name() +-- Exposed for tests +Public._loader_frame_name = loader_frame_name +Public._loader_machine_frame_name = loader_machine_frame_name + local loaders = { ['loader'] = true, ['fast-loader'] = true, @@ -92,10 +97,11 @@ local function any_loader_enabled(recipes) end local function draw_loader_frame(parent, entity) - local frame = parent[loader_frame_name] + local frame_name = entity and loader_machine_frame_name or loader_frame_name + local frame = parent[frame_name] local player = entity or safe_get_player(parent.player_index) - local recipes = player.force.recipes - if not player or not any_loader_enabled(recipes) then + local recipes = player and player.force.recipes + if not recipes or not any_loader_enabled(recipes) then if frame and frame.valid then Gui.destroy(frame) end @@ -107,7 +113,7 @@ local function draw_loader_frame(parent, entity) else frame = parent.add { type = 'frame', - name = loader_frame_name, + name = frame_name, anchor = { gui = defines.relative_gui_type[entity and 'assembling_machine_select_recipe_gui' or 'controller_gui'], position = defines.relative_gui_position.right @@ -321,6 +327,9 @@ local features = { [defines.events.on_research_finished] = 'on_research_finished', [defines.events.on_gui_opened] = 'on_gui_opened', [defines.events.on_gui_closed] = 'on_gui_closed', + [defines.events.on_player_created] = 'on_player_created', + [defines.events.on_player_joined_game] = 'on_player_created', + [defines.events.on_player_changed_force] = 'on_player_created', }, handlers = { on_built = Token.register(snap_loader), @@ -330,10 +339,18 @@ local features = { return end e.research.force.recipes[recipe].enabled = true - for _, p in pairs(game.players) do - if p.opened_gui_type == defines.gui_type.controller then - draw_loader_frame(p.gui.relative) - end + for _, p in pairs(e.research.force.players) do + draw_loader_frame(p.gui.relative) + end + end), + -- The frame anchored to the controller GUI is kept in place permanently. + -- Creating it inside on_gui_opened instead would delay its appearance in + -- multiplayer by the latency round trip, as script events do not run in + -- the client's latency-hidden state. + on_player_created = Token.register(function(e) + local p = safe_get_player(e.player_index) + if p then + draw_loader_frame(p.gui.relative) end end), on_gui_opened = Token.register(function(e) @@ -353,12 +370,27 @@ local features = { if not p then return end - local frame = p.gui.relative[loader_frame_name] + local frame = p.gui.relative[loader_machine_frame_name] if frame and frame.valid then Gui.destroy(frame) end end) - } + }, + on_register = function() + for _, p in pairs(game.players) do + draw_loader_frame(p.gui.relative) + end + end, + on_unregister = function() + for _, p in pairs(game.players) do + for _, name in pairs({ loader_frame_name, loader_machine_frame_name }) do + local frame = p.gui.relative[name] + if frame and frame.valid then + Gui.destroy(frame) + end + end + end + end, }, { name = 'save_bots', @@ -379,6 +411,9 @@ local function register_feature(feature) for event_id, handler_id in pairs(feature.events) do Event.add_removable(event_id, feature.handlers[handler_id]) end + if feature.on_register and game then + feature.on_register() + end return true end @@ -387,6 +422,9 @@ local function unregister_feature(feature) Event.remove_removable(event_id, feature.handlers[handler_id]) end enabled[feature.name] = false + if feature.on_unregister and game then + feature.on_unregister() + end return true end @@ -409,7 +447,8 @@ end Gui.on_click(loader_button_player, function(event) local player = event.player local recipe = event.element.elem_value - if not player.force.recipes[recipe].enabled then + local force_recipe = recipe and player.force.recipes[recipe] + if not (force_recipe and force_recipe.enabled) then return end local count = (event.button == defines.mouse_button_type.left) and (event.shift and 4294967295 or 1) or (event.button == defines.mouse_button_type.right) and 5 or nil @@ -420,7 +459,8 @@ end) Gui.on_click(loader_button_machine, function(event) local recipe = event.element.elem_value - if not event.player.force.recipes[recipe].enabled then + local force_recipe = recipe and event.player.force.recipes[recipe] + if not (force_recipe and force_recipe.enabled) then return end local entity = Gui.get_data(event.element) @@ -439,6 +479,11 @@ local loader_check_token = Token.register(function() end end end + if enabled.loaders then + for _, p in pairs(game.players) do + draw_loader_frame(p.gui.relative) + end + end end) Event.on_init(function() @@ -452,9 +497,11 @@ Event.on_configuration_changed(function() Task.set_timeout_in_ticks(1, loader_check_token) end for _, p in pairs(game.players) do - local frame = p.gui.relative[loader_frame_name] - if frame then - Gui.destroy(frame) + for _, name in pairs({ loader_frame_name, loader_machine_frame_name }) do + local frame = p.gui.relative[name] + if frame then + Gui.destroy(frame) + end end end end) diff --git a/features/redmew_qol_tests.lua b/features/redmew_qol_tests.lua new file mode 100644 index 000000000..499957b75 --- /dev/null +++ b/features/redmew_qol_tests.lua @@ -0,0 +1,156 @@ +local Declare = require 'utils.test.declare' +local EventFactory = require 'utils.test.event_factory' +local Assert = require 'utils.test.assert' +local RedmewQol = require 'features.redmew_qol' + +local loader_recipes = { 'loader', 'fast-loader', 'express-loader', 'turbo-loader' } + +local function controller_frame(player) + return player.gui.relative[RedmewQol._loader_frame_name] +end + +local function machine_frame(player) + return player.gui.relative[RedmewQol._loader_machine_frame_name] +end + +local function raise_controller_gui_event(event_name, player) + EventFactory.raise({ + name = event_name, + tick = game.tick, + player_index = player.index, + gui_type = defines.gui_type.controller + }) +end + +local function count_loader_buttons(element) + local count = 0 + for _, child in pairs(element.children) do + if child.type == 'choose-elem-button' then + count = count + 1 + else + count = count + count_loader_buttons(child) + end + end + return count +end + +Declare.module({'features', 'redmew qol', 'loaders gui'}, function() + local old_feature_enabled + local old_recipe_enabled + + Declare.module_startup(function(context) + local force = context.player.force + old_feature_enabled = RedmewQol.get_loaders() + old_recipe_enabled = force.recipes['loader'].enabled + + force.recipes['loader'].enabled = true + if not old_feature_enabled then + RedmewQol.set_loaders(true) + end + end) + + Declare.module_teardown(function(context) + context.player.force.recipes['loader'].enabled = old_recipe_enabled + if not old_feature_enabled then + RedmewQol.set_loaders(false) + else + -- redraw with the restored recipe state + raise_controller_gui_event(defines.events.on_gui_opened, context.player) + end + end) + + Declare.test('frame is drawn for all players when the feature is enabled', function(context) + local player = context.player + + RedmewQol.set_loaders(false) + Assert.is_nil(controller_frame(player), 'frame should be removed when the feature is disabled') + + RedmewQol.set_loaders(true) + Assert.valid(controller_frame(player), 'frame should be drawn when the feature is enabled') + end) + + Declare.test('frame persists when the crafting menu is closed', function(context) + local player = context.player + + raise_controller_gui_event(defines.events.on_gui_opened, player) + Assert.valid(controller_frame(player), 'frame should exist after opening the crafting menu') + + raise_controller_gui_event(defines.events.on_gui_closed, player) + Assert.valid(controller_frame(player), 'frame should persist after closing the crafting menu') + end) + + Declare.test('frame is removed when no loader recipe is enabled', function(context) + local player = context.player + local recipes = player.force.recipes + + local old_states = {} + for _, name in pairs(loader_recipes) do + local recipe = recipes[name] + if recipe then + old_states[name] = recipe.enabled + recipe.enabled = false + end + end + + raise_controller_gui_event(defines.events.on_gui_opened, player) + local removed = controller_frame(player) == nil + + for name, state in pairs(old_states) do + recipes[name].enabled = state + end + raise_controller_gui_event(defines.events.on_gui_opened, player) + + Assert.is_true(removed, 'frame should be removed when no loader recipe is enabled') + Assert.valid(controller_frame(player), 'frame should be drawn again when a loader recipe is enabled') + end) + + Declare.test('frame lists one button per enabled loader recipe', function(context) + local player = context.player + + raise_controller_gui_event(defines.events.on_gui_opened, player) + + local expected = 0 + for _, name in pairs(loader_recipes) do + local recipe = player.force.recipes[name] + if recipe and recipe.enabled then + expected = expected + 1 + end + end + + Assert.equal(expected, count_loader_buttons(controller_frame(player)), 'frame should have one button per enabled loader recipe') + end) + + Declare.test('machine frame is separate and removed when its gui is closed', function(context) + local player = context.player + local surface = player.surface + local position = surface.find_non_colliding_position('assembling-machine-1', player.position, 32, 1) + local machine = surface.create_entity({ name = 'assembling-machine-1', position = position, force = player.force }) + + raise_controller_gui_event(defines.events.on_gui_opened, player) + + EventFactory.raise({ + name = defines.events.on_gui_opened, + tick = game.tick, + player_index = player.index, + gui_type = defines.gui_type.entity, + entity = machine + }) + local machine_frame_drawn = machine_frame(player) ~= nil + + EventFactory.raise({ + name = defines.events.on_gui_closed, + tick = game.tick, + player_index = player.index, + gui_type = defines.gui_type.entity, + entity = machine + }) + local machine_frame_removed = machine_frame(player) == nil + local controller_frame_kept = controller_frame(player) ~= nil + + machine.destroy() + + Assert.is_true(machine_frame_drawn, 'machine frame should be drawn when a machine gui is opened') + Assert.is_true(machine_frame_removed, 'machine frame should be removed when the machine gui is closed') + Assert.is_true(controller_frame_kept, 'controller frame should not be affected by the machine gui closing') + end) +end)