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 1a161a5a..9e85f680 100644 --- a/exp_legacy/module/locale/en.cfg +++ b/exp_legacy/module/locale/en.cfg @@ -15,15 +15,9 @@ 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 Bonus=Player Bonus Bonus-tooltip=The bonus given to your character Bonus-value-tooltip=Change by using /bonus -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 @@ -90,8 +84,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 20758f41..4d1ce2e2 100644 --- a/exp_legacy/module/locale/zh-CN.cfg +++ b/exp_legacy/module/locale/zh-CN.cfg @@ -15,15 +15,9 @@ lhd=列車必須是左則通行。這是本服務器長久以來的規則。 # data [exp-settings] -JoinMessage=加入信息 -JoinMessage-tooltip=你加入時所顯示的信息 -JoinMessage-value-tooltip=使用 /join-message 來更換加入信息 Bonus=人物附加屬性 Bonus-tooltip=你的人物附加屬性 Bonus-value-tooltip=使用 /bonus 來更改 -ToolbarState=工具箱 -ToolbarState-tooltip=次序和喜愛項目 -ToolbarState-value-tooltip=此值在離開時自動計算 [exp-statistics] MapsPlayed=地圖遊玩次數 @@ -90,8 +84,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 20758f41..4d1ce2e2 100644 --- a/exp_legacy/module/locale/zh-TW.cfg +++ b/exp_legacy/module/locale/zh-TW.cfg @@ -15,15 +15,9 @@ lhd=列車必須是左則通行。這是本服務器長久以來的規則。 # data [exp-settings] -JoinMessage=加入信息 -JoinMessage-tooltip=你加入時所顯示的信息 -JoinMessage-value-tooltip=使用 /join-message 來更換加入信息 Bonus=人物附加屬性 Bonus-tooltip=你的人物附加屬性 Bonus-value-tooltip=使用 /bonus 來更改 -ToolbarState=工具箱 -ToolbarState-tooltip=次序和喜愛項目 -ToolbarState-value-tooltip=此值在離開時自動計算 [exp-statistics] MapsPlayed=地圖遊玩次數 @@ -90,8 +84,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_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 a24d4a39..f93dbca5 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -18,6 +18,30 @@ 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, 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); + } + } /** Add new features, delete removed ones, and drop stored values which no longer fit a field */ function reconcileFeatures() { @@ -202,8 +226,29 @@ 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.handle(messages.PlayerLocaleEvent, async (event: messages.PlayerLocaleEvent) => { + 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(); + }); + 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..cfcc7aee 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,10 @@ export const plugin: lib.PluginDeclaration = { messages.FeatureUpdatedEvent, messages.FeatureListRequest, messages.FeatureUpdateRequest, + messages.PlayerGetRequest, + messages.JoinMessageSetRequest, + messages.PlayerLocaleEvent, + messages.PlayerToolbarEvent, ], permissions: [ @@ -42,6 +48,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..57621a14 100644 --- a/exp_scenario/instance.ts +++ b/exp_scenario/instance.ts @@ -3,11 +3,32 @@ 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 }; +/** 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) { + 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 +46,74 @@ 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 })"); + } + 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 => { 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.server.handle("exp_scenario:locale", async (event: IpcLocale) => { + 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 sending the settings of ${event.name}:\n${err.stack ?? err.message}`, + )); + } + }); + instance.hooks.start.attach(plugin.name, async () => { started = true; await syncFeatures(); 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 3438c061..52f09ddb 100644 --- a/exp_scenario/messages.ts +++ b/exp_scenario/messages.ts @@ -126,3 +126,187 @@ 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, + /** 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, + ) {} + + static jsonSchema = Type.Object({ + 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()), + }); + + toJSON() { + const json: Static = { id: this.id }; + if (this.joinMessage) { + json.join_message = this.joinMessage; + } + if (this.locale) { + json.locale = this.locale; + } + if (this.toolbar) { + json.toolbar = this.toolbar; + } + 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.locale, + json.toolbar, + 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); + } +} + +/** 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); + } +} + +/** 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.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/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/control/player_settings.lua b/exp_scenario/module/control/player_settings.lua new file mode 100644 index 00000000..44a82607 --- /dev/null +++ b/exp_scenario/module/control/player_settings.lua @@ -0,0 +1,68 @@ +--[[-- 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 +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 + +--- @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/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..34df792b 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,106 @@ 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("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("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, + 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..30b9598b 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,87 @@ t.test("instance plugin", t2 => { t2.end(); }); + +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 => { + 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("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" }); + 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"); + 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/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/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() 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 new file mode 100644 index 00000000..d6646cd3 --- /dev/null +++ b/exp_scenario/web/components/UserPlayerSettings.tsx @@ -0,0 +1,81 @@ +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"; +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">) { + 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 <> + + + + {record.locale + ? `${localeFlag(record.locale)} ${localeName(record.locale)} (${record.locale})`.trim() + : Unknown} + + + {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", () => [