Files
2026-11-09 09:22:27 +00:00
..
2026-08-07 15:31:18 +00:00
2026-06-26 15:09:11 +01:00
2026-11-07 15:18:47 +00:00
2026-11-04 15:58:43 +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_roles")

local Commands = require("modules/exp_commands")

local ExpPlayerStatistics = require("modules/exp_player_statistics")

local ExpServerList = require("modules/exp_server_list")

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



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"

        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]]

)



--- Content area for the statistics 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_name = Gui.get_player(parent).name



        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)

        local statistics = scroll_pane.add{ type = "table", column_count = 4, style = "bordered_table" }

        statistics.style.cell_padding = 0

        statistics.style.horizontally_stretchable = true

        for _, name in ipairs(ExpPlayerStatistics.display_order) do

            local value = ExpPlayerStatistics.format(name, ExpPlayerStatistics.get(player_name, name), true)

            local tooltip = { "exp-statistics." .. name .. "-tooltip" }

            Gui.elements.centered_label(statistics, 150, { "exp-statistics." .. name }, tooltip)

            Gui.elements.centered_label(statistics, 130, { "exp-gui_readme.data-format", value, ExpPlayerStatistics.units[name] or "" }, tooltip)

        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,

    },

}