From ca7caedfbeaeceb9d0c578dad3fcc584021c6bd7 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sun, 18 Oct 2026 22:09:26 +1100 Subject: [PATCH] Restore recipe notifications a prefetched copy would leave cleared A prefetched copy is sent without the save's list of cleared recipes, so its delta had only add, and a recipe cleared on the destination but not in the stored list stayed cleared where a download would give it its notification back. Without a current list the instance now sends the whole stored list marked full, and the save gives the recipes it has cleared that aren't in it their notification back, as a download's remove does. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/player_sync/instance.ts | 13 ++++++++----- plugins/player_sync/module/serialize.lua | 18 ++++++++++++++++++ plugins/player_sync/test/instance.js | 19 ++++++++++++++----- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/plugins/player_sync/instance.ts b/plugins/player_sync/instance.ts index c133faae..50543bf7 100644 --- a/plugins/player_sync/instance.ts +++ b/plugins/player_sync/instance.ts @@ -61,6 +61,8 @@ async function encodeLuaString(text: string): Promise { export type RecipeNotificationDelta = { add?: string[], remove?: string[], + /** add is the whole stored list, sent without the instance's: the save works out what to remove */ + full?: true, } /** @@ -87,6 +89,7 @@ export function recipeNotificationDelta(stored: string[], current: string[]): Re /** * Replace the stored recipe notifications with the difference from the * current state on the instance to reduce the amount of data sent to Lua. + * Without a current state the whole stored list is sent, marked full. */ export async function applyRecipeNotificationDelta( playerData: IpcPlayerData, @@ -97,10 +100,10 @@ export async function applyRecipeNotificationDelta( return; } try { - const delta = recipeNotificationDelta( - JSON.parse(await decodeLuaString(playerData.recipe_notifications)), - current ? JSON.parse(await decodeLuaString(current)) : [], - ); + const stored = JSON.parse(await decodeLuaString(playerData.recipe_notifications)); + const delta: RecipeNotificationDelta = current + ? recipeNotificationDelta(stored, JSON.parse(await decodeLuaString(current))) + : { ...recipeNotificationDelta(stored, []), full: true }; playerData.recipe_notifications = await encodeLuaString(JSON.stringify(delta)); } catch (err: any) { logger.warn(`Dropping invalid recipe notifications for ${playerData.name}:\n${err.stack}`); @@ -282,7 +285,7 @@ export default async function(context: InstancePluginContext) { const { playerData } = event; const { name, generation } = playerData; logger.verbose(`Prefetching ${name}`); - // This side doesn't know the save's cleared recipe list, so the whole stored list goes as the delta's add + // This side doesn't know the save's cleared recipe list, so the whole stored list goes as a full delta await sendPlayerData(playerData, undefined, (chunk, number, total, extra) => ( `/sc player_sync.prefetch_inventory('${name}',${generation},'${chunk}',${number},${total}${extra})` ), true); diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index 41d37f4f..7a79d9ed 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -895,6 +895,7 @@ end --- @class CraftingNotificationDelta --- @field add string[]? Recipes cleared elsewhere that still have a notification here --- @field remove string[]? Recipes with a notification elsewhere that are cleared here +--- @field full boolean? add is the whole stored list, sent without this save's (a prefetched copy) --- @param player LuaPlayer --- @param serialized string Encoded delta against the state sent with the download request @@ -929,6 +930,23 @@ function serialize.deserialize_crafting_notifications(player, serialized) end end + -- A full delta has no remove: recipes cleared here that aren't in add get their notification back, as remove does + if delta.full then + local stored = {} + for _, recipe_name in pairs(delta.add or {}) do + stored[recipe_name] = true + end + local notified = {} + for _, recipe in pairs(player.get_recipe_notifications()) do + notified[recipe.name] = true + end + for recipe_name in pairs(enabled) do + if not stored[recipe_name] and not notified[recipe_name] then + player.add_recipe_notification(recipe_name) + end + end + end + return next(failed) and failed or nil end diff --git a/plugins/player_sync/test/instance.js b/plugins/player_sync/test/instance.js index f6828929..a31d5e1e 100644 --- a/plugins/player_sync/test/instance.js +++ b/plugins/player_sync/test/instance.js @@ -54,10 +54,15 @@ describe("player_sync", function() { await apply(playerData, await encode(["a"])); assert.deepEqual(await decode(playerData.recipe_notifications), {}); }); - it("should add everything without a current list", async function() { + it("should send the whole list marked full 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"] }); + assert.deepEqual(await decode(playerData.recipe_notifications), { add: ["a"], full: true }); + }); + it("should mark an empty list 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 do nothing without stored notifications", async function() { const playerData = { name: "test" }; @@ -118,10 +123,12 @@ describe("player_sync", function() { ["/sc player_sync.download_inventory('test',nil,0,0)"] ); }); - it("should send everything as a delta when no snapshot was given", async function() { + it("should send everything as a full delta when no snapshot was given", async function() { respondWith({ generation: 1, name: "test", recipe_notifications: await encode(["a"]) }); await download({ player_name: "test" }); - const expected = { generation: 1, name: "test", recipe_notifications: await encode({ add: ["a"] }) }; + const expected = { + generation: 1, name: "test", recipe_notifications: await encode({ add: ["a"], full: true }), + }; assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]); }); it("should send a delta when a snapshot was given", async function() { @@ -275,7 +282,9 @@ describe("player_sync", function() { it("should send prefetch_inventory chunks with the generation and the full recipe list", async function() { await prefetch({ generation: 3, name: "test", recipe_notifications: await encode(["a"]) }); - const expected = { generation: 3, name: "test", recipe_notifications: await encode({ add: ["a"] }) }; + const expected = { + generation: 3, name: "test", recipe_notifications: await encode({ add: ["a"], full: true }), + }; assert.ok(instance.server.rconCommands.length > 1, "more than one chunk"); assert.deepEqual(instance.server.rconCommands, prefetchCommands(expected)); });