From c0289ed8a7d8c2de40c98e719856ad1d929f55c8 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:38:39 +0000 Subject: [PATCH] Address review on exp_server_list The controller and instance plugins are init functions with closures. The controller batches refreshes with setMany and deleteMany, and the instance gates updates on its status instead of a started flag. short_name is now display_name. New always_on_top field sorts servers first in get_servers. The record carries has_password from the game password in factorio.settings, never the password itself, and the description falls back to the one in factorio.settings. A controller welcome is used when the instance has none, and the readme falls back to a default welcome from the locale. Lua gets is_synced for connect.lua. The readme passes an empty detail instead of nil to the join tooltip so the player count stays in __2__, restores the Password status, shows the welcome title as a centered label and shortens long descriptions with the full text as a tooltip. The Lua tests assert the lookups they index, which fixes the lint. Co-Authored-By: Claude Opus 5.5 --- exp_scenario/module/commands/connect.lua | 2 +- exp_scenario/module/gui/readme.lua | 37 +++- exp_scenario/module/locale/en.cfg | 2 + exp_scenario/module/locale/zh-CN.cfg | 1 + exp_scenario/module/locale/zh-TW.cfg | 1 + exp_server_list/README.md | 24 +- exp_server_list/controller.ts | 245 +++++++++------------ exp_server_list/index.ts | 29 ++- exp_server_list/instance.ts | 94 +++----- exp_server_list/messages.ts | 22 +- exp_server_list/module/control.lua | 25 ++- exp_server_list/test/controller.test.js | 267 +++++++++++++---------- exp_server_list/test/instance.test.js | 143 ++++++------ exp_server_list/test/module/control.lua | 50 +++-- exp_server_list/test/module/env.lua | 4 +- 15 files changed, 528 insertions(+), 418 deletions(-) diff --git a/exp_scenario/module/commands/connect.lua b/exp_scenario/module/commands/connect.lua index 90a19568..f5c2c0dd 100644 --- a/exp_scenario/module/commands/connect.lua +++ b/exp_scenario/module/commands/connect.lua @@ -49,7 +49,7 @@ end --- @param is_address boolean? --- @return boolean, LocalisedString | ExpServerList.Server | string # True for success local function resolve_server(server, is_address) - if is_address or not ExpServerList.get_current() then + if is_address or not ExpServerList.is_synced() then return true, server end return find_server(server) diff --git a/exp_scenario/module/gui/readme.lua b/exp_scenario/module/gui/readme.lua index 8548b30b..b0d4e510 100644 --- a/exp_scenario/module/gui/readme.lua +++ b/exp_scenario/module/gui/readme.lua @@ -16,6 +16,23 @@ local format_time = ExpUtil.format_time_factory_locale{ format = "long", days = 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 = {} @@ -137,12 +154,15 @@ function Elements.join_server.refresh(button, server) 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, player_count } + button.tooltip = { "exp-gui_readme.servers-connect-" .. status, detail or "", player_count } if status == "Offline" or status == "Current" then button.enabled = false @@ -154,6 +174,11 @@ function Elements.join_server.refresh(button, server) 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" @@ -192,7 +217,7 @@ define_tab( if current then server_details = { name = current.name, - welcome = current.welcome, + 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 @@ -205,7 +230,7 @@ define_tab( 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, { "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" } @@ -318,8 +343,10 @@ define_tab( local factorio_servers = Elements.title_table(scroll_pane, 225, { "exp-gui_readme.servers-factorio" }, 3) for _, server in ipairs(ExpServerList.get_servers()) do - Gui.elements.centered_label(factorio_servers, 110, server.short_name) - Gui.elements.centered_label(factorio_servers, 436, server.description) + 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 diff --git a/exp_scenario/module/locale/en.cfg b/exp_scenario/module/locale/en.cfg index 0ccbf7e0..f5856db3 100644 --- a/exp_scenario/module/locale/en.cfg +++ b/exp_scenario/module/locale/en.cfg @@ -320,6 +320,7 @@ main-tooltip=Information welcome-tab=Welcome welcome-tooltip=Welcome to __1__ welcome-title=Welcome to __1__ +welcome-default=Thanks for playing on our server, we hope you enjoy your stay. welcome-general=Welcome to __1__; we host many Factorio servers. While you are here, we ask you to follow our rules. You can find these in the tab above. You can also find our custom commands and links to our other servers. This map has been online for __2__.\nPlease note that our servers reset periodically, the next reset is: __3__ welcome-roles=We run a custom role system to help protect the work of others. As a result you may not be able to use your deconstruction planner yet or drop items on the ground. Roles also give you access to some custom features such as adding tasks to our task list or making new warp points.\nYou have been assigned the roles: __1__ welcome-chat=Chatting can be difficult for new players because it’s different than other games! It’s very simple, the button you need to press is the “GRAVE/TILDE” key (which is located under the “ESC key”). If you would like to change the key, go to your Controls tab in options.\nThe setting you need to change is “Toggle chat (and Lua console)” you currently have it set to "__CONTROL__toggle-console__" @@ -353,6 +354,7 @@ servers-open-in-browser=Open in your browser servers-connect-Offline=Server is currently offline servers-connect-Current=This is your current server servers-connect-Version=Server is on a different version: __1__ +servers-connect-Password=Server requires a password\nPlayers online: __2__ servers-connect-Modded=Server uses the __1__ mod pack, mods may need to be downloaded\nPlayers online: __2__ servers-connect-Online=Server is online\nPlayers online: __2__ backers-tab=Backers diff --git a/exp_scenario/module/locale/zh-CN.cfg b/exp_scenario/module/locale/zh-CN.cfg index 6ee70795..535b11fc 100644 --- a/exp_scenario/module/locale/zh-CN.cfg +++ b/exp_scenario/module/locale/zh-CN.cfg @@ -342,6 +342,7 @@ servers-factorio=異星工廠伺服器 servers-connect-Offline=該服務器目前沒有在運行 servers-connect-Current=你目前所在的伺服器 servers-connect-Version=該服務器運行不同的遊戲版本: __1__ +servers-connect-Password=該服務器需要密碼 servers-connect-Modded=該服務器需要下載模組 servers-connect-Online=該服務器在線 servers-external=外部連結 diff --git a/exp_scenario/module/locale/zh-TW.cfg b/exp_scenario/module/locale/zh-TW.cfg index 6ee70795..535b11fc 100644 --- a/exp_scenario/module/locale/zh-TW.cfg +++ b/exp_scenario/module/locale/zh-TW.cfg @@ -342,6 +342,7 @@ servers-factorio=異星工廠伺服器 servers-connect-Offline=該服務器目前沒有在運行 servers-connect-Current=你目前所在的伺服器 servers-connect-Version=該服務器運行不同的遊戲版本: __1__ +servers-connect-Password=該服務器需要密碼 servers-connect-Modded=該服務器需要下載模組 servers-connect-Online=該服務器在線 servers-external=外部連結 diff --git a/exp_server_list/README.md b/exp_server_list/README.md index b94218f6..93d18450 100644 --- a/exp_server_list/README.md +++ b/exp_server_list/README.md @@ -2,29 +2,37 @@ Shares the details of every instance with every other instance. Replaces `expcore/external` from exp_legacy, which read the same data from a file written by an outside service. -The controller keeps one record per instance with its name, mod pack, Factorio version, players online, status and address. The address is the public address of the host and the game port of the instance, the same one the web UI shows. Nothing is saved, the records are rebuilt from the instances when the controller starts. +The controller keeps one record per instance with its name, mod pack, Factorio version, players online, status and address. The address is the public address of the host and the game port of the instance, the same one the web UI shows. Whether the server has a game password is sent, the password is not. Nothing is saved, the records are rebuilt from the instances when the controller starts. ## Config -All fields are on the instance. +On the controller: | Field | Default | Description | |---|---|---| -| `exp_server_list.short_name` | | Name shown in the server list, the instance name when empty. | -| `exp_server_list.description` | | One line about the server shown in the server list. | -| `exp_server_list.welcome` | | Message shown on the welcome tab of the readme. | +| `exp_server_list.welcome` | | Message shown on the welcome tab of the readme for instances without their own. | + +On the instance: + +| Field | Default | Description | +|---|---|---| +| `exp_server_list.display_name` | | Name shown in the server list, the instance name when empty. | +| `exp_server_list.description` | | One line about the server shown in the server list, the description in `factorio.settings` when empty. | +| `exp_server_list.welcome` | | Message shown on the welcome tab of the readme, the controller welcome when empty. | | `exp_server_list.reset_time` | | When the map next resets, shown on the welcome tab of the readme. | | `exp_server_list.hidden` | false | Leave this instance out of the server list on other instances. | +| `exp_server_list.always_on_top` | false | List this instance before those without it. | ## Lua ```lua local ExpServerList = require("modules/exp_server_list") -ExpServerList.get_servers() -- Every server that is not hidden, sorted by name +ExpServerList.get_servers() -- Every server that is not hidden, always on top first then by name ExpServerList.get_server(instance_id) +ExpServerList.is_synced() -- false until the controller has sent the list ExpServerList.get_current() -- nil until the controller has sent the list -ExpServerList.find_servers("weekly") -- Matches name, short name or id +ExpServerList.find_servers("weekly") -- Matches name, display name or id ExpServerList.is_online(server) ExpServerList.request_connection(player, server_or_address, self_requested) @@ -33,4 +41,4 @@ ExpServerList.request_connection(player, server_or_address, self_requested) ## Node -Controller plugins can read the records with `ControllerPlugin.get(controller).servers`. Web plugins and control connections can subscribe to `ServerUpdatesEvent` with the `exp_server_list.list` permission. +`ServerListRequest` returns every record, hidden ones included. Web plugins and control connections can subscribe to `ServerUpdatesEvent` with the `exp_server_list.list` permission. diff --git a/exp_server_list/controller.ts b/exp_server_list/controller.ts index 48f05395..7a105b54 100644 --- a/exp_server_list/controller.ts +++ b/exp_server_list/controller.ts @@ -1,19 +1,25 @@ -import type { Controller, ControllerPluginContext, HostRecord, InstanceRecord } from "@clusterio/controller"; +import type { ControllerPluginContext, InstanceRecord } from "@clusterio/controller"; import * as lib from "@clusterio/lib"; import * as messages from "./messages.js"; -const loaded = new WeakMap(); - /** Instance config fields which change the record */ const recordFields = new Set([ "instance.name", "instance.assigned_host", "factorio.mod_pack_id", - "exp_server_list.short_name", + "factorio.settings", + "exp_server_list.display_name", "exp_server_list.description", "exp_server_list.welcome", "exp_server_list.reset_time", "exp_server_list.hidden", + "exp_server_list.always_on_top", +]); + +/** Controller config fields which change every record */ +const controllerFields = new Set([ + "controller.default_mod_pack_id", + "exp_server_list.welcome", ]); /** @@ -21,129 +27,19 @@ const recordFields = new Set([ * * Records are rebuilt from controller state on start, nothing is saved. */ -export class ControllerPlugin { - /** The plugin loaded on a controller, for other plugins to read the list. */ - static get(controller: Controller) { - return loaded.get(controller); - } +export default async function (context: ControllerPluginContext) { + const { controller, plugin } = context; + const servers = new lib.SubscribableDatastore(); - private constructor( - public controller: Controller, - public logger: lib.Logger, - public name: string, - public servers: lib.SubscribableDatastore, - ) {} - - static async fromContext(context: ControllerPluginContext) { - const controller = context.controller; - const plugin = new ControllerPlugin( - controller, context.logger, context.plugin.name, new lib.SubscribableDatastore(), - ); - loaded.set(controller, plugin); - - plugin.refreshAll(); - plugin.servers.on("update", plugin.serversUpdated.bind(plugin)); - controller.hosts.on("update", plugin.hostsUpdated.bind(plugin)); - - controller.subscriptions.handle(messages.ServerUpdatesEvent, plugin.handleServerSubscription.bind(plugin)); - controller.handle(messages.ServerListRequest, plugin.handleServerListRequest.bind(plugin)); - - const hooks = controller.hooks; - hooks.instanceStatusChanged.attach(plugin.name, plugin.onInstanceStatusChanged.bind(plugin)); - hooks.instanceConfigFieldChanged.attach(plugin.name, plugin.onInstanceConfigFieldChanged.bind(plugin)); - hooks.controllerConfigFieldChanged.attach(plugin.name, plugin.onControllerConfigFieldChanged.bind(plugin)); - hooks.playerEvent.attach(plugin.name, plugin.onPlayerEvent.bind(plugin)); - hooks.modPacksUpdated.attach(plugin.name, plugin.onModPacksUpdated.bind(plugin)); - return plugin; - } - - async onInstanceStatusChanged(instance: InstanceRecord) { - this.refresh(instance); - } - - async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string) { - if (recordFields.has(field)) { - this.refresh(instance); - } - } - - async onControllerConfigFieldChanged(field: string) { - if (field === "controller.default_mod_pack_id") { - this.refreshAll(); - } - } - - async onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) { - if (event.type === "join" || event.type === "leave") { - this.refresh(instance); - } - } - - async onModPacksUpdated() { - this.refreshAll(); - } - - /** The public address of a host arrives after it connects */ - hostsUpdated(hosts: HostRecord[]) { - const hostIds = new Set(hosts.map(host => host.id)); - for (const instance of this.controller.instances.values()) { - const hostId = instance.config.get("instance.assigned_host"); - if (hostId !== null && hostIds.has(hostId)) { - this.refresh(instance); - } - } - } - - refreshAll() { - for (const instance of this.controller.instances.values()) { - this.refresh(instance); - } - } - - /** Rebuild the record of an instance, and store it if anything changed */ - refresh(instance: InstanceRecord) { - const existing = this.servers.get(instance.id); - if (instance.isDeleted) { - if (existing) { - this.servers.delete(existing); - } - return; - } - - const record = this.buildRecord(instance); - if (!existing || !existing.sameAs(record)) { - this.servers.set(record); - } - } - - buildRecord(instance: InstanceRecord) { - const config = instance.config; - const running = instance.status === "running"; - return new messages.ServerRecord( - instance.id, - config.get("instance.name"), - config.get("exp_server_list.short_name") || config.get("instance.name"), - config.get("exp_server_list.description"), - config.get("exp_server_list.welcome"), - config.get("exp_server_list.reset_time"), - config.get("exp_server_list.hidden"), - this.modPackName(instance), - instance.factorioVersion === "latest" ? "" : instance.factorioVersion ?? "", - running ? this.playerCount(instance.id) : 0, - instance.status, - this.address(instance), - ); - } - - modPackName(instance: InstanceRecord) { + function modPackName(instance: InstanceRecord) { const id = instance.config.get("factorio.mod_pack_id") - ?? this.controller.config.get("controller.default_mod_pack_id"); - return id !== null && id !== undefined ? this.controller.modPacks.get(id)?.name ?? "" : ""; + ?? controller.config.get("controller.default_mod_pack_id"); + return id !== null && id !== undefined ? controller.modPacks.get(id)?.name ?? "" : ""; } - playerCount(instanceId: number) { + function playerCount(instanceId: number) { let count = 0; - for (const user of this.controller.users.records.values()) { + for (const user of controller.users.records.values()) { if (user.instances.has(instanceId)) { count += 1; } @@ -152,29 +48,106 @@ export class ControllerPlugin { } /** Same as the address the web ui shows for the instance */ - address(instance: InstanceRecord) { + function address(instance: InstanceRecord) { const hostId = instance.config.get("instance.assigned_host"); - const publicAddress = hostId !== null ? this.controller.hosts.get(hostId)?.publicAddress : undefined; + const publicAddress = hostId !== null ? controller.hosts.get(hostId)?.publicAddress : undefined; if (!publicAddress) { return ""; } return instance.gamePort === undefined ? publicAddress : `${publicAddress}:${instance.gamePort}`; } - serversUpdated(updates: messages.ServerRecord[]) { - this.controller.subscriptions.broadcast(new messages.ServerUpdatesEvent(updates)); + function buildRecord(instance: InstanceRecord) { + const config = instance.config; + const settings = config.get("factorio.settings"); + const settingString = (name: string) => (typeof settings[name] === "string" ? settings[name] : ""); + return new messages.ServerRecord( + instance.id, + config.get("instance.name"), + config.get("exp_server_list.display_name") || config.get("instance.name"), + config.get("exp_server_list.description") || settingString("description"), + config.get("exp_server_list.welcome") || controller.config.get("exp_server_list.welcome"), + config.get("exp_server_list.reset_time"), + config.get("exp_server_list.hidden"), + config.get("exp_server_list.always_on_top"), + settingString("game_password") !== "", + modPackName(instance), + instance.factorioVersion === "latest" ? "" : instance.factorioVersion ?? "", + instance.status === "running" ? playerCount(instance.id) : 0, + instance.status, + address(instance), + ); } - async handleServerSubscription(request: lib.SubscriptionRequest) { - const updates = [...this.servers.values()].filter(server => server.updatedAtMs > request.lastRequestTimeMs); + /** Rebuild the records of the instances, and store the ones which changed in one update */ + function refresh(instances: Iterable) { + const changed: messages.ServerRecord[] = []; + const deleted: messages.ServerRecord[] = []; + for (const instance of instances) { + const existing = servers.get(instance.id); + if (instance.isDeleted) { + if (existing) { + deleted.push(existing); + } + continue; + } + + const record = buildRecord(instance); + if (!existing || !existing.sameAs(record)) { + changed.push(record); + } + } + + if (changed.length) { + servers.setMany(changed); + } + if (deleted.length) { + servers.deleteMany(deleted); + } + } + + const refreshAll = () => refresh(controller.instances.values()); + + refreshAll(); + servers.on("update", updates => { + controller.subscriptions.broadcast(new messages.ServerUpdatesEvent(updates)); + }); + + // The public address of a host arrives after it connects + controller.hosts.on("update", hosts => { + const hostIds = new Set(hosts.map(host => host.id)); + refresh([...controller.instances.values()].filter(instance => { + const hostId = instance.config.get("instance.assigned_host"); + return hostId !== null && hostIds.has(hostId); + })); + }); + + controller.subscriptions.handle(messages.ServerUpdatesEvent, async request => { + const updates = [...servers.values()].filter(server => server.updatedAtMs > request.lastRequestTimeMs); return updates.length ? new messages.ServerUpdatesEvent(updates) : null; - } + }); + controller.handle(messages.ServerListRequest, async () => [...servers.values()]); - async handleServerListRequest() { - return [...this.servers.values()]; - } -} - -export default async function (context: ControllerPluginContext) { - await ControllerPlugin.fromContext(context); + const hooks = controller.hooks; + hooks.instanceStatusChanged.attach(plugin.name, async instance => { + refresh([instance]); + }); + hooks.instanceConfigFieldChanged.attach(plugin.name, async (instance, field) => { + if (recordFields.has(field)) { + refresh([instance]); + } + }); + hooks.controllerConfigFieldChanged.attach(plugin.name, async field => { + if (controllerFields.has(field)) { + refreshAll(); + } + }); + hooks.playerEvent.attach(plugin.name, async (instance, event) => { + if (event.type === "join" || event.type === "leave") { + refresh([instance]); + } + }); + hooks.modPacksUpdated.attach(plugin.name, async () => { + refreshAll(); + }); } diff --git a/exp_server_list/index.ts b/exp_server_list/index.ts index 33ae3f78..2bbcc7b7 100644 --- a/exp_server_list/index.ts +++ b/exp_server_list/index.ts @@ -2,12 +2,17 @@ import * as lib from "@clusterio/lib"; import * as messages from "./messages.js"; declare module "@clusterio/lib" { + export interface ControllerConfigFields { + "exp_server_list.welcome": string; + } + export interface InstanceConfigFields { - "exp_server_list.short_name": string; + "exp_server_list.display_name": string; "exp_server_list.description": string; "exp_server_list.welcome": string; "exp_server_list.reset_time": string; "exp_server_list.hidden": boolean; + "exp_server_list.always_on_top": boolean; } export interface Permissions { @@ -22,22 +27,30 @@ export const plugin: lib.PluginDeclaration = { controllerEntrypoint: "./dist/node/controller.js", instanceEntrypoint: "./dist/node/instance.js", + controllerConfigFields: { + "exp_server_list.welcome": { + title: "Welcome", + description: "Message shown on the welcome tab of the readme for instances without their own", + type: "string", + initialValue: "", + }, + }, instanceConfigFields: { - "exp_server_list.short_name": { - title: "Short Name", + "exp_server_list.display_name": { + title: "Display Name", description: "Name shown in the server list, the instance name when empty", type: "string", initialValue: "", }, "exp_server_list.description": { title: "Description", - description: "One line about the server shown in the server list", + description: "One line about the server shown in the server list, the server description when empty", type: "string", initialValue: "", }, "exp_server_list.welcome": { title: "Welcome", - description: "Message shown on the welcome tab of the readme", + description: "Message shown on the welcome tab of the readme, the controller welcome when empty", type: "string", initialValue: "", }, @@ -53,6 +66,12 @@ export const plugin: lib.PluginDeclaration = { type: "boolean", initialValue: false, }, + "exp_server_list.always_on_top": { + title: "Always On Top", + description: "List this instance before those without it", + type: "boolean", + initialValue: false, + }, }, messages: [ diff --git a/exp_server_list/instance.ts b/exp_server_list/instance.ts index fc945bd9..5f3b80fa 100644 --- a/exp_server_list/instance.ts +++ b/exp_server_list/instance.ts @@ -1,63 +1,19 @@ -import type { Instance, InstancePluginContext } from "@clusterio/host"; +import type { InstancePluginContext } from "@clusterio/host"; import * as lib from "@clusterio/lib"; import * as messages from "./messages.js"; -/** Sends the server list to the lua side, see module/control.lua. */ -export class InstancePlugin { - /** Rcon is up and the lua side can take updates */ - started = false; +/** + * Sends the server list to the lua side, see module/control.lua. + * + * The subscription is made by the start hook once rcon is up, so updates + * only arrive while the game can take them. + */ +export default async function (context: InstancePluginContext) { + const { instance, plugin } = context; - private constructor( - public instance: Instance, - public logger: lib.Logger, - public name: string, - ) {} - - static async fromContext(context: InstancePluginContext) { - const instance = context.instance; - const plugin = new InstancePlugin(instance, context.logger, context.plugin.name); - - instance.handle(messages.ServerUpdatesEvent, plugin.handleServerUpdatesEvent.bind(plugin)); - instance.hooks.start.attach(plugin.name, plugin.onStart.bind(plugin)); - instance.hooks.exit.attach(plugin.name, plugin.onExit.bind(plugin)); - instance.hooks.controllerConnectionEvent.attach(plugin.name, plugin.onControllerConnectionEvent.bind(plugin)); - return plugin; - } - - async onStart() { - this.started = true; - await this.syncServers(); - } - - async onExit() { - this.started = false; - } - - /** A new session means the controller restarted and forgot the subscription. */ - async onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { - if (event === "connect" && this.started) { - await this.syncServers(); - } - } - - /** Subscribe, then replace the whole list */ - async syncServers() { - await this.instance.sendTo("controller", new lib.SubscriptionRequest( - `exp_server_list:${messages.ServerUpdatesEvent.name}`, "subscribe", Date.now(), - )); - const servers = await this.instance.sendTo("controller", new messages.ServerListRequest()); - await this.luaSendServers(servers, true); - } - - async handleServerUpdatesEvent(event: messages.ServerUpdatesEvent) { - if (this.started) { - await this.luaSendServers(event.updates, false); - } - } - - async luaSendServers(servers: messages.ServerRecord[], replace: boolean) { + async function luaSendServers(servers: messages.ServerRecord[], replace: boolean) { const json = JSON.stringify({ - current_id: this.instance.id, + current_id: instance.id, replace, servers: servers.map(server => server.toJSON()), }); @@ -66,12 +22,32 @@ export class InstancePlugin { while (json.includes(`]${level}]`)) { level += "="; } - await this.instance.sendRcon( + await instance.sendRcon( `/sc exp_server_list.receive_update(helpers.json_to_table[${level}[${json}]${level}])`, true, ); } -} -export default async function (context: InstancePluginContext) { - await InstancePlugin.fromContext(context); + /** Subscribe, then replace the whole list */ + async function syncServers() { + await instance.sendTo("controller", new lib.SubscriptionRequest( + `exp_server_list:${messages.ServerUpdatesEvent.name}`, "subscribe", Date.now(), + )); + const servers = await instance.sendTo("controller", new messages.ServerListRequest()); + await luaSendServers(servers, true); + } + + instance.handle(messages.ServerUpdatesEvent, async event => { + if (instance.status === "starting" || instance.status === "running") { + await luaSendServers(event.updates, false); + } + }); + + instance.hooks.start.attach(plugin.name, syncServers); + + // A new session means the controller restarted and forgot the subscription + instance.hooks.controllerConnectionEvent.attach(plugin.name, async event => { + if (event === "connect" && instance.status === "running") { + await syncServers(); + } + }); } diff --git a/exp_server_list/messages.ts b/exp_server_list/messages.ts index f61cf3da..19c70f8f 100644 --- a/exp_server_list/messages.ts +++ b/exp_server_list/messages.ts @@ -11,11 +11,15 @@ export class ServerRecord { constructor( public id: number, public name: string, - public shortName: string, + public displayName: string, public description: string, public welcome: string, public resetTime: string, public hidden: boolean, + /** Sorted before the servers which are not */ + public alwaysOnTop: boolean, + /** The password itself is never sent */ + public hasPassword: boolean, /** Empty when the instance has no mod pack */ public modPackName: string, /** Version the server last ran, empty if it has never started */ @@ -31,11 +35,13 @@ export class ServerRecord { static jsonSchema = Type.Object({ id: Type.Integer(), name: Type.String(), - short_name: Type.String(), + display_name: Type.String(), description: Type.String(), welcome: Type.String(), reset_time: Type.String(), hidden: Type.Boolean(), + always_on_top: Type.Boolean(), + has_password: Type.Boolean(), mod_pack_name: Type.String(), factorio_version: Type.String(), player_count: Type.Integer(), @@ -49,11 +55,13 @@ export class ServerRecord { const json: Static = { id: this.id, name: this.name, - short_name: this.shortName, + display_name: this.displayName, description: this.description, welcome: this.welcome, reset_time: this.resetTime, hidden: this.hidden, + always_on_top: this.alwaysOnTop, + has_password: this.hasPassword, mod_pack_name: this.modPackName, factorio_version: this.factorioVersion, player_count: this.playerCount, @@ -76,11 +84,13 @@ export class ServerRecord { return new this( json.id, json.name, - json.short_name, + json.display_name, json.description, json.welcome, json.reset_time, json.hidden, + json.always_on_top, + json.has_password, json.mod_pack_name, json.factorio_version, json.player_count, @@ -95,11 +105,13 @@ export class ServerRecord { sameAs(other: ServerRecord) { return this.id === other.id && this.name === other.name - && this.shortName === other.shortName + && this.displayName === other.displayName && this.description === other.description && this.welcome === other.welcome && this.resetTime === other.resetTime && this.hidden === other.hidden + && this.alwaysOnTop === other.alwaysOnTop + && this.hasPassword === other.hasPassword && this.modPackName === other.modPackName && this.factorioVersion === other.factorioVersion && this.playerCount === other.playerCount diff --git a/exp_server_list/module/control.lua b/exp_server_list/module/control.lua index 58674250..23b79f91 100644 --- a/exp_server_list/module/control.lua +++ b/exp_server_list/module/control.lua @@ -3,7 +3,7 @@ The details of every instance in the cluster, kept up to date by the controller. The instance plugin sends the whole list when the server starts and each changed server after that. Hidden servers are kept but left out of -get_servers and find_servers. +get_servers and find_servers. Servers which are always on top come first. local ExpServerList = require("modules/exp_server_list") @@ -19,11 +19,13 @@ local Storage = require("modules/exp_util/storage") --- @class ExpServerList.Server --- @field id number The instance id --- @field name string ---- @field short_name string The name when not set ---- @field description string ---- @field welcome string +--- @field display_name string The name when not set +--- @field description string The server description when not set +--- @field welcome string The controller welcome when not set, can still be empty --- @field reset_time string --- @field hidden boolean +--- @field always_on_top boolean +--- @field has_password boolean --- @field mod_pack_name string --- @field factorio_version string Empty if it has never started --- @field player_count number @@ -55,10 +57,13 @@ end) --- @param a ExpServerList.Server --- @param b ExpServerList.Server local function by_name(a, b) + if a.always_on_top ~= b.always_on_top then + return a.always_on_top + end return a.name < b.name end ---- Get every server that is not hidden, sorted by name +--- Get every server that is not hidden, those always on top first then by name --- @return ExpServerList.Server[] function ExpServerList.get_servers() local rtn, count = {}, 0 @@ -79,20 +84,26 @@ function ExpServerList.get_server(server_id) return servers[server_id] end +--- True once the controller has sent the list +--- @return boolean +function ExpServerList.is_synced() + return current.id ~= nil +end + --- Get this server, nil until the controller has sent the list --- @return ExpServerList.Server? function ExpServerList.get_current() return current.id and servers[current.id] end ---- Get the servers that are not hidden whose name, short name or id contains the search, sorted by name +--- Get the servers that are not hidden whose name, display name or id contains the search, sorted like get_servers --- @param search string --- @return ExpServerList.Server[] function ExpServerList.find_servers(search) search = search:lower() local rtn, count = {}, 0 for _, server in ipairs(ExpServerList.get_servers()) do - local str = server.name .. " " .. server.short_name .. " " .. server.id + local str = server.name .. " " .. server.display_name .. " " .. server.id if str:lower():find(search, 1, true) then count = count + 1 rtn[count] = server diff --git a/exp_server_list/test/controller.test.js b/exp_server_list/test/controller.test.js index cb059e10..11a8e853 100644 --- a/exp_server_list/test/controller.test.js +++ b/exp_server_list/test/controller.test.js @@ -1,7 +1,7 @@ import t from "tap"; import * as lib from "@clusterio/lib"; import { Controller, HostRecord, InstanceRecord } from "@clusterio/controller"; -import entrypoint, { ControllerPlugin } from "../dist/node/controller.js"; +import entrypoint from "../dist/node/controller.js"; import { plugin as pluginDeclaration } from "../dist/node/index.js"; import * as messages from "../dist/node/messages.js"; @@ -13,7 +13,7 @@ for (const Message of pluginDeclaration.messages) { const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; -/** Build the plugin around a real controller, which is side effect free while not started. */ +/** Run the plugin on a real controller, which is side effect free while not started. */ async function startPlugin(t2, setup = () => {}) { const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir(), @@ -28,9 +28,22 @@ async function startPlugin(t2, setup = () => {}) { } }; - const context = { controller, logger, plugin: pluginDeclaration, metrics: undefined }; - const plugin = await ControllerPlugin.fromContext(context); - return { plugin, controller, broadcasts }; + const handlers = new Map(); + controller.handle = (Class, handler) => handlers.set(Class, handler); + controller.subscriptions.handle = (Class, handler) => handlers.set(Class, handler); + + await entrypoint({ controller, logger, plugin: pluginDeclaration, metrics: undefined }); + const list = async () => handlers.get(messages.ServerListRequest)(new messages.ServerListRequest()); + return { + controller, + broadcasts, + list, + /** The record of one instance */ + get: async id => (await list()).find(server => server.id === id), + subscribe: since => handlers.get(messages.ServerUpdatesEvent)( + new lib.SubscriptionRequest("exp_server_list:ServerUpdatesEvent", "subscribe", since), + ), + }; } function addHost(controller, id, publicAddress = "") { @@ -50,13 +63,10 @@ function addInstance(controller, id, { hostId = 1, status = "running", gamePort, return record; } -const subscription = since => new lib.SubscriptionRequest("exp_server_list:ServerUpdatesEvent", "subscribe", since); const join = (controller, name, instanceId) => controller.users.getOrCreateUser(name).notifyJoin(instanceId); -t.test("entrypoint", async t2 => { - const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() }); - const controller = new Controller(logger, [], controllerConfig); - await entrypoint({ controller, logger, plugin: pluginDeclaration, metrics: undefined }); +t.test("entrypoint attaches to the controller hooks", async t2 => { + const { controller } = await startPlugin(t2); const hooks = [ "instanceStatusChanged", "instanceConfigFieldChanged", @@ -67,121 +77,148 @@ t.test("entrypoint", async t2 => { for (const hook of hooks) { t2.ok([...controller.hooks[hook].attached].includes("exp_server_list"), `attached to ${hook}`); } - t2.ok(ControllerPlugin.get(controller), "other plugins can find it"); }); -t.test("class ControllerPlugin", t2 => { - t2.test("builds a record for every instance on start", async t3 => { - const { plugin } = await startPlugin(t3, controller => { - addHost(controller, 1, "example.com"); - addInstance(controller, 10, { - gamePort: 34197, - factorioVersion: "2.1.14", - fields: { - "exp_server_list.description": "Weekly reset", - "exp_server_list.hidden": true, - }, - }); - addInstance(controller, 11, { - status: "stopped", - factorioVersion: "latest", - fields: { "exp_server_list.short_name": "S11" }, - }); - }); - - const server = plugin.servers.get(10); - t3.equal(server.name, "instance-10"); - t3.equal(server.shortName, "instance-10", "short name falls back to the name"); - t3.equal(server.description, "Weekly reset"); - t3.equal(server.hidden, true); - t3.equal(server.factorioVersion, "2.1.14"); - t3.equal(server.address, "example.com:34197", "public address of the host and the game port"); - t3.equal(server.status, "running"); - - const stopped = plugin.servers.get(11); - t3.equal(stopped.shortName, "S11"); - t3.equal(stopped.factorioVersion, "", "the latest target is not a version"); - t3.equal(stopped.address, "example.com", "host address alone without a game port"); - }); - - t2.test("uses the mod pack of the instance or the default", async t3 => { - const { plugin, controller } = await startPlugin(t3, c => { - addHost(c, 1); - c.modPacks.setMany(lib.ModPack.defaultModPacks.map(pack => lib.ModPack.fromJSON(pack.toJSON()))); - const packs = [...c.modPacks.values()]; - c.config.set("controller.default_mod_pack_id", packs[0].id); - addInstance(c, 10); - addInstance(c, 11, { fields: { "factorio.mod_pack_id": packs[1].id } }); - }); - const packs = [...controller.modPacks.values()]; - t3.equal(plugin.servers.get(10).modPackName, packs[0].name, "the default mod pack"); - t3.equal(plugin.servers.get(11).modPackName, packs[1].name, "the instance mod pack"); - t3.equal(plugin.servers.get(10).address, "", "no address while the host has none"); - }); - - t2.test("counts players only while running", async t3 => { - const { plugin, controller, broadcasts } = await startPlugin(t3, c => { - addHost(c, 1); - addInstance(c, 10); - }); - const instance = controller.instances.get(10); - - join(controller, "alice", 10); - join(controller, "bob", 10); - await controller.hooks.playerEvent.invoke(instance, { type: "join", name: "bob" }); - t3.equal(plugin.servers.get(10).playerCount, 2); - t3.equal(broadcasts.at(-1).updates[0].playerCount, 2, "the change is broadcast"); - - instance.status = "stopped"; - await controller.hooks.instanceStatusChanged.invoke(instance, "running"); - t3.equal(plugin.servers.get(10).playerCount, 0, "players left online by a crash are not counted"); - }); - - t2.test("follows config, host and status changes", async t3 => { - const { plugin, controller, broadcasts } = await startPlugin(t3, c => { - addHost(c, 1); - addInstance(c, 10, { gamePort: 34197 }); - }); - const instance = controller.instances.getMutable(10); - - instance.config.set("instance.name", "Weekly"); - t3.equal(plugin.servers.get(10).name, "Weekly", "renames are picked up"); - - instance.config.set("exp_server_list.welcome", "Hello"); - t3.equal(plugin.servers.get(10).welcome, "Hello", "plugin fields are picked up"); - +t.test("builds a record for every instance on start", async t2 => { + const { get } = await startPlugin(t2, controller => { addHost(controller, 1, "example.com"); - t3.equal(plugin.servers.get(10).address, "example.com:34197", "the public address once the host sends it"); - - const count = broadcasts.length; - addHost(controller, 1, "example.com"); - t3.equal(broadcasts.length, count, "nothing is broadcast when nothing changed"); - - // What deleteInstance does once the host has deleted it - controller.instances.records.delete(instance); - await controller.hooks.instanceStatusChanged.invoke(instance, "running"); - t3.equal(plugin.servers.get(10), undefined, "deleted instances are removed"); - t3.equal(broadcasts.at(-1).updates[0].isDeleted, true, "and broadcast as deleted"); - }); - - t2.test(".handleServerSubscription() sends servers updated since the last request", async t3 => { - const { plugin } = await startPlugin(t3, controller => { - addHost(controller, 1); - addInstance(controller, 10); + addInstance(controller, 10, { + gamePort: 34197, + factorioVersion: "2.1.14", + fields: { + "exp_server_list.description": "Weekly reset", + "exp_server_list.hidden": true, + "exp_server_list.always_on_top": true, + "factorio.settings": { description: "From the settings", game_password: "secret" }, + }, + }); + addInstance(controller, 11, { + status: "stopped", + factorioVersion: "latest", + fields: { + "exp_server_list.display_name": "S11", + "factorio.settings": { description: "From the settings", game_password: "" }, + }, }); - const all = await plugin.handleServerSubscription(subscription(0)); - t3.equal(all.updates.length, 1); - const none = await plugin.handleServerSubscription(subscription(Date.now() + 1000)); - t3.equal(none, null); - t3.equal((await plugin.handleServerListRequest()).length, 1); }); - t2.end(); + const server = await get(10); + t2.equal(server.name, "instance-10"); + t2.equal(server.displayName, "instance-10", "display name falls back to the name"); + t2.equal(server.description, "Weekly reset", "own description wins over the settings"); + t2.equal(server.hidden, true); + t2.equal(server.alwaysOnTop, true); + t2.equal(server.hasPassword, true); + t2.notMatch(JSON.stringify(server.toJSON()), "secret", "the password is not sent"); + t2.equal(server.factorioVersion, "2.1.14"); + t2.equal(server.address, "example.com:34197", "public address of the host and the game port"); + t2.equal(server.status, "running"); + + const stopped = await get(11); + t2.equal(stopped.displayName, "S11"); + t2.equal(stopped.description, "From the settings", "description falls back to the settings"); + t2.equal(stopped.hasPassword, false, "an empty password is no password"); + t2.equal(stopped.factorioVersion, "", "the latest target is not a version"); + t2.equal(stopped.address, "example.com", "host address alone without a game port"); +}); + +t.test("uses the mod pack of the instance or the default", async t2 => { + const { get, controller } = await startPlugin(t2, c => { + addHost(c, 1); + c.modPacks.setMany(lib.ModPack.defaultModPacks.map(pack => lib.ModPack.fromJSON(pack.toJSON()))); + const packs = [...c.modPacks.values()]; + c.config.set("controller.default_mod_pack_id", packs[0].id); + addInstance(c, 10); + addInstance(c, 11, { fields: { "factorio.mod_pack_id": packs[1].id } }); + }); + const packs = [...controller.modPacks.values()]; + t2.equal((await get(10)).modPackName, packs[0].name, "the default mod pack"); + t2.equal((await get(11)).modPackName, packs[1].name, "the instance mod pack"); + t2.equal((await get(10)).address, "", "no address while the host has none"); +}); + +t.test("uses the controller welcome when the instance has none", async t2 => { + const { get, controller, broadcasts } = await startPlugin(t2, c => { + addHost(c, 1); + addInstance(c, 10); + addInstance(c, 11, { fields: { "exp_server_list.welcome": "Own welcome" } }); + }); + t2.equal((await get(10)).welcome, "", "empty when neither is set"); + + controller.config.set("exp_server_list.welcome", "Cluster welcome"); + await controller.hooks.controllerConfigFieldChanged.invoke("exp_server_list.welcome", ""); + t2.equal((await get(10)).welcome, "Cluster welcome"); + t2.equal((await get(11)).welcome, "Own welcome", "the instance welcome wins"); + t2.strictSame(broadcasts.at(-1).updates.map(server => server.id), [10], "only changed records are sent"); +}); + +t.test("counts players only while running", async t2 => { + const { get, controller, broadcasts } = await startPlugin(t2, c => { + addHost(c, 1); + addInstance(c, 10); + }); + const instance = controller.instances.get(10); + + join(controller, "alice", 10); + join(controller, "bob", 10); + await controller.hooks.playerEvent.invoke(instance, { type: "join", name: "bob" }); + t2.equal((await get(10)).playerCount, 2); + t2.equal(broadcasts.at(-1).updates[0].playerCount, 2, "the change is broadcast"); + + instance.status = "stopped"; + await controller.hooks.instanceStatusChanged.invoke(instance, "running"); + t2.equal((await get(10)).playerCount, 0, "players left online by a crash are not counted"); +}); + +t.test("follows config, host and status changes", async t2 => { + const { get, controller, broadcasts } = await startPlugin(t2, c => { + addHost(c, 1); + addHost(c, 2); + addInstance(c, 10, { gamePort: 34197 }); + addInstance(c, 11, { gamePort: 34198 }); + addInstance(c, 12, { hostId: 2 }); + }); + const instance = controller.instances.getMutable(10); + + instance.config.set("instance.name", "Weekly"); + t2.equal((await get(10)).name, "Weekly", "renames are picked up"); + + instance.config.set("exp_server_list.welcome", "Hello"); + t2.equal((await get(10)).welcome, "Hello", "plugin fields are picked up"); + + instance.config.set("factorio.settings", { game_password: "secret" }); + t2.equal((await get(10)).hasPassword, true, "server settings are picked up"); + + addHost(controller, 1, "example.com"); + t2.equal((await get(10)).address, "example.com:34197", "the public address once the host sends it"); + t2.strictSame( + broadcasts.at(-1).updates.map(server => server.id), [10, 11], "the instances on that host in one update", + ); + + const count = broadcasts.length; + addHost(controller, 1, "example.com"); + t2.equal(broadcasts.length, count, "nothing is broadcast when nothing changed"); + + // What deleteInstance does once the host has deleted it + controller.instances.records.delete(instance); + await controller.hooks.instanceStatusChanged.invoke(instance, "running"); + t2.equal(await get(10), undefined, "deleted instances are removed"); + t2.equal(broadcasts.at(-1).updates[0].isDeleted, true, "and broadcast as deleted"); +}); + +t.test("subscriptions get the servers updated since the last request", async t2 => { + const { subscribe, list } = await startPlugin(t2, controller => { + addHost(controller, 1); + addInstance(controller, 10); + }); + t2.equal((await subscribe(0)).updates.length, 1); + t2.equal(await subscribe(Date.now() + 1000), null); + t2.equal((await list()).length, 1); }); t.test("ServerRecord round trips through json", async t2 => { const record = new messages.ServerRecord( - 1, "a", "b", "c", "d", "e", true, "Base", "2.1.14", 3, "running", "localhost:1", 5, true, + 1, "a", "b", "c", "d", "e", true, true, true, "Base", "2.1.14", 3, "running", "localhost:1", 5, true, ); t2.same(messages.ServerRecord.fromJSON(record.toJSON()), record); t2.ok(record.sameAs(messages.ServerRecord.fromJSON(record.toJSON()))); diff --git a/exp_server_list/test/instance.test.js b/exp_server_list/test/instance.test.js index eba095e8..758ede46 100644 --- a/exp_server_list/test/instance.test.js +++ b/exp_server_list/test/instance.test.js @@ -1,7 +1,7 @@ import t from "tap"; import * as lib from "@clusterio/lib"; import { Instance } from "@clusterio/host"; -import { InstancePlugin } from "../dist/node/instance.js"; +import entrypoint from "../dist/node/instance.js"; import { plugin as pluginDeclaration } from "../dist/node/index.js"; import * as messages from "../dist/node/messages.js"; @@ -24,10 +24,12 @@ class TestConnector extends lib.BaseConnector { } function server(id, name = `instance-${id}`) { - return new messages.ServerRecord(id, name, name, "", "", "", false, "Base", "2.1.14", 0, "running", ""); + return new messages.ServerRecord( + id, name, name, "", "", "", false, false, false, "Base", "2.1.14", 0, "running", "", + ); } -/** Build a plugin around a real instance with spies on what leaves it. */ +/** Run the plugin on a real instance with spies on what leaves it. */ async function startPlugin(t2, servers = [server(1), server(2)]) { const instanceConfig = new lib.InstanceConfig("host"); instanceConfig.set("instance.id", 1); @@ -46,13 +48,33 @@ async function startPlugin(t2, servers = [server(1), server(2)]) { }, }; instance.sendTo = async (dst, request) => { + if (request instanceof lib.InstanceStatusChangedEvent) { + return undefined; + } state.sent.push(request); return request instanceof messages.ServerListRequest ? servers : undefined; }; - const context = { plugin: { name: "exp_server_list" }, instance, host: {}, logger }; - const plugin = await InstancePlugin.fromContext(context); - return { plugin, instance, state }; + const handlers = new Map(); + instance.handle = (Class, handler) => handlers.set(Class, handler); + + await entrypoint({ plugin: { name: "exp_server_list" }, instance, host: {}, logger }); + return { + instance, + state, + /** What the host does once rcon is up */ + start: async () => { + instance.notifyStatus("starting"); + await instance.hooks.start.invoke(); + instance.notifyStatus("running"); + }, + update: event => handlers.get(messages.ServerUpdatesEvent)(event), + connection: event => instance.hooks.controllerConnectionEvent.invoke(event), + clear: () => { + state.sent.length = 0; + state.rcons.length = 0; + }, + }; } /** The payload of a recorded rcon command. */ @@ -61,62 +83,57 @@ function decodeRcon(command) { return JSON.parse(match[2]); } -t.test("class InstancePlugin", t2 => { - t2.test(".onStart() subscribes and replaces the list in lua", async t3 => { - const { plugin, state } = await startPlugin(t3); - await plugin.onStart(); +t.test("start subscribes and replaces the list in lua", async t2 => { + const { start, state } = await startPlugin(t2); + await start(); - t3.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes to updates first"); - t3.ok(state.sent[1] instanceof messages.ServerListRequest, "then lists the servers"); - const payload = decodeRcon(state.rcons[0]); - t3.equal(payload.current_id, 1, "tells lua which server it is"); - t3.equal(payload.replace, true); - t3.strictSame(payload.servers.map(entry => entry.short_name), ["instance-1", "instance-2"]); - }); - - t2.test(".handleServerUpdatesEvent() only sends while the game is up", async t3 => { - const { plugin, state } = await startPlugin(t3); - const event = new messages.ServerUpdatesEvent([server(2, "renamed")]); - - await plugin.handleServerUpdatesEvent(event); - t3.strictSame(state.rcons, [], "nothing is sent before start"); - - await plugin.onStart(); - state.rcons.length = 0; - await plugin.handleServerUpdatesEvent(event); - const payload = decodeRcon(state.rcons[0]); - t3.equal(payload.replace, false, "updates are merged"); - t3.strictSame(payload.servers.map(entry => entry.name), ["renamed"]); - - await plugin.onExit(); - state.rcons.length = 0; - await plugin.handleServerUpdatesEvent(event); - t3.strictSame(state.rcons, [], "nothing is sent after exit"); - }); - - t2.test(".onControllerConnectionEvent() syncs again after the controller restarts", async t3 => { - const { plugin, state } = await startPlugin(t3); - await plugin.onControllerConnectionEvent("connect"); - t3.strictSame(state.sent, [], "nothing happens before start"); - - await plugin.onStart(); - state.sent.length = 0; - state.rcons.length = 0; - await plugin.onControllerConnectionEvent("resume"); - t3.strictSame(state.sent, [], "a resumed session keeps its subscription"); - - await plugin.onControllerConnectionEvent("connect"); - t3.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes again"); - t3.equal(decodeRcon(state.rcons[0]).replace, true, "and replaces the list"); - }); - - t2.test(".luaSendServers() picks a long bracket the values do not close", async t3 => { - const { plugin, state } = await startPlugin(t3); - const name = "a]=]b]==]c"; - await plugin.luaSendServers([server(3, name)], false); - t3.match(state.rcons[0], "json_to_table[===[", "uses a level not found in the json"); - t3.equal(decodeRcon(state.rcons[0]).servers[0].name, name); - }); - - t2.end(); + t2.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes to updates first"); + t2.ok(state.sent[1] instanceof messages.ServerListRequest, "then lists the servers"); + const payload = decodeRcon(state.rcons[0]); + t2.equal(payload.current_id, 1, "tells lua which server it is"); + t2.equal(payload.replace, true); + t2.strictSame(payload.servers.map(entry => entry.display_name), ["instance-1", "instance-2"]); +}); + +t.test("updates are only sent while the game is up", async t2 => { + const { instance, start, update, clear, state } = await startPlugin(t2); + const event = new messages.ServerUpdatesEvent([server(2, "renamed")]); + + await update(event); + t2.strictSame(state.rcons, [], "nothing is sent before start"); + + await start(); + clear(); + await update(event); + const payload = decodeRcon(state.rcons[0]); + t2.equal(payload.replace, false, "updates are merged"); + t2.strictSame(payload.servers.map(entry => entry.name), ["renamed"]); + + instance.notifyStatus("stopped"); + clear(); + await update(event); + t2.strictSame(state.rcons, [], "nothing is sent after the game stopped"); +}); + +t.test("syncs again after the controller restarts", async t2 => { + const { start, connection, clear, state } = await startPlugin(t2); + await connection("connect"); + t2.strictSame(state.sent, [], "nothing happens before start"); + + await start(); + clear(); + await connection("resume"); + t2.strictSame(state.sent, [], "a resumed session keeps its subscription"); + + await connection("connect"); + t2.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes again"); + t2.equal(decodeRcon(state.rcons[0]).replace, true, "and replaces the list"); +}); + +t.test("picks a long bracket the values do not close", async t2 => { + const name = "a]=]b]==]c"; + const { start, state } = await startPlugin(t2, [server(3, name)]); + await start(); + t2.match(state.rcons[0], "json_to_table[===[", "uses a level not found in the json"); + t2.equal(decodeRcon(state.rcons[0]).servers[0].name, name); }); diff --git a/exp_server_list/test/module/control.lua b/exp_server_list/test/module/control.lua index 0ffe9740..5c398161 100644 --- a/exp_server_list/test/module/control.lua +++ b/exp_server_list/test/module/control.lua @@ -1,19 +1,27 @@ local Suite = ... --- @type Suite Suite.test("nothing is known before the first update", function(env) + Suite.eq(env.ServerList.is_synced(), false, "not synced") Suite.eq(env.ServerList.get_current(), nil, "no current server") Suite.empty(env.ServerList.get_servers(), "no servers") end) +Suite.test("is_synced() is true once the list arrives, even without this server in it", function(env) + local List = env.ServerList + List.receive_update{ current_id = 5, replace = true, servers = { env.server(1) } } + Suite.eq(List.is_synced(), true, "synced") + Suite.eq(List.get_current(), nil, "this server is not in the list") +end) + Suite.test("receive_update() with replace swaps the whole list", function(env) local List = env.ServerList List.receive_update{ current_id = 1, replace = true, servers = { env.server(1), env.server(2) } } List.receive_update{ current_id = 1, replace = true, servers = { env.server(1), env.server(3) } } - Suite.eq(List.get_current().id, 1, "the current server is set") + Suite.eq(assert(List.get_current()).id, 1, "the current server is set") Suite.eq(List.get_server(2), nil, "servers missing from the new list are removed") - Suite.eq(List.get_server(3).name, "Server 3", "new servers are added") - local ids = env.events[2].server_ids + Suite.eq(assert(List.get_server(3)).name, "Server 3", "new servers are added") + local ids = assert(env.events[2]).server_ids table.sort(ids) Suite.eq(ids, { 1, 2, 3 }, "the event lists removed and added servers") end) @@ -26,13 +34,13 @@ Suite.test("receive_update() without replace changes only the given servers", fu { id = 3, is_deleted = true }, } } - Suite.eq(List.get_server(1).name, "Server 1", "untouched servers stay") - Suite.eq(List.get_server(2).player_count, 4, "changed servers are replaced") + Suite.eq(assert(List.get_server(1)).name, "Server 1", "untouched servers stay") + Suite.eq(assert(List.get_server(2)).player_count, 4, "changed servers are replaced") Suite.eq(List.get_server(3), nil, "deleted servers are removed") - local ids = env.events[2].server_ids + local ids = assert(env.events[2]).server_ids table.sort(ids) Suite.eq(ids, { 2, 3 }, "the event lists only the given servers") - Suite.eq(env.events[2].name, List.on_servers_updated, "on_servers_updated is raised") + Suite.eq(assert(env.events[2]).name, List.on_servers_updated, "on_servers_updated is raised") end) Suite.test("get_servers() leaves out hidden servers and sorts by name", function(env) @@ -48,15 +56,31 @@ Suite.test("get_servers() leaves out hidden servers and sorts by name", function names[index] = server.name end Suite.eq(names, { "Alpha", "Charlie" }, "hidden servers are not listed") - Suite.eq(List.get_server(3).name, "Bravo", "but can still be looked up by id") + Suite.eq(assert(List.get_server(3)).name, "Bravo", "but can still be looked up by id") end) -Suite.test("find_servers() matches name, short name and id ignoring case", function(env) +Suite.test("get_servers() puts servers always on top first", function(env) local List = env.ServerList List.receive_update{ current_id = 1, replace = true, servers = { - env.server(1, { name = "Public Weekly", short_name = "S1" }), - env.server(2, { name = "Modded", short_name = "S2" }), - env.server(37, { name = "Event", short_name = "Event" }), + env.server(1, { name = "Alpha" }), + env.server(2, { name = "Delta", always_on_top = true }), + env.server(3, { name = "Bravo" }), + env.server(4, { name = "Charlie", always_on_top = true }), + } } + + local names = {} + for index, server in ipairs(List.get_servers()) do + names[index] = server.name + end + Suite.eq(names, { "Charlie", "Delta", "Alpha", "Bravo" }, "on top by name, then the rest by name") +end) + +Suite.test("find_servers() matches name, display name and id ignoring case", function(env) + local List = env.ServerList + List.receive_update{ current_id = 1, replace = true, servers = { + env.server(1, { name = "Public Weekly", display_name = "S1" }), + env.server(2, { name = "Modded", display_name = "S2" }), + env.server(37, { name = "Event", display_name = "Event" }), } } local function ids(search) @@ -68,7 +92,7 @@ Suite.test("find_servers() matches name, short name and id ignoring case", funct end Suite.eq(ids("weekly"), { 1 }, "by name") - Suite.eq(ids("s2"), { 2 }, "by short name") + Suite.eq(ids("s2"), { 2 }, "by display name") Suite.eq(ids("37"), { 37 }, "by id") Suite.eq(ids("nothing"), {}, "no match") end) diff --git a/exp_server_list/test/module/env.lua b/exp_server_list/test/module/env.lua index 7711980e..b1bda527 100644 --- a/exp_server_list/test/module/env.lua +++ b/exp_server_list/test/module/env.lua @@ -31,11 +31,13 @@ return Framework.suite(function(env) local server = { id = id, name = "Server " .. id, - short_name = "S" .. id, + display_name = "S" .. id, description = "", welcome = "", reset_time = "", hidden = false, + always_on_top = false, + has_password = false, mod_pack_name = "Base", factorio_version = "2.1.14", player_count = 0,