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); }); });