From 0d6beeba4761aa8035ab6b9fdfb23c423e772eee Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:52:32 +0000 Subject: [PATCH 1/6] Send only recipe notification changes on inventory download Lua includes its current cleared recipe list in the download request. The instance plugin diffs it against the stored list and sends back the names to add and remove instead of the full list. Lua rebuilds the full list from the snapshot kept in the download record and runs the existing deserializer, so the end state is unchanged. Also stop the serializer from appending to the stored failed recipe list in place, which grew the list on every upload without a download in between. Co-Authored-By: Claude Fable 5.1 --- plugins/inventory_sync/README.md | 4 +- plugins/inventory_sync/instance.ts | 78 ++++++++++++++++++- plugins/inventory_sync/messages.ts | 1 + .../inventory_sync/module/inventory_sync.lua | 15 +++- plugins/inventory_sync/module/serialize.lua | 54 +++++++++++-- plugins/inventory_sync/test/instance.js | 68 ++++++++++++++++ 6 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 plugins/inventory_sync/test/instance.js diff --git a/plugins/inventory_sync/README.md b/plugins/inventory_sync/README.md index c39a8283..ecf5d615 100644 --- a/plugins/inventory_sync/README.md +++ b/plugins/inventory_sync/README.md @@ -32,8 +32,8 @@ The greatly simplified data flow is as follows: 3. If the player inventory was previously uploaded but is not the same then the player inventory is deleted and the player is turned into a spectator. 4. Otherwise the player inventory is kept. -5. Scenario script asks for the player inventory from the controller. -6. Controller sends it to the scenario in a stream of chunks. +5. Scenario script asks for the player inventory from the controller, including its current recipe notification state. +6. Controller sends it to the scenario in a stream of chunks, with only the recipe notifications that differ from the current state. 7. Scenario displays and updates a progress bar as each chunk is received. 8. Once all chunks have loaded the player's synced inventory is recreated from the data and the player can start playing. diff --git a/plugins/inventory_sync/instance.ts b/plugins/inventory_sync/instance.ts index c831e8fd..39ddea17 100644 --- a/plugins/inventory_sync/instance.ts +++ b/plugins/inventory_sync/instance.ts @@ -1,3 +1,5 @@ +import util from "util"; +import zlib from "zlib"; import * as lib from "@clusterio/lib"; import { BaseInstancePlugin } from "@clusterio/host"; import { @@ -8,6 +10,11 @@ type IpcPlayerName = { player_name: string } +type IpcDownloadRequest = { + player_name: string, + recipe_notifications?: string, +} + type IpcAcquireResponse = { player_name: string, status: string, @@ -26,6 +33,49 @@ function chunkify(chunkSize: number, string: string): string[] { return string.match(new RegExp(`.{1,${chunkSize}}`, "g")) || []; } +const inflate = util.promisify(zlib.inflate); +const deflate = util.promisify(zlib.deflate); + +/** + * Decode a string produced by helpers.encode_string in Factorio + */ +async function decodeLuaString(encoded: string): Promise { + return (await inflate(Buffer.from(encoded, "base64"))).toString("utf8"); +} + +/** + * Encode a string so that helpers.decode_string in Factorio can read it + */ +async function encodeLuaString(text: string): Promise { + return (await deflate(Buffer.from(text, "utf8"))).toString("base64"); +} + +export type RecipeNotificationDelta = { + add?: string[], + remove?: string[], +} + +/** + * Compute the changes needed to turn the current cleared recipe list into the stored one + * @param stored - Cleared recipe names stored on the controller + * @param current - Cleared recipe names the instance currently has + * @returns names to add to and remove from the current list + */ +export function recipeNotificationDelta(stored: string[], current: string[]): RecipeNotificationDelta { + const storedSet = new Set(stored); + const currentSet = new Set(current); + const delta: RecipeNotificationDelta = {}; + const add = stored.filter(name => !currentSet.has(name)); + const remove = current.filter(name => !storedSet.has(name)); + if (add.length) { + delta.add = add; + } + if (remove.length) { + delta.remove = remove; + } + return delta; +} + export class InstancePlugin extends BaseInstancePlugin { playersToRelease!: Set; disconnecting!: boolean; @@ -55,7 +105,7 @@ export class InstancePlugin extends BaseInstancePlugin { ); this.instance.server.on( "ipc-inventory_sync_download", - (request: IpcPlayerName) => this.handleDownload(request).catch( + (request: IpcDownloadRequest) => this.handleDownload(request).catch( err => this.logger.error(`Error handling ipc-inventory_sync_download:\n${err.stack}`) ), ); @@ -163,7 +213,27 @@ export class InstancePlugin extends BaseInstancePlugin { ); } - async handleDownload(request: IpcPlayerName) { + /** + * Replace the stored recipe notifications with the difference from the + * current state on the instance to reduce the amount of data sent to Lua. + */ + async applyRecipeNotificationDelta(playerData: IpcPlayerData, current: string) { + if (!playerData.recipe_notifications) { + return; + } + try { + const delta = recipeNotificationDelta( + JSON.parse(await decodeLuaString(playerData.recipe_notifications)), + JSON.parse(await decodeLuaString(current)), + ); + playerData.recipe_notifications_delta = await encodeLuaString(JSON.stringify(delta)); + delete playerData.recipe_notifications; + } catch (err: any) { + this.logger.warn(`Sending full recipe notifications for ${playerData.name}:\n${err.stack}`); + } + } + + async handleDownload(request: IpcDownloadRequest) { const playerName = request.player_name; this.logger.verbose(`Downloading ${playerName}`); @@ -177,6 +247,10 @@ export class InstancePlugin extends BaseInstancePlugin { return; } + if (request.recipe_notifications) { + await this.applyRecipeNotificationDelta(response.playerData, request.recipe_notifications); + } + const chunkSize = this.instance.config.get("inventory_sync.rcon_chunk_size"); const chunks = chunkify(chunkSize, JSON.stringify(response.playerData)); this.logger.verbose(`Sending inventory for ${playerName} in ${chunks.length} chunks`); diff --git a/plugins/inventory_sync/messages.ts b/plugins/inventory_sync/messages.ts index 028c39e0..5585fe55 100644 --- a/plugins/inventory_sync/messages.ts +++ b/plugins/inventory_sync/messages.ts @@ -21,6 +21,7 @@ export type IpcPlayerData = { personal_logistic_slots?: {name:string, min:number, max:number}[], crafting_queue?: any, recipe_notifications?: string, + recipe_notifications_delta?: string, } const jsonPlayerData = Type.Object({ diff --git a/plugins/inventory_sync/module/inventory_sync.lua b/plugins/inventory_sync/module/inventory_sync.lua index bf56a57e..6df45f77 100644 --- a/plugins/inventory_sync/module/inventory_sync.lua +++ b/plugins/inventory_sync/module/inventory_sync.lua @@ -111,6 +111,13 @@ function inventory_sync.deserialize_player(player, finished_record) -- Deserialize downloaded player data local serialized_player = compat.json_to_table(finished_record.data) assert(type(serialized_player) == "table", "wrong type for serialized_player") + if serialized_player.recipe_notifications_delta then + serialized_player.recipe_notifications = serialize.apply_crafting_notification_delta( + assert(finished_record.recipe_notifications, "recipe notification delta without snapshot"), + serialized_player.recipe_notifications_delta + ) + serialized_player.recipe_notifications_delta = nil + end script_data.failed_deserialization[player.name] = serialize.deserialize_player(player, serialized_player) -- Restore player position and driving state @@ -441,8 +448,14 @@ function inventory_sync.initiate_inventory_download(player, player_record, gener local script_data = get_script_data() script_data.active_downloads[player.name] = record + -- The plugin only sends back what differs from the current notification state + record.recipe_notifications = serialize.crafting_notification_snapshot( + player, script_data.failed_deserialization[player.name] + ) + clusterio_api.send_json("inventory_sync_download", { - player_name = player.name + player_name = player.name, + recipe_notifications = record.recipe_notifications, }) -- If this is a synced player turn them into a spectator while the diff --git a/plugins/inventory_sync/module/serialize.lua b/plugins/inventory_sync/module/serialize.lua index 6d62a6aa..78430de3 100644 --- a/plugins/inventory_sync/module/serialize.lua +++ b/plugins/inventory_sync/module/serialize.lua @@ -533,17 +533,61 @@ function serialize.serialize_crafting_notifications(player, failed) recipes[recipe.name] = nil end - -- Convert to a list of names - local recipe_names = failed or {} -- Includes previous recipes if any - local index = #recipe_names + 1 + -- Convert to a list of names, including previously failed recipes if any + local recipe_names = {} + for _, name in ipairs(failed or {}) do + recipe_names[#recipe_names + 1] = name + end for name in pairs(recipes) do - recipe_names[index] = name - index = index + 1 + recipe_names[#recipe_names + 1] = name end return helpers.encode_string(helpers.table_to_json(recipe_names)) end +--- Current notification state, sent with download requests so the instance plugin only returns the difference +--- @param player LuaPlayer +--- @param failed FailedDeserializationPlayerData? +--- @return string? +function serialize.crafting_notification_snapshot(player, failed) + if not recipe_notifications_api then + return nil + end + return serialize.serialize_crafting_notifications(player, failed and failed.recipe_notifications) +end + +--- @class CraftingNotificationDelta +--- @field add string[]? Recipe names to add to the cleared list +--- @field remove string[]? Recipe names to remove from the cleared list + +--- Rebuild the full serialized form from the snapshot the delta was computed against +--- @param snapshot string +--- @param serialized_delta string +--- @return string? +function serialize.apply_crafting_notification_delta(snapshot, serialized_delta) + local recipe_names = helpers.json_to_table(assert(helpers.decode_string(snapshot))) + assert(type(recipe_names) == "table", "wrong type decoded from json_to_table") + --- @type CraftingNotificationDelta + local delta = helpers.json_to_table(assert(helpers.decode_string(serialized_delta))) + assert(type(delta) == "table", "wrong type decoded from json_to_table") + local cleared = {} + for _, name in pairs(recipe_names) do + cleared[name] = true + end + for _, name in pairs(delta.remove or {}) do + cleared[name] = nil + end + for _, name in pairs(delta.add or {}) do + cleared[name] = true + end + + local merged = {} + for name in pairs(cleared) do + merged[#merged + 1] = name + end + return helpers.encode_string(helpers.table_to_json(merged)) +end + --- @param player LuaPlayer --- @param serialized string --- @return string[]? failed Recipe names that do not exist diff --git a/plugins/inventory_sync/test/instance.js b/plugins/inventory_sync/test/instance.js new file mode 100644 index 00000000..3354142a --- /dev/null +++ b/plugins/inventory_sync/test/instance.js @@ -0,0 +1,68 @@ +"use strict"; +const assert = require("assert").strict; +const util = require("util"); +const zlib = require("zlib"); + +const { recipeNotificationDelta, InstancePlugin } = require("../dist/node/instance"); + +const inflate = util.promisify(zlib.inflate); +const deflate = util.promisify(zlib.deflate); + +// Matches helpers.encode_string in Factorio +async function encode(value) { + return (await deflate(Buffer.from(JSON.stringify(value)))).toString("base64"); +} +async function decode(encoded) { + return JSON.parse((await inflate(Buffer.from(encoded, "base64"))).toString()); +} + +describe("inventory_sync", function() { + describe("recipeNotificationDelta()", function() { + it("should be empty when the lists match", function() { + assert.deepEqual(recipeNotificationDelta(["a", "b"], ["b", "a"]), {}); + }); + it("should add stored names missing from current", function() { + assert.deepEqual(recipeNotificationDelta(["a", "b", "c"], ["a"]), { add: ["b", "c"] }); + }); + it("should remove current names missing from stored", function() { + assert.deepEqual(recipeNotificationDelta(["a"], ["a", "b"]), { remove: ["b"] }); + }); + it("should handle both directions at once", function() { + assert.deepEqual(recipeNotificationDelta(["a", "b"], ["b", "c"]), { add: ["a"], remove: ["c"] }); + }); + it("should keep duplicates in stored out of the current list", function() { + assert.deepEqual(recipeNotificationDelta(["a", "a"], ["a"]), {}); + }); + }); + + describe("InstancePlugin.applyRecipeNotificationDelta()", function() { + const warnings = []; + const plugin = { logger: { warn: msg => warnings.push(msg) } }; + const apply = InstancePlugin.prototype.applyRecipeNotificationDelta.bind(plugin); + + it("should replace the stored list with an encoded delta", async function() { + const playerData = { name: "test", recipe_notifications: await encode(["a", "b"]) }; + await apply(playerData, await encode(["b", "c"])); + assert.equal(playerData.recipe_notifications, undefined); + assert.deepEqual(await decode(playerData.recipe_notifications_delta), { add: ["a"], remove: ["c"] }); + }); + it("should send an empty delta when nothing changed", async function() { + const playerData = { name: "test", recipe_notifications: await encode(["a"]) }; + await apply(playerData, await encode(["a"])); + assert.deepEqual(await decode(playerData.recipe_notifications_delta), {}); + }); + it("should do nothing without stored notifications", async function() { + const playerData = { name: "test" }; + await apply(playerData, await encode(["a"])); + assert.deepEqual(playerData, { name: "test" }); + }); + it("should fall back to the full list on invalid input", async function() { + const stored = await encode(["a"]); + const playerData = { name: "test", recipe_notifications: stored }; + await apply(playerData, "not base64 zlib"); + assert.equal(playerData.recipe_notifications, stored); + assert.equal(playerData.recipe_notifications_delta, undefined); + assert.equal(warnings.length, 1); + }); + }); +}); From 1272c0e8e4b3a910fdcea8110dbf8c3ada493e43 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:18:24 +0000 Subject: [PATCH 2/6] Test the inventory sync download handler Covers the empty response, the full list without a snapshot, the delta with a snapshot, and the IPC registration. Co-Authored-By: Claude Fable 5.1 --- plugins/inventory_sync/test/instance.js | 62 +++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/plugins/inventory_sync/test/instance.js b/plugins/inventory_sync/test/instance.js index 3354142a..e292c19f 100644 --- a/plugins/inventory_sync/test/instance.js +++ b/plugins/inventory_sync/test/instance.js @@ -3,7 +3,12 @@ const assert = require("assert").strict; const util = require("util"); const zlib = require("zlib"); +const lib = require("@clusterio/lib"); + +const mock = require("../../../test/mock"); const { recipeNotificationDelta, InstancePlugin } = require("../dist/node/instance"); +const { DownloadResponse } = require("../dist/node/messages"); +const info = require("../dist/node/index").plugin; const inflate = util.promisify(zlib.inflate); const deflate = util.promisify(zlib.deflate); @@ -65,4 +70,61 @@ describe("inventory_sync", function() { assert.equal(warnings.length, 1); }); }); + + describe("InstancePlugin.handleDownload()", function() { + let instancePlugin; + before(async function() { + try { + lib.registerPluginMessages([info]); + } catch (err) { + // Already registered by the full test suite + } + instancePlugin = await mock.createInstancePlugin(InstancePlugin, info); + instancePlugin.instance.mockConfigEntries.set("inventory_sync.rcon_chunk_size", 100000); + }); + beforeEach(function() { + instancePlugin.instance.server.reset(); + }); + + function respondWith(playerData) { + instancePlugin.instance.connector.once("send", message => { + instancePlugin.instance.connector.emit("message", new lib.MessageResponse( + 1, message.dst, message.src, new DownloadResponse(playerData) + )); + }); + } + function downloadCommand(playerData) { + const json = lib.escapeString(JSON.stringify(playerData)); + return `/sc inventory_sync.download_inventory('test','${json}',1,1)`; + } + + it("should send an empty download when the controller has no data", async function() { + respondWith(undefined); + await instancePlugin.handleDownload({ player_name: "test" }); + assert.deepEqual( + instancePlugin.instance.server.rconCommands, + ["/sc inventory_sync.download_inventory('test',nil,0,0)"] + ); + }); + it("should send the full list when no snapshot was given", async function() { + const playerData = { generation: 1, name: "test", recipe_notifications: await encode(["a"]) }; + respondWith({ ...playerData }); + await instancePlugin.handleDownload({ player_name: "test" }); + assert.deepEqual(instancePlugin.instance.server.rconCommands, [downloadCommand(playerData)]); + }); + it("should send a delta when a snapshot was given", async function() { + respondWith({ generation: 1, name: "test", recipe_notifications: await encode(["a", "b"]) }); + await instancePlugin.handleDownload({ player_name: "test", recipe_notifications: await encode(["b"]) }); + const expected = { generation: 1, name: "test", recipe_notifications_delta: await encode({ add: ["a"] }) }; + assert.deepEqual(instancePlugin.instance.server.rconCommands, [downloadCommand(expected)]); + }); + it("should be invoked by the download ipc", async function() { + respondWith(undefined); + instancePlugin.instance.server.emit("ipc-inventory_sync_download", { player_name: "test" }); + for (let i = 0; i < 100 && !instancePlugin.instance.server.rconCommands.length; i++) { + await new Promise(resolve => setImmediate(resolve)); + } + assert.equal(instancePlugin.instance.server.rconCommands.length, 1); + }); + }); }); From bb36af08fe90ef4aca485745b974f00209e17957 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:26:38 +0000 Subject: [PATCH 3/6] Apply recipe notification deltas directly in Lua Review feedback: the download field now always carries the delta and the deserializer applies it against the current state instead of rebuilding the full list first. The previous failed recipe names are passed in so they survive the round trip. The version check moved to the call site. Co-Authored-By: Claude Fable 5.1 --- plugins/inventory_sync/instance.ts | 14 +-- plugins/inventory_sync/messages.ts | 1 - .../inventory_sync/module/inventory_sync.lua | 24 ++-- plugins/inventory_sync/module/serialize.lua | 109 ++++++------------ plugins/inventory_sync/test/instance.js | 30 ++--- 5 files changed, 69 insertions(+), 109 deletions(-) diff --git a/plugins/inventory_sync/instance.ts b/plugins/inventory_sync/instance.ts index 39ddea17..32324517 100644 --- a/plugins/inventory_sync/instance.ts +++ b/plugins/inventory_sync/instance.ts @@ -217,19 +217,19 @@ export class InstancePlugin extends BaseInstancePlugin { * Replace the stored recipe notifications with the difference from the * current state on the instance to reduce the amount of data sent to Lua. */ - async applyRecipeNotificationDelta(playerData: IpcPlayerData, current: string) { + async applyRecipeNotificationDelta(playerData: IpcPlayerData, current?: string) { if (!playerData.recipe_notifications) { return; } try { const delta = recipeNotificationDelta( JSON.parse(await decodeLuaString(playerData.recipe_notifications)), - JSON.parse(await decodeLuaString(current)), + current ? JSON.parse(await decodeLuaString(current)) : [], ); - playerData.recipe_notifications_delta = await encodeLuaString(JSON.stringify(delta)); - delete playerData.recipe_notifications; + playerData.recipe_notifications = await encodeLuaString(JSON.stringify(delta)); } catch (err: any) { - this.logger.warn(`Sending full recipe notifications for ${playerData.name}:\n${err.stack}`); + this.logger.warn(`Dropping invalid recipe notifications for ${playerData.name}:\n${err.stack}`); + delete playerData.recipe_notifications; } } @@ -247,9 +247,7 @@ export class InstancePlugin extends BaseInstancePlugin { return; } - if (request.recipe_notifications) { - await this.applyRecipeNotificationDelta(response.playerData, request.recipe_notifications); - } + await this.applyRecipeNotificationDelta(response.playerData, request.recipe_notifications); const chunkSize = this.instance.config.get("inventory_sync.rcon_chunk_size"); const chunks = chunkify(chunkSize, JSON.stringify(response.playerData)); diff --git a/plugins/inventory_sync/messages.ts b/plugins/inventory_sync/messages.ts index 5585fe55..028c39e0 100644 --- a/plugins/inventory_sync/messages.ts +++ b/plugins/inventory_sync/messages.ts @@ -21,7 +21,6 @@ export type IpcPlayerData = { personal_logistic_slots?: {name:string, min:number, max:number}[], crafting_queue?: any, recipe_notifications?: string, - recipe_notifications_delta?: string, } const jsonPlayerData = Type.Object({ diff --git a/plugins/inventory_sync/module/inventory_sync.lua b/plugins/inventory_sync/module/inventory_sync.lua index 6df45f77..89250148 100644 --- a/plugins/inventory_sync/module/inventory_sync.lua +++ b/plugins/inventory_sync/module/inventory_sync.lua @@ -17,6 +17,7 @@ local progress_dialog = require("modules/inventory_sync/gui/progress_dialog") local dialog_failed_download = require("modules/inventory_sync/gui/dialog_failed_download") local v2_remote_controller = compat.version_ge("2.0.0") +local recipe_notifications_api = compat.version_ge("2.0.67") -- Returns true if the player is currently in a cutscene local function is_in_cutscene(player) @@ -111,14 +112,9 @@ function inventory_sync.deserialize_player(player, finished_record) -- Deserialize downloaded player data local serialized_player = compat.json_to_table(finished_record.data) assert(type(serialized_player) == "table", "wrong type for serialized_player") - if serialized_player.recipe_notifications_delta then - serialized_player.recipe_notifications = serialize.apply_crafting_notification_delta( - assert(finished_record.recipe_notifications, "recipe notification delta without snapshot"), - serialized_player.recipe_notifications_delta - ) - serialized_player.recipe_notifications_delta = nil - end - script_data.failed_deserialization[player.name] = serialize.deserialize_player(player, serialized_player) + script_data.failed_deserialization[player.name] = serialize.deserialize_player( + player, serialized_player, script_data.failed_deserialization[player.name] + ) -- Restore player position and driving state restore_position(player, finished_record) @@ -449,13 +445,17 @@ function inventory_sync.initiate_inventory_download(player, player_record, gener script_data.active_downloads[player.name] = record -- The plugin only sends back what differs from the current notification state - record.recipe_notifications = serialize.crafting_notification_snapshot( - player, script_data.failed_deserialization[player.name] - ) + local recipe_notifications + if recipe_notifications_api then + local failed = script_data.failed_deserialization[player.name] + recipe_notifications = serialize.serialize_crafting_notifications( + player, failed and failed.recipe_notifications + ) + end clusterio_api.send_json("inventory_sync_download", { player_name = player.name, - recipe_notifications = record.recipe_notifications, + recipe_notifications = recipe_notifications, }) -- If this is a synced player turn them into a spectator while the diff --git a/plugins/inventory_sync/module/serialize.lua b/plugins/inventory_sync/module/serialize.lua index 78430de3..7fabb07c 100644 --- a/plugins/inventory_sync/module/serialize.lua +++ b/plugins/inventory_sync/module/serialize.lua @@ -545,95 +545,54 @@ function serialize.serialize_crafting_notifications(player, failed) return helpers.encode_string(helpers.table_to_json(recipe_names)) end ---- Current notification state, sent with download requests so the instance plugin only returns the difference ---- @param player LuaPlayer ---- @param failed FailedDeserializationPlayerData? ---- @return string? -function serialize.crafting_notification_snapshot(player, failed) - if not recipe_notifications_api then - return nil - end - return serialize.serialize_crafting_notifications(player, failed and failed.recipe_notifications) -end - --- @class CraftingNotificationDelta ---- @field add string[]? Recipe names to add to the cleared list ---- @field remove string[]? Recipe names to remove from the cleared list - ---- Rebuild the full serialized form from the snapshot the delta was computed against ---- @param snapshot string ---- @param serialized_delta string ---- @return string? -function serialize.apply_crafting_notification_delta(snapshot, serialized_delta) - local recipe_names = helpers.json_to_table(assert(helpers.decode_string(snapshot))) - assert(type(recipe_names) == "table", "wrong type decoded from json_to_table") - --- @type CraftingNotificationDelta - local delta = helpers.json_to_table(assert(helpers.decode_string(serialized_delta))) - assert(type(delta) == "table", "wrong type decoded from json_to_table") - local cleared = {} - for _, name in pairs(recipe_names) do - cleared[name] = true - end - for _, name in pairs(delta.remove or {}) do - cleared[name] = nil - end - for _, name in pairs(delta.add or {}) do - cleared[name] = true - end - - local merged = {} - for name in pairs(cleared) do - merged[#merged + 1] = name - end - return helpers.encode_string(helpers.table_to_json(merged)) -end +--- @field add string[]? Recipes cleared elsewhere that still have a notification here +--- @field remove string[]? Recipes with a notification elsewhere that are cleared here --- @param player LuaPlayer ---- @param serialized string +--- @param serialized string Encoded delta against the state sent with the download request +--- @param previous_failed string[]? Recipe names that did not exist at the last deserialization --- @return string[]? failed Recipe names that do not exist -function serialize.deserialize_crafting_notifications(player, serialized) - -- Decode the recipe name strings - local recipe_names = helpers.json_to_table(assert(helpers.decode_string(serialized))) - assert(type(recipe_names) == "table", "wrong type decoded from json_to_table") - local clear_notification = player.clear_recipe_notification - local add_notification = player.add_recipe_notification - local notifications = {} +function serialize.deserialize_crafting_notifications(player, serialized, previous_failed) + --- @type CraftingNotificationDelta + local delta = helpers.json_to_table(assert(helpers.decode_string(serialized))) + assert(type(delta) == "table", "wrong type decoded from json_to_table") local failed = {} + for _, recipe_name in pairs(previous_failed or {}) do + failed[recipe_name] = true + end - -- Assume all recipes need a notification + -- Recipes which can have a notification on this server + local enabled = {} for _, recipe in pairs(player.force.recipes) do if recipe.enabled and not recipe.hidden then - notifications[recipe.name] = true + enabled[recipe.name] = true end end - -- Remove recipes the player has cleared - for _, recipe_name in pairs(recipe_names) do - if notifications[recipe_name] then - notifications[recipe_name] = false + -- Clear notifications the player has cleared elsewhere + for _, recipe_name in pairs(delta.add or {}) do + if enabled[recipe_name] then + player.clear_recipe_notification(recipe_name) else - -- Recipe does not exist on this server - failed[#failed+1] = recipe_name + failed[recipe_name] = true -- Recipe does not exist on this server end end - -- Clear ones which are ones which are not required - for _, recipe in pairs(player.get_recipe_notifications()) do - if notifications[recipe.name] == false then - clear_notification(recipe.name) - end - notifications[recipe.name] = nil -- Prevents double add below - end - - -- Add any which the player did not have - for recipe_name, should_add in pairs(notifications) do - if should_add then - add_notification(recipe_name) + -- Add notifications the player has elsewhere + for _, recipe_name in pairs(delta.remove or {}) do + if enabled[recipe_name] then + player.add_recipe_notification(recipe_name) + else + failed[recipe_name] = nil end end - -- Return failed recipes if any - return next(failed) and failed or nil + local failed_names = {} + for recipe_name in pairs(failed) do + failed_names[#failed_names + 1] = recipe_name + end + return next(failed_names) and failed_names or nil end local controller_to_name = {} @@ -839,8 +798,9 @@ end --- @param player LuaPlayer --- @param serialized SerializedPlayerData +--- @param previous_failed FailedDeserializationPlayerData? --- @return FailedDeserializationPlayerData? -function serialize.deserialize_player(player, serialized) +function serialize.deserialize_player(player, serialized, previous_failed) local failed_deserialization = {} local target_controller = defines.controllers[serialized.controller] @@ -927,8 +887,9 @@ function serialize.deserialize_player(player, serialized) -- Deserialize recipe notifications if recipe_notifications_api and serialized.recipe_notifications then - failed_deserialization.recipe_notifications = - serialize.deserialize_crafting_notifications(player, serialized.recipe_notifications) + failed_deserialization.recipe_notifications = serialize.deserialize_crafting_notifications( + player, serialized.recipe_notifications, previous_failed and previous_failed.recipe_notifications + ) end return next(failed_deserialization) and failed_deserialization or nil diff --git a/plugins/inventory_sync/test/instance.js b/plugins/inventory_sync/test/instance.js index e292c19f..298a89ad 100644 --- a/plugins/inventory_sync/test/instance.js +++ b/plugins/inventory_sync/test/instance.js @@ -48,25 +48,27 @@ describe("inventory_sync", function() { it("should replace the stored list with an encoded delta", async function() { const playerData = { name: "test", recipe_notifications: await encode(["a", "b"]) }; await apply(playerData, await encode(["b", "c"])); - assert.equal(playerData.recipe_notifications, undefined); - assert.deepEqual(await decode(playerData.recipe_notifications_delta), { add: ["a"], remove: ["c"] }); + assert.deepEqual(await decode(playerData.recipe_notifications), { add: ["a"], remove: ["c"] }); }); it("should send an empty delta when nothing changed", async function() { const playerData = { name: "test", recipe_notifications: await encode(["a"]) }; await apply(playerData, await encode(["a"])); - assert.deepEqual(await decode(playerData.recipe_notifications_delta), {}); + assert.deepEqual(await decode(playerData.recipe_notifications), {}); + }); + it("should add everything without a current list", async function() { + const playerData = { name: "test", recipe_notifications: await encode(["a"]) }; + await apply(playerData, undefined); + assert.deepEqual(await decode(playerData.recipe_notifications), { add: ["a"] }); }); it("should do nothing without stored notifications", async function() { const playerData = { name: "test" }; await apply(playerData, await encode(["a"])); assert.deepEqual(playerData, { name: "test" }); }); - it("should fall back to the full list on invalid input", async function() { - const stored = await encode(["a"]); - const playerData = { name: "test", recipe_notifications: stored }; - await apply(playerData, "not base64 zlib"); - assert.equal(playerData.recipe_notifications, stored); - assert.equal(playerData.recipe_notifications_delta, undefined); + it("should drop the field on invalid input", async function() { + const playerData = { name: "test", recipe_notifications: "not base64 zlib" }; + await apply(playerData, await encode(["a"])); + assert.equal(playerData.recipe_notifications, undefined); assert.equal(warnings.length, 1); }); }); @@ -106,16 +108,16 @@ describe("inventory_sync", function() { ["/sc inventory_sync.download_inventory('test',nil,0,0)"] ); }); - it("should send the full list when no snapshot was given", async function() { - const playerData = { generation: 1, name: "test", recipe_notifications: await encode(["a"]) }; - respondWith({ ...playerData }); + it("should send everything as a delta when no snapshot was given", async function() { + respondWith({ generation: 1, name: "test", recipe_notifications: await encode(["a"]) }); await instancePlugin.handleDownload({ player_name: "test" }); - assert.deepEqual(instancePlugin.instance.server.rconCommands, [downloadCommand(playerData)]); + const expected = { generation: 1, name: "test", recipe_notifications: await encode({ add: ["a"] }) }; + assert.deepEqual(instancePlugin.instance.server.rconCommands, [downloadCommand(expected)]); }); it("should send a delta when a snapshot was given", async function() { respondWith({ generation: 1, name: "test", recipe_notifications: await encode(["a", "b"]) }); await instancePlugin.handleDownload({ player_name: "test", recipe_notifications: await encode(["b"]) }); - const expected = { generation: 1, name: "test", recipe_notifications_delta: await encode({ add: ["a"] }) }; + const expected = { generation: 1, name: "test", recipe_notifications: await encode({ add: ["a"] }) }; assert.deepEqual(instancePlugin.instance.server.rconCommands, [downloadCommand(expected)]); }); it("should be invoked by the download ipc", async function() { From 68ce13ead408b020a93585070e9a2d68b57414b2 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:46:44 +0100 Subject: [PATCH 4/6] Fix failed not being cleared --- plugins/inventory_sync/module/serialize.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/inventory_sync/module/serialize.lua b/plugins/inventory_sync/module/serialize.lua index 7fabb07c..4aed3a93 100644 --- a/plugins/inventory_sync/module/serialize.lua +++ b/plugins/inventory_sync/module/serialize.lua @@ -557,6 +557,7 @@ function serialize.deserialize_crafting_notifications(player, serialized, previo --- @type CraftingNotificationDelta local delta = helpers.json_to_table(assert(helpers.decode_string(serialized))) assert(type(delta) == "table", "wrong type decoded from json_to_table") + local failed = {} for _, recipe_name in pairs(previous_failed or {}) do failed[recipe_name] = true @@ -567,10 +568,11 @@ function serialize.deserialize_crafting_notifications(player, serialized, previo for _, recipe in pairs(player.force.recipes) do if recipe.enabled and not recipe.hidden then enabled[recipe.name] = true + failed[recipe.name] = nil end end - -- Clear notifications the player has cleared elsewhere + -- Clear notifications the player has cleared elsewhere, this means "add seen notification" for _, recipe_name in pairs(delta.add or {}) do if enabled[recipe_name] then player.clear_recipe_notification(recipe_name) @@ -579,7 +581,7 @@ function serialize.deserialize_crafting_notifications(player, serialized, previo end end - -- Add notifications the player has elsewhere + -- Add notifications the player has elsewhere, this means "remove seen notification" for _, recipe_name in pairs(delta.remove or {}) do if enabled[recipe_name] then player.add_recipe_notification(recipe_name) From b1e79cf86dae5be62e8eb4934fa8c5670792fe08 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:20:56 +0000 Subject: [PATCH 5/6] Send only the local state on download init The current state sent with the download request no longer includes the failed names from the previous deserialization. Stored names unknown on this instance always end up in the add list, so the deserializer recomputes the failed list from that on every download and needs no previous state. Co-Authored-By: Claude Fable 5.1 --- .../inventory_sync/module/inventory_sync.lua | 9 ++---- plugins/inventory_sync/module/serialize.lua | 28 +++++-------------- 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/plugins/inventory_sync/module/inventory_sync.lua b/plugins/inventory_sync/module/inventory_sync.lua index 89250148..c6804251 100644 --- a/plugins/inventory_sync/module/inventory_sync.lua +++ b/plugins/inventory_sync/module/inventory_sync.lua @@ -112,9 +112,7 @@ function inventory_sync.deserialize_player(player, finished_record) -- Deserialize downloaded player data local serialized_player = compat.json_to_table(finished_record.data) assert(type(serialized_player) == "table", "wrong type for serialized_player") - script_data.failed_deserialization[player.name] = serialize.deserialize_player( - player, serialized_player, script_data.failed_deserialization[player.name] - ) + script_data.failed_deserialization[player.name] = serialize.deserialize_player(player, serialized_player) -- Restore player position and driving state restore_position(player, finished_record) @@ -447,10 +445,7 @@ function inventory_sync.initiate_inventory_download(player, player_record, gener -- The plugin only sends back what differs from the current notification state local recipe_notifications if recipe_notifications_api then - local failed = script_data.failed_deserialization[player.name] - recipe_notifications = serialize.serialize_crafting_notifications( - player, failed and failed.recipe_notifications - ) + recipe_notifications = serialize.serialize_crafting_notifications(player) end clusterio_api.send_json("inventory_sync_download", { diff --git a/plugins/inventory_sync/module/serialize.lua b/plugins/inventory_sync/module/serialize.lua index 4aed3a93..0ef6705e 100644 --- a/plugins/inventory_sync/module/serialize.lua +++ b/plugins/inventory_sync/module/serialize.lua @@ -551,33 +551,27 @@ end --- @param player LuaPlayer --- @param serialized string Encoded delta against the state sent with the download request ---- @param previous_failed string[]? Recipe names that did not exist at the last deserialization --- @return string[]? failed Recipe names that do not exist -function serialize.deserialize_crafting_notifications(player, serialized, previous_failed) +function serialize.deserialize_crafting_notifications(player, serialized) --- @type CraftingNotificationDelta local delta = helpers.json_to_table(assert(helpers.decode_string(serialized))) assert(type(delta) == "table", "wrong type decoded from json_to_table") - local failed = {} - for _, recipe_name in pairs(previous_failed or {}) do - failed[recipe_name] = true - end - -- Recipes which can have a notification on this server local enabled = {} for _, recipe in pairs(player.force.recipes) do if recipe.enabled and not recipe.hidden then enabled[recipe.name] = true - failed[recipe.name] = nil end end -- Clear notifications the player has cleared elsewhere, this means "add seen notification" + local failed = {} for _, recipe_name in pairs(delta.add or {}) do if enabled[recipe_name] then player.clear_recipe_notification(recipe_name) else - failed[recipe_name] = true -- Recipe does not exist on this server + failed[#failed + 1] = recipe_name -- Recipe does not exist on this server end end @@ -585,16 +579,10 @@ function serialize.deserialize_crafting_notifications(player, serialized, previo for _, recipe_name in pairs(delta.remove or {}) do if enabled[recipe_name] then player.add_recipe_notification(recipe_name) - else - failed[recipe_name] = nil end end - local failed_names = {} - for recipe_name in pairs(failed) do - failed_names[#failed_names + 1] = recipe_name - end - return next(failed_names) and failed_names or nil + return next(failed) and failed or nil end local controller_to_name = {} @@ -800,9 +788,8 @@ end --- @param player LuaPlayer --- @param serialized SerializedPlayerData ---- @param previous_failed FailedDeserializationPlayerData? --- @return FailedDeserializationPlayerData? -function serialize.deserialize_player(player, serialized, previous_failed) +function serialize.deserialize_player(player, serialized) local failed_deserialization = {} local target_controller = defines.controllers[serialized.controller] @@ -889,9 +876,8 @@ function serialize.deserialize_player(player, serialized, previous_failed) -- Deserialize recipe notifications if recipe_notifications_api and serialized.recipe_notifications then - failed_deserialization.recipe_notifications = serialize.deserialize_crafting_notifications( - player, serialized.recipe_notifications, previous_failed and previous_failed.recipe_notifications - ) + failed_deserialization.recipe_notifications = + serialize.deserialize_crafting_notifications(player, serialized.recipe_notifications) end return next(failed_deserialization) and failed_deserialization or nil From 8c20af64745f2ea720315789265f003ad40e0e4b Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:29:04 +0000 Subject: [PATCH 6/6] Convert inventory sync test to ES module Master migrated to ESM after this branch was created, so the CommonJS test failed to load on the merge commit. Co-Authored-By: Claude Fable 5.1 --- plugins/inventory_sync/test/instance.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/plugins/inventory_sync/test/instance.js b/plugins/inventory_sync/test/instance.js index 298a89ad..020f5979 100644 --- a/plugins/inventory_sync/test/instance.js +++ b/plugins/inventory_sync/test/instance.js @@ -1,14 +1,12 @@ -"use strict"; -const assert = require("assert").strict; -const util = require("util"); -const zlib = require("zlib"); +import assert from "node:assert/strict"; +import util from "node:util"; +import zlib from "node:zlib"; +import * as lib from "@clusterio/lib"; -const lib = require("@clusterio/lib"); - -const mock = require("../../../test/mock"); -const { recipeNotificationDelta, InstancePlugin } = require("../dist/node/instance"); -const { DownloadResponse } = require("../dist/node/messages"); -const info = require("../dist/node/index").plugin; +import * as mock from "../../../test/mock.js"; +import { recipeNotificationDelta, InstancePlugin } from "../dist/node/instance.js"; +import { DownloadResponse } from "../dist/node/messages.js"; +import { plugin as info } from "../dist/node/index.js"; const inflate = util.promisify(zlib.inflate); const deflate = util.promisify(zlib.deflate);