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