From b32f5416a18ab7bfd6e43630c5c2aa0cc00e759b Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sat, 17 Oct 2026 00:06:34 +1100 Subject: [PATCH] Keep main inventory slots from armor equipment when restoring a character The inventory bonus of equipment in the armor's grid (toolbelts) is applied by the engine on the tick after the armor is inserted. The main inventory was restored in the same tick, so the items of every slot past 80 plus research plus the armor's own bonus did not fit and were put in a corpse: legendary mech armor with legendary toolbelts left 2125 of 2330 slots in the corpse. The armor is now restored first, the character's inventory bonus is raised to fit the saved main inventory, and the raise is removed again on the next tick, or before an upload if the player leaves first. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../player_sync/module/get_script_data.lua | 1 + plugins/player_sync/module/player_sync.lua | 39 ++++++++++++++- plugins/player_sync/module/serialize.lua | 50 +++++++++++++++++-- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/plugins/player_sync/module/get_script_data.lua b/plugins/player_sync/module/get_script_data.lua index 52f207b7..7a1dbf8b 100644 --- a/plugins/player_sync/module/get_script_data.lua +++ b/plugins/player_sync/module/get_script_data.lua @@ -18,6 +18,7 @@ return function(no_early_return) player_sync.players_waiting_for_acquire = player_sync.players_waiting_for_acquire or {} player_sync.players_in_cutscene_to_sync = player_sync.players_in_cutscene_to_sync or {} player_sync.failed_deserialization = player_sync.failed_deserialization or {} + player_sync.inventory_bonus_raises = player_sync.inventory_bonus_raises or {} player_sync.active_downloads = player_sync.active_downloads or {} player_sync.finished_downloads = player_sync.finished_downloads or {} diff --git a/plugins/player_sync/module/player_sync.lua b/plugins/player_sync/module/player_sync.lua index 6285c8fe..6d9e7923 100644 --- a/plugins/player_sync/module/player_sync.lua +++ b/plugins/player_sync/module/player_sync.lua @@ -60,6 +60,19 @@ local function remove_player(player) script_data.players[player.name] = nil end +-- Remove the temporary inventory bonus a deserialized character got to fit its main inventory +local function remove_inventory_bonus_raise(script_data, player_name) + local raises = script_data.inventory_bonus_raises + local raise = raises and raises[player_name] + if raise then + raises[player_name] = nil + local character = raise.character + if character.valid then + character.character_inventory_slots_bonus = character.character_inventory_slots_bonus - raise.bonus + end + end +end + player_sync = {} -- This function is called by instance.js as /sc player_sync.download_inventory("danielv", Escaped JSON string, package_number, total_packages_count) with data from the controller @@ -73,6 +86,9 @@ end -- This function is called internally when a player leaves the game to -- serialize the player for upload. function player_sync.serialize_player(player, player_record) + -- Leaving on the tick of a download, the temporary bonus must not be uploaded + remove_inventory_bonus_raise(get_script_data(), player.name) + -- Editor mode is not supported. if player.controller_type == defines.controllers.editor then player.toggle_map_editor() @@ -134,10 +150,17 @@ function player_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") - local failed, spill = + local failed, spill, inventory_bonus_raise = serialize.deserialize_player(player, serialized_player, finished_record.components, local_controller) script_data.failed_deserialization[player.name] = failed player_record.local_controller = nil + if inventory_bonus_raise and inventory_bonus_raise > 0 then + script_data.inventory_bonus_raises[player.name] = { + character = player.character, + bonus = inventory_bonus_raise, + tick = game.tick, + } + end -- Restore player position and driving state restore_position(player, finished_record) @@ -396,6 +419,20 @@ player_sync.events[defines.events.on_player_joined_game] = function(event) script_data.active_uploads[player.name] = nil end +-- The inventory bonus of armor equipment applies on the tick after a download, the temporary bonus is no longer needed +player_sync.events[defines.events.on_tick] = function(event) + local script_data = get_script_data() + local raises = script_data.inventory_bonus_raises + if not raises or next(raises) == nil then + return + end + for player_name, raise in pairs(raises) do + if raise.tick < event.tick then + remove_inventory_bonus_raise(script_data, player_name) + end + end +end + player_sync.on_nth_tick = {} player_sync.on_nth_tick[33] = function(event) -- Periodically check players currently in the process of syncing diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index 7f9b3b2a..10bc518b 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -73,6 +73,23 @@ function serialize.deserialize_character(character, serialized) end end +--- Index of the last slot used by a serialized inventory +local function last_slot_index(serialized) + local last = 0 + for _, entry in ipairs(serialized.i) do + last = (entry.s or last + 1) + (entry.r or 0) + end + return last +end + +--- Raise the inventory bonus of a character with its armor restored to fit a serialized main inventory +--- @return integer raise Slots added to character_inventory_slots_bonus, to be removed again on the next tick +local function raise_inventory_bonus(character, main) + local raise = math.max(0, last_slot_index(main) - #character.get_main_inventory()) + character.character_inventory_slots_bonus = character.character_inventory_slots_bonus + raise + return raise +end + --- Restore the armor grid state, the grid exists after the armor inventory is restored function serialize.deserialize_character_grid(character, serialized) local grid = character.grid @@ -190,15 +207,18 @@ end --- @param destinations table --- @param inventories table? --- @param items { name: string, count: integer, quality: string? }[]? +--- @param after_armor fun()? Called once the armor is restored, before the other inventories --- @return table[] leftovers -function serialize.place_items(destinations, inventories, items) +function serialize.place_items(destinations, inventories, items, after_armor) for _, destination in pairs(destinations) do destination.clear() end - -- Every inventory is restored before inserting into them, as restoring a slot replaces what is in it + -- Every inventory is restored before inserting into them, as restoring a slot replaces what is in it. + -- The armor first, its inventory bonus changes the size of the main inventory. + inventories = inventories or {} local pending = {} - for name, serialized in pairs(inventories or {}) do + local function restore(name, serialized) local destination = destinations[name] local fitting, rest = split_inventory(serialized, destination and #destination or 0) if destination then @@ -208,6 +228,17 @@ function serialize.place_items(destinations, inventories, items) table.insert(pending, { from = destination, stack = stack }) end end + if inventories.armor then + restore("armor", inventories.armor) + end + if after_armor then + after_armor() + end + for name, serialized in pairs(inventories) do + if name ~= "armor" then + restore(name, serialized) + end + end for _, item in pairs(items or {}) do table.insert(pending, { stack = { n = item.name, c = item.count, q = item.quality } }) end @@ -1226,6 +1257,7 @@ end --- @param components SyncComponents --- @param local_controller LocalControllerState? Controller to restore when the controller is not synced --- @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) local failed_deserialization = {} local sync_controller = syncs(components, "controller") @@ -1347,14 +1379,22 @@ function serialize.deserialize_player(player, serialized, components, local_cont -- Deserialize items, a ghost got them while it had a character above local character = player.character + local inventory_bonus_raise if place and not to_ghost then local stats = character and items.character if stats then serialize.deserialize_character(character, stats) end local craft = character and queue + -- The bonus of the armor's equipment (toolbelts) only applies on the next tick, so once the armor is + -- restored the main inventory is raised to fit the data spill = serialize.place_items( - serialize.item_destinations(player), inventories, not craft and queue and queue.ingredients or nil + serialize.item_destinations(player), inventories, not craft and queue and queue.ingredients or nil, + function() + if stats and inventories.main then + inventory_bonus_raise = raise_inventory_bonus(character, inventories.main) + end + end ) if stats then serialize.deserialize_character_grid(character, stats) @@ -1378,7 +1418,7 @@ function serialize.deserialize_player(player, serialized, components, local_cont serialize.deserialize_crafting_notifications(player, serialized.recipe_notifications) end - return next(failed_deserialization) and failed_deserialization or nil, spill + return next(failed_deserialization) and failed_deserialization or nil, spill, inventory_bonus_raise end return serialize