mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-15 08:24:52 +09:00
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:
1 parent
ebe7504c00
commit
54941b85aa
15 files changed
+228
-45
No files matched your search
@@ -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
|
||||
|
||||
@@ -22,9 +22,6 @@ Bonus=人物附加屬性
|
||||
Bonus-tooltip=你的人物附加屬性
|
||||
Bonus-value-tooltip=使用 /bonus 來更改
|
||||
HasEnabledDecon=快速拆除樹木
|
||||
ToolbarState=工具箱
|
||||
ToolbarState-tooltip=次序和喜愛項目
|
||||
ToolbarState-value-tooltip=此值在離開時自動計算
|
||||
|
||||
[exp-statistics]
|
||||
MapsPlayed=地圖遊玩次數
|
||||
|
||||
@@ -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)
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export const plugin: lib.PluginDeclaration = {
|
||||
messages.PlayerGetRequest,
|
||||
messages.JoinMessageSetRequest,
|
||||
messages.PlayerLocaleEvent,
|
||||
messages.PlayerToolbarEvent,
|
||||
],
|
||||
|
||||
permissions: [
|
||||
|
||||
@@ -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}`,
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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"),
|
||||
));
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user