From b32f5416a18ab7bfd6e43630c5c2aa0cc00e759b Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sat, 17 Oct 2026 00:06:34 +1100 Subject: [PATCH 1/3] 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 From 4228b405624e5a0aa377e2516aeafe1e0a08eeb7 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sat, 17 Oct 2026 19:21:12 +1100 Subject: [PATCH 2/3] Remove the inventory bonus raise on the armor inventory event on_player_armor_inventory_changed for the restored armor is raised at the end of the download's tick, after the inventory bonus of the armor's equipment applied, so the raise is removed there instead of in the next tick's on_tick. The raise is only made for armor with a grid, as the event needs the armor to be restored. A player leaving on the tick of a download no longer has the raise removed from the character first, which shrank the main inventory before the bonus applied and spilled the extra slots. The raise is taken off the uploaded inventory bonus instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/player_sync/module/player_sync.lua | 31 +++++++++++----------- plugins/player_sync/module/serialize.lua | 9 ++++--- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/plugins/player_sync/module/player_sync.lua b/plugins/player_sync/module/player_sync.lua index 6d9e7923..1569ae9f 100644 --- a/plugins/player_sync/module/player_sync.lua +++ b/plugins/player_sync/module/player_sync.lua @@ -86,9 +86,6 @@ 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() @@ -116,6 +113,15 @@ function player_sync.serialize_player(player, player_record) local serialized_player = serialize.serialize_player(player, failed_deserialization, player_record.components) serialized_player.generation = player_record.generation + -- Leaving on the tick of a download the temporary bonus is still there and must not be uploaded. Removing it + -- from the character here would shrink the main inventory before the armor's bonus applies and spill items. + local raises = get_script_data().inventory_bonus_raises + local raise = raises and raises[player.name] + local character = serialized_player.character + if raise and character and raise.character == player.character then + character.character_inventory_slots_bonus = character.character_inventory_slots_bonus - raise.bonus + end + return serialized_player end @@ -150,6 +156,8 @@ 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") + -- 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) script_data.failed_deserialization[player.name] = failed @@ -158,7 +166,6 @@ function player_sync.deserialize_player(player, finished_record) script_data.inventory_bonus_raises[player.name] = { character = player.character, bonus = inventory_bonus_raise, - tick = game.tick, } end @@ -419,18 +426,10 @@ 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 +-- Raised for a restored armor after its equipment's inventory bonus applied, the temporary bonus is no longer needed +player_sync.events[defines.events.on_player_armor_inventory_changed] = function(event) + local player = assert(game.get_player(event.player_index)) + remove_inventory_bonus_raise(get_script_data(), player.name) end player_sync.on_nth_tick = {} diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index 10bc518b..fd27b7fa 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -83,7 +83,7 @@ local function last_slot_index(serialized) 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 +--- @return integer raise Slots added to character_inventory_slots_bonus, removed again once the armor's bonus applies 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 @@ -1386,12 +1386,13 @@ function serialize.deserialize_player(player, serialized, components, local_cont 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 + -- The bonus of the armor's equipment (toolbelts) only applies at the end of the tick, so once the armor is + -- restored the main inventory is raised to fit the data. Only for armor with a grid, restoring it raises + -- on_player_armor_inventory_changed which removes the raise. spill = serialize.place_items( serialize.item_destinations(player), inventories, not craft and queue and queue.ingredients or nil, function() - if stats and inventories.main then + if stats and inventories.main and character and character.grid then inventory_bonus_raise = raise_inventory_bonus(character, inventories.main) end end From 916b57b37c1c9cf210af3f5116bad34660b33a2e Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sun, 18 Oct 2026 23:19:20 +1100 Subject: [PATCH 3/3] Raise the main inventory only by the armor equipment's bonus The raise covered the whole gap between the serialized main inventory and the main inventory with the armor restored, but only the bonus of the armor's equipment arrives at the end of the tick. When the destination force has a smaller character_inventory_slots_bonus than the source, the rest of the gap never arrived and removing the raise on the armor event dropped those slots on the ground. The raise is now the equipment's inventory bonus minus what the grid already applies, at most the gap, and the slots past it overflow into a corpse as without the raise. Factorio 1.1 has no inventory bonus equipment and gets no raise. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/player_sync/module/serialize.lua | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index fd27b7fa..368766a6 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -10,6 +10,7 @@ local v2_storage_api = compat.version_ge("2.0.0") local v2_remote_controller = compat.version_ge("2.0.0") local v2_space_platform = compat.version_ge("2.0.0") local v2_exit_remote_view = compat.version_ge("2.0.56") +local v2_inventory_bonus_equipment = compat.version_ge("2.0.0") local recipe_notifications_api = compat.version_ge("2.0.67") local v2_0_quick_bar_api = compat.version_ge("2.0.0") local v2_1_quick_bar_api = compat.version_ge("2.1.0") @@ -82,10 +83,17 @@ local function last_slot_index(serialized) return last end ---- Raise the inventory bonus of a character with its armor restored to fit a serialized main inventory +--- Raise the inventory bonus of a character with its armor restored by the bonus of the armor's equipment which is +--- still to come, as far as a serialized main inventory needs it --- @return integer raise Slots added to character_inventory_slots_bonus, removed again once the armor's bonus applies local function raise_inventory_bonus(character, main) - local raise = math.max(0, last_slot_index(main) - #character.get_main_inventory()) + local grid = character.grid --[[@as LuaEquipmentGrid]] + local equipment_bonus = 0 + for _, equipment in pairs(grid.equipment) do + equipment_bonus = equipment_bonus + equipment.inventory_bonus + end + local delayed = equipment_bonus - grid.inventory_bonus + local raise = math.max(0, math.min(last_slot_index(main) - #character.get_main_inventory(), delayed)) character.character_inventory_slots_bonus = character.character_inventory_slots_bonus + raise return raise end @@ -1387,12 +1395,13 @@ function serialize.deserialize_player(player, serialized, components, local_cont end local craft = character and queue -- The bonus of the armor's equipment (toolbelts) only applies at the end of the tick, so once the armor is - -- restored the main inventory is raised to fit the data. Only for armor with a grid, restoring it raises + -- restored the main inventory is raised by the part of that bonus still to come. Slots past it overflow and + -- are left over as without the raise. Only for armor with a grid, restoring it raises -- on_player_armor_inventory_changed which removes the raise. spill = serialize.place_items( serialize.item_destinations(player), inventories, not craft and queue and queue.ingredients or nil, function() - if stats and inventories.main and character and character.grid then + if v2_inventory_bonus_equipment and stats and inventories.main and character and character.grid then inventory_bonus_raise = raise_inventory_bonus(character, inventories.main) end end