Files
factorio-expcluster/exp_scenario/module/gui
bbassieandClaude Opus 5.5 628e4df514 Rework features into a Feature class with per field events
Review points from Cooldude:

- Feature.register(name[, config]) is required again and returns a
  feature object. Each feature has its config, its fields, and its
  overrides, so one registry holds all the data. Only the overrides are
  stored. On load each feature points at its own stored table again.
- The methods are feature:guard(lib), feature:update_config(enabled,
  values), and feature:is_enabled(). Feature.get(name) looks up a
  feature, and commands flag the feature object.
- update_config raises on_config_updated once per changed value, with
  the feature name, path, old value, and new value. The gui refresh only
  reacts to the "enabled" path.
- apply only sets values. on_apply is gone. Lookup sets built from
  lists use Feature.to_set, which is cached by the list itself. A
  replaced list gets a new set, so the sets are also correct after a
  load. Protection exposes getters for its protected names and types.

Web: settings use small inputs, the same size as the config pages.
Fields are shaped like clusterio config field definitions. A field
with an inputComponent renders the component registered with the web
interface, so the role setting now uses core's role input, and other
plugin inputs can be used later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:03:11 +00:00
..
2026-08-07 15:31:18 +00:00
2026-06-26 15:09:11 +01:00
2026-09-09 18:43:29 +01:00
2026-09-09 18:43:29 +01:00
2026-09-06 01:44:40 +01: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_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 config_server_detail = require("modules.exp_legacy.config.server_detail") --- @dep config.server_detail



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", config_server_detail["community_name"] },

    Gui.define("readme/welcome")

    :draw(function(_, parent)

        local player = Gui.get_player(parent)



        local server_details = {

            name = config_server_detail["community_name"] .. " 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_scenario/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_scenario/gui/logo.png" }



        Gui.elements.bar(container)

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



        local role_names = {}

        for i, role in ipairs(Roles.sort_roles(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",

                config_server_detail["community_name"],

                format_time(game.tick),

                server_details.reset_time,

            }

        )



        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"

        local rules_style = rules.style --[[@as LuaStyle]]

        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"

        local commands_style = commands.style --[[@as LuaStyle]]

        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, config_server_detail[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 = {}, --- @type string[]

            },

            {

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

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

                width = 145,

                players = {}, --- @type string[]

            },

            {

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

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

                width = 196,

                players = {}, --- @type string[]

            },

            {

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

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

                width = 235,

                players = {}, --- @type string[]

            },

            {

                roles = {},

                time = 3 * 3600 * 60,

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

                width = 235,

                players = {}, --- @type string[]

            },

        }



        local done = {}



        -- Fill groups from the players who hold the roles, a player can be in more than one group

        for _, group in ipairs(groups) do

            local seen = {}

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

                local role = Roles.get_role_by_name(role_name)

                for _, player_name in ipairs(role and role:get_player_names() or {}) do

                    if not seen[player_name] then

                        seen[player_name] = true

                        done[player_name] = true

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

                    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_has_permission(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>

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

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_has_permission(player, "exp_scenario.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,

    }

}