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) <noreply@anthropic.com>
This commit is contained in:
ILLISISandClaude Opus 5.5 committed 2026-10-20 12:23:38 +11:00
1 parent fe416da26c
commit ca7caedfbe
3 files changed
+40 -10

No files matched your search

+8 -5
View File
@@ -61,6 +61,8 @@ async function encodeLuaString(text: string): Promise<string> {
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);
+18
View File
@@ -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
+14 -5
View File
@@ -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));
});