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] 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", () => [