From 6821d62c10eb830e9fb54a54c9bcc7d8c5470b72 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:47:28 +0000 Subject: [PATCH 1/3] Keep join messages on the controller and print them from the instance Join messages were stored in exp_legacy's PlayerData, which nothing ever synced. The controller now keeps a players.json datastore of per-player settings keyed by player name, starting with the join message, and saves it on the save and shutdown hooks. Records with nothing set are removed. When a player joins, the instance plugin fetches their record and prints the message to everyone over rcon in the player's colour, so no lua is needed to show it. The in-game /set-join-message and /remove-join-message commands send ipc which the instance forwards to the controller; /set-join-message without a message asks the instance, which prints the stored message back to the player. Greetings is now a feature. Disabling it stops the greeting, the commands, and the printed join messages. The private greeting with the community name and Discord link is shown to every joining player, even those with a join message, since one is a welcome for the player and the other an announcement to everyone else. Two controller permissions cover the web interface: exp_scenario.join_message.set for your own message and .set_other for anyone's, checked against the requesting user the same way exp_discord_auth checks unlinking. The seed grants them to Supporter and Moderator alongside the in-game commands. The user page gets a Scenario section showing the message with an edit button for users who may change it. Co-Authored-By: Claude Opus 5.5 --- exp_legacy/module/locale/en.cfg | 3 - exp_legacy/module/locale/zh-CN.cfg | 3 - exp_legacy/module/locale/zh-TW.cfg | 3 - exp_scenario/controller.ts | 33 +++++ exp_scenario/features.ts | 6 + exp_scenario/index.ts | 14 +++ exp_scenario/instance.ts | 65 ++++++++-- exp_scenario/messages.ts | 116 ++++++++++++++++++ exp_scenario/module/control/greetings.lua | 46 +++---- exp_scenario/module/locale/en.cfg | 1 + exp_scenario/module/locale/zh-CN.cfg | 1 + exp_scenario/module/locale/zh-TW.cfg | 1 + exp_scenario/seed.ts | 2 + exp_scenario/test/controller.test.js | 82 +++++++++++++ exp_scenario/test/instance.test.js | 78 +++++++++++- .../web/components/UserPlayerSettings.tsx | 75 +++++++++++ exp_scenario/web/index.tsx | 2 + 17 files changed, 491 insertions(+), 40 deletions(-) create mode 100644 exp_scenario/web/components/UserPlayerSettings.tsx diff --git a/exp_legacy/module/locale/en.cfg b/exp_legacy/module/locale/en.cfg index 87766701..9e1a6de2 100644 --- a/exp_legacy/module/locale/en.cfg +++ b/exp_legacy/module/locale/en.cfg @@ -15,9 +15,6 @@ lhd=All trains must be LHD! This is a long standing rule on our servers, please # data [exp-settings] -JoinMessage=Join Message -JoinMessage-tooltip=The message displayed when you join -JoinMessage-value-tooltip=Change by using /join-message UsesServerUps=Server UPS UsesServerUps-tooltip=Whether the current server UPS is shown UsesServerUps-value-tooltip=Change by using /server-ups diff --git a/exp_legacy/module/locale/zh-CN.cfg b/exp_legacy/module/locale/zh-CN.cfg index 990aafce..d7a7e1bc 100644 --- a/exp_legacy/module/locale/zh-CN.cfg +++ b/exp_legacy/module/locale/zh-CN.cfg @@ -15,9 +15,6 @@ lhd=列車必須是左則通行。這是本服務器長久以來的規則。 # data [exp-settings] -JoinMessage=加入信息 -JoinMessage-tooltip=你加入時所顯示的信息 -JoinMessage-value-tooltip=使用 /join-message 來更換加入信息 UsesServerUps=服務器更新數 (SUPS) UsesServerUps-tooltip=現在是否顯示服務器更新數 (SUPS) UsesServerUps-value-tooltip=使用 /server-ups 來更換顯示 diff --git a/exp_legacy/module/locale/zh-TW.cfg b/exp_legacy/module/locale/zh-TW.cfg index 990aafce..d7a7e1bc 100644 --- a/exp_legacy/module/locale/zh-TW.cfg +++ b/exp_legacy/module/locale/zh-TW.cfg @@ -15,9 +15,6 @@ lhd=列車必須是左則通行。這是本服務器長久以來的規則。 # data [exp-settings] -JoinMessage=加入信息 -JoinMessage-tooltip=你加入時所顯示的信息 -JoinMessage-value-tooltip=使用 /join-message 來更換加入信息 UsesServerUps=服務器更新數 (SUPS) UsesServerUps-tooltip=現在是否顯示服務器更新數 (SUPS) UsesServerUps-value-tooltip=使用 /server-ups 來更換顯示 diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index a24d4a39..abc856cb 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -18,6 +18,26 @@ export default async function (context: ControllerPluginContext) { messages.FeatureRecord.fromJSON.bind(messages.FeatureRecord), ).bootstrap() ); + const playerStore = new lib.SubscribableDatastore( + ...await new lib.JsonIdDatastoreProvider( + path.join(databaseDirectory, "exp_scenario", "players.json"), + messages.PlayerRecord.fromJSON.bind(messages.PlayerRecord), + ).bootstrap() + ); + + type PlayerSettings = Pick; + + /** Change some settings of a player, empty values are removed and so is a record left empty */ + function updatePlayer(name: string, changes: PlayerSettings) { + const old = playerStore.get(name); + const settings: PlayerSettings = { joinMessage: old?.joinMessage, ...changes }; + const record = new messages.PlayerRecord(name, settings.joinMessage || undefined); + if (record.joinMessage) { + playerStore.set(record); + } else if (old) { + playerStore.delete(old); + } + } /** Add new features, delete removed ones, and drop stored values which no longer fit a field */ function reconcileFeatures() { @@ -202,8 +222,21 @@ export default async function (context: ControllerPluginContext) { return feature; }); + controller.handle(messages.PlayerGetRequest, async (request: messages.PlayerGetRequest) => ( + playerStore.get(request.playerName) ?? new messages.PlayerRecord(request.playerName) + )); + + controller.handle(messages.JoinMessageSetRequest, async (request: messages.JoinMessageSetRequest) => { + updatePlayer(request.playerName, { joinMessage: request.message?.trim() }); + }); + + controller.hooks.save.attach(plugin.name, async () => { + await playerStore.save(); + }); + controller.hooks.shutdown.attach(plugin.name, async () => { await featureStore.save(); + await playerStore.save(); }); } diff --git a/exp_scenario/features.ts b/exp_scenario/features.ts index 9c1e70a9..0056b691 100644 --- a/exp_scenario/features.ts +++ b/exp_scenario/features.ts @@ -174,6 +174,12 @@ export const features: Feature[] = [ num("rocket_launch_display_rate", "Rocket log interval", "After the first few launches, log every this many rockets", 500, 1, "rockets"), ], }, + { + name: "greetings", + title: "Greetings", + description: "Greet players when they join and show the join message they set, the messages are edited on the user page", + fields: [], + }, { name: "help_bubbles", title: "Help Bubbles", diff --git a/exp_scenario/index.ts b/exp_scenario/index.ts index 51b5db8a..5fc959bc 100644 --- a/exp_scenario/index.ts +++ b/exp_scenario/index.ts @@ -8,6 +8,8 @@ declare module "@clusterio/lib" { "exp_scenario.config.view": never; "exp_scenario.config.edit": never; "exp_scenario.seed": never; + "exp_scenario.join_message.set": never; + "exp_scenario.join_message.set_other": never; } } @@ -23,6 +25,8 @@ export const plugin: lib.PluginDeclaration = { messages.FeatureUpdatedEvent, messages.FeatureListRequest, messages.FeatureUpdateRequest, + messages.PlayerGetRequest, + messages.JoinMessageSetRequest, ], permissions: [ @@ -42,6 +46,16 @@ export const plugin: lib.PluginDeclaration = { title: "Seed ExpScenario roles and groups", description: "Create the roles and permission groups the scenario shipped with", }, + { + name: "exp_scenario.join_message.set", + title: "Set own join message", + description: "Set the message shown to everyone when you join, in game this is /set-join-message", + }, + { + name: "exp_scenario.join_message.set_other", + title: "Set join messages of others", + description: "Set the message shown to everyone when another user joins", + }, ], webEntrypoint: "./web", diff --git a/exp_scenario/instance.ts b/exp_scenario/instance.ts index 93f0ef73..ec36a730 100644 --- a/exp_scenario/instance.ts +++ b/exp_scenario/instance.ts @@ -3,11 +3,28 @@ import * as lib from "@clusterio/lib"; import * as messages from "./messages.js"; import { features, FeatureValue } from "./features.js"; -/** Sends feature config to the lua side, see module/features.lua. */ +/** Sent by the lua side when a player sets or removes their join message, see module/control/greetings.lua */ +export type IpcJoinMessageSet = { player_name: string, message?: string }; +/** Sent by the lua side when a player asks for their join message */ +export type IpcJoinMessageGet = { player_name: string }; + +/** Json as a lua expression for a table, inside a long bracket the json does not contain */ +export function luaJson(value: unknown) { + const json = JSON.stringify(value); + let level = "="; + while (json.includes(`]${level}]`)) { + level += "="; + } + return `helpers.json_to_table[${level}[${json}]${level}]`; +} + +/** Sends feature config and player settings to the lua side, see module/features.lua. */ export default async function (context: InstancePluginContext) { - const { instance, plugin } = context; + const { instance, logger, plugin } = context; /** Rcon is up and the lua side can take updates */ let started = false; + /** Enabled state of features which need it on this side, by name */ + const featureEnabled = new Map(); /** Subscribe, then send every feature */ async function syncFeatures() { @@ -25,29 +42,61 @@ export default async function (context: InstancePluginContext) { } const updates = records.map(record => { + featureEnabled.set(record.id, record.enabled); const values: Record = { enabled: record.enabled }; for (const field of features.find(feature => feature.name === record.id)?.fields ?? []) { values[field.name] = field.name in record.values ? record.values[field.name] : field.default; } return { name: record.id, values }; }); - const json = JSON.stringify(updates); - // Pick a long bracket the json does not contain - let level = "="; - while (json.includes(`]${level}]`)) { - level += "="; + await instance.sendRcon(`/sc exp_scenario.features.receive_update(${luaJson(updates)})`, true); + } + + /** Run lua with the player as `p` if they are still in the game, the data is available as `d` */ + async function luaWithPlayer(name: string, data: Record, code: string) { + if (instance.status !== "running") { + return; } await instance.sendRcon( - `/sc exp_scenario.features.receive_update(helpers.json_to_table[${level}[${json}]${level}])`, true, + `/sc local d = ${luaJson({ ...data, name })} local p = game.get_player(d.name) if p then ${code} end`, true, ); } + async function onPlayerJoin(name: string) { + const record = await instance.sendTo("controller", new messages.PlayerGetRequest(name)); + if (record.joinMessage && featureEnabled.get("greetings") !== false) { + await luaWithPlayer(name, { message: record.joinMessage }, "game.print(d.message, { color = p.color })"); + } + } + instance.handle(messages.FeatureUpdatedEvent, async event => { if (started) { await luaSendFeatures(event.updates.filter(feature => !feature.isDeleted)); } }); + instance.server.handle("exp_scenario:join_message_set", async (event: IpcJoinMessageSet) => { + const request = new messages.JoinMessageSetRequest(event.player_name, event.message ?? null); + await instance.sendTo("controller", request); + }); + + instance.server.handle("exp_scenario:join_message_get", async (event: IpcJoinMessageGet) => { + const record = await instance.sendTo("controller", new messages.PlayerGetRequest(event.player_name)); + await luaWithPlayer( + event.player_name, + record.joinMessage ? { message: record.joinMessage } : {}, + "p.print(d.message and { \"exp_greetings.message-get\", d.message } or { \"exp_greetings.message-none\" })", + ); + }); + + instance.hooks.playerEvent.attach(plugin.name, async event => { + if (event.type === "join") { + await onPlayerJoin(event.name).catch(err => logger.error( + `Error showing the join message of ${event.name}:\n${err.stack ?? err.message}`, + )); + } + }); + instance.hooks.start.attach(plugin.name, async () => { started = true; await syncFeatures(); diff --git a/exp_scenario/messages.ts b/exp_scenario/messages.ts index 3438c061..5c6f91e5 100644 --- a/exp_scenario/messages.ts +++ b/exp_scenario/messages.ts @@ -126,3 +126,119 @@ export class FeatureUpdateRequest { return new this(json.id, json.enabled, json.values); } } + +/** Settings of a player kept by the controller, by player name. Only set values are stored. */ +export class PlayerRecord { + constructor( + /** Name of the player */ + public id: string, + /** Shown to everyone when the player joins */ + public joinMessage?: string, + public updatedAtMs: number = 0, + public isDeleted: boolean = false, + ) {} + + static jsonSchema = Type.Object({ + id: Type.String(), + join_message: Type.Optional(Type.String()), + updated_at_ms: Type.Optional(Type.Number()), + is_deleted: Type.Optional(Type.Boolean()), + }); + + toJSON() { + const json: Static = { id: this.id }; + if (this.joinMessage) { + json.join_message = this.joinMessage; + } + if (this.updatedAtMs) { + json.updated_at_ms = this.updatedAtMs; + } + if (this.isDeleted) { + json.is_deleted = true; + } + return json; + } + + static fromJSON(json: Static) { + return new this( + json.id, + json.join_message, + json.updated_at_ms ?? 0, + json.is_deleted ?? false, + ); + } +} + +/** Users may always read their own settings, otherwise they need to be able to see users. */ +function selfOrPermission(permission: lib.PermissionName) { + return (user: lib.IUser, message: lib.MessageRequest) => { + const playerName = (message.data as { player_name: string }).player_name; + if (user.name.toLowerCase() !== playerName.toLowerCase()) { + user.checkPermission(permission); + } + }; +} + +/** Get the settings of a player, an empty record when nothing is stored. */ +export class PlayerGetRequest { + declare ["constructor"]: typeof PlayerGetRequest; + static plugin = "exp_scenario" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = selfOrPermission("core.user.get"); + static Response = PlayerRecord; + + constructor( + public playerName: string, + ) {} + + static jsonSchema = Type.Object({ + player_name: Type.String(), + }); + + toJSON() { + return { player_name: this.playerName }; + } + + static fromJSON(json: Static) { + return new this(json.player_name); + } +} + +export const joinMessageMaxLength = 255; + +/** Set or clear (null or empty) the join message of a player. */ +export class JoinMessageSetRequest { + declare ["constructor"]: typeof JoinMessageSetRequest; + static plugin = "exp_scenario" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission(user: lib.IUser, message: lib.MessageRequest) { + const playerName = (message.data as { player_name: string }).player_name; + if (user.name.toLowerCase() === playerName.toLowerCase()) { + user.checkPermission("exp_scenario.join_message.set"); + } else { + user.checkPermission("exp_scenario.join_message.set_other"); + } + } + + constructor( + public playerName: string, + public message: string | null, + ) {} + + static jsonSchema = Type.Object({ + player_name: Type.String(), + message: Type.Union([Type.String({ maxLength: joinMessageMaxLength }), Type.Null()]), + }); + + toJSON() { + return { player_name: this.playerName, message: this.message }; + } + + static fromJSON(json: Static) { + return new this(json.player_name, json.message); + } +} diff --git a/exp_scenario/module/control/greetings.lua b/exp_scenario/module/control/greetings.lua index 8b536879..2a667fe5 100644 --- a/exp_scenario/module/control/greetings.lua +++ b/exp_scenario/module/control/greetings.lua @@ -1,46 +1,50 @@ --[[-- Control - Greetings -Greets players when they join, players can set a message shown to everyone instead +Greets players when they join, and lets players set a message shown to everyone when they join + +The join messages are kept by the controller, the instance plugin prints them when a player joins ]] local Commands = require("modules/exp_commands") +local Feature = require("modules/exp_scenario/features") +local clusterio_api = require("modules/clusterio/api") local community_details = require("modules/exp_scenario/config/community_details").config -local PlayerData = require("modules.exp_legacy.expcore.player_data") -local CustomMessages = PlayerData.Settings:combine("JoinMessage") -CustomMessages:set_metadata{ - permission = "exp_scenario.command.set_join_message", -} +local feature = Feature.register("greetings") ---- Show the message of a player once their data loads, or greet them -CustomMessages:on_load(function(player_name, player_message) - local player = assert(game.get_player(player_name)) - if player_message then - game.print(player_message, { color = player.color }) - else - player.print{ "exp_greetings.greet", community_details.community_name, community_details.discord } - end -end) +--- @param event EventData.on_player_joined_game +local function on_player_joined_game(event) + local player = assert(game.get_player(event.player_index)) + player.print{ "exp_greetings.greet", community_details.community_name, community_details.discord } +end ---- Set your custom join message +--- Set your custom join message, or show it when no message is given Commands.new("set-join-message", { "exp_greetings.description-set" }) :optional("message", { "exp_greetings.arg-message" }, Commands.types.string_max_length(255)) :enable_auto_concatenation() :add_aliases{ "join-message" } + :add_flags{ feature = feature } :register(function(player, message) --- @cast message string? if message then - CustomMessages:set(player, message) + clusterio_api.send_json("exp_scenario:join_message_set", { player_name = player.name, message = message }) return Commands.status.success{ "exp_greetings.message-set" } - else - return Commands.status.success{ "exp_greetings.message-get", CustomMessages:get(player) or "" } end + -- The instance plugin prints the message + clusterio_api.send_json("exp_scenario:join_message_get", { player_name = player.name }) end) --- Removes your custom join message Commands.new("remove-join-message", { "exp_greetings.description-remove" }) + :add_flags{ feature = feature } :register(function(player) - CustomMessages:remove(player) + clusterio_api.send_json("exp_scenario:join_message_set", { player_name = player.name }) return Commands.status.success{ "exp_greetings.message-cleared" } end) -return {} +local e = defines.events + +return feature:guard{ + events = { + [e.on_player_joined_game] = on_player_joined_game, + }, +} diff --git a/exp_scenario/module/locale/en.cfg b/exp_scenario/module/locale/en.cfg index 901c0e38..a1b22a40 100644 --- a/exp_scenario/module/locale/en.cfg +++ b/exp_scenario/module/locale/en.cfg @@ -594,6 +594,7 @@ greet=[color=0,1,0] Welcome to __1__ community server! If you like the server jo message-set=Your join message has been updated. message-get=Your join message is: __1__ message-cleared=Your join message has been cleared. +message-none=You have not set a join message. [exp_nuke-protection] chat-found=You cannot have __1__ in your inventory, so it was placed into the chests at spawn. diff --git a/exp_scenario/module/locale/zh-CN.cfg b/exp_scenario/module/locale/zh-CN.cfg index 8315c280..ead737cc 100644 --- a/exp_scenario/module/locale/zh-CN.cfg +++ b/exp_scenario/module/locale/zh-CN.cfg @@ -586,6 +586,7 @@ greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器 message-set=你的加入信息已更新。 message-get=你的加入信息為: __1__ message-cleared=你的加入信息已清除。 +message-none=你沒有設定加入信息。 [exp_nuke-protection] chat-found=你的用戶組不允許你有 __1__ ,所以該物品已放在出生點的箱子。 diff --git a/exp_scenario/module/locale/zh-TW.cfg b/exp_scenario/module/locale/zh-TW.cfg index 8315c280..ead737cc 100644 --- a/exp_scenario/module/locale/zh-TW.cfg +++ b/exp_scenario/module/locale/zh-TW.cfg @@ -586,6 +586,7 @@ greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器 message-set=你的加入信息已更新。 message-get=你的加入信息為: __1__ message-cleared=你的加入信息已清除。 +message-none=你沒有設定加入信息。 [exp_nuke-protection] chat-found=你的用戶組不允許你有 __1__ ,所以該物品已放在出生點的箱子。 diff --git a/exp_scenario/seed.ts b/exp_scenario/seed.ts index ea62cbf7..5ad7d6cd 100644 --- a/exp_scenario/seed.ts +++ b/exp_scenario/seed.ts @@ -87,6 +87,7 @@ export const seedRoles: SeedRole[] = [ "exp_scenario.command.tag_clear.always", "exp_scenario.command.spawn.always", "exp_scenario.command.clear_reports", + "exp_scenario.join_message.set_other", "exp_scenario.command.clear_inventory", "exp_scenario.command.kill_enemies", "exp_scenario.command.remove_enemies", @@ -193,6 +194,7 @@ export const seedRoles: SeedRole[] = [ "exp_scenario.command.unjail", "exp_scenario.command.set_join_message", "exp_scenario.command.remove_join_message", + "exp_scenario.join_message.set", ], }, { diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js index 32806c21..a08f51d6 100644 --- a/exp_scenario/test/controller.test.js +++ b/exp_scenario/test/controller.test.js @@ -66,6 +66,9 @@ async function startPlugin(t2, { withPlugins = true, databaseDirectory = t2.test updateFeature: request => handlers.get(messages.FeatureUpdateRequest)(request), featureSubscription: request => handlers.get(messages.FeatureUpdatedEvent)(request), shutdown: () => controller.hooks.shutdown.invoke(), + save: () => controller.hooks.save.invoke(), + /** Call the handler of a message as it would be called for a request or event */ + send: message => handlers.get(message.constructor)(message), }; } @@ -226,3 +229,82 @@ t.test("controller plugin", t2 => { t2.end(); }); + +/** Read players.json as the next run would. */ +async function loadPlayers(databaseDirectory) { + return new lib.JsonIdDatastoreProvider( + path.join(databaseDirectory, "exp_scenario", "players.json"), + messages.PlayerRecord.fromJSON.bind(messages.PlayerRecord), + ).load(); +} + +t.test("player settings", t2 => { + t2.test("PlayerGetRequest returns an empty record for unknown players", async t3 => { + const { send } = await startPlugin(t3, { withPlugins: false }); + const record = await send(new messages.PlayerGetRequest("Alice")); + t3.equal(record.id, "Alice"); + t3.strictSame(record.toJSON(), { id: "Alice" }, "nothing is set"); + }); + + t2.test("JoinMessageSetRequest sets, trims, and clears the message", async t3 => { + const { send } = await startPlugin(t3, { withPlugins: false }); + await send(new messages.JoinMessageSetRequest("Alice", " Hello there ")); + t3.equal((await send(new messages.PlayerGetRequest("Alice"))).joinMessage, "Hello there"); + + await send(new messages.JoinMessageSetRequest("Alice", null)); + t3.equal((await send(new messages.PlayerGetRequest("Alice"))).joinMessage, undefined, "null clears it"); + + await send(new messages.JoinMessageSetRequest("Alice", "Hi")); + await send(new messages.JoinMessageSetRequest("Alice", " ")); + t3.equal((await send(new messages.PlayerGetRequest("Alice"))).joinMessage, undefined, "blank clears it"); + }); + + t2.test("players are saved on the save hook and empty records are removed", async t3 => { + const databaseDirectory = t3.testdir(); + const { send, save, shutdown } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); + await send(new messages.JoinMessageSetRequest("Alice", "Hi")); + await send(new messages.JoinMessageSetRequest("Bob", "Hey")); + await send(new messages.JoinMessageSetRequest("Bob", null)); + await save(); + + const stored = await loadPlayers(databaseDirectory); + t3.strictSame([...stored.keys()], ["Alice"], "only players with a setting are stored"); + t3.equal(stored.get("Alice").joinMessage, "Hi"); + + await send(new messages.JoinMessageSetRequest("Alice", "Bye")); + await shutdown(); + const restarted = await startPlugin(t3, { withPlugins: false, databaseDirectory }); + const record = await restarted.send(new messages.PlayerGetRequest("Alice")); + t3.equal(record.joinMessage, "Bye", "kept across restarts"); + }); + + t2.test("permissions", async t3 => { + const user = (name, granted = []) => ({ + name, + checkPermission(permission) { + if (!granted.includes(permission)) { + throw new lib.PermissionError("denied"); + } + }, + }); + const request = name => ({ data: { player_name: name } }); + + const set = messages.JoinMessageSetRequest.permission; + t3.throws(() => set(user("Alice"), request("alice")), "own message needs the set permission"); + t3.doesNotThrow(() => set(user("Alice", ["exp_scenario.join_message.set"]), request("alice")), "own message"); + t3.throws( + () => set(user("Alice", ["exp_scenario.join_message.set"]), request("Bob")), + "someone else's message needs set_other", + ); + t3.doesNotThrow( + () => set(user("Alice", ["exp_scenario.join_message.set_other"]), request("Bob")), "with set_other", + ); + + const get = messages.PlayerGetRequest.permission; + t3.doesNotThrow(() => get(user("Alice"), request("alice")), "own settings"); + t3.throws(() => get(user("Alice"), request("Bob")), "someone else's"); + t3.doesNotThrow(() => get(user("Alice", ["core.user.get"]), request("Bob")), "with core.user.get"); + }); + + t2.end(); +}); diff --git a/exp_scenario/test/instance.test.js b/exp_scenario/test/instance.test.js index 1a53adc8..c2569b19 100644 --- a/exp_scenario/test/instance.test.js +++ b/exp_scenario/test/instance.test.js @@ -25,7 +25,9 @@ class TestConnector extends lib.BaseConnector { } /** Run the plugin on a real instance with spies on what leaves it, returns the event handler it registered. */ -async function startPlugin(t2, features = [new messages.FeatureRecord("death_markers", false, { show_map_markers: false })]) { +async function startPlugin( + t2, features = [new messages.FeatureRecord("death_markers", false, { show_map_markers: false })], players = [], +) { const instanceConfig = new lib.InstanceConfig("host"); instanceConfig.set("instance.id", 1); instanceConfig.set("instance.name", "test"); @@ -35,8 +37,9 @@ async function startPlugin(t2, features = [new messages.FeatureRecord("death_mar ); const state = { sent: [], rcons: [] }; + const ipcHandlers = new Map(); instance.server = { - handle: () => {}, + handle: (channel, handler) => ipcHandlers.set(channel, handler), sendRcon: async command => { state.rcons.push(command); return ""; @@ -44,6 +47,10 @@ async function startPlugin(t2, features = [new messages.FeatureRecord("death_mar }; instance.sendTo = async (dst, request) => { state.sent.push(request); + if (request instanceof messages.PlayerGetRequest) { + const found = players.find(record => record.id === request.playerName); + return found ?? new messages.PlayerRecord(request.playerName); + } return request instanceof messages.FeatureListRequest ? features : undefined; }; @@ -58,6 +65,9 @@ async function startPlugin(t2, features = [new messages.FeatureRecord("death_mar exit: () => hooks.exit.invoke(), connectionEvent: event => hooks.controllerConnectionEvent.invoke(event), featureUpdated: handlers.get(messages.FeatureUpdatedEvent), + ipc: (channel, event) => ipcHandlers.get(channel)(event), + playerEvent: event => hooks.playerEvent.invoke(event), + setStatus: status => { instance._status = status; }, }; } @@ -67,6 +77,14 @@ function decodeRcon(command) { return JSON.parse(match[2]); } +/** The player data and code of a recorded rcon command made by luaWithPlayer. */ +function decodePlayerRcon(command) { + const match = command.match( + /^\/sc local d = helpers\.json_to_table\[(=+)\[(.*)\]\1\] local p = game\.get_player\(d\.name\) if p then (.*) end$/s, + ); + return { data: JSON.parse(match[2]), code: match[3] }; +} + t.test("instance plugin", t2 => { t2.test("start subscribes and sends every feature to lua", async t3 => { const { start, state } = await startPlugin(t3); @@ -134,3 +152,59 @@ t.test("instance plugin", t2 => { t2.end(); }); + +t.test("join messages", t2 => { + const alice = new messages.PlayerRecord("Alice", "Alice has arrived ]=] ]==]"); + + t2.test("a joining player's message is printed to everyone", async t3 => { + const { start, playerEvent, setStatus, state } = await startPlugin(t3, [], [alice]); + await start(); + setStatus("running"); + state.rcons.length = 0; + + await playerEvent({ type: "join", name: "Alice" }); + t3.equal(state.rcons.length, 1); + const { data, code } = decodePlayerRcon(state.rcons[0]); + t3.strictSame(data, { message: alice.joinMessage, name: "Alice" }, "the message survives the long bracket"); + t3.equal(code, "game.print(d.message, { color = p.color })"); + + await playerEvent({ type: "join", name: "Bob" }); + await playerEvent({ type: "leave", name: "Alice" }); + t3.equal(state.rcons.length, 1, "players without a message and leaves print nothing"); + }); + + t2.test("nothing is printed while the instance is not running or greetings are disabled", async t3 => { + const { start, playerEvent, setStatus, featureUpdated, state } = await startPlugin(t3, [], [alice]); + await start(); + state.rcons.length = 0; + await playerEvent({ type: "join", name: "Alice" }); + t3.strictSame(state.rcons, [], "not running"); + + setStatus("running"); + await featureUpdated(new messages.FeatureUpdatedEvent([new messages.FeatureRecord("greetings", false)])); + state.rcons.length = 0; + await playerEvent({ type: "join", name: "Alice" }); + t3.strictSame(state.rcons, [], "greetings disabled"); + }); + + t2.test("ipc from the set and remove commands is forwarded to the controller", async t3 => { + const { ipc, state } = await startPlugin(t3); + await ipc("exp_scenario:join_message_set", { player_name: "Alice", message: "Hi" }); + await ipc("exp_scenario:join_message_set", { player_name: "Alice" }); + const sent = state.sent.filter(request => request instanceof messages.JoinMessageSetRequest); + t3.strictSame(sent.map(request => [request.playerName, request.message]), [["Alice", "Hi"], ["Alice", null]]); + }); + + t2.test("ipc asking for the message prints it to the player", async t3 => { + const { ipc, setStatus, state } = await startPlugin(t3, [], [alice]); + setStatus("running"); + await ipc("exp_scenario:join_message_get", { player_name: "Alice" }); + await ipc("exp_scenario:join_message_get", { player_name: "Bob" }); + const [found, missing] = state.rcons.map(decodePlayerRcon); + t3.equal(found.data.message, alice.joinMessage); + t3.equal(missing.data.message, undefined, "no message key, so the lua side prints message-none"); + t3.match(found.code, "exp_greetings.message-none"); + }); + + t2.end(); +}); diff --git a/exp_scenario/web/components/UserPlayerSettings.tsx b/exp_scenario/web/components/UserPlayerSettings.tsx new file mode 100644 index 00000000..a09b1d52 --- /dev/null +++ b/exp_scenario/web/components/UserPlayerSettings.tsx @@ -0,0 +1,75 @@ +import React, { useContext, useEffect, useState } from "react"; +import { Button, Descriptions, Input, Space } from "antd"; + +import { ControlContext, PluginExtensionProps, SectionHeader, notifyErrorHandler, useAccount } from "@clusterio/web_ui"; + +import { JoinMessageSetRequest, PlayerGetRequest, PlayerRecord, joinMessageMaxLength } from "../../messages.js"; + +/** The settings the scenario keeps for a player, shown on their user page. */ +export default function UserPlayerSettings(props: PluginExtensionProps<"UserViewPage">) { + const control = useContext(ControlContext); + const account = useAccount(); + const name = props.user.name; + const own = account.name.toLowerCase() === name.toLowerCase(); + const canGet = own || Boolean(account.hasPermission("core.user.get")); + const canEdit = Boolean(account.hasPermission( + own ? "exp_scenario.join_message.set" : "exp_scenario.join_message.set_other", + )); + const [record, setRecord] = useState(); + /** The message being typed, undefined while not editing */ + const [draft, setDraft] = useState(); + + function load() { + control.send(new PlayerGetRequest(name)) + .then(setRecord) + .catch(notifyErrorHandler("Error loading player settings")); + } + + useEffect(() => { + if (canGet) { + load(); + } + }, [name, canGet]); + + if (!canGet || !record) { + return null; + } + + async function save(message: string) { + await control.send(new JoinMessageSetRequest(name, message.trim() || null)); + setDraft(undefined); + load(); + } + + function onSave() { + save(draft ?? "").catch(notifyErrorHandler("Error saving join message")); + } + + return <> + + + + {draft !== undefined + ? + setDraft(e.target.value)} + onPressEnter={onSave} + /> + + + + : <> + {record.joinMessage ?? None} + {canEdit && } + } + + + ; +} diff --git a/exp_scenario/web/index.tsx b/exp_scenario/web/index.tsx index c9ad368b..feeda23c 100644 --- a/exp_scenario/web/index.tsx +++ b/exp_scenario/web/index.tsx @@ -6,6 +6,7 @@ import * as messages from "../messages.js"; import FeaturesPage from "./components/FeaturesPage"; import Seed from "./components/Seed"; +import UserPlayerSettings from "./components/UserPlayerSettings"; let features: lib.MapSubscriber | undefined; @@ -17,6 +18,7 @@ export function useFeatures() { export default function registerPlugin(context: WebPluginContext) { context.control.hooks.extensionComponents.attach("exp_scenario", () => ({ RolesPage: Seed, + UserViewPage: UserPlayerSettings, })); context.control.hooks.pages.attach("exp_scenario", () => [ From ebe7504c006feb6f2f76c89131384e2dfd640817 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:48:50 +0000 Subject: [PATCH 2/3] Show the language of players on their user page exp_legacy recorded each player's locale in PlayerData statistics which nothing read. The lua side now sends one ipc event with the player's locale when they join and when they change language, the instance forwards it, and the controller stores it on the player record. The Scenario section of the user page shows the language by name with a flag made from regional indicator symbols. Locales with a region use it directly, the rest map to a country through a small table covering Factorio's locale list. Arabic, Catalan, Basque, and Esperanto have no single country and get no flag. Co-Authored-By: Claude Opus 5.5 --- exp_legacy/module/config/_file_loader.lua | 1 - exp_legacy/module/locale/en.cfg | 2 -- exp_legacy/module/locale/zh-CN.cfg | 2 -- exp_legacy/module/locale/zh-TW.cfg | 2 -- exp_legacy/module/modules/data/language.lua | 21 ------------ exp_scenario/controller.ts | 12 ++++--- exp_scenario/index.ts | 1 + exp_scenario/instance.ts | 6 ++++ exp_scenario/locales.ts | 32 +++++++++++++++++ exp_scenario/messages.ts | 34 +++++++++++++++++++ exp_scenario/module/control.lua | 1 + .../module/control/player_settings.lua | 20 +++++++++++ exp_scenario/test/controller.test.js | 12 +++++++ exp_scenario/test/instance.test.js | 9 ++++- exp_scenario/test/locales.test.js | 26 ++++++++++++++ exp_scenario/tsconfig.browser.json | 2 +- .../web/components/UserPlayerSettings.tsx | 6 ++++ 17 files changed, 155 insertions(+), 34 deletions(-) delete mode 100644 exp_legacy/module/modules/data/language.lua create mode 100644 exp_scenario/locales.ts create mode 100644 exp_scenario/module/control/player_settings.lua create mode 100644 exp_scenario/test/locales.test.js diff --git a/exp_legacy/module/config/_file_loader.lua b/exp_legacy/module/config/_file_loader.lua index b9e10b1d..79e8688f 100644 --- a/exp_legacy/module/config/_file_loader.lua +++ b/exp_legacy/module/config/_file_loader.lua @@ -8,5 +8,4 @@ return { --- Data "modules.data.statistics", - "modules.data.language", } diff --git a/exp_legacy/module/locale/en.cfg b/exp_legacy/module/locale/en.cfg index 9e1a6de2..39224f9f 100644 --- a/exp_legacy/module/locale/en.cfg +++ b/exp_legacy/module/locale/en.cfg @@ -91,8 +91,6 @@ NetPlayTime=Net Play Time NetPlayTime-tooltip=Net Play Time AFKTimeRatio=AFK Time Ratio AFKTimeRatio-tooltip=AFK Time over Play Time -Locale=Locale -Locale-tooltip=The language of player interface # expcore [expcore-data] diff --git a/exp_legacy/module/locale/zh-CN.cfg b/exp_legacy/module/locale/zh-CN.cfg index d7a7e1bc..69a4bfad 100644 --- a/exp_legacy/module/locale/zh-CN.cfg +++ b/exp_legacy/module/locale/zh-CN.cfg @@ -91,8 +91,6 @@ NetPlayTime=淨遊玩時間 NetPlayTime-tooltip=遊玩時間減掛機時間 AFKTimeRatio=掛機時間比 AFKTimeRatio-tooltip=掛機時間除以遊玩時間 -Locale=語言 -Locale-tooltip=用戶介面語言 # expcore [expcore-data] diff --git a/exp_legacy/module/locale/zh-TW.cfg b/exp_legacy/module/locale/zh-TW.cfg index d7a7e1bc..69a4bfad 100644 --- a/exp_legacy/module/locale/zh-TW.cfg +++ b/exp_legacy/module/locale/zh-TW.cfg @@ -91,8 +91,6 @@ NetPlayTime=淨遊玩時間 NetPlayTime-tooltip=遊玩時間減掛機時間 AFKTimeRatio=掛機時間比 AFKTimeRatio-tooltip=掛機時間除以遊玩時間 -Locale=語言 -Locale-tooltip=用戶介面語言 # expcore [expcore-data] diff --git a/exp_legacy/module/modules/data/language.lua b/exp_legacy/module/modules/data/language.lua deleted file mode 100644 index 5a188b65..00000000 --- a/exp_legacy/module/modules/data/language.lua +++ /dev/null @@ -1,21 +0,0 @@ ---- Stores the language used to join the server --- @data Language - -local Event = require("modules/exp_legacy/utils/event") --- @dep utils.event -local PlayerData = require("modules.exp_legacy.expcore.player_data") --- @dep expcore.player_data -local LocalLanguage = PlayerData.Statistics:combine("LocalLanguage") -LocalLanguage:set_default("Unknown") -LocalLanguage:set_metadata{ - name = { "exp-statistics.Locale" }, - tooltip = { "exp-statistics.Locale-tooltip" }, -} - -local function set_locale(event) - local player = game.players[event.player_index] - LocalLanguage:set(player, player.locale) -end - ---- Set the players language when they join and change language -Event.add(defines.events.on_player_created, set_locale) -Event.add(defines.events.on_player_joined_game, set_locale) -Event.add(defines.events.on_player_locale_changed, set_locale) diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index abc856cb..530a7fd9 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -25,14 +25,14 @@ export default async function (context: ControllerPluginContext) { ).bootstrap() ); - type PlayerSettings = Pick; + type PlayerSettings = Pick; /** Change some settings of a player, empty values are removed and so is a record left empty */ function updatePlayer(name: string, changes: PlayerSettings) { const old = playerStore.get(name); - const settings: PlayerSettings = { joinMessage: old?.joinMessage, ...changes }; - const record = new messages.PlayerRecord(name, settings.joinMessage || undefined); - if (record.joinMessage) { + const settings: PlayerSettings = { joinMessage: old?.joinMessage, locale: old?.locale, ...changes }; + const record = new messages.PlayerRecord(name, settings.joinMessage || undefined, settings.locale || undefined); + if (record.joinMessage || record.locale) { playerStore.set(record); } else if (old) { playerStore.delete(old); @@ -230,6 +230,10 @@ export default async function (context: ControllerPluginContext) { updatePlayer(request.playerName, { joinMessage: request.message?.trim() }); }); + controller.handle(messages.PlayerLocaleEvent, async (event: messages.PlayerLocaleEvent) => { + updatePlayer(event.playerName, { locale: event.locale }); + }); + controller.hooks.save.attach(plugin.name, async () => { await playerStore.save(); }); diff --git a/exp_scenario/index.ts b/exp_scenario/index.ts index 5fc959bc..9ec4a65c 100644 --- a/exp_scenario/index.ts +++ b/exp_scenario/index.ts @@ -27,6 +27,7 @@ export const plugin: lib.PluginDeclaration = { messages.FeatureUpdateRequest, messages.PlayerGetRequest, messages.JoinMessageSetRequest, + messages.PlayerLocaleEvent, ], permissions: [ diff --git a/exp_scenario/instance.ts b/exp_scenario/instance.ts index ec36a730..2cc50908 100644 --- a/exp_scenario/instance.ts +++ b/exp_scenario/instance.ts @@ -7,6 +7,8 @@ import { features, FeatureValue } from "./features.js"; export type IpcJoinMessageSet = { player_name: string, message?: string }; /** Sent by the lua side when a player asks for their join message */ export type IpcJoinMessageGet = { player_name: string }; +/** Sent by the lua side when a player joins or changes language, see module/control/player_settings.lua */ +export type IpcLocale = { player_name: string, locale: string }; /** Json as a lua expression for a table, inside a long bracket the json does not contain */ export function luaJson(value: unknown) { @@ -89,6 +91,10 @@ export default async function (context: InstancePluginContext) { ); }); + instance.server.handle("exp_scenario:locale", async (event: IpcLocale) => { + instance.sendTo("controller", new messages.PlayerLocaleEvent(event.player_name, event.locale)); + }); + instance.hooks.playerEvent.attach(plugin.name, async event => { if (event.type === "join") { await onPlayerJoin(event.name).catch(err => logger.error( diff --git a/exp_scenario/locales.ts b/exp_scenario/locales.ts new file mode 100644 index 00000000..a0701c0f --- /dev/null +++ b/exp_scenario/locales.ts @@ -0,0 +1,32 @@ +/** + * Country of each Factorio locale without a region in its code, used for the + * flag. Languages spoken across many countries or without one, such as ar, + * ca, eo, and eu, are left out and get no flag. + */ +const localeCountries: Record = { + af: "ZA", be: "BY", bg: "BG", cs: "CZ", da: "DK", de: "DE", el: "GR", en: "GB", et: "EE", + fa: "IR", fi: "FI", fil: "PH", fr: "FR", he: "IL", hr: "HR", hu: "HU", id: "ID", is: "IS", + it: "IT", ja: "JP", ka: "GE", kk: "KZ", ko: "KR", lt: "LT", lv: "LV", nl: "NL", no: "NO", + pl: "PL", ro: "RO", ru: "RU", sk: "SK", sl: "SI", sq: "AL", sr: "RS", th: "TH", tr: "TR", + uk: "UA", vi: "VN", +}; + +/** Flag emoji for a Factorio locale such as "de" or "pt-BR", empty when it has no obvious country. */ +export function localeFlag(locale: string) { + const [language, region] = locale.split("-"); + const country = region ?? localeCountries[language]; + if (!country || !/^[A-Z]{2}$/.test(country)) { + return ""; + } + // Regional indicator symbols start at U+1F1E6 for A + return String.fromCodePoint(...[...country].map(letter => 0x1F1E6 + letter.charCodeAt(0) - 65)); +} + +/** English name of a locale, the code itself when it is not known. */ +export function localeName(locale: string) { + try { + return new Intl.DisplayNames(["en"], { type: "language" }).of(locale) ?? locale; + } catch { + return locale; + } +} diff --git a/exp_scenario/messages.ts b/exp_scenario/messages.ts index 5c6f91e5..a76a9250 100644 --- a/exp_scenario/messages.ts +++ b/exp_scenario/messages.ts @@ -134,6 +134,8 @@ export class PlayerRecord { public id: string, /** Shown to everyone when the player joins */ public joinMessage?: string, + /** The language of their game, such as "en" or "pt-BR" */ + public locale?: string, public updatedAtMs: number = 0, public isDeleted: boolean = false, ) {} @@ -141,6 +143,7 @@ export class PlayerRecord { static jsonSchema = Type.Object({ id: Type.String(), join_message: Type.Optional(Type.String()), + locale: Type.Optional(Type.String()), updated_at_ms: Type.Optional(Type.Number()), is_deleted: Type.Optional(Type.Boolean()), }); @@ -150,6 +153,9 @@ export class PlayerRecord { if (this.joinMessage) { json.join_message = this.joinMessage; } + if (this.locale) { + json.locale = this.locale; + } if (this.updatedAtMs) { json.updated_at_ms = this.updatedAtMs; } @@ -163,6 +169,7 @@ export class PlayerRecord { return new this( json.id, json.join_message, + json.locale, json.updated_at_ms ?? 0, json.is_deleted ?? false, ); @@ -242,3 +249,30 @@ export class JoinMessageSetRequest { return new this(json.player_name, json.message); } } + +/** The language a player's game uses, sent when they join and when they change it. */ +export class PlayerLocaleEvent { + declare ["constructor"]: typeof PlayerLocaleEvent; + static plugin = "exp_scenario" as const; + static type = "event" as const; + static src = "instance" as const; + static dst = "controller" as const; + + constructor( + public playerName: string, + public locale: string, + ) {} + + static jsonSchema = Type.Object({ + player_name: Type.String(), + locale: Type.String(), + }); + + toJSON() { + return { player_name: this.playerName, locale: this.locale }; + } + + static fromJSON(json: Static) { + return new this(json.player_name, json.locale); + } +} diff --git a/exp_scenario/module/control.lua b/exp_scenario/module/control.lua index 255806e9..6e6f8e83 100644 --- a/exp_scenario/module/control.lua +++ b/exp_scenario/module/control.lua @@ -58,6 +58,7 @@ add(require("modules/exp_scenario/control/inserter_pickup")) add(require("modules/exp_scenario/control/inventory_clear")) add(require("modules/exp_scenario/control/mine_depletion")) add(require("modules/exp_scenario/control/nuke_protection")) +add(require("modules/exp_scenario/control/player_settings")) add(require("modules/exp_scenario/control/pollution_grading")) add(require("modules/exp_scenario/control/popups")) add(require("modules/exp_scenario/control/protection")) diff --git a/exp_scenario/module/control/player_settings.lua b/exp_scenario/module/control/player_settings.lua new file mode 100644 index 00000000..7329bc1b --- /dev/null +++ b/exp_scenario/module/control/player_settings.lua @@ -0,0 +1,20 @@ +--[[-- Control - Player Settings +Sends settings of players to the controller, which keeps them between servers +]] + +local clusterio_api = require("modules/clusterio/api") + +--- @param event EventData.on_player_joined_game | EventData.on_player_locale_changed +local function send_locale(event) + local player = assert(game.get_player(event.player_index)) + clusterio_api.send_json("exp_scenario:locale", { player_name = player.name, locale = player.locale }) +end + +local e = defines.events + +return { + events = { + [e.on_player_joined_game] = send_locale, + [e.on_player_locale_changed] = send_locale, + }, +} diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js index a08f51d6..5960ca59 100644 --- a/exp_scenario/test/controller.test.js +++ b/exp_scenario/test/controller.test.js @@ -278,6 +278,18 @@ t.test("player settings", t2 => { t3.equal(record.joinMessage, "Bye", "kept across restarts"); }); + t2.test("PlayerLocaleEvent stores the locale next to the join message", async t3 => { + const { send } = await startPlugin(t3, { withPlugins: false }); + await send(new messages.JoinMessageSetRequest("Alice", "Hi")); + await send(new messages.PlayerLocaleEvent("Alice", "pt-BR")); + const record = await send(new messages.PlayerGetRequest("Alice")); + t3.strictSame([record.joinMessage, record.locale], ["Hi", "pt-BR"]); + + await send(new messages.JoinMessageSetRequest("Alice", null)); + const cleared = await send(new messages.PlayerGetRequest("Alice")); + t3.equal(cleared.locale, "pt-BR", "clearing the message keeps the locale"); + }); + t2.test("permissions", async t3 => { const user = (name, granted = []) => ({ name, diff --git a/exp_scenario/test/instance.test.js b/exp_scenario/test/instance.test.js index c2569b19..90725f4b 100644 --- a/exp_scenario/test/instance.test.js +++ b/exp_scenario/test/instance.test.js @@ -153,7 +153,7 @@ t.test("instance plugin", t2 => { t2.end(); }); -t.test("join messages", t2 => { +t.test("player settings", t2 => { const alice = new messages.PlayerRecord("Alice", "Alice has arrived ]=] ]==]"); t2.test("a joining player's message is printed to everyone", async t3 => { @@ -195,6 +195,13 @@ t.test("join messages", t2 => { t3.strictSame(sent.map(request => [request.playerName, request.message]), [["Alice", "Hi"], ["Alice", null]]); }); + t2.test("ipc with a locale is forwarded to the controller", async t3 => { + const { ipc, state } = await startPlugin(t3); + await ipc("exp_scenario:locale", { player_name: "Alice", locale: "de" }); + const [event] = state.sent.filter(request => request instanceof messages.PlayerLocaleEvent); + t3.strictSame([event.playerName, event.locale], ["Alice", "de"]); + }); + t2.test("ipc asking for the message prints it to the player", async t3 => { const { ipc, setStatus, state } = await startPlugin(t3, [], [alice]); setStatus("running"); diff --git a/exp_scenario/test/locales.test.js b/exp_scenario/test/locales.test.js new file mode 100644 index 00000000..1b375e1b --- /dev/null +++ b/exp_scenario/test/locales.test.js @@ -0,0 +1,26 @@ +import t from "tap"; +import { localeFlag, localeName } from "../dist/node/locales.js"; + +// The locale folders of Factorio 2.1 +const factorioLocales = [ + "af", "ar", "be", "bg", "ca", "cs", "da", "de", "el", "en", "eo", "es-ES", "et", "eu", "fa", "fi", "fil", "fr", + "fy-NL", "ga-IE", "he", "hr", "hu", "id", "is", "it", "ja", "ka", "kk", "ko", "lt", "lv", "nl", "no", "pl", + "pt-BR", "pt-PT", "ro", "ru", "sk", "sl", "sq", "sr", "sv-SE", "th", "tr", "uk", "vi", "zh-CN", "zh-TW", +]; +const noFlag = ["ar", "ca", "eo", "eu"]; + +t.test("localeFlag()", async t2 => { + t2.equal(localeFlag("de"), "\u{1F1E9}\u{1F1EA}", "language codes use the table"); + t2.equal(localeFlag("pt-BR"), "\u{1F1E7}\u{1F1F7}", "regions are used directly"); + t2.equal(localeFlag("zh-TW"), "\u{1F1F9}\u{1F1FC}"); + for (const locale of factorioLocales) { + t2.equal(localeFlag(locale) === "", noFlag.includes(locale), `${locale} has a flag unless it has no country`); + } + t2.equal(localeFlag("xx"), "", "unknown languages have no flag"); + t2.equal(localeFlag(""), ""); +}); + +t.test("localeName()", async t2 => { + t2.equal(localeName("de"), "German"); + t2.equal(localeName("not a locale"), "not a locale", "invalid codes are shown as they are"); +}); diff --git a/exp_scenario/tsconfig.browser.json b/exp_scenario/tsconfig.browser.json index c60f963b..fea6f6d7 100644 --- a/exp_scenario/tsconfig.browser.json +++ b/exp_scenario/tsconfig.browser.json @@ -1,4 +1,4 @@ { "extends": "../tsconfig.browser.json", - "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "features.ts", "permissions.ts", "package.json" ], + "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "features.ts", "locales.ts", "permissions.ts", "package.json" ], } diff --git a/exp_scenario/web/components/UserPlayerSettings.tsx b/exp_scenario/web/components/UserPlayerSettings.tsx index a09b1d52..d6646cd3 100644 --- a/exp_scenario/web/components/UserPlayerSettings.tsx +++ b/exp_scenario/web/components/UserPlayerSettings.tsx @@ -4,6 +4,7 @@ import { Button, Descriptions, Input, Space } from "antd"; import { ControlContext, PluginExtensionProps, SectionHeader, notifyErrorHandler, useAccount } from "@clusterio/web_ui"; import { JoinMessageSetRequest, PlayerGetRequest, PlayerRecord, joinMessageMaxLength } from "../../messages.js"; +import { localeFlag, localeName } from "../../locales.js"; /** The settings the scenario keeps for a player, shown on their user page. */ export default function UserPlayerSettings(props: PluginExtensionProps<"UserViewPage">) { @@ -48,6 +49,11 @@ export default function UserPlayerSettings(props: PluginExtensionProps<"UserView return <> + + {record.locale + ? `${localeFlag(record.locale)} ${localeName(record.locale)} (${record.locale})`.trim() + : Unknown} + {draft !== undefined ? From 54941b85aab880b34ed1570ce936ef8ed073332d Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:52:07 +0000 Subject: [PATCH 3/3] Sync the toolbar of players through the controller The toolbar order, favourites, and open panels were saved into exp_legacy's PlayerData, which was never synced, and the file doing so was no longer loaded. When a player leaves, the lua side now sends Gui.toolbar.get_state as json compressed with helpers.encode_string, and the controller stores the string on the player record. When the player joins again the instance plugin sends the stored string over rcon to exp_scenario.player_settings.receive_toolbar, which decodes it and calls Gui.toolbar.set_state. exp_gui builds the toolbar in on_player_joined_game, so it exists by the time the rcon arrives. A string which does not decode, or a state naming buttons which no longer exist, is logged and ignored rather than raising in the game. Co-Authored-By: Claude Opus 5.5 --- exp_legacy/module/locale/en.cfg | 3 - exp_legacy/module/locale/zh-CN.cfg | 3 - exp_legacy/module/locale/zh-TW.cfg | 3 - exp_legacy/module/modules/data/toolbar.lua | 31 -------- exp_scenario/controller.ts | 16 +++- exp_scenario/index.ts | 1 + exp_scenario/instance.ts | 13 ++- exp_scenario/messages.ts | 34 ++++++++ .../module/control/player_settings.lua | 48 +++++++++++ exp_scenario/module/globals.lua | 1 + exp_scenario/test/controller.test.js | 12 +++ exp_scenario/test/instance.test.js | 21 +++++ exp_scenario/test/module.test.js | 3 + exp_scenario/test/module/env.lua | 5 ++ exp_scenario/test/module/player_settings.lua | 79 +++++++++++++++++++ 15 files changed, 228 insertions(+), 45 deletions(-) delete mode 100644 exp_legacy/module/modules/data/toolbar.lua create mode 100644 exp_scenario/test/module/player_settings.lua diff --git a/exp_legacy/module/locale/en.cfg b/exp_legacy/module/locale/en.cfg index 39224f9f..054bb723 100644 --- a/exp_legacy/module/locale/en.cfg +++ b/exp_legacy/module/locale/en.cfg @@ -22,9 +22,6 @@ Bonus=Player Bonus Bonus-tooltip=The bonus given to your character Bonus-value-tooltip=Change by using /bonus HasEnabledDecon=Quick Tree Decon -ToolbarState=Toolbox -ToolbarState-tooltip=The order and favourites in your toolbox -ToolbarState-value-tooltip=This value is calculated automatically when you leave the game [exp-statistics] MapsPlayed=Maps Played diff --git a/exp_legacy/module/locale/zh-CN.cfg b/exp_legacy/module/locale/zh-CN.cfg index 69a4bfad..a2d5356e 100644 --- a/exp_legacy/module/locale/zh-CN.cfg +++ b/exp_legacy/module/locale/zh-CN.cfg @@ -22,9 +22,6 @@ Bonus=人物附加屬性 Bonus-tooltip=你的人物附加屬性 Bonus-value-tooltip=使用 /bonus 來更改 HasEnabledDecon=快速拆除樹木 -ToolbarState=工具箱 -ToolbarState-tooltip=次序和喜愛項目 -ToolbarState-value-tooltip=此值在離開時自動計算 [exp-statistics] MapsPlayed=地圖遊玩次數 diff --git a/exp_legacy/module/locale/zh-TW.cfg b/exp_legacy/module/locale/zh-TW.cfg index 69a4bfad..a2d5356e 100644 --- a/exp_legacy/module/locale/zh-TW.cfg +++ b/exp_legacy/module/locale/zh-TW.cfg @@ -22,9 +22,6 @@ Bonus=人物附加屬性 Bonus-tooltip=你的人物附加屬性 Bonus-value-tooltip=使用 /bonus 來更改 HasEnabledDecon=快速拆除樹木 -ToolbarState=工具箱 -ToolbarState-tooltip=次序和喜愛項目 -ToolbarState-value-tooltip=此值在離開時自動計算 [exp-statistics] MapsPlayed=地圖遊玩次數 diff --git a/exp_legacy/module/modules/data/toolbar.lua b/exp_legacy/module/modules/data/toolbar.lua deleted file mode 100644 index 177aba50..00000000 --- a/exp_legacy/module/modules/data/toolbar.lua +++ /dev/null @@ -1,31 +0,0 @@ -local Gui = require("modules/exp_gui") -local PlayerData = require("modules.exp_legacy.expcore.player_data") - --- Used to store the state of the toolbar when a player leaves -local ToolbarState = PlayerData.Settings:combine("ToolbarState") -ToolbarState:set_metadata{ - stringify = function() - return "Toolbar is saved on exit" - end, -} - ---- Uncompress the data to be more useable -ToolbarState:on_load(function(player_name, value) - -- If there is no value, do nothing - if value == nil then return end - -- Old format, we discard it [ string[], string[], string[], boolean ] - if type(value) ~= "string" then return end - - local decompressed = helpers.json_to_table(assert(helpers.decode_string(value), "Failed String Decode")) - local player = assert(game.get_player(player_name)) - Gui.toolbar.set_state(player, decompressed --[[@as Gui.ToolbarState]]) - - return nil -- We don't save the state, use Gui.toolbar.get_state -end) - ---- Save the current state of the players toolbar menu -ToolbarState:on_save(function(player_name, _) - local player = assert(game.get_player(player_name)) - local value = Gui.toolbar.get_state(player) - return helpers.encode_string(helpers.table_to_json(value)) -end) diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index 530a7fd9..f93dbca5 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -25,14 +25,18 @@ export default async function (context: ControllerPluginContext) { ).bootstrap() ); - type PlayerSettings = Pick; + type PlayerSettings = Pick; /** Change some settings of a player, empty values are removed and so is a record left empty */ function updatePlayer(name: string, changes: PlayerSettings) { const old = playerStore.get(name); - const settings: PlayerSettings = { joinMessage: old?.joinMessage, locale: old?.locale, ...changes }; - const record = new messages.PlayerRecord(name, settings.joinMessage || undefined, settings.locale || undefined); - if (record.joinMessage || record.locale) { + const settings: PlayerSettings = { + joinMessage: old?.joinMessage, locale: old?.locale, toolbar: old?.toolbar, ...changes, + }; + const record = new messages.PlayerRecord( + name, settings.joinMessage || undefined, settings.locale || undefined, settings.toolbar || undefined, + ); + if (record.joinMessage || record.locale || record.toolbar) { playerStore.set(record); } else if (old) { playerStore.delete(old); @@ -234,6 +238,10 @@ export default async function (context: ControllerPluginContext) { updatePlayer(event.playerName, { locale: event.locale }); }); + controller.handle(messages.PlayerToolbarEvent, async (event: messages.PlayerToolbarEvent) => { + updatePlayer(event.playerName, { toolbar: event.toolbar }); + }); + controller.hooks.save.attach(plugin.name, async () => { await playerStore.save(); }); diff --git a/exp_scenario/index.ts b/exp_scenario/index.ts index 9ec4a65c..cfcc7aee 100644 --- a/exp_scenario/index.ts +++ b/exp_scenario/index.ts @@ -28,6 +28,7 @@ export const plugin: lib.PluginDeclaration = { messages.PlayerGetRequest, messages.JoinMessageSetRequest, messages.PlayerLocaleEvent, + messages.PlayerToolbarEvent, ], permissions: [ diff --git a/exp_scenario/instance.ts b/exp_scenario/instance.ts index 2cc50908..57621a14 100644 --- a/exp_scenario/instance.ts +++ b/exp_scenario/instance.ts @@ -9,6 +9,8 @@ export type IpcJoinMessageSet = { player_name: string, message?: string }; export type IpcJoinMessageGet = { player_name: string }; /** Sent by the lua side when a player joins or changes language, see module/control/player_settings.lua */ export type IpcLocale = { player_name: string, locale: string }; +/** Sent by the lua side when a player leaves */ +export type IpcToolbar = { player_name: string, toolbar: string }; /** Json as a lua expression for a table, inside a long bracket the json does not contain */ export function luaJson(value: unknown) { @@ -69,6 +71,11 @@ export default async function (context: InstancePluginContext) { if (record.joinMessage && featureEnabled.get("greetings") !== false) { await luaWithPlayer(name, { message: record.joinMessage }, "game.print(d.message, { color = p.color })"); } + if (record.toolbar) { + await luaWithPlayer( + name, { toolbar: record.toolbar }, "exp_scenario.player_settings.receive_toolbar(d.name, d.toolbar)", + ); + } } instance.handle(messages.FeatureUpdatedEvent, async event => { @@ -95,10 +102,14 @@ export default async function (context: InstancePluginContext) { instance.sendTo("controller", new messages.PlayerLocaleEvent(event.player_name, event.locale)); }); + instance.server.handle("exp_scenario:toolbar", async (event: IpcToolbar) => { + instance.sendTo("controller", new messages.PlayerToolbarEvent(event.player_name, event.toolbar)); + }); + instance.hooks.playerEvent.attach(plugin.name, async event => { if (event.type === "join") { await onPlayerJoin(event.name).catch(err => logger.error( - `Error showing the join message of ${event.name}:\n${err.stack ?? err.message}`, + `Error sending the settings of ${event.name}:\n${err.stack ?? err.message}`, )); } }); diff --git a/exp_scenario/messages.ts b/exp_scenario/messages.ts index a76a9250..52f09ddb 100644 --- a/exp_scenario/messages.ts +++ b/exp_scenario/messages.ts @@ -136,6 +136,8 @@ export class PlayerRecord { public joinMessage?: string, /** The language of their game, such as "en" or "pt-BR" */ public locale?: string, + /** Toolbar state from Gui.toolbar.get_state, json compressed with helpers.encode_string */ + public toolbar?: string, public updatedAtMs: number = 0, public isDeleted: boolean = false, ) {} @@ -144,6 +146,7 @@ export class PlayerRecord { id: Type.String(), join_message: Type.Optional(Type.String()), locale: Type.Optional(Type.String()), + toolbar: Type.Optional(Type.String()), updated_at_ms: Type.Optional(Type.Number()), is_deleted: Type.Optional(Type.Boolean()), }); @@ -156,6 +159,9 @@ export class PlayerRecord { if (this.locale) { json.locale = this.locale; } + if (this.toolbar) { + json.toolbar = this.toolbar; + } if (this.updatedAtMs) { json.updated_at_ms = this.updatedAtMs; } @@ -170,6 +176,7 @@ export class PlayerRecord { json.id, json.join_message, json.locale, + json.toolbar, json.updated_at_ms ?? 0, json.is_deleted ?? false, ); @@ -276,3 +283,30 @@ export class PlayerLocaleEvent { return new this(json.player_name, json.locale); } } + +/** The toolbar of a player, sent when they leave. */ +export class PlayerToolbarEvent { + declare ["constructor"]: typeof PlayerToolbarEvent; + static plugin = "exp_scenario" as const; + static type = "event" as const; + static src = "instance" as const; + static dst = "controller" as const; + + constructor( + public playerName: string, + public toolbar: string, + ) {} + + static jsonSchema = Type.Object({ + player_name: Type.String(), + toolbar: Type.String(), + }); + + toJSON() { + return { player_name: this.playerName, toolbar: this.toolbar }; + } + + static fromJSON(json: Static) { + return new this(json.player_name, json.toolbar); + } +} diff --git a/exp_scenario/module/control/player_settings.lua b/exp_scenario/module/control/player_settings.lua index 7329bc1b..44a82607 100644 --- a/exp_scenario/module/control/player_settings.lua +++ b/exp_scenario/module/control/player_settings.lua @@ -1,7 +1,11 @@ --[[-- Control - Player Settings Sends settings of players to the controller, which keeps them between servers + +The toolbar is sent compressed when a player leaves, the instance plugin +calls receive_toolbar with it when they join again ]] +local Gui = require("modules/exp_gui") local clusterio_api = require("modules/clusterio/api") --- @param event EventData.on_player_joined_game | EventData.on_player_locale_changed @@ -10,11 +14,55 @@ local function send_locale(event) clusterio_api.send_json("exp_scenario:locale", { player_name = player.name, locale = player.locale }) end +--- @param event EventData.on_player_left_game +local function send_toolbar(event) + local player = assert(game.get_player(event.player_index)) + local success, state = pcall(Gui.toolbar.get_state, player) + if not success then + log("[WARNING] Could not read the toolbar of " .. player.name .. ": " .. tostring(state)) + return + end + local toolbar = helpers.encode_string(helpers.table_to_json(state)) + clusterio_api.send_json("exp_scenario:toolbar", { player_name = player.name, toolbar = toolbar }) +end + +--- @param toolbar string +--- @return Gui.ToolbarState? +local function decode_toolbar(toolbar) + local json = helpers.decode_string(toolbar) + local state = json and helpers.json_to_table(json) + if type(state) == "table" and type(state.order) == "table" and type(state.open) == "table" + and type(state.visible) == "boolean" then + return state --[[@as Gui.ToolbarState]] + end +end + +--- Restore the toolbar of a player, called over rcon by the instance plugin +--- @param player_name string +--- @param toolbar string From send_toolbar +local function receive_toolbar(player_name, toolbar) + local player = game.get_player(player_name) + if not player or not player.connected then + return + end + + -- Stored states can name buttons which no longer exist + local success, err = pcall(function() + local state = assert(decode_toolbar(toolbar), "invalid toolbar state") + Gui.toolbar.set_state(player, state) + end) + if not success then + log("[WARNING] Could not restore the toolbar of " .. player_name .. ": " .. tostring(err)) + end +end + local e = defines.events return { events = { [e.on_player_joined_game] = send_locale, [e.on_player_locale_changed] = send_locale, + [e.on_player_left_game] = send_toolbar, }, + receive_toolbar = receive_toolbar, } diff --git a/exp_scenario/module/globals.lua b/exp_scenario/module/globals.lua index bdb9ef6a..b0deda40 100644 --- a/exp_scenario/module/globals.lua +++ b/exp_scenario/module/globals.lua @@ -3,4 +3,5 @@ -- Access using `/sc exp_scenario.features.foo()` exp_scenario = { features = require("modules/exp_scenario/features"), + player_settings = require("modules/exp_scenario/control/player_settings"), } diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js index 5960ca59..34df792b 100644 --- a/exp_scenario/test/controller.test.js +++ b/exp_scenario/test/controller.test.js @@ -290,6 +290,18 @@ t.test("player settings", t2 => { t3.equal(cleared.locale, "pt-BR", "clearing the message keeps the locale"); }); + t2.test("PlayerToolbarEvent stores the toolbar and it is saved", async t3 => { + const databaseDirectory = t3.testdir(); + const { send, save } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); + await send(new messages.PlayerToolbarEvent("Alice", "abc")); + t3.equal((await send(new messages.PlayerGetRequest("Alice"))).toolbar, "abc"); + + await save(); + const stored = await loadPlayers(databaseDirectory); + const alice = stored.get("Alice"); + t3.strictSame(alice.toJSON(), { id: "Alice", toolbar: "abc", updated_at_ms: alice.updatedAtMs }); + }); + t2.test("permissions", async t3 => { const user = (name, granted = []) => ({ name, diff --git a/exp_scenario/test/instance.test.js b/exp_scenario/test/instance.test.js index 90725f4b..30b9598b 100644 --- a/exp_scenario/test/instance.test.js +++ b/exp_scenario/test/instance.test.js @@ -195,6 +195,27 @@ t.test("player settings", t2 => { t3.strictSame(sent.map(request => [request.playerName, request.message]), [["Alice", "Hi"], ["Alice", null]]); }); + t2.test("a joining player's stored toolbar is sent to the lua side", async t3 => { + const bob = new messages.PlayerRecord("Bob", undefined, undefined, "eNqrVkrLz1eyUlBKSixSqgUAGQwD8g=="); + const { start, playerEvent, setStatus, state } = await startPlugin(t3, [], [bob]); + await start(); + setStatus("running"); + state.rcons.length = 0; + + await playerEvent({ type: "join", name: "Bob" }); + t3.equal(state.rcons.length, 1, "only the toolbar, Bob has no join message"); + const { data, code } = decodePlayerRcon(state.rcons[0]); + t3.strictSame(data, { toolbar: bob.toolbar, name: "Bob" }); + t3.equal(code, "exp_scenario.player_settings.receive_toolbar(d.name, d.toolbar)"); + }); + + t2.test("ipc with a toolbar is forwarded to the controller", async t3 => { + const { ipc, state } = await startPlugin(t3); + await ipc("exp_scenario:toolbar", { player_name: "Alice", toolbar: "abc" }); + const [event] = state.sent.filter(request => request instanceof messages.PlayerToolbarEvent); + t3.strictSame([event.playerName, event.toolbar], ["Alice", "abc"]); + }); + t2.test("ipc with a locale is forwarded to the controller", async t3 => { const { ipc, state } = await startPlugin(t3); await ipc("exp_scenario:locale", { player_name: "Alice", locale: "de" }); diff --git a/exp_scenario/test/module.test.js b/exp_scenario/test/module.test.js index 0aa3fde7..78c1a4b0 100644 --- a/exp_scenario/test/module.test.js +++ b/exp_scenario/test/module.test.js @@ -6,3 +6,6 @@ const envFile = path.join(import.meta.dirname, "module", "env.lua"); // Each file runs in its own lua state, and each test in a fresh environment t.test("features.lua", t2 => reportLuaTests(t2, envFile, path.join(import.meta.dirname, "module", "features.lua"))); +t.test("player_settings.lua", t2 => reportLuaTests( + t2, envFile, path.join(import.meta.dirname, "module", "player_settings.lua"), +)); diff --git a/exp_scenario/test/module/env.lua b/exp_scenario/test/module/env.lua index 96c41456..1d5acfa3 100644 --- a/exp_scenario/test/module/env.lua +++ b/exp_scenario/test/module/env.lua @@ -17,6 +17,7 @@ local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type --- @field storage table The table registered with storage, as on_init would store it --- @field on_load fun(tbl: table) Act out on_load with the given storage table --- @field load_config fun(name: string): ExpScenario.Feature Load module/config/.lua +--- @field load_module fun(name: string): any Load module/.lua, such as "control/player_settings" --- @field logged string[] Messages passed to log --- @field events table[] Raised events, name and tick filled in @@ -51,5 +52,9 @@ return Framework.suite(function(env) return assert(loadfile(plugin_root .. "/module/config/" .. name .. ".lua"))() end + function env.load_module(name) + return assert(loadfile(plugin_root .. "/module/" .. name .. ".lua"))() + end + return env end) diff --git a/exp_scenario/test/module/player_settings.lua b/exp_scenario/test/module/player_settings.lua new file mode 100644 index 00000000..1e9107c4 --- /dev/null +++ b/exp_scenario/test/module/player_settings.lua @@ -0,0 +1,79 @@ +local Suite = ... --- @type Suite + +local valid_state = { order = { { name = "readme", favourite = true } }, open = {}, visible = true } + +--- Load control/player_settings.lua with a stubbed toolbar, encoding is a "enc:" prefix +--- @param env ExpScenario.TestEnv +--- @param toolbar table? Replaces the Gui.toolbar stub +--- @return table PlayerSettings +--- @return table restored Calls to set_state as { player name, state } +local function load(env, toolbar) + local restored = {} + env.extend_defines{ events = { on_player_locale_changed = 3, on_player_left_game = 4 } } + env.extend_requires{ + ["modules/exp_gui"] = { + toolbar = toolbar or { + get_state = function() return valid_state end, + set_state = function(player, state) restored[#restored + 1] = { player.name, state } end, + }, + }, + } + local json_values = { ['{"valid"}'] = valid_state, ['{"wrong shape"}'] = { order = "x" } } + helpers = { + encode_string = function(str) return "enc:" .. str end, + decode_string = function(str) return str:match("^enc:(.*)$") end, + table_to_json = function(tbl) return tbl == valid_state and '{"valid"}' or "{}" end, + json_to_table = function(json) return json_values[json] end, + } + return env.load_module("control/player_settings"), restored +end + +Suite.test("send_locale() sends the locale of the player", function(env) + local PlayerSettings = load(env) + rawset(env.add_player{ name = "Alice" }, "locale", "pt-BR") + PlayerSettings.events[defines.events.on_player_joined_game]{ player_index = 1 } + Suite.eq(env.sent, { { channel = "exp_scenario:locale", data = { player_name = "Alice", locale = "pt-BR" } } }, "sent") +end) + +Suite.test("send_toolbar() sends the compressed toolbar when a player leaves", function(env) + local PlayerSettings = load(env) + env.add_player{ name = "Alice" } + PlayerSettings.events[defines.events.on_player_left_game]{ player_index = 1 } + Suite.eq(env.sent, { { channel = "exp_scenario:toolbar", data = { player_name = "Alice", toolbar = 'enc:{"valid"}' } } }, "sent") +end) + +Suite.test("send_toolbar() sends nothing when the toolbar can not be read", function(env) + local PlayerSettings = load(env, { get_state = function() error("no toolbar") end }) + env.add_player{ name = "Alice" } + PlayerSettings.events[defines.events.on_player_left_game]{ player_index = 1 } + Suite.eq(env.sent, {}, "nothing sent") + Suite.eq(#env.logged, 1, "a warning is logged") +end) + +Suite.test("receive_toolbar() restores the toolbar of online players", function(env) + local PlayerSettings, restored = load(env) + env.add_player{ name = "Alice" } + env.add_player{ name = "Bob", connected = false } + PlayerSettings.receive_toolbar("Alice", 'enc:{"valid"}') + PlayerSettings.receive_toolbar("Bob", 'enc:{"valid"}') + PlayerSettings.receive_toolbar("Unknown", 'enc:{"valid"}') + Suite.eq(restored, { { "Alice", valid_state } }, "only Alice is online") +end) + +Suite.test("receive_toolbar() logs malformed toolbars instead of raising", function(env) + local PlayerSettings, restored = load(env) + env.add_player{ name = "Alice" } + for _, toolbar in ipairs{ "not encoded", "enc:not json", 'enc:{"wrong shape"}' } do + PlayerSettings.receive_toolbar("Alice", toolbar) + end + Suite.eq(restored, {}, "nothing is restored") + Suite.eq(#env.logged, 3, "each one is logged") +end) + +Suite.test("receive_toolbar() logs errors from set_state", function(env) + local PlayerSettings = load(env, { set_state = function() error("Missing toolbox item for removed") end }) + env.add_player{ name = "Alice" } + PlayerSettings.receive_toolbar("Alice", 'enc:{"valid"}') + Suite.eq(#env.logged, 1, "a warning is logged") +end) +return Suite.run()