From 1fcf351566e7f94df8be4f689d2039e7dd9e4560 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:18:37 +0000 Subject: [PATCH] Migrate rocket info gui to scenario and fix for Space Age Port modules/gui/rocket-info into exp_scenario/gui/rocket_info using the new exp_gui element API, matching the other scenario guis. The per-force stats, launch times and silo tracking from the legacy control module are folded into the gui module. - Drop the removed auto/remote launch controls and count launches via on_cargo_pod_finished_ascending instead of the old launch event - Add the exp-gui_rocket-info locale section - Disable the legacy module in the file loader and re-enable the gui/rocket-info role permission Co-Authored-By: Claude Opus 4.8 (1M context) --- exp_legacy/module/config/_file_loader.lua | 2 +- exp_legacy/module/config/expcore/roles.lua | 2 +- exp_scenario/module/control.lua | 1 + exp_scenario/module/gui/rocket_info.lua | 510 +++++++++++++++++++++ exp_scenario/module/locale/en.cfg | 36 ++ 5 files changed, 549 insertions(+), 2 deletions(-) create mode 100644 exp_scenario/module/gui/rocket_info.lua diff --git a/exp_legacy/module/config/_file_loader.lua b/exp_legacy/module/config/_file_loader.lua index 91be3f15..8a4c7530 100644 --- a/exp_legacy/module/config/_file_loader.lua +++ b/exp_legacy/module/config/_file_loader.lua @@ -22,7 +22,7 @@ return { --"modules.data.toolbar", --- GUI - "modules.gui.rocket-info", + -- "modules.gui.rocket-info", -- migrated to exp_scenario/gui/rocket_info "modules.gui.warp-list", "modules.gui.player-list", "modules.gui.vlayer", diff --git a/exp_legacy/module/config/expcore/roles.lua b/exp_legacy/module/config/expcore/roles.lua index 618b57f9..46e26d73 100644 --- a/exp_legacy/module/config/expcore/roles.lua +++ b/exp_legacy/module/config/expcore/roles.lua @@ -283,7 +283,7 @@ local default = Roles.new_role("Guest", "") "command/data-preference", "command/connect", "gui/player-list", - --"gui/rocket-info", + "gui/rocket-info", "gui/science-info", "gui/task-list", "gui/warp-list", diff --git a/exp_scenario/module/control.lua b/exp_scenario/module/control.lua index 65e35d78..b031e221 100644 --- a/exp_scenario/module/control.lua +++ b/exp_scenario/module/control.lua @@ -78,6 +78,7 @@ add(require("modules/exp_scenario/gui/production_stats")) add(require("modules/exp_scenario/gui/quick_actions")) add(require("modules/exp_scenario/gui/readme")) add(require("modules/exp_scenario/gui/research_milestones")) +add(require("modules/exp_scenario/gui/rocket_info")) add(require("modules/exp_scenario/gui/science_production")) add(require("modules/exp_scenario/gui/surveillance")) add(require("modules/exp_scenario/gui/task_list")) diff --git a/exp_scenario/module/gui/rocket_info.lua b/exp_scenario/module/gui/rocket_info.lua new file mode 100644 index 00000000..cacf897a --- /dev/null +++ b/exp_scenario/module/gui/rocket_info.lua @@ -0,0 +1,510 @@ +--[[-- Gui - Rocket Info +Adds a rocket information gui which shows general stats, milestones and build progress of rockets. +The auto launch and remote launch controls were removed because the api no longer exposes auto launch in space age. +]] + +local ExpUtil = require("modules/exp_util") +local Storage = require("modules/exp_util/storage") +local Gui = require("modules/exp_gui") +local Roles = require("modules/exp_legacy/expcore/roles") +local config = require("modules/exp_legacy/config/gui/rockets") + +--- @class ExpGui_RocketInfo.elements +local Elements = {} + +local time_formats = { + caption = ExpUtil.format_time_factory_locale{ format = "short", minutes = true, seconds = true }, + caption_hours = ExpUtil.format_time_factory_locale{ format = "short", hours = true, minutes = true }, + tooltip = ExpUtil.format_time_factory_locale{ format = "long", minutes = true, seconds = true }, + tooltip_hours = ExpUtil.format_time_factory_locale{ format = "long", hours = true, minutes = true, seconds = true }, +} + +--- The font colours used for the build progress label +local font_color = { + neutral = { r = 1, g = 1, b = 1 }, + waiting = { r = 0.3, g = 1, b = 1 }, + launched = { r = 0.3, g = 1, b = 0.3 }, +} + +--[[ +Below here is the rocket data tracking, it stores per force stats and the times each rocket was launched. +This used to live in modules/control/rockets but it is only used by this gui so it has been folded in. +]] + +--- @class ExpGui_RocketInfo.silo_data +--- @field entity LuaEntity The rocket silo entity +--- @field force string The name of the force that owns the silo +--- @field launched number The number of rockets launched from this silo +--- @field awaiting_reset boolean True when a launch is ordered but the silo has not reset yet + +--- @type table> Force name to an array of launch ticks indexed by rocket number +local rocket_times = {} +--- @type table Force name to launch stats +local rocket_stats = {} +--- @type table Silo unit number to its data +local rocket_silos = {} + +Storage.register({ + rocket_times = rocket_times, + rocket_stats = rocket_stats, + rocket_silos = rocket_silos, +}, function(tbl) + rocket_times = tbl.rocket_times + rocket_stats = tbl.rocket_stats + rocket_silos = tbl.rocket_silos +end) + +-- The largest rolling average is used to know when an old launch time can be discarded +local largest_rolling_avg = 0 +for _, avg_over in pairs(config.stats.rolling_avg) do + if avg_over > largest_rolling_avg then + largest_rolling_avg = avg_over + end +end + +--- Get all the valid rocket silos that belong to a force, pruning any that are no longer valid +--- @param force_name string Name of the force to get the silos for +--- @return ExpGui_RocketInfo.silo_data[] +local function get_silos(force_name) + local rtn = {} + for unit_number, silo_data in pairs(rocket_silos) do + if not silo_data.entity.valid then + rocket_silos[unit_number] = nil + elseif silo_data.force == force_name then + rtn[#rtn + 1] = silo_data + end + end + + return rtn +end + +--- Get the number of rockets that a force has launched +--- @param force_name string Name of the force to get the count for +--- @return number +local function get_rocket_count(force_name) + return game.forces[force_name].rockets_launched +end + +--- Get the total number of rockets launched by all forces +--- @return number +local function get_game_rocket_count() + local rtn = 0 + for _, force in pairs(game.forces) do + rtn = rtn + force.rockets_launched + end + + return rtn +end + +--- Get the rolling average time to launch a rocket based on the last count rockets +--- @param force_name string Name of the force to get the average for +--- @param count number Number of rockets to average over +--- @return number # Number of ticks required to launch one rocket +local function get_rolling_average(force_name, count) + local times = rocket_times[force_name] + local rocket_count = game.forces[force_name].rockets_launched + if rocket_count == 0 or not times then return 0 end + + local last_launch_time = times[rocket_count] or 0 + local start_rocket_time = 0 + if count < rocket_count then + start_rocket_time = times[rocket_count - count + 1] or 0 + rocket_count = count + end + + return math.floor((last_launch_time - start_rocket_time) / rocket_count) +end + +--[[ +Below here is the gui, it is split into three collapsible sections; stats, milestones, and build progress. +]] + +--- Toggle the visible state of a section, the section is stored in the element data +--- @class ExpGui_RocketInfo.elements.toggle_section_button: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, section: LuaGuiElement): LuaGuiElement +Elements.toggle_section_button = Gui.define("rocket_info/toggle_section_button") + :draw{ + type = "sprite-button", + sprite = "utility/expand", + tooltip = { "exp-gui_rocket-info.tooltip-toggle-section-expand" }, + style = "frame_action_button", + } + :style{ + size = 20, + padding = -2, + } + :element_data( + Gui.from_argument(1) + ) + :on_click(function(def, player, element) + --- @cast def ExpGui_RocketInfo.elements.toggle_section_button + local section = def.data[element] + if Gui.toggle_visible_state(section) then + element.sprite = "utility/collapse" + element.tooltip = { "exp-gui_rocket-info.tooltip-toggle-section-collapse" } + else + element.sprite = "utility/expand" + element.tooltip = { "exp-gui_rocket-info.tooltip-toggle-section-expand" } + end + end) --[[ @as any ]] + +--- A clickable coordinate label which opens the silo location on the map when pressed +--- @class ExpGui_RocketInfo.elements.position_label: ExpElement +--- @field data table +--- @overload fun(parent: LuaGuiElement, opts: { caption: LocalisedString, tooltip: LocalisedString?, unit_number: number }): LuaGuiElement +Elements.position_label = Gui.define("rocket_info/position_label") + :draw{ + type = "label", + caption = Gui.from_argument("caption"), + tooltip = Gui.from_argument("tooltip"), + } + :style{ + padding = { 0, 2 }, + } + :element_data( + Gui.from_argument("unit_number") + ) + :on_click(function(def, player, element) + --- @cast def ExpGui_RocketInfo.elements.position_label + if not config.progress.allow_zoom_to_map then return end + local silo_data = rocket_silos[def.data[element]] + if not silo_data or not silo_data.entity.valid then return end + local entity = silo_data.entity + player.set_controller{ type = defines.controllers.remote, position = entity.position, surface = entity.surface } + end) --[[ @as any ]] + +--- Add a name and value label pair to a data table +--- @param data_table LuaGuiElement The two column table to add the pair to +--- @param name string The data name, used to select the locale key +--- @param subname number? Optional subname passed as a parameter to the locale string +--- @param value LocalisedString The value to display +--- @param value_tooltip LocalisedString? Optional tooltip for the value label +local function add_data_label(data_table, name, subname, value, value_tooltip) + local name_label = data_table.add{ + type = "label", + caption = { "exp-gui_rocket-info.data-caption-" .. name, subname }, + tooltip = { "exp-gui_rocket-info.data-tooltip-" .. name, subname }, + } + name_label.style.padding = { 0, 2 } + + local value_label = data_table.add{ + type = "label", + caption = value, + tooltip = value_tooltip, + } + value_label.style.padding = { 0, 2 } +end + +--- Data table showing the launch statistics for a force +--- @class ExpGui_RocketInfo.elements.stats_table: ExpElement +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.stats_table = Gui.define("rocket_info/stats_table") + :track_all_elements() + :draw(function(def, parent) + return Gui.elements.scroll_table(parent, 215, 2) + end) --[[ @as any ]] + +--- Refresh a stats table with the most recent data for a force +--- @param stats_table LuaGuiElement +--- @param force_name string +function Elements.stats_table.refresh(stats_table, force_name) + stats_table.clear() + local stats = rocket_stats[force_name] or {} + local force_rockets = get_rocket_count(force_name) + + if config.stats.show_first_rocket then + local value = stats.first_launch or 0 + add_data_label(stats_table, "first-launch", nil, time_formats.caption_hours(value), time_formats.tooltip_hours(value)) + end + + if config.stats.show_last_rocket then + local value = stats.last_launch or 0 + add_data_label(stats_table, "last-launch", nil, time_formats.caption_hours(value), time_formats.tooltip_hours(value)) + end + + if config.stats.show_fastest_rocket then + local value = stats.fastest_launch or 0 + add_data_label(stats_table, "fastest-launch", nil, time_formats.caption_hours(value), time_formats.tooltip_hours(value)) + end + + if config.stats.show_total_rockets then + local total_rockets = get_game_rocket_count() + if total_rockets == 0 then total_rockets = 1 end + local percentage = math.floor(force_rockets / total_rockets * 1000) / 10 + add_data_label(stats_table, "total-rockets", nil, tostring(force_rockets), { "exp-gui_rocket-info.value-tooltip-total-rockets", percentage }) + end + + if config.stats.show_game_avg then + local avg = force_rockets > 0 and math.floor(game.tick / force_rockets) or 0 + add_data_label(stats_table, "avg-launch", nil, time_formats.caption(avg), time_formats.tooltip(avg)) + end + + for _, avg_over in pairs(config.stats.rolling_avg) do + local avg = get_rolling_average(force_name, avg_over) + add_data_label(stats_table, "avg-launch-n", avg_over, time_formats.caption(avg), time_formats.tooltip(avg)) + end +end + +--- Data table showing the milestones for a force +--- @class ExpGui_RocketInfo.elements.milestones_table: ExpElement +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.milestones_table = Gui.define("rocket_info/milestones_table") + :track_all_elements() + :draw(function(def, parent) + return Gui.elements.scroll_table(parent, 215, 2) + end) --[[ @as any ]] + +--- Refresh a milestones table with the most recent data for a force +--- @param milestones_table LuaGuiElement +--- @param force_name string +function Elements.milestones_table.refresh(milestones_table, force_name) + milestones_table.clear() + local force_rockets = get_rocket_count(force_name) + local times = rocket_times[force_name] or {} + + for _, milestone in ipairs(config.milestones) do + if milestone <= force_rockets then + local time = times[milestone] or 0 + add_data_label(milestones_table, "milestone-n", milestone, time_formats.caption_hours(time), time_formats.tooltip_hours(time)) + else + -- The first unachieved milestone is shown as the next milestone then we stop + add_data_label(milestones_table, "milestone-n", milestone, { "exp-gui_rocket-info.data-caption-milestone-next" }, { "exp-gui_rocket-info.data-tooltip-milestone-next" }) + break + end + end +end + +--- Data table showing the build progress of each silo for a force +--- @class ExpGui_RocketInfo.elements.progress_table: ExpElement +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.progress_table = Gui.define("rocket_info/progress_table") + :track_all_elements() + :draw(function(def, parent) + return Gui.elements.scroll_table(parent, 215, 3) + end) --[[ @as any ]] + +--- Refresh a progress table with the most recent data for a force +--- @param progress_table LuaGuiElement +--- @param force_name string +function Elements.progress_table.refresh(progress_table, force_name) + progress_table.clear() + local silos = get_silos(force_name) + + if #silos == 0 then + progress_table.add{ + type = "label", + caption = { "exp-gui_rocket-info.progress-no-silos" }, + }.style.padding = { 1, 2 } + return + end + + local zoom_tooltip = config.progress.allow_zoom_to_map and { "exp-gui_rocket-info.progress-label-tooltip" } or nil + for _, silo_data in pairs(silos) do + local entity = silo_data.entity + local position = entity.position + + -- Work out the progress caption, tooltip and colour + local waiting = entity.status == defines.entity_status.waiting_to_launch_rocket + local progress_caption = { "exp-gui_rocket-info.progress-caption", entity.rocket_parts } + local progress_tooltip = { "exp-gui_rocket-info.progress-tooltip", silo_data.launched } + local progress_color = font_color.neutral + if waiting and silo_data.awaiting_reset then + progress_caption = { "exp-gui_rocket-info.progress-launched" } + progress_color = font_color.launched + elseif waiting then + progress_caption = { "exp-gui_rocket-info.progress-caption", 100 } + progress_color = font_color.waiting + else + silo_data.awaiting_reset = false + end + + -- Add the clickable coordinates and the progress label + Elements.position_label(progress_table, { caption = { "exp-gui_rocket-info.progress-x-pos", position.x }, tooltip = zoom_tooltip, unit_number = entity.unit_number }) + Elements.position_label(progress_table, { caption = { "exp-gui_rocket-info.progress-y-pos", position.y }, tooltip = zoom_tooltip, unit_number = entity.unit_number }) + local progress_label = progress_table.add{ + type = "label", + caption = progress_caption, + tooltip = progress_tooltip, + } + progress_label.style.padding = { 0, 2 } + progress_label.style.font_color = progress_color + end +end + +--- Add a collapsible section to a container, returns the populated data table +--- @param container LuaGuiElement The container frame to add the section to +--- @param section_name string Used to select the locale keys for the header +--- @param table_define ExpElement The data table element define to add +--- @param force_name string The force to populate the table with +--- @return LuaGuiElement +local function add_section(container, section_name, table_define, force_name) + local header = Gui.elements.header(container, { + caption = { "exp-gui_rocket-info.section-caption-" .. section_name }, + tooltip = { "exp-gui_rocket-info.section-tooltip-" .. section_name }, + }) + + local data_table = table_define(container) + table_define.refresh(data_table, force_name) + + -- The scroll pane is the parent of the table, it is what gets collapsed + local scroll_pane = assert(data_table.parent) + scroll_pane.visible = false + Elements.toggle_section_button(header, scroll_pane) + + return data_table +end + +--- Container added to the left gui flow +--- @class ExpGui_RocketInfo.elements.container: ExpElement +--- @overload fun(parent: LuaGuiElement): LuaGuiElement +Elements.container = Gui.define("rocket_info/container") + :draw(function(def, parent) + local container = Gui.elements.container(parent, 200) + container.style.padding = 0 + + local force_name = Gui.get_player(parent).force.name --[[ @as string ]] + + if config.stats.show_stats then + add_section(container, "stats", Elements.stats_table, force_name) + end + + if config.milestones.show_milestones then + add_section(container, "milestones", Elements.milestones_table, force_name) + end + + if config.progress.show_progress then + add_section(container, "progress", Elements.progress_table, force_name) + end + + return Gui.elements.container.get_root_element(container) + end) --[[ @as any ]] + +--- Add the element to the left flow with a toolbar button +Gui.add_left_element(Elements.container, false) +Gui.toolbar.create_button{ + name = "toggle_rocket_info", + left_element = Elements.container, + sprite = "item/rocket-silo", + tooltip = { "exp-gui_rocket-info.tooltip-main" }, + visible = function(player, element) + return Roles.player_allowed(player, "gui/rocket-info") + end +} + +--[[ +Below here is the event handling, the data tracking and gui refreshing are wired up to the same events. +]] + +--- Refresh the stats and milestones tables for all online players on a force +--- @param force_name string +local function refresh_force_stats(force_name) + local force = game.forces[force_name] + for _, stats_table in Elements.stats_table:online_elements(force) do + Elements.stats_table.refresh(stats_table, force_name) + end + for _, milestones_table in Elements.milestones_table:online_elements(force) do + Elements.milestones_table.refresh(milestones_table, force_name) + end +end + +--- Refresh the progress table for all online players on a force +--- @param force_name string +local function refresh_force_progress(force_name) + local force = game.forces[force_name] + for _, progress_table in Elements.progress_table:online_elements(force) do + Elements.progress_table.refresh(progress_table, force_name) + end +end + +--- Record the launch and update the stats when a cargo pod finishes ascending +--- @param event EventData.on_cargo_pod_finished_ascending +local function on_cargo_pod_finished_ascending(event) + local force = event.cargo_pod.force --[[ @as LuaForce ]] + local force_name = force.name + local rockets_launched = force.rockets_launched + + -- Update the launch stats for the force + local stats = rocket_stats[force_name] + if not stats then + stats = {} + rocket_stats[force_name] = stats + end + + if rockets_launched == 1 then + stats.first_launch = event.tick + stats.fastest_launch = event.tick + elseif stats.last_launch and event.tick - stats.last_launch < (stats.fastest_launch or math.huge) then + stats.fastest_launch = event.tick - stats.last_launch + end + + stats.last_launch = event.tick + + -- Append the launch tick into the times array + local times = rocket_times[force_name] + if not times then + times = {} + rocket_times[force_name] = times + end + + times[rockets_launched] = event.tick + + -- Discard the launch time that is no longer needed by any rolling average unless it is a milestone + local remove_rocket = rockets_launched - largest_rolling_avg + if remove_rocket > 0 and not table.array_contains(config.milestones, remove_rocket) then + times[remove_rocket] = nil + end + + refresh_force_stats(force_name) + refresh_force_progress(force_name) +end + +--- Mark a silo as awaiting reset when a launch is ordered +--- @param event EventData.on_rocket_launch_ordered +local function on_rocket_launch_ordered(event) + local silo_data = rocket_silos[event.rocket_silo.unit_number] + if not silo_data then return end + silo_data.launched = silo_data.launched + 1 + silo_data.awaiting_reset = true + refresh_force_progress(event.rocket_silo.force.name) +end + +--- Add a silo to the list when it is built +--- @param event EventData.on_built_entity | EventData.on_robot_built_entity | EventData.script_raised_built | EventData.script_raised_revive +local function on_built(event) + local entity = event.entity + if not entity.valid or entity.name ~= "rocket-silo" then return end + rocket_silos[entity.unit_number] = { + entity = entity, + force = entity.force.name, + launched = 0, + awaiting_reset = false, + } + refresh_force_progress(entity.force.name) +end + +--- Refresh the progress for all forces that own at least one silo +local function refresh_all_progress() + for _, force in pairs(game.forces) do + if #get_silos(force.name) > 0 then + refresh_force_progress(force.name) + end + end +end + +local e = defines.events + +return { + elements = Elements, + events = { + [e.on_cargo_pod_finished_ascending] = on_cargo_pod_finished_ascending, + [e.on_rocket_launch_ordered] = on_rocket_launch_ordered, + [e.on_built_entity] = on_built, + [e.on_robot_built_entity] = on_built, + [e.script_raised_built] = on_built, + [e.script_raised_revive] = on_built, + }, + on_nth_tick = { + [150] = refresh_all_progress, + } +} diff --git a/exp_scenario/module/locale/en.cfg b/exp_scenario/module/locale/en.cfg index d2c5cf99..6c6071fa 100644 --- a/exp_scenario/module/locale/en.cfg +++ b/exp_scenario/module/locale/en.cfg @@ -431,6 +431,42 @@ caption-research-name=[technology=__1__] __2__ notice-inf=[color=255, 255, 255] Research completed at __1__ - [technology=__2__] - __3__[/color] notice=[color=255, 255, 255] Research completed at __1__ - [technology=__2__][/color] +[exp-gui_rocket-info] +tooltip-main=Rocket Info +caption-main=Rocket Info +tooltip-toggle-section-expand=Expand Section +tooltip-toggle-section-collapse=Collapse Section +section-caption-stats=Statistics +section-tooltip-stats=Statistics about how rockets are launched +section-caption-milestones=Milestones +section-tooltip-milestones=The times when milestones were met +section-caption-progress=Build Progress +section-tooltip-progress=The progress for your rocket silos +progress-no-silos=You have no rocket silos +data-caption-first-launch=First Launch +data-tooltip-first-launch=The time of the first launch +data-caption-last-launch=Last Launch +data-tooltip-last-launch=The time of the last launch +data-caption-fastest-launch=Fastest Launch +data-tooltip-fastest-launch=The time taken for the fastest launch +data-caption-total-rockets=Total Launched +data-tooltip-total-rockets=Total number of rockets launched by your force +value-tooltip-total-rockets=__1__% of all rockets on this map +data-caption-avg-launch=Average Time +data-tooltip-avg-launch=The average amount of time taken to launch a rocket +data-caption-avg-launch-n=Average Time __1__ +data-tooltip-avg-launch-n=The average amount of time taken to launch the last __1__ rockets +data-caption-milestone-n=Milestone __1__ +data-tooltip-milestone-n=Time taken to reach __1__ rockets +data-caption-milestone-next=N/A +data-tooltip-milestone-next=Not yet achieved +progress-x-pos=X __1__ +progress-y-pos=Y __1__ +progress-label-tooltip=View on map +progress-launched=Launched +progress-caption=__1__% +progress-tooltip=This silo has launched __1__ rockets + [exp-gui_science-production] tooltip-main=Science Production caption-main=Science