diff --git a/exp_legacy/module/locale/en.cfg b/exp_legacy/module/locale/en.cfg index 39224f9f..054bb723 100644 --- a/exp_legacy/module/locale/en.cfg +++ b/exp_legacy/module/locale/en.cfg @@ -22,9 +22,6 @@ Bonus=Player Bonus Bonus-tooltip=The bonus given to your character Bonus-value-tooltip=Change by using /bonus HasEnabledDecon=Quick Tree Decon -ToolbarState=Toolbox -ToolbarState-tooltip=The order and favourites in your toolbox -ToolbarState-value-tooltip=This value is calculated automatically when you leave the game [exp-statistics] MapsPlayed=Maps Played diff --git a/exp_legacy/module/locale/zh-CN.cfg b/exp_legacy/module/locale/zh-CN.cfg index 69a4bfad..a2d5356e 100644 --- a/exp_legacy/module/locale/zh-CN.cfg +++ b/exp_legacy/module/locale/zh-CN.cfg @@ -22,9 +22,6 @@ Bonus=人物附加屬性 Bonus-tooltip=你的人物附加屬性 Bonus-value-tooltip=使用 /bonus 來更改 HasEnabledDecon=快速拆除樹木 -ToolbarState=工具箱 -ToolbarState-tooltip=次序和喜愛項目 -ToolbarState-value-tooltip=此值在離開時自動計算 [exp-statistics] MapsPlayed=地圖遊玩次數 diff --git a/exp_legacy/module/locale/zh-TW.cfg b/exp_legacy/module/locale/zh-TW.cfg index 69a4bfad..a2d5356e 100644 --- a/exp_legacy/module/locale/zh-TW.cfg +++ b/exp_legacy/module/locale/zh-TW.cfg @@ -22,9 +22,6 @@ Bonus=人物附加屬性 Bonus-tooltip=你的人物附加屬性 Bonus-value-tooltip=使用 /bonus 來更改 HasEnabledDecon=快速拆除樹木 -ToolbarState=工具箱 -ToolbarState-tooltip=次序和喜愛項目 -ToolbarState-value-tooltip=此值在離開時自動計算 [exp-statistics] MapsPlayed=地圖遊玩次數 diff --git a/exp_legacy/module/modules/data/toolbar.lua b/exp_legacy/module/modules/data/toolbar.lua deleted file mode 100644 index 177aba50..00000000 --- a/exp_legacy/module/modules/data/toolbar.lua +++ /dev/null @@ -1,31 +0,0 @@ -local Gui = require("modules/exp_gui") -local PlayerData = require("modules.exp_legacy.expcore.player_data") - --- Used to store the state of the toolbar when a player leaves -local ToolbarState = PlayerData.Settings:combine("ToolbarState") -ToolbarState:set_metadata{ - stringify = function() - return "Toolbar is saved on exit" - end, -} - ---- Uncompress the data to be more useable -ToolbarState:on_load(function(player_name, value) - -- If there is no value, do nothing - if value == nil then return end - -- Old format, we discard it [ string[], string[], string[], boolean ] - if type(value) ~= "string" then return end - - local decompressed = helpers.json_to_table(assert(helpers.decode_string(value), "Failed String Decode")) - local player = assert(game.get_player(player_name)) - Gui.toolbar.set_state(player, decompressed --[[@as Gui.ToolbarState]]) - - return nil -- We don't save the state, use Gui.toolbar.get_state -end) - ---- Save the current state of the players toolbar menu -ToolbarState:on_save(function(player_name, _) - local player = assert(game.get_player(player_name)) - local value = Gui.toolbar.get_state(player) - return helpers.encode_string(helpers.table_to_json(value)) -end) diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index 530a7fd9..f93dbca5 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -25,14 +25,18 @@ export default async function (context: ControllerPluginContext) { ).bootstrap() ); - type PlayerSettings = Pick; + type PlayerSettings = Pick; /** Change some settings of a player, empty values are removed and so is a record left empty */ function updatePlayer(name: string, changes: PlayerSettings) { const old = playerStore.get(name); - const settings: PlayerSettings = { joinMessage: old?.joinMessage, locale: old?.locale, ...changes }; - const record = new messages.PlayerRecord(name, settings.joinMessage || undefined, settings.locale || undefined); - if (record.joinMessage || record.locale) { + const settings: PlayerSettings = { + joinMessage: old?.joinMessage, locale: old?.locale, toolbar: old?.toolbar, ...changes, + }; + const record = new messages.PlayerRecord( + name, settings.joinMessage || undefined, settings.locale || undefined, settings.toolbar || undefined, + ); + if (record.joinMessage || record.locale || record.toolbar) { playerStore.set(record); } else if (old) { playerStore.delete(old); @@ -234,6 +238,10 @@ export default async function (context: ControllerPluginContext) { updatePlayer(event.playerName, { locale: event.locale }); }); + controller.handle(messages.PlayerToolbarEvent, async (event: messages.PlayerToolbarEvent) => { + updatePlayer(event.playerName, { toolbar: event.toolbar }); + }); + controller.hooks.save.attach(plugin.name, async () => { await playerStore.save(); }); diff --git a/exp_scenario/index.ts b/exp_scenario/index.ts index 9ec4a65c..cfcc7aee 100644 --- a/exp_scenario/index.ts +++ b/exp_scenario/index.ts @@ -28,6 +28,7 @@ export const plugin: lib.PluginDeclaration = { messages.PlayerGetRequest, messages.JoinMessageSetRequest, messages.PlayerLocaleEvent, + messages.PlayerToolbarEvent, ], permissions: [ diff --git a/exp_scenario/instance.ts b/exp_scenario/instance.ts index 2cc50908..57621a14 100644 --- a/exp_scenario/instance.ts +++ b/exp_scenario/instance.ts @@ -9,6 +9,8 @@ export type IpcJoinMessageSet = { player_name: string, message?: string }; export type IpcJoinMessageGet = { player_name: string }; /** Sent by the lua side when a player joins or changes language, see module/control/player_settings.lua */ export type IpcLocale = { player_name: string, locale: string }; +/** Sent by the lua side when a player leaves */ +export type IpcToolbar = { player_name: string, toolbar: string }; /** Json as a lua expression for a table, inside a long bracket the json does not contain */ export function luaJson(value: unknown) { @@ -69,6 +71,11 @@ export default async function (context: InstancePluginContext) { if (record.joinMessage && featureEnabled.get("greetings") !== false) { await luaWithPlayer(name, { message: record.joinMessage }, "game.print(d.message, { color = p.color })"); } + if (record.toolbar) { + await luaWithPlayer( + name, { toolbar: record.toolbar }, "exp_scenario.player_settings.receive_toolbar(d.name, d.toolbar)", + ); + } } instance.handle(messages.FeatureUpdatedEvent, async event => { @@ -95,10 +102,14 @@ export default async function (context: InstancePluginContext) { instance.sendTo("controller", new messages.PlayerLocaleEvent(event.player_name, event.locale)); }); + instance.server.handle("exp_scenario:toolbar", async (event: IpcToolbar) => { + instance.sendTo("controller", new messages.PlayerToolbarEvent(event.player_name, event.toolbar)); + }); + instance.hooks.playerEvent.attach(plugin.name, async event => { if (event.type === "join") { await onPlayerJoin(event.name).catch(err => logger.error( - `Error showing the join message of ${event.name}:\n${err.stack ?? err.message}`, + `Error sending the settings of ${event.name}:\n${err.stack ?? err.message}`, )); } }); diff --git a/exp_scenario/messages.ts b/exp_scenario/messages.ts index a76a9250..52f09ddb 100644 --- a/exp_scenario/messages.ts +++ b/exp_scenario/messages.ts @@ -136,6 +136,8 @@ export class PlayerRecord { public joinMessage?: string, /** The language of their game, such as "en" or "pt-BR" */ public locale?: string, + /** Toolbar state from Gui.toolbar.get_state, json compressed with helpers.encode_string */ + public toolbar?: string, public updatedAtMs: number = 0, public isDeleted: boolean = false, ) {} @@ -144,6 +146,7 @@ export class PlayerRecord { id: Type.String(), join_message: Type.Optional(Type.String()), locale: Type.Optional(Type.String()), + toolbar: Type.Optional(Type.String()), updated_at_ms: Type.Optional(Type.Number()), is_deleted: Type.Optional(Type.Boolean()), }); @@ -156,6 +159,9 @@ export class PlayerRecord { if (this.locale) { json.locale = this.locale; } + if (this.toolbar) { + json.toolbar = this.toolbar; + } if (this.updatedAtMs) { json.updated_at_ms = this.updatedAtMs; } @@ -170,6 +176,7 @@ export class PlayerRecord { json.id, json.join_message, json.locale, + json.toolbar, json.updated_at_ms ?? 0, json.is_deleted ?? false, ); @@ -276,3 +283,30 @@ export class PlayerLocaleEvent { return new this(json.player_name, json.locale); } } + +/** The toolbar of a player, sent when they leave. */ +export class PlayerToolbarEvent { + declare ["constructor"]: typeof PlayerToolbarEvent; + static plugin = "exp_scenario" as const; + static type = "event" as const; + static src = "instance" as const; + static dst = "controller" as const; + + constructor( + public playerName: string, + public toolbar: string, + ) {} + + static jsonSchema = Type.Object({ + player_name: Type.String(), + toolbar: Type.String(), + }); + + toJSON() { + return { player_name: this.playerName, toolbar: this.toolbar }; + } + + static fromJSON(json: Static) { + return new this(json.player_name, json.toolbar); + } +} diff --git a/exp_scenario/module/control/player_settings.lua b/exp_scenario/module/control/player_settings.lua index 7329bc1b..44a82607 100644 --- a/exp_scenario/module/control/player_settings.lua +++ b/exp_scenario/module/control/player_settings.lua @@ -1,7 +1,11 @@ --[[-- Control - Player Settings Sends settings of players to the controller, which keeps them between servers + +The toolbar is sent compressed when a player leaves, the instance plugin +calls receive_toolbar with it when they join again ]] +local Gui = require("modules/exp_gui") local clusterio_api = require("modules/clusterio/api") --- @param event EventData.on_player_joined_game | EventData.on_player_locale_changed @@ -10,11 +14,55 @@ local function send_locale(event) clusterio_api.send_json("exp_scenario:locale", { player_name = player.name, locale = player.locale }) end +--- @param event EventData.on_player_left_game +local function send_toolbar(event) + local player = assert(game.get_player(event.player_index)) + local success, state = pcall(Gui.toolbar.get_state, player) + if not success then + log("[WARNING] Could not read the toolbar of " .. player.name .. ": " .. tostring(state)) + return + end + local toolbar = helpers.encode_string(helpers.table_to_json(state)) + clusterio_api.send_json("exp_scenario:toolbar", { player_name = player.name, toolbar = toolbar }) +end + +--- @param toolbar string +--- @return Gui.ToolbarState? +local function decode_toolbar(toolbar) + local json = helpers.decode_string(toolbar) + local state = json and helpers.json_to_table(json) + if type(state) == "table" and type(state.order) == "table" and type(state.open) == "table" + and type(state.visible) == "boolean" then + return state --[[@as Gui.ToolbarState]] + end +end + +--- Restore the toolbar of a player, called over rcon by the instance plugin +--- @param player_name string +--- @param toolbar string From send_toolbar +local function receive_toolbar(player_name, toolbar) + local player = game.get_player(player_name) + if not player or not player.connected then + return + end + + -- Stored states can name buttons which no longer exist + local success, err = pcall(function() + local state = assert(decode_toolbar(toolbar), "invalid toolbar state") + Gui.toolbar.set_state(player, state) + end) + if not success then + log("[WARNING] Could not restore the toolbar of " .. player_name .. ": " .. tostring(err)) + end +end + local e = defines.events return { events = { [e.on_player_joined_game] = send_locale, [e.on_player_locale_changed] = send_locale, + [e.on_player_left_game] = send_toolbar, }, + receive_toolbar = receive_toolbar, } diff --git a/exp_scenario/module/globals.lua b/exp_scenario/module/globals.lua index bdb9ef6a..b0deda40 100644 --- a/exp_scenario/module/globals.lua +++ b/exp_scenario/module/globals.lua @@ -3,4 +3,5 @@ -- Access using `/sc exp_scenario.features.foo()` exp_scenario = { features = require("modules/exp_scenario/features"), + player_settings = require("modules/exp_scenario/control/player_settings"), } diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js index 5960ca59..34df792b 100644 --- a/exp_scenario/test/controller.test.js +++ b/exp_scenario/test/controller.test.js @@ -290,6 +290,18 @@ t.test("player settings", t2 => { t3.equal(cleared.locale, "pt-BR", "clearing the message keeps the locale"); }); + t2.test("PlayerToolbarEvent stores the toolbar and it is saved", async t3 => { + const databaseDirectory = t3.testdir(); + const { send, save } = await startPlugin(t3, { withPlugins: false, databaseDirectory }); + await send(new messages.PlayerToolbarEvent("Alice", "abc")); + t3.equal((await send(new messages.PlayerGetRequest("Alice"))).toolbar, "abc"); + + await save(); + const stored = await loadPlayers(databaseDirectory); + const alice = stored.get("Alice"); + t3.strictSame(alice.toJSON(), { id: "Alice", toolbar: "abc", updated_at_ms: alice.updatedAtMs }); + }); + t2.test("permissions", async t3 => { const user = (name, granted = []) => ({ name, diff --git a/exp_scenario/test/instance.test.js b/exp_scenario/test/instance.test.js index 90725f4b..30b9598b 100644 --- a/exp_scenario/test/instance.test.js +++ b/exp_scenario/test/instance.test.js @@ -195,6 +195,27 @@ t.test("player settings", t2 => { t3.strictSame(sent.map(request => [request.playerName, request.message]), [["Alice", "Hi"], ["Alice", null]]); }); + t2.test("a joining player's stored toolbar is sent to the lua side", async t3 => { + const bob = new messages.PlayerRecord("Bob", undefined, undefined, "eNqrVkrLz1eyUlBKSixSqgUAGQwD8g=="); + const { start, playerEvent, setStatus, state } = await startPlugin(t3, [], [bob]); + await start(); + setStatus("running"); + state.rcons.length = 0; + + await playerEvent({ type: "join", name: "Bob" }); + t3.equal(state.rcons.length, 1, "only the toolbar, Bob has no join message"); + const { data, code } = decodePlayerRcon(state.rcons[0]); + t3.strictSame(data, { toolbar: bob.toolbar, name: "Bob" }); + t3.equal(code, "exp_scenario.player_settings.receive_toolbar(d.name, d.toolbar)"); + }); + + t2.test("ipc with a toolbar is forwarded to the controller", async t3 => { + const { ipc, state } = await startPlugin(t3); + await ipc("exp_scenario:toolbar", { player_name: "Alice", toolbar: "abc" }); + const [event] = state.sent.filter(request => request instanceof messages.PlayerToolbarEvent); + t3.strictSame([event.playerName, event.toolbar], ["Alice", "abc"]); + }); + t2.test("ipc with a locale is forwarded to the controller", async t3 => { const { ipc, state } = await startPlugin(t3); await ipc("exp_scenario:locale", { player_name: "Alice", locale: "de" }); diff --git a/exp_scenario/test/module.test.js b/exp_scenario/test/module.test.js index 0aa3fde7..78c1a4b0 100644 --- a/exp_scenario/test/module.test.js +++ b/exp_scenario/test/module.test.js @@ -6,3 +6,6 @@ const envFile = path.join(import.meta.dirname, "module", "env.lua"); // Each file runs in its own lua state, and each test in a fresh environment t.test("features.lua", t2 => reportLuaTests(t2, envFile, path.join(import.meta.dirname, "module", "features.lua"))); +t.test("player_settings.lua", t2 => reportLuaTests( + t2, envFile, path.join(import.meta.dirname, "module", "player_settings.lua"), +)); diff --git a/exp_scenario/test/module/env.lua b/exp_scenario/test/module/env.lua index 96c41456..1d5acfa3 100644 --- a/exp_scenario/test/module/env.lua +++ b/exp_scenario/test/module/env.lua @@ -17,6 +17,7 @@ local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type --- @field storage table The table registered with storage, as on_init would store it --- @field on_load fun(tbl: table) Act out on_load with the given storage table --- @field load_config fun(name: string): ExpScenario.Feature Load module/config/.lua +--- @field load_module fun(name: string): any Load module/.lua, such as "control/player_settings" --- @field logged string[] Messages passed to log --- @field events table[] Raised events, name and tick filled in @@ -51,5 +52,9 @@ return Framework.suite(function(env) return assert(loadfile(plugin_root .. "/module/config/" .. name .. ".lua"))() end + function env.load_module(name) + return assert(loadfile(plugin_root .. "/module/" .. name .. ".lua"))() + end + return env end) diff --git a/exp_scenario/test/module/player_settings.lua b/exp_scenario/test/module/player_settings.lua new file mode 100644 index 00000000..1e9107c4 --- /dev/null +++ b/exp_scenario/test/module/player_settings.lua @@ -0,0 +1,79 @@ +local Suite = ... --- @type Suite + +local valid_state = { order = { { name = "readme", favourite = true } }, open = {}, visible = true } + +--- Load control/player_settings.lua with a stubbed toolbar, encoding is a "enc:" prefix +--- @param env ExpScenario.TestEnv +--- @param toolbar table? Replaces the Gui.toolbar stub +--- @return table PlayerSettings +--- @return table restored Calls to set_state as { player name, state } +local function load(env, toolbar) + local restored = {} + env.extend_defines{ events = { on_player_locale_changed = 3, on_player_left_game = 4 } } + env.extend_requires{ + ["modules/exp_gui"] = { + toolbar = toolbar or { + get_state = function() return valid_state end, + set_state = function(player, state) restored[#restored + 1] = { player.name, state } end, + }, + }, + } + local json_values = { ['{"valid"}'] = valid_state, ['{"wrong shape"}'] = { order = "x" } } + helpers = { + encode_string = function(str) return "enc:" .. str end, + decode_string = function(str) return str:match("^enc:(.*)$") end, + table_to_json = function(tbl) return tbl == valid_state and '{"valid"}' or "{}" end, + json_to_table = function(json) return json_values[json] end, + } + return env.load_module("control/player_settings"), restored +end + +Suite.test("send_locale() sends the locale of the player", function(env) + local PlayerSettings = load(env) + rawset(env.add_player{ name = "Alice" }, "locale", "pt-BR") + PlayerSettings.events[defines.events.on_player_joined_game]{ player_index = 1 } + Suite.eq(env.sent, { { channel = "exp_scenario:locale", data = { player_name = "Alice", locale = "pt-BR" } } }, "sent") +end) + +Suite.test("send_toolbar() sends the compressed toolbar when a player leaves", function(env) + local PlayerSettings = load(env) + env.add_player{ name = "Alice" } + PlayerSettings.events[defines.events.on_player_left_game]{ player_index = 1 } + Suite.eq(env.sent, { { channel = "exp_scenario:toolbar", data = { player_name = "Alice", toolbar = 'enc:{"valid"}' } } }, "sent") +end) + +Suite.test("send_toolbar() sends nothing when the toolbar can not be read", function(env) + local PlayerSettings = load(env, { get_state = function() error("no toolbar") end }) + env.add_player{ name = "Alice" } + PlayerSettings.events[defines.events.on_player_left_game]{ player_index = 1 } + Suite.eq(env.sent, {}, "nothing sent") + Suite.eq(#env.logged, 1, "a warning is logged") +end) + +Suite.test("receive_toolbar() restores the toolbar of online players", function(env) + local PlayerSettings, restored = load(env) + env.add_player{ name = "Alice" } + env.add_player{ name = "Bob", connected = false } + PlayerSettings.receive_toolbar("Alice", 'enc:{"valid"}') + PlayerSettings.receive_toolbar("Bob", 'enc:{"valid"}') + PlayerSettings.receive_toolbar("Unknown", 'enc:{"valid"}') + Suite.eq(restored, { { "Alice", valid_state } }, "only Alice is online") +end) + +Suite.test("receive_toolbar() logs malformed toolbars instead of raising", function(env) + local PlayerSettings, restored = load(env) + env.add_player{ name = "Alice" } + for _, toolbar in ipairs{ "not encoded", "enc:not json", 'enc:{"wrong shape"}' } do + PlayerSettings.receive_toolbar("Alice", toolbar) + end + Suite.eq(restored, {}, "nothing is restored") + Suite.eq(#env.logged, 3, "each one is logged") +end) + +Suite.test("receive_toolbar() logs errors from set_state", function(env) + local PlayerSettings = load(env, { set_state = function() error("Missing toolbox item for removed") end }) + env.add_player{ name = "Alice" } + PlayerSettings.receive_toolbar("Alice", 'enc:{"valid"}') + Suite.eq(#env.logged, 1, "a warning is logged") +end) +return Suite.run()