From 1311df575935fc08205b00f1af300969c67da105 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 2 Nov 2026 00:06:16 +0100 Subject: [PATCH] Keep recipe notifications when a recipe list is empty (#1170) Factorio encodes an empty Lua table as {}, so a player with no cleared recipes sent {} with the download request. new Set({}) threw, the instance dropped the recipe notifications from the download, and the next upload replaced the stored list on the controller. An empty stored list was dropped the same way. Decode both lists treating {} as empty. --- plugins/player_sync/instance.ts | 20 ++++++++++++++++++-- plugins/player_sync/test/instance.js | 26 +++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/plugins/player_sync/instance.ts b/plugins/player_sync/instance.ts index 30ed0223..4c185527 100644 --- a/plugins/player_sync/instance.ts +++ b/plugins/player_sync/instance.ts @@ -87,6 +87,22 @@ export function recipeNotificationDelta(stored: string[], current: string[]): Re return delta; } +/** + * Decode a list of cleared recipe names, Factorio serializes an empty list as {} + * @param encoded - Encoded JSON list of recipe names + * @returns the recipe names + */ +async function decodeRecipeNames(encoded: string): Promise { + const names = JSON.parse(await decodeLuaString(encoded)); + if (Array.isArray(names)) { + return names; + } + if (names !== null && typeof names === "object" && Object.keys(names).length === 0) { + return []; + } + throw new Error(`Expected a list of recipe names, got ${JSON.stringify(names)}`); +} + /** * Replace the stored recipe notifications with the difference from the * current state on the instance to reduce the amount of data sent to Lua. @@ -101,9 +117,9 @@ export async function applyRecipeNotificationDelta( return; } try { - const stored = JSON.parse(await decodeLuaString(playerData.recipe_notifications)); + const stored = await decodeRecipeNames(playerData.recipe_notifications); const delta: RecipeNotificationDelta = current - ? recipeNotificationDelta(stored, JSON.parse(await decodeLuaString(current))) + ? recipeNotificationDelta(stored, await decodeRecipeNames(current)) : { ...recipeNotificationDelta(stored, []), full: true }; playerData.recipe_notifications = await encodeLuaString(JSON.stringify(delta)); } catch (err: any) { diff --git a/plugins/player_sync/test/instance.js b/plugins/player_sync/test/instance.js index 12209271..67132ce6 100644 --- a/plugins/player_sync/test/instance.js +++ b/plugins/player_sync/test/instance.js @@ -64,6 +64,29 @@ describe("player_sync", function() { await apply(playerData, undefined); assert.deepEqual(await decode(playerData.recipe_notifications), { full: true }); }); + // helpers.table_to_json in Factorio encodes an empty list as {} + it("should treat an empty current list encoded as {} as empty", async function() { + const playerData = { name: "test", recipe_notifications: await encode(["a", "b"]) }; + await apply(playerData, await encode({})); + assert.deepEqual(await decode(playerData.recipe_notifications), { add: ["a", "b"] }); + }); + it("should treat an empty stored list encoded as {} as empty", async function() { + const playerData = { name: "test", recipe_notifications: await encode({}) }; + await apply(playerData, await encode(["a"])); + assert.deepEqual(await decode(playerData.recipe_notifications), { remove: ["a"] }); + }); + it("should mark an empty stored list encoded as {} full without a current list", async function() { + const playerData = { name: "test", recipe_notifications: await encode({}) }; + await apply(playerData, undefined); + assert.deepEqual(await decode(playerData.recipe_notifications), { full: true }); + }); + it("should drop the field when the list is a non-empty object", async function() { + const playerData = { name: "test", recipe_notifications: await encode({ a: 1 }) }; + const count = warnings.length; + await apply(playerData, await encode(["a"])); + assert.equal(playerData.recipe_notifications, undefined); + assert.equal(warnings.length, count + 1); + }); it("should do nothing without stored notifications", async function() { const playerData = { name: "test" }; await apply(playerData, await encode(["a"])); @@ -71,9 +94,10 @@ describe("player_sync", function() { }); it("should drop the field on invalid input", async function() { const playerData = { name: "test", recipe_notifications: "not base64 zlib" }; + const count = warnings.length; await apply(playerData, await encode(["a"])); assert.equal(playerData.recipe_notifications, undefined); - assert.equal(warnings.length, 1); + assert.equal(warnings.length, count + 1); }); });