Sync the toolbar of players through the controller

The toolbar order, favourites, and open panels were saved into
exp_legacy's PlayerData, which was never synced, and the file doing so
was no longer loaded. When a player leaves, the lua side now sends
Gui.toolbar.get_state as json compressed with helpers.encode_string,
and the controller stores the string on the player record.

When the player joins again the instance plugin sends the stored string
over rcon to exp_scenario.player_settings.receive_toolbar, which
decodes it and calls Gui.toolbar.set_state. exp_gui builds the toolbar
in on_player_joined_game, so it exists by the time the rcon arrives.
A string which does not decode, or a state naming buttons which no
longer exist, is logged and ignored rather than raising in the game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-10-14 22:10:31 +00:00
1 parent ebe7504c00
commit 54941b85aa
15 files changed
+228 -45

No files matched your search

-3
View File
@@ -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
-3
View File
@@ -22,9 +22,6 @@ Bonus=人物附加屬性
Bonus-tooltip=你的人物附加屬性
Bonus-value-tooltip=使用 /bonus 來更改
HasEnabledDecon=快速拆除樹木
ToolbarState=工具箱
ToolbarState-tooltip=次序和喜愛項目
ToolbarState-value-tooltip=此值在離開時自動計算
[exp-statistics]
MapsPlayed=地圖遊玩次數
-3
View File
@@ -22,9 +22,6 @@ Bonus=人物附加屬性
Bonus-tooltip=你的人物附加屬性
Bonus-value-tooltip=使用 /bonus 來更改
HasEnabledDecon=快速拆除樹木
ToolbarState=工具箱
ToolbarState-tooltip=次序和喜愛項目
ToolbarState-value-tooltip=此值在離開時自動計算
[exp-statistics]
MapsPlayed=地圖遊玩次數
@@ -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)
+12 -4
View File
@@ -25,14 +25,18 @@ export default async function (context: ControllerPluginContext) {
).bootstrap()
);
type PlayerSettings = Pick<messages.PlayerRecord, "joinMessage" | "locale">;
type PlayerSettings = Pick<messages.PlayerRecord, "joinMessage" | "locale" | "toolbar">;
/** 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();
});
+1
View File
@@ -28,6 +28,7 @@ export const plugin: lib.PluginDeclaration = {
messages.PlayerGetRequest,
messages.JoinMessageSetRequest,
messages.PlayerLocaleEvent,
messages.PlayerToolbarEvent,
],
permissions: [
+12 -1
View File
@@ -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}`,
));
}
});
+34
View File
@@ -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<typeof this.jsonSchema>) {
return new this(json.player_name, json.toolbar);
}
}
@@ -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,
}
+1
View File
@@ -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"),
}
+12
View File
@@ -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,
+21
View File
@@ -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" });
+3
View File
@@ -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"),
));
+5
View File
@@ -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/<name>.lua
--- @field load_module fun(name: string): any Load module/<name>.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)
@@ -0,0 +1,79 @@
local Suite = ... --- @type Suite<ExpScenario.TestEnv>
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()