From b0fb43d6787329723f0d556dde9571152fe01592 Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:25:32 +0100 Subject: [PATCH] Merge recipe notifications against the restored force (#1182) The instance works out the recipe notification delta against the cleared list the player had when the download started, but the save applied it after the download restored player.force. A player whose force changed kept notifications they had cleared elsewhere, and recipes only the old force had dropped out of the failed list and were lost on the next upload. The download record now keeps the cleared list it sent. The save rebuilds the stored list from it and the delta, then merges that against the force the player has once the data is applied, as it did before #997. Nothing extra goes over RCON. Co-authored-by: Claude Opus 5.5 --- plugins/player_sync/module/player_sync.lua | 6 +- plugins/player_sync/module/serialize.lua | 121 +++++++++++---------- 2 files changed, 68 insertions(+), 59 deletions(-) diff --git a/plugins/player_sync/module/player_sync.lua b/plugins/player_sync/module/player_sync.lua index e0326fad..b2df31b6 100644 --- a/plugins/player_sync/module/player_sync.lua +++ b/plugins/player_sync/module/player_sync.lua @@ -256,7 +256,9 @@ function player_sync.deserialize_player(player, finished_record) -- An earlier raise not removed yet is replaced, the character's inventory bonus is restored with the data script_data.inventory_bonus_raises[player.name] = nil local failed, spill, inventory_bonus_raise = - serialize.deserialize_player(player, serialized_player, finished_record.components, local_controller) + serialize.deserialize_player( + player, serialized_player, finished_record.components, local_controller, finished_record.recipe_notifications + ) script_data.failed_deserialization[player.name] = failed player_record.local_controller = nil if inventory_bonus_raise and inventory_bonus_raise > 0 then @@ -741,6 +743,8 @@ function player_sync.initiate_inventory_download(player, player_record, generati recipe_notifications = serialize.serialize_crafting_notifications(player) end + -- Kept to rebuild the stored list from the delta once the player's force is restored + record.recipe_notifications = recipe_notifications clusterio_api.send_json("player_sync_download", { player_name = player.name, recipe_notifications = recipe_notifications, diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index 7a79d9ed..cab17c6d 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -863,29 +863,36 @@ function serialize.deserialize_crafting_queue(player, serialized) player.crafting_queue_progress = serialized.crafting_queue_progress end +--- @param player LuaPlayer +--- @return table enabled Recipes which can have a notification on this server +--- @return table cleared Enabled recipes without a notification +local function crafting_notification_state(player) + local enabled = {} + local cleared = {} + for _, recipe in pairs(player.force.recipes) do + if recipe.enabled and not recipe.hidden then + enabled[recipe.name] = true + cleared[recipe.name] = true + end + end + for _, recipe in pairs(player.get_recipe_notifications()) do + cleared[recipe.name] = nil + end + return enabled, cleared +end + ---@param player LuaPlayer ---@param failed string[]? ---@return string? function serialize.serialize_crafting_notifications(player, failed) - -- Get all unlocked recipes - local recipes = {} - for _, recipe in pairs(player.force.recipes) do - if recipe.enabled and not recipe.hidden then - recipes[recipe.name] = true - end - end - - -- Remove those with notifications - for _, recipe in pairs(player.get_recipe_notifications()) do - recipes[recipe.name] = nil - end + local _, cleared = crafting_notification_state(player) -- 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 + for name in pairs(cleared) do recipe_names[#recipe_names + 1] = name end @@ -893,57 +900,53 @@ function serialize.serialize_crafting_notifications(player, failed) 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 add string[]? Stored recipes missing from the cleared list sent with the download request +--- @field remove string[]? Recipes in the sent list that are not stored --- @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 +--- @param serialized string Encoded delta against the cleared list sent with the download request +--- @param sent string? That list, as sent. Without it the delta applies to the player's current list --- @return string[]? failed Recipe names that do not exist -function serialize.deserialize_crafting_notifications(player, serialized) +function serialize.deserialize_crafting_notifications(player, serialized, sent) --- @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 enabled, cleared_here = crafting_notification_state(player) - -- 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 - 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[#failed + 1] = recipe_name -- Recipe does not exist on this server - end - end - - -- 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) - 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) + -- Rebuild the stored list, then merge it against the force the player has now + local stored = {} + if not delta.full then + if sent then + local sent_names = helpers.json_to_table(assert(helpers.decode_string(sent))) + assert(type(sent_names) == "table", "wrong type decoded from json_to_table") + for _, recipe_name in pairs(sent_names) do + stored[recipe_name] = true end + else + for recipe_name in pairs(cleared_here) do + stored[recipe_name] = true + end + end + for _, recipe_name in pairs(delta.remove or {}) do + stored[recipe_name] = nil + end + end + for _, recipe_name in pairs(delta.add or {}) do + stored[recipe_name] = true + end + + local failed = {} + for recipe_name in pairs(stored) do + if not enabled[recipe_name] then + failed[#failed + 1] = recipe_name -- Recipe does not exist on this server + elseif not cleared_here[recipe_name] then + player.clear_recipe_notification(recipe_name) + end + end + for recipe_name in pairs(cleared_here) do + if not stored[recipe_name] then + player.add_recipe_notification(recipe_name) end end @@ -1287,9 +1290,10 @@ end --- @param serialized SerializedPlayerData --- @param components SyncComponents --- @param local_controller LocalControllerState? Controller to restore when the controller is not synced +--- @param sent_recipe_notifications string? Cleared recipes sent with the download request --- @return FailedDeserializationPlayerData?, table[]? Serialized stacks which did not fit, see spill_items --- @return integer? inventory_bonus_raise See raise_inventory_bonus -function serialize.deserialize_player(player, serialized, components, local_controller) +function serialize.deserialize_player(player, serialized, components, local_controller, sent_recipe_notifications) local failed_deserialization = {} local sync_controller = syncs(components, "controller") local sync_inventories = syncs(components, "inventories") @@ -1448,8 +1452,9 @@ function serialize.deserialize_player(player, serialized, components, local_cont -- Deserialize recipe notifications if recipe_notifications_api and syncs(components, "recipe_notifications") 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, sent_recipe_notifications + ) end return next(failed_deserialization) and failed_deserialization or nil, spill, inventory_bonus_raise