Files
factorio-scenario-ExpCluster/exp_scenario/module/gui
bbassieandClaude Opus 4.8 4bd5394268 Align names and call patterns to best practice
Apply the conventions from the rocket info gui review to the player list,
and remove the now migrated legacy module.

- Move row building onto the table def as calculate_row_data, and refresh
  via refresh_player which computes the row data once per player
- Store the selection on the container with private/public accessor
  methods instead of poking element data directly
- Store the selected player as a LuaPlayer rather than a string and update
  the actions config to match
- Use :style{} on the player table and make the action and reason bars
  their own element definitions
- Delete the migrated legacy gui and its file loader entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:10:20 +00:00
..
2026-05-13 23:31:33 +01:00
2025-12-15 14:52:27 +00:00

--[[-- Gui Module - Readme

Adds a main gui that contains important information about the server.

]]



local ExpUtil = require("modules/exp_util")

local Gui = require("modules/exp_gui")

local Roles = require("modules.exp_legacy.expcore.roles")

local Commands = require("modules/exp_commands")

local PlayerData = require("modules.exp_legacy.expcore.player_data")

local External = require("modules.exp_legacy.expcore.external")



local format_number = require("util").format_number

local format_time = ExpUtil.format_time_factory_locale{ format = "long", days = true, hours = true, minutes = true }



local frame_width = 595

local title_width = 270

local scroll_height = 275



--- @class ExpGui_Readme.elements

local Elements = {}



--- @type table<number, { caption: LocalisedString, tooltip: LocalisedString, element: ExpElement }>

local tabs = {}



--- Register a readme tab

--- @param caption LocalisedString

--- @param tooltip LocalisedString

--- @param element ExpElement

local function define_tab(caption, tooltip, element)

    tabs[#tabs + 1] = { caption = caption, tooltip = tooltip, element = element }

end



--- Create a title section table

--- @class ExpGui_Readme.elements.title_table: ExpElement

--- @overload fun(parent: LuaGuiElement, bar_size: number, caption: LocalisedString, column_count: number): LuaGuiElement

Elements.title_table = Gui.define("readme/title_table")

    :draw(function(_, parent, bar_size, caption, column_count)

        Gui.elements.title_label(parent, bar_size, caption)

        return parent.add{

            type = "table",

            column_count = column_count,

            style = "bordered_table",

        }

    end)

    :style{

        padding = 0,

        cell_padding = 0,

        vertical_align = "center",

        horizontally_stretchable = true,

    } --[[ @as any ]]



--- Scroll pane used for title tables

--- @class ExpGui_Readme.elements.title_table_scroll: ExpElement

--- @overload fun(parent: LuaGuiElement): LuaGuiElement

Elements.title_table_scroll = Gui.define("readme/title_table_scroll")

    :draw{

        type = "scroll-pane",

        direction = "vertical",

        horizontal_scroll_policy = "never",

        vertical_scroll_policy = "auto",

        style = "scroll_pane_under_subheader",

    }

    :style{

        padding = { 1, 3 },

        maximal_height = scroll_height,

        horizontally_stretchable = true,

    } --[[ @as any ]]



--- Sub content frame

--- @class ExpGui_Readme.elements.sub_content: ExpElement

--- @overload fun(parent: LuaGuiElement): LuaGuiElement

Elements.sub_content = Gui.define("readme/sub_content")

    :draw{

        type = "frame",

        direction = "vertical",

        style = "inside_deep_frame",

    }

    :style{

        horizontally_stretchable = true,

        horizontal_align = "center",

        padding = { 2, 2 },

        top_margin = 2,

    } --[[ @as any ]]



--- @class ExpGui_Readme.elements.join_server.elements

--- @field server_id string



--- Join server button

--- @class ExpGui_Readme.elements.join_server: ExpElement

--- @field data table<LuaGuiElement, ExpGui_Readme.elements.join_server.elements>

--- @overload fun(parent: LuaGuiElement, server_id: string, wrong_version: string?): LuaGuiElement

Elements.join_server = Gui.define("readme/join_server")

    :track_all_elements()

    :draw(function(def, parent, server_id, wrong_version)

        --- @cast def ExpGui_Readme.elements.join_server

        local flow = parent.add{

            type = "flow",

        }



        local button = flow.add{

            type = "sprite-button",

            sprite = "utility/circuit_network_panel",

            hovered_sprite = "utility/circuit_network_panel",

            style = "frame_action_button",

        }



        def.data[button] = {

            server_id = server_id,

        }



        Elements.join_server.refresh(button, wrong_version)

        return button

    end)

    :style{

        size = 20,

        padding = -1,

    }

    :on_click(function(def, player, button)

        --- @cast def ExpGui_Readme.elements.join_server

        local server_id = def.data[button].server_id

        External.request_connection(player, server_id, true)

    end) --[[ @as any ]]



--- Refresh join server button

--- @param button LuaGuiElement

--- @param wrong_version string?

function Elements.join_server.refresh(button, wrong_version)

    local server_id = Elements.join_server.data[button].server_id

    local status = External.get_server_status(server_id) or "Offline"



    if wrong_version then

        status = "Version"

    end



    button.tooltip = { "exp-gui_readme.servers-connect-" .. status, wrong_version }



    if status == "Offline" or status == "Current" then

        button.enabled = false

        button.sprite = "utility/circuit_network_panel"

        button.hovered_sprite = "utility/circuit_network_panel"



    elseif status == "Version" then

        button.enabled = false

        button.sprite = "utility/shuffle"

        button.hovered_sprite = "utility/shuffle"



    elseif status == "Password" then

        button.enabled = true

        button.sprite = "utility/warning_white"

        button.hovered_sprite = "utility/warning"



    elseif status == "Modded" then

        button.enabled = true

        button.sprite = "utility/downloading_white"

        button.hovered_sprite = "utility/downloading"



    else

        button.enabled = true

        button.sprite = "utility/circuit_network_panel"

        button.hovered_sprite = "utility/circuit_network_panel"

    end

end



--- Refresh all online join buttons

function Elements.join_server.refresh_all()

    if not External.valid() then

        return

    end



    local current_version = External.get_current_server().version



    for _, button in Elements.join_server:tracked_elements() do

        local server_id = Elements.join_server.data[button].server_id

        local server = External.get_servers()[server_id]



        if server then

            Elements.join_server.refresh(button, current_version ~= server.version and server.version or nil)

        end

    end

end



--- Welcome tab

define_tab(

    { "exp-gui_readme.welcome-tab" },

    { "exp-gui_readme.welcome-tooltip" },

    Gui.define("readme/welcome")

    :draw(function(_, parent)

        local player = Gui.get_player(parent)



        local server_details = {

            name = "ExpGaming S0 - Local",

            welcome = "Failed to load description: disconnected from external api.",

            reset_time = "Not Set",

        }



        if External.valid() then

            server_details = External.get_current_server()

        end



        local container = parent.add{ type = "flow", direction = "vertical" }



        local top_flow = container.add{ type = "flow" }

        top_flow.add{ type = "sprite", sprite = "file/modules/exp_legacy/modules/gui/logo.png" }



        local center_flow = top_flow.add{ type = "flow", direction = "vertical" }

        center_flow.style.horizontal_align = "center"



        Gui.elements.title_label(center_flow, 62, { "exp-gui_readme.welcome-title", server_details.name })

        Gui.elements.centered_label(center_flow, 380, server_details.welcome)



        top_flow.add{ type = "sprite", sprite = "file/modules/exp_legacy/modules/gui/logo.png" }



        Gui.elements.bar(container)

        container.add{ type = "flow" }.style.height = 4



        local role_names = {}

        for i, role in ipairs(Roles.get_player_roles(player)) do

            role_names[i] = role.name

        end



        Gui.elements.centered_label(

            Elements.sub_content(container),

            frame_width,

            {

                "exp-gui_readme.welcome-general",

                server_details.reset_time,

                format_time(game.tick),

            }

        )



        Gui.elements.centered_label(

            Elements.sub_content(container),

            frame_width,

            {

                "exp-gui_readme.welcome-roles",

                table.concat(role_names, ", "),

            }

        )



        Gui.elements.centered_label(

            Elements.sub_content(container),

            frame_width,

            { "exp-gui_readme.welcome-chat" }

        )



        return container

    end) --[[ @as any ]]

)



--- Rules tab

define_tab(

    { "exp-gui_readme.rules-tab" },

    { "exp-gui_readme.rules-tooltip" },

    Gui.define("readme/rules")

    :draw(function(_, parent)

        local container = parent.add{ type = "flow", direction = "vertical" }

        Gui.elements.title_label(container, title_width - 3, { "exp-gui_readme.rules-tab" })

        Gui.elements.centered_label(container, frame_width, { "exp-gui_readme.rules-general" })

        Gui.elements.bar(container)

        container.add{ type = "flow" }



        local rules = Gui.elements.scroll_table(container, scroll_height, 1)

        rules.style = "bordered_table"

        rules.style.cell_padding = 4



        for i = 1, 15 do

            Gui.elements.centered_label(rules, frame_width - 30, { "exp-gui_readme.rules-" .. i })

        end



        return container

    end) --[[ @as any ]]

)



--- Commands tab

define_tab(

    { "exp-gui_readme.commands-tab" },

    { "exp-gui_readme.commands-tooltip" },

    Gui.define("readme/commands")

    :draw(function(_, parent)

        local player = Gui.get_player(parent)



        local container = parent.add{ type = "flow", direction = "vertical" }

        Gui.elements.title_label(container, title_width - 20, { "exp-gui_readme.commands-tab" })

        Gui.elements.centered_label(container, frame_width, { "exp-gui_readme.commands-general" })

        Gui.elements.bar(container)

        container.add{ type = "flow" }



        local commands = Gui.elements.scroll_table(container, scroll_height, 2)

        commands.style = "bordered_table"

        commands.style.cell_padding = 0



        for name, command in pairs(Commands.list_for_player(player)) do

            Gui.elements.centered_label(commands, 120, name)

            Gui.elements.centered_label(commands, 450, command.description)

        end



        return container

    end) --[[ @as any ]]

)



--- Servers tab

define_tab(

    { "exp-gui_readme.servers-tab" },

    { "exp-gui_readme.servers-tooltip" },

    Gui.define("readme/servers")

    :draw(function(_, parent)

        local container = parent.add{ type = "flow", direction = "vertical" }

        Gui.elements.title_label(container, title_width - 10, { "exp-gui_readme.servers-tab" })

        Gui.elements.centered_label(container, frame_width, { "exp-gui_readme.servers-general" })

        Gui.elements.bar(container)

        container.add{ type = "flow" }



        local scroll_pane = Elements.title_table_scroll(container)

        scroll_pane.style.maximal_height = scroll_height + 20



        if External.valid() then

            local current_version = External.get_current_server().version



            local factorio_servers = Elements.title_table(scroll_pane, 225, { "exp-gui_readme.servers-factorio" }, 3)



            for server_id, server in pairs(External.get_servers()) do

                Gui.elements.centered_label(factorio_servers, 110, server.short_name)

                Gui.elements.centered_label(factorio_servers, 436, server.description)

                Elements.join_server(factorio_servers, server_id, current_version ~= server.version and server.version or nil)

            end

        else

            local factorio_servers = Elements.title_table(scroll_pane, 225, { "exp-gui_readme.servers-factorio" }, 2)

            for i = 1, 8 do

                Gui.elements.centered_label(factorio_servers, 110, { "exp-gui_readme.servers-" .. i })

                Gui.elements.centered_label(factorio_servers, 460, { "exp-gui_readme.servers-d" .. i })

            end

        end



        local external_links = Elements.title_table(scroll_pane, 235, { "exp-gui_readme.servers-external" }, 2)

        for _, key in ipairs{ "discord", "website", "patreon", "status", "github" } do

            Gui.elements.centered_label(external_links, 110, key:gsub("^%l", string.upper))

            Gui.elements.centered_label(external_links, 460, { "links." .. key }, { "exp-gui_readme.servers-open-in-browser" })

        end



        return container

    end) --[[ @as any ]]

)



--- Backers tab

--- Content area for the backers tab

define_tab(

    { "exp-gui_readme.backers-tab" },

    { "exp-gui_readme.backers-tooltip" },

    Gui.define("readme/backers")

    :draw(function(_, parent)

        local container = parent.add{ type = "flow", direction = "vertical" }

        Gui.elements.title_label(container, title_width - 10, { "exp-gui_readme.backers-tab" })

        Gui.elements.centered_label(container, frame_width, { "exp-gui_readme.backers-general" })

        Gui.elements.bar(container)

        container.add{ type = "flow" }



        local groups = {

            {

                roles = { "Senior Administrator", "Administrator" },

                title = { "exp-gui_readme.backers-management" },

                width = 230,

                players = {},

            },

            {

                roles = { "Board Member", "Senior Backer" },

                title = { "exp-gui_readme.backers-board" },

                width = 145,

                players = {},

            },

            {

                roles = { "Sponsor", "Supporter" },

                title = { "exp-gui_readme.backers-backers" },

                width = 196,

                players = {},

            },

            {

                roles = { "Moderator", "Trainee" },

                title = { "exp-gui_readme.backers-staff" },

                width = 235,

                players = {},

            },

            {

                roles = {},

                time = 3 * 3600 * 60,

                title = { "exp-gui_readme.backers-active" },

                width = 235,

                players = {},

            },

        }



        local done = {}



        -- Fill groups from configured roles

        for player_name, player_roles in pairs(Roles.config.players) do

            for _, group in ipairs(groups) do

                for _, role_name in ipairs(group.roles) do

                    if table.contains(player_roles, role_name) then

                        done[player_name] = true

                        group.players[#group.players + 1] = player_name

                        break

                    end

                end

            end

        end



        -- Fill active player group

        for _, player in pairs(game.players) do

            if not done[player.name] then

                for _, group in ipairs(groups) do

                    if group.time and player.online_time > group.time then

                        group.players[#group.players + 1] = player.name

                    end

                end

            end

        end



        local scroll_pane = Elements.title_table_scroll(container)



        for _, group in ipairs(groups) do

            if #group.players > 0 then

                local backers_table = Elements.title_table(scroll_pane, group.width, group.title, 4)



                for _, player_name in ipairs(group.players) do

                    Gui.elements.centered_label(backers_table, 140, player_name)

                end



                if #group.players < 4 then

                    for i = 1, 4 - #group.players do

                        Gui.elements.centered_label(backers_table, 140)

                    end

                end

            end

        end



        return container

    end) --[[ @as any ]]

)



--- @class (exact) ExpGui_Readme.elements.readme_data.param table

--- @field scroll_pane LuaGuiElement

--- @field player LuaPlayer

--- @field player_name string

--- @field children table

--- @field title LocalisedString

--- @field locale_prefix string

--- @field default_stringify fun(value: any): string

--- @field columns number

--- @field title_width number

--- @field column_width number

--- @field extra_rows fun(data_table: LuaGuiElement)?



--- Render a player data category

--- @param opts ExpGui_Readme.elements.readme_data.param

local function render_data_category(opts)

    local data_table = Elements.title_table(opts.scroll_pane, opts.title_width, opts.title, opts.columns)



    if opts.extra_rows then

        opts.extra_rows(data_table)

    end



    for name, child in pairs(opts.children) do

        local metadata = child.metadata



        if not metadata.permission or Roles.player_allowed(opts.player, metadata.permission) then

            local value = child:get(opts.player_name)



            if value ~= nil or metadata.show_always then

                if metadata.stringify_short then

                    value = metadata.stringify_short(value)

                elseif metadata.stringify then

                    value = metadata.stringify(value)

                else

                    value = opts.default_stringify(value)

                end



                local tooltip = metadata.tooltip or { opts.locale_prefix .. name .. "-tooltip" }

                Gui.elements.centered_label(

                    data_table, 150,

                    metadata.name or { opts.locale_prefix .. name },

                    tooltip

                )



                Gui.elements.centered_label(

                    data_table, opts.column_width,

                    { "exp-gui_readme.data-format", value, metadata.unit or "" },

                    metadata.value_tooltip or { "?", { opts.locale_prefix .. name .. "-value-tooltip" }, tooltip }

                )

            end

        end

    end

end



--- Content area for the player data tab

define_tab(

    { "exp-gui_readme.data-tab" },

    { "exp-gui_readme.data-tooltip" },

    Gui.define("readme/data")

    :draw(function(_, parent)

        local container = parent.add{ type = "flow", direction = "vertical" }



        local player = Gui.get_player(parent)

        local player_name = player.name



        local enum = PlayerData.PreferenceEnum

        local preference = PlayerData.DataSavingPreference:get(player_name)

        local preference_meta = PlayerData.DataSavingPreference.metadata

        preference = enum[preference]



        Gui.elements.title_label(container, title_width, { "exp-gui_readme.data-tab" })

        Gui.elements.centered_label(container, frame_width, { "exp-gui_readme.data-general" })

        Gui.elements.bar(container)

        container.add{ type = "flow" }



        local scroll_pane = Elements.title_table_scroll(container)



        render_data_category{

            scroll_pane = scroll_pane,

            player = player,

            player_name = player_name,

            children = PlayerData.Required.children,

            title = { "exp-gui_readme.data-required" },

            locale_prefix = "exp-required.",

            columns = 2,

            title_width = 250,

            column_width = 420,

            default_stringify = tostring,

            extra_rows = function(required)

                Gui.elements.centered_label(required, 150, preference_meta.name, preference_meta.tooltip)

                Gui.elements.centered_label(required, 420, { "expcore-data.preference-" .. enum[preference] }, preference_meta.value_tooltip)

            end,

        }



        if preference <= enum.Settings then

            render_data_category{

                scroll_pane = scroll_pane,

                player = player,

                player_name = player_name,

                children = PlayerData.Settings.children,

                title = { "exp-gui_readme.data-settings" },

                locale_prefix = "exp-settings.",

                columns = 2,

                title_width = 255,

                column_width = 420,

                default_stringify = function(value)

                    return tostring(value or "None set")

                end,

            }

        end



        if preference <= enum.Statistics then

            render_data_category{

                scroll_pane = scroll_pane,

                player = player,

                player_name = player_name,

                children = PlayerData.Statistics.children,

                title = { "exp-gui_readme.data-statistics" },

                locale_prefix = "exp-statistics.",

                columns = 4,

                title_width = 250,

                column_width = 130,

                default_stringify = function(value)

                    return format_number(value or 0, false)

                end,

            }

        end



        local skip = {

            DataSavingPreference = true,

            Settings = true,

            Statistics = true,

            Required = true,

        }



        local count = 0

        for _ in pairs(PlayerData.All.children) do

            count = count + 1

        end



        if preference <= enum.All and count > 4 then

            local misc = {}

            for name, child in pairs(PlayerData.All.children) do

                if not skip[name] then

                    misc[name] = child

                end

            end



            render_data_category{

                scroll_pane = scroll_pane,

                player = player,

                player_name = player_name,

                children = misc,

                title = { "exp-gui_readme.data-misc" },

                locale_prefix = "",

                columns = 2,

                title_width = 232,

                column_width = 420,

                default_stringify = tostring,

            }

        end



        return container

    end) --[[ @as any ]]

)



--- @class ExpGui_Readme.elements.container.elements

--- @field pane LuaGuiElement



--- Main readme container

--- @class ExpGui_Readme.elements.container: ExpElement

--- @field data table<LuaGuiElement, ExpGui_Readme.elements.container.elements>

Elements.container = Gui.define("readme/container")

    :track_all_elements()

    :draw(function(def, parent)

        --- @cast def ExpGui_Readme.elements.container

        local container = parent.add{ name = def.name, type = "frame", style = "invisible_frame" }



        local left_alignment = Gui.elements.aligned_flow(container, { vertical_align = "bottom" })

        left_alignment.style.padding = { 32, 0, 0, 0 }



        local left_side = left_alignment.add{ type = "frame", style = "character_gui_left_side" }

        left_side.style.vertically_stretchable = true

        left_side.style.padding = 0

        left_side.style.width = 5



        local pane = container.add{

            name = "pane",

            type = "tabbed-pane",

            style = "frame_tabbed_pane",

        }



        for _, tab in ipairs(tabs) do

            local gui_tab = pane.add{

                type = "tab",

                style = "frame_tab",

                caption = tab.caption,

                tooltip = tab.tooltip,

            }



            pane.add_tab(gui_tab, tab.element(pane))

        end



        def.data[container] = {

            pane = pane,

        }



        return container

    end)

    :on_opened(function(def, player)

        Gui.toolbar.set_button_toggled_state(Elements.toggle_button, player, true)

    end)

    :on_closed(function(def, player, element)

        Gui.toolbar.set_button_toggled_state(Elements.toggle_button, player, false)

        Gui.destroy_if_valid(element)

    end)



--- Toggle button

Elements.toggle_button = Gui.toolbar.create_button{

        name = "readme_toggle",

        auto_toggle = true,

        sprite = "virtual-signal/signal-info",

        tooltip = { "exp-gui_readme.main-tooltip" },

        visible = function(player)

            return Roles.player_allowed(player, "gui/readme")

        end,

    }

    :on_click(function(_, player)

        local center = player.gui.center

        local readme = center[Elements.container.name]



        if readme then

            player.opened = nil

        else

            player.opened = Elements.container(center)

        end

    end)



--- Open readme for new players

--- @param event EventData.on_player_created

local function open_readme(event)

    local player = assert(game.get_player(event.player_index))

    local element = Elements.container(player.gui.center)

    element.pane.selected_tab_index = 1

    player.opened = element

end



--- Clear stale readme

--- @param event EventData.on_player_joined_game | EventData.on_player_respawned

local function clear_readme(event)

    local player = game.players[event.player_index]

    if not player.opened then

        Gui.destroy_if_valid(player.gui.center[Elements.container.name])

    end

end



local e = defines.events



return {

    elements = Elements,

    events = {

        [e.on_player_created] = open_readme,

        [e.on_player_joined_game] = clear_readme,

        [e.on_player_respawned] = clear_readme,

    },

    on_nth_tick = {

        [60 * 60] = Elements.join_server.refresh_all,

    }

}