From c06555c5da8ab7ce54a498c67c30f9138d37f2df Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:18:51 +0000 Subject: [PATCH] Validate supplied components in imported player data An import with `crafting_queue: {}` passed the schema and broke the next restore in deserialize_crafting_queue. The import schema now checks the members module/serialize.lua reads from the crafting queue, quick bar, personal logistic slots and character stats, while still allowing a component to be left out. Character stats missing from the data are left as they are instead of being set to nil. Co-Authored-By: Claude Opus 5.5 --- plugins/player_sync/messages.ts | 95 ++++++++++++++++++++---- plugins/player_sync/module/serialize.lua | 5 +- plugins/player_sync/test/controller.js | 17 +++++ plugins/player_sync/test/plugin.js | 38 +++++++++- 4 files changed, 137 insertions(+), 18 deletions(-) diff --git a/plugins/player_sync/messages.ts b/plugins/player_sync/messages.ts index 478bdafd..4b246f01 100644 --- a/plugins/player_sync/messages.ts +++ b/plugins/player_sync/messages.ts @@ -1,4 +1,4 @@ -import { Type, Static } from "@sinclair/typebox"; +import { Type, Static, TSchema } from "@sinclair/typebox"; import { StringEnum, jsonArray } from "@clusterio/lib"; // .\module\serialize.lua:serialize.serialize_player() @@ -38,22 +38,89 @@ const jsonPlayerData = Type.Object({ }); // The parts of an import that module/serialize.lua relies on, item stacks are checked by the Lua deserializer +// An empty Lua table is encoded as an object +function jsonList(item: T) { + return Type.Union([Type.Array(item), Type.Object({}, { maxProperties: 0 })]); +} 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 }), - ]), + i: jsonList(Type.Object({ + s: Type.Optional(Type.Integer({ minimum: 1 })), + r: Type.Optional(Type.Integer({ minimum: 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 }), ]); +// module/define_player_stat_keys.lua, missing stats are left as they are +const jsonCharacter = Type.Object({ + character_crafting_speed_modifier: Type.Optional(Type.Number()), + character_mining_speed_modifier: Type.Optional(Type.Number()), + character_additional_mining_categories: Type.Optional(jsonList(Type.String())), + character_running_speed_modifier: Type.Optional(Type.Number()), + character_build_distance_bonus: Type.Optional(Type.Number()), + character_item_drop_distance_bonus: Type.Optional(Type.Number()), + character_reach_distance_bonus: Type.Optional(Type.Number()), + character_resource_reach_distance_bonus: Type.Optional(Type.Number()), + character_item_pickup_distance_bonus: Type.Optional(Type.Number()), + character_loot_pickup_distance_bonus: Type.Optional(Type.Number()), + character_inventory_slots_bonus: Type.Optional(Type.Number()), + character_trash_slot_count_bonus: Type.Optional(Type.Number()), + character_maximum_following_robot_count_bonus: Type.Optional(Type.Number()), + character_health_bonus: Type.Optional(Type.Number()), + character_personal_logistic_requests_enabled: Type.Optional(Type.Boolean()), + allow_dispatching_robots: Type.Optional(Type.Boolean()), + inhibit_movement_bonus: Type.Optional(Type.Boolean()), + inventories: Type.Optional(jsonInventories), +}); +const jsonLogisticSlot = Type.Object({ + name: Type.String(), + min: Type.Optional(Type.Integer({ minimum: 0 })), + max: Type.Optional(Type.Integer({ minimum: 0 })), +}); +const jsonPersonalLogisticSlots = Type.Union([ + Type.Object({ + enabled: Type.Boolean(), + trash_not_requested: Type.Boolean(), + sections: jsonList(Type.Object({ + group: Type.String(), + active: Type.Boolean(), + multiplier: Type.Number({ minimum: 0 }), + filters: jsonList(Type.Object({})), + })), + }), + // From before 2.0, slot index to request + Type.Record(Type.String({ pattern: "^[1-9][0-9]*$" }), jsonLogisticSlot, { additionalProperties: false }), + Type.Array(jsonLogisticSlot), +]); +// QuickBarSlotEncoded in module/serialize.lua +const jsonQuickBarSlot = Type.Union([ + Type.String(), + Type.Object({ t: Type.Literal("f"), n: Type.String(), q: Type.String(), c: Type.String() }), + Type.Object({ + t: Type.Literal("i"), + n: Type.String(), + s: Type.Integer({ minimum: 1 }), + b: Type.Optional(jsonList(Type.Integer({ minimum: 1 }))), + }), + Type.Object({ + t: Type.Literal("r"), + p: jsonList(Type.Integer({ minimum: 1 })), + y: Type.String(), + l: Type.Optional(Type.String()), + }), +]); +const jsonCraftingQueue = Type.Object({ + crafting_queue: jsonList(Type.Object({ recipe: Type.String(), count: Type.Integer({ minimum: 1 }) })), + crafting_queue_progress: Type.Number({ minimum: 0, maximum: 1 }), + ingredients: jsonList(Type.Object({ + name: Type.String(), + count: Type.Integer({ minimum: 1 }), + quality: Type.Optional(Type.String()), + })), +}); const jsonTable = Type.Union([Type.Array(Type.Unknown()), Type.Object({})]); const jsonImportedPlayerData = Type.Object({ generation: Type.Integer({ minimum: 0 }), @@ -70,12 +137,12 @@ const jsonImportedPlayerData = Type.Object({ 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) })), + character: Type.Optional(jsonCharacter), inventories: Type.Optional(jsonInventories), - quick_bar: Type.Optional(jsonTable), - hotbar: Type.Optional(jsonTable), - personal_logistic_slots: Type.Optional(jsonTable), - crafting_queue: Type.Optional(jsonTable), + quick_bar: Type.Optional(Type.Record(Type.String(), jsonQuickBarSlot)), + hotbar: Type.Optional(Type.Record(Type.String(), Type.String())), + personal_logistic_slots: Type.Optional(jsonPersonalLogisticSlots), + crafting_queue: Type.Optional(jsonCraftingQueue), recipe_notifications: Type.Optional(Type.String()), components: Type.Optional(jsonTable), }); diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index a2176ebf..40b351e0 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -69,7 +69,10 @@ end --- Restore the stats of a character, the inventories are restored with place_items function serialize.deserialize_character(character, serialized) for _, key in pairs(character_stat_keys) do - character[key] = serialized[key] + -- Missing from imports and from data saved by another Factorio version + if serialized[key] ~= nil then + character[key] = serialized[key] + end end end diff --git a/plugins/player_sync/test/controller.js b/plugins/player_sync/test/controller.js index 6328dbac..4177bdf1 100644 --- a/plugins/player_sync/test/controller.js +++ b/plugins/player_sync/test/controller.js @@ -208,6 +208,23 @@ describe("player_sync controller", function() { await assert.rejects(set("stored", playerData("stored", 3)), acquiredError); assert.equal((await saved()).get("stored").generation, 3); }); + it("should keep the stored data when an import fails validation", async function() { + // The link validates requests before the handler runs + const fromJSON = lib.Link.requestFromJSON(msg.SetPlayerDataRequest, "SetPlayerDataRequest"); + async function setJSON(name, data) { + const request = fromJSON({ playerName: name, playerData: data }); + return await handler(msg.SetPlayerDataRequest)(request); + } + await release("stored"); + await setJSON("stored", playerData("stored", 0)); + const before = (await saved()).get("stored"); + assert.equal(before.epoch, 1); + await assert.rejects( + setJSON("stored", playerData("stored", 0, { crafting_queue: {} })), + { message: "Request SetPlayerDataRequest failed validation" }, + ); + assert.deepEqual((await saved()).get("stored"), before); + }); }); describe("DeletePlayerDataRequest", function() { diff --git a/plugins/player_sync/test/plugin.js b/plugins/player_sync/test/plugin.js index aabfbe57..e04d69bb 100644 --- a/plugins/player_sync/test/plugin.js +++ b/plugins/player_sync/test/plugin.js @@ -65,14 +65,29 @@ describe("player_sync plugin", function() { game_view_settings: { show_minimap: true }, character: { character_running_speed_modifier: 0, + character_additional_mining_categories: {}, + allow_dispatching_robots: true, 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: [] }, + personal_logistic_slots: { + enabled: true, + trash_not_requested: false, + sections: [{ group: "", active: true, multiplier: 1, filters: [{ index: 1, min: 50 }] }], + }, + quick_bar: { + 1: "iron-plate", + 2: { t: "f", n: "iron-gear-wheel", q: "normal", c: "=" }, + 3: { t: "i", n: "main", s: 4, b: [2] }, + 4: { t: "r", p: [1], y: "blueprint" }, + }, + crafting_queue: { + crafting_queue: [{ recipe: "iron-gear-wheel", count: 5 }], + crafting_queue_progress: 0.25, + ingredients: [{ name: "iron-plate", count: 10 }], + }, recipe_notifications: "", components: { controller: true, inventories: true }, }); @@ -85,6 +100,11 @@ describe("player_sync plugin", function() { 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] }))); + assert(accepts(playerData("player", 0, { + crafting_queue: { crafting_queue: {}, crafting_queue_progress: 0, ingredients: {} }, + personal_logistic_slots: { 1: { name: "iron-plate", min: 50 } }, + hotbar: { 1: "iron-plate" }, + }))); }); it("should reject data the scenario can't restore", function() { for (const [field, value] of [ @@ -97,6 +117,18 @@ describe("player_sync plugin", function() { ["inventories", { main: [] }], ["character", { inventories: { main: { i: [{ s: 0 }] } } }], ["shortcuts", { "toggle-personal-roboport": "yes" }], + ["character", { character_health_bonus: "100" }], + ["crafting_queue", {}], + ["crafting_queue", { crafting_queue: [], crafting_queue_progress: 0 }], + ["crafting_queue", { + crafting_queue: [{ recipe: "iron-gear-wheel" }], crafting_queue_progress: 0, ingredients: [], + }], + ["quick_bar", { 1: { t: "i", n: "main" } }], + ["quick_bar", { 1: { t: "r", y: "blueprint" } }], + ["quick_bar", { 1: 5 }], + ["personal_logistic_slots", { enabled: true, sections: [] }], + ["personal_logistic_slots", { enabled: true, trash_not_requested: false, sections: [{}] }], + ["personal_logistic_slots", { 1: { min: 50 } }], ]) { assert(!accepts({ ...exported, [field]: value }), `accepted ${field} ${JSON.stringify(value)}`); }