From c260baaf033dcef3277ed0921a479841b9d42ba1 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:12:12 +0000 Subject: [PATCH] Validate imported player data before storing it An import with an unknown controller or malformed inventories replaced the stored snapshot and bumped its epoch, then broke the player's next join. SetPlayerDataRequest now checks the fields the scenario restores, leaving item stacks to the Lua deserializer and allowing the partial snapshots instances with disabled components upload. Co-Authored-By: Claude Opus 5.5 --- plugins/player_sync/messages.ts | 45 +++++++++++++++++++++++++- plugins/player_sync/test/plugin.js | 51 ++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/plugins/player_sync/messages.ts b/plugins/player_sync/messages.ts index 07019686..3557ac62 100644 --- a/plugins/player_sync/messages.ts +++ b/plugins/player_sync/messages.ts @@ -34,6 +34,49 @@ const jsonPlayerData = Type.Object({ name: Type.String(), }); +// The parts of an import that module/serialize.lua relies on, item stacks are checked by the Lua deserializer +const jsonInventory = Type.Object({ + b: Type.Optional(Type.Integer({ minimum: 0 })), + // An empty Lua table is encoded as an object + i: Type.Union([ + Type.Array(Type.Object({ + s: Type.Optional(Type.Integer({ minimum: 1 })), + r: Type.Optional(Type.Integer({ minimum: 0 })), + })), + Type.Object({}, { maxProperties: 0 }), + ]), +}); +const jsonInventories = Type.Record(Type.String(), jsonInventory); +const jsonColor = Type.Union([ + Type.Object({ r: Type.Number(), g: Type.Number(), b: Type.Number(), a: Type.Optional(Type.Number()) }), + Type.Array(Type.Number(), { minItems: 3, maxItems: 4 }), +]); +const jsonTable = Type.Union([Type.Array(Type.Unknown()), Type.Object({})]); +const jsonImportedPlayerData = Type.Object({ + generation: Type.Integer({ minimum: 0 }), + epoch: Type.Optional(Type.Integer({ minimum: 0 })), + name: Type.String(), + // Any physical controller, remote view is never stored + controller: Type.Optional(StringEnum(["character", "god", "editor", "cutscene", "spectator", "ghost"])), + color: Type.Optional(jsonColor), + chat_color: Type.Optional(jsonColor), + tag: Type.Optional(Type.String()), + force: Type.Optional(Type.String()), + cheat_mode: Type.Optional(Type.Boolean()), + flashlight: Type.Optional(Type.Boolean()), + shortcuts: Type.Optional(Type.Record(Type.String(), Type.Boolean())), + game_view_settings: Type.Optional(Type.Record(Type.String(), Type.Boolean())), + ticks_to_respawn: Type.Optional(Type.Integer({ minimum: 0 })), + character: Type.Optional(Type.Object({ inventories: Type.Optional(jsonInventories) })), + inventories: Type.Optional(jsonInventories), + quick_bar: Type.Optional(jsonTable), + hotbar: Type.Optional(jsonTable), + personal_logistic_slots: Type.Optional(jsonTable), + crafting_queue: Type.Optional(jsonTable), + recipe_notifications: Type.Optional(Type.String()), + components: Type.Optional(jsonTable), +}); + export class AcquireResponse { constructor( public status: string, @@ -302,7 +345,7 @@ export class SetPlayerDataRequest { static jsonSchema = Type.Object({ "playerName": Type.String(), - "playerData": jsonPlayerData, + "playerData": jsonImportedPlayerData, }); static fromJSON(json: Static) { diff --git a/plugins/player_sync/test/plugin.js b/plugins/player_sync/test/plugin.js index 3c30f6be..aabfbe57 100644 --- a/plugins/player_sync/test/plugin.js +++ b/plugins/player_sync/test/plugin.js @@ -51,6 +51,57 @@ describe("player_sync plugin", function() { assert(!validate(request), `accepted ${generation}`); } }); + describe("SetPlayerDataRequest schema", function() { + const validate = lib.compile(msg.SetPlayerDataRequest.jsonSchema); + // Shaped like module/serialize.lua output + const exported = playerData("player", 7, { + epoch: 2, + color: { r: 1, g: 0.5, b: 0, a: 0.5 }, + chat_color: { r: 1, g: 0.5, b: 0, a: 1 }, + tag: "", + cheat_mode: false, + flashlight: true, + shortcuts: { "toggle-personal-roboport": true }, + game_view_settings: { show_minimap: true }, + character: { + character_running_speed_modifier: 0, + inventories: { + main: { b: 80, i: [{ n: "iron-plate", c: 100, r: 2 }, { s: 10, f: { name: "coal" } }] }, + armor: { i: {} }, + }, + }, + personal_logistic_slots: [], + quick_bar: { 1: { n: "iron-plate" } }, + crafting_queue: { crafting_queue: [], ingredients: [] }, + recipe_notifications: "", + components: { controller: true, inventories: true }, + }); + function accepts(data) { + return validate({ playerName: "player", playerData: data }); + } + + it("should accept exported and partial player data", function() { + assert(accepts(exported), JSON.stringify(validate.errors)); + assert(accepts({ name: "player", generation: 0 })); + assert(accepts(playerData("player", 0, { controller: "ghost", ticks_to_respawn: 300 }))); + assert(accepts(playerData("player", 0, { color: [1, 0, 0] }))); + }); + it("should reject data the scenario can't restore", function() { + for (const [field, value] of [ + ["controller", "not-a-controller"], + ["controller", "remote"], + ["color", "red"], + ["force", 1], + ["ticks_to_respawn", -1], + ["inventories", { main: { i: "iron-plate" } }], + ["inventories", { main: [] }], + ["character", { inventories: { main: { i: [{ s: 0 }] } } }], + ["shortcuts", { "toggle-personal-roboport": "yes" }], + ]) { + assert(!accepts({ ...exported, [field]: value }), `accepted ${field} ${JSON.stringify(value)}`); + } + }); + }); it("SetPlayerDataResponse should be round trip json serialisable", function() { testRoundTripJsonSerialisable(msg.SetPlayerDataResponse, testMatrix([1, 5])); });