Files
factorio-expcluster/exp_scenario/module/gui
bbassieandClaude Opus 5.5 a6c453a88e Add a community_details feature for the community name and links
The community name, Discord, website, status, GitHub, and Patreon links
lived in exp_legacy/config/server_detail.lua, so changing them meant
editing the scenario. They are now the community_details feature and can
be edited from the Scenario Features page.

Chat auto reply, help bubbles, and the readme read the live config
instead of copying the values when the file loads, so an edit shows up
without a restart. Help bubbles store the location name in their task
and build the messages each cycle; the readme tab tooltip can be a
function which is called when the readme opens.

Every feature gets an enabled switch from the framework. There is no
sensible meaning for a disabled community name, so the values are
always used and the feature description says the switch does nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:10:22 +00:00
..
2026-08-07 15:31:18 +00:00
2026-06-26 15:09:11 +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 ExpServerList = require("modules/exp_server_list")

local community = require("modules/exp_scenario/config/community_details").config



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

local description_length = 120



--- Cut a string to a number of bytes without splitting a utf8 character

--- @param str string

--- @param length number

--- @return string

local function shorten(str, length)

    if #str <= length then

        return str

    end

    local next_byte = str:byte(length + 1)

    while length > 0 and next_byte >= 0x80 and next_byte < 0xC0 do

        length = length - 1

        next_byte = str:byte(length + 1)

    end

    return str:sub(1, length) .. "..."

end



--- @class ExpGui_Readme.elements

local Elements = {}



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

local tabs = {}



--- Register a readme tab

--- @param caption LocalisedString

--- @param tooltip LocalisedString | fun(): LocalisedString Functions are called when the readme opens

--- @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 number



--- 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: ExpServerList.Server): LuaGuiElement

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

    :track_all_elements()

    :draw(function(def, parent, server)

        --- @cast def ExpGui_Readme.elements.join_server

        --- @cast server ExpServerList.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, server)

        return button

    end)

    :style{

        size = 20,

        padding = -1,

    }

    :on_click(function(def, player, button)

        --- @cast def ExpGui_Readme.elements.join_server

        local server = ExpServerList.get_server(def.data[button].server_id)

        if server and ExpServerList.is_online(server) then

            ExpServerList.request_connection(player, server, true)

        end

    end) --[[@as any]]



--- Refresh join server button

--- @param button LuaGuiElement

--- @param server ExpServerList.Server?

function Elements.join_server.refresh(button, server)

    local current = ExpServerList.get_current()

    local status, detail = "Online", nil

    if not server or not ExpServerList.is_online(server) then

        status = "Offline"

    elseif current and server.id == current.id then

        status = "Current"

    elseif current and server.factorio_version ~= current.factorio_version then

        status, detail = "Version", server.factorio_version

    elseif server.has_password then

        status = "Password"

    elseif current and server.mod_pack_name ~= current.mod_pack_name then

        status, detail = "Modded", server.mod_pack_name

    end



    -- Factorio drops nil parameters, which would shift the player count into __1__

    local player_count = server and server.player_count or 0

    button.tooltip = { "exp-gui_readme.servers-connect-" .. status, detail or "", player_count }



    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 join buttons

function Elements.join_server.refresh_all()

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

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

        Elements.join_server.refresh(button, ExpServerList.get_server(server_id))

    end

end



--- Welcome tab

define_tab(

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

    function() return { "exp-gui_readme.welcome-tooltip", community.community_name } end,

    Gui.define("readme/welcome")

    :draw(function(_, parent)

        local player = Gui.get_player(parent)



        local server_details = {

            name = community.community_name .. " S0 - Local",

            welcome = "Failed to load description: disconnected from the controller.",

            reset_time = "Not Set",

        }



        local current = ExpServerList.get_current()

        if current then

            server_details = {

                name = current.name,

                welcome = current.welcome ~= "" and current.welcome or { "exp-gui_readme.welcome-default" },

                reset_time = current.reset_time ~= "" and current.reset_time or "Not Set",

            }

        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.centered_label(center_flow, 380, { "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",

                community.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



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

        for _, server in ipairs(ExpServerList.get_servers()) do

            local description = shorten(server.description, description_length)

            local tooltip = description ~= server.description and server.description or nil

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

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

            Elements.join_server(factorio_servers, server)

        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, community[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 tooltip = tab.tooltip

            if type(tooltip) == "function" then

                tooltip = tooltip()

            end



            local gui_tab = pane.add{

                type = "tab",

                style = "frame_tab",

                caption = tab.caption,

                tooltip = 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,

        [ExpServerList.on_servers_updated] = Elements.join_server.refresh_all,

    },

}