Files
factorio-expcluster/exp_scenario/module/gui/readme.lua
T
bbassieandClaude Opus 5.5 ad1c83def8 Address review on the statistics plugin
The plugin is now exp_player_statistics (@expcluster/player-statistics),
with the module, global, permission, route and database directory
renamed to match. The exp-statistics locale section keeps its name.

Every statistic is tracked unconditionally, so the enable flags are gone
from config.lua. Playtime and JoinCount are no longer counted in game:
clusterio already keeps online time and joins for each user, so the
controller fills them in from the user when a player's totals are
requested, and the web page does the same from the user list. They are
not stored or adjusted by the plugin.

The web table has a column picker, a multiple select with check boxes,
Select all and Reset buttons, showing "N statistics selected". The
choice is kept in local storage. The user page shows the statistics of
the player in a grid like the core player stats.

The readme statistics table drops its second title and uses the short
time format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:36:00 +00:00

595 lines
22 KiB
Lua

--[[-- 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_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]]
)
--- 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,
},
}