Move items to whatever controller the player ends up with

Syncing the controller required syncing the inventories, and with the controller off a god or ghost kept its local items while the stored character was left alone. Cooldude pointed out there is no reason the items can't follow the player to a different controller.

The controller and inventories components are now independent. The god inventory and the hidden ghost inventory belong to the inventories component. On download the scenario first settles the controller, synced or local, and then places the stored items into whatever that controller holds. An inventory goes slot for slot into the inventory of the same name when it fits, otherwise its items are inserted into the main, guns, ammo and armor inventories, and anything left goes into a corpse below the player. A spectator holds nothing, so everything goes into the corpse. A player without a character gets the crafting queue ingredients as items.

This removes the early respawn of a ghost with a stored character and the upload of the respawned character, a ghost now gets and uploads its hidden inventory. Download spectates every synced player when the inventories are synced, as a god or ghost now holds synced items too. The controller keeps the stored logistic requests when the upload came from a player with nowhere to hold them.

Slot filters are dropped when the destination does not support them, such as a script inventory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-10-17 15:25:06 +00:00
1 parent f3d67ed7d9
commit aa06b9afe3
5 files changed
+256 -100

No files matched your search

+217 -47
View File
@@ -27,16 +27,6 @@ function serialize.serialize_inventories(source, inventories)
return serialized
end
function serialize.deserialize_inventories(destination, serialized, inventories)
for name, index in pairs(inventories) do
local inventory = destination.get_inventory(index)
if inventory ~= nil and serialized[name] ~= nil then
inventory.clear()
clusterio_serialize.deserialize_inventory(inventory, serialized[name])
end
end
end
-- Characters are serialized into a table with the following fields:
-- character_crafting_speed_modifier
-- character_mining_speed_modifier
@@ -76,22 +66,198 @@ function serialize.serialize_character(character)
return serialized
end
--- Restore the stats of a character, the inventories are restored with place_items
function serialize.deserialize_character(character, serialized)
-- Deserialize character stats
for _, key in pairs(character_stat_keys) do
character[key] = serialized[key]
end
end
-- Deserialize character inventories
serialize.deserialize_inventories(character, serialized.inventories, character_inventories)
-- Deserialize armor grid state, the grid exists after the armor inventory is restored
--- 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
if grid and serialized.inhibit_movement_bonus ~= nil then
grid.inhibit_movement_bonus = serialized.inhibit_movement_bonus
end
end
-- Inventories by the names used in serialized data in the order they are filled, armor first as it adds slots to main
local fill_order = { "armor", "guns", "ammo", "main", "trash" }
-- Inventories items which do not fit into the inventory they came from are inserted into, in order
local overflow_order = { "main", "guns", "ammo", "armor" }
--- Number of slots a serialized inventory uses
--- @param serialized table
--- @return number
local function serialized_size(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
--- Copy of a serialized inventory without slot filters, for inventories which do not support them
--- @param serialized table
--- @return table
local function without_filters(serialized)
local copy = { i = {} }
for _, entry in ipairs(serialized.i) do
local item = {}
for key, value in pairs(entry) do
item[key] = value
end
item.f = nil
table.insert(copy.i, item)
end
return copy
end
--- Inventories the items of a player go into, by the names used in serialized data
--- @param player LuaPlayer
--- @return table<string, LuaInventory>
function serialize.item_destinations(player)
local destinations = {}
local character = player.character
if character then
for name, index in pairs(character_inventories) do
destinations[name] = character.get_inventory(index)
end
elseif player.controller_type == defines.controllers.god
or v2_remote_controller and player.physical_controller_type == defines.controllers.god
then
destinations.main = player.get_inventory(defines.inventory.god_main) --[[@as LuaInventory]]
end
return destinations
end
--- Insert as much of a stack as possible, starting with the inventory it came from
--- @param stack LuaItemStack
--- @param destinations table<string, LuaInventory>
--- @param from string?
local function insert_stack(stack, destinations, from)
local function insert(destination)
if destination and stack.valid_for_read then
local inserted = destination.insert(stack)
if inserted >= stack.count then
stack.clear()
elseif inserted > 0 then
stack.count = stack.count - inserted
end
end
end
if from then
insert(destinations[from])
end
for _, name in ipairs(overflow_order) do
insert(destinations[name])
end
end
--- Replace the items in destinations with the serialized inventories and loose items
---
--- Each inventory goes slot for slot into the destination of the same name when it fits, otherwise its items are
--- inserted where there is space. Returns a script inventory with what did not fit, to be put into a corpse.
--- @param destinations table<string, LuaInventory>
--- @param inventories table<string, table>?
--- @param items { name: string, count: integer, quality: string? }[]?
--- @return LuaInventory?
function serialize.place_items(destinations, inventories, items)
for _, destination in pairs(destinations) do
destination.clear()
end
local leftovers = {}
for _, name in ipairs(fill_order) do
local serialized = inventories and inventories[name]
local size = serialized and serialized_size(serialized) or 0
local destination = destinations[name]
if serialized and destination and size <= #destination then
if not destination.supports_filters() then
serialized = without_filters(serialized)
end
clusterio_serialize.deserialize_inventory(destination, serialized)
elseif serialized and size > 0 then
-- Slot filters belong to the slot they were set on
local inventory = game.create_inventory(size)
clusterio_serialize.deserialize_inventory(inventory, without_filters(serialized))
table.insert(leftovers, { from = name, inventory = inventory })
end
end
if items and next(items) then
local inventory = game.create_inventory(#items)
for _, item in pairs(items) do
local remaining = item.count
while remaining > 0 do
local inserted = inventory.insert({ name = item.name, count = remaining, quality = item.quality })
remaining = remaining - inserted
if remaining > 0 then
if inserted == 0 and not inventory.is_full() then
log("ERROR: Unable to place " .. remaining .. " " .. item.name .. ", voiding it")
break
end
inventory.resize(#inventory * 2)
end
end
end
table.insert(leftovers, { inventory = inventory })
end
local spill
local spilled = 0
for _, leftover in ipairs(leftovers) do
local inventory = leftover.inventory
for i = 1, #inventory do
local stack = inventory[i]
if stack.valid_for_read then
insert_stack(stack, destinations, leftover.from)
end
if stack.valid_for_read then
spill = spill or game.create_inventory(1)
if spilled == #spill then
spill.resize(#spill * 2)
end
spilled = spilled + 1
spill[spilled].transfer_stack(stack)
end
end
inventory.destroy()
end
return spill
end
--- Put items which did not fit into a corpse below the player
--- @param player LuaPlayer
--- @param spill LuaInventory?
function serialize.spill_items(player, spill)
if not spill then
return
end
spill.sort_and_merge()
local count = #spill - spill.count_empty_stacks()
local corpse = player.surface.create_entity({
name = "character-corpse",
position = player.position,
inventory_size = count,
player_index = player.index,
})
if corpse then
local inventory = assert(corpse.get_inventory(defines.inventory.character_corpse))
for i = 1, #spill do
if spill[i].valid_for_read then
assert(inventory.find_empty_stack()).transfer_stack(spill[i])
end
end
player.print(
"Some of your synced items do not fit into your inventory here and have been placed in a corpse below you."
)
else
log("ERROR: Unable to create a corpse for " .. player.name .. ", voiding " .. count .. " stacks of items")
end
spill.destroy()
end
-- Personal logistic slots is a table mapping string indexes to a table with the following fields:
-- name
-- min
@@ -857,18 +1023,17 @@ function serialize.serialize_player(player, failed_deserialization, components)
end
-- For the waiting to respawn state the inventory logistic requests and filters are hidden on the player
if sync_inventories and player.controller_type == defines.controllers.ghost and player.ticks_to_respawn then
if (sync_inventories or sync_logistics)
and player.controller_type == defines.controllers.ghost and player.ticks_to_respawn
then
local ticks_to_respawn = player.ticks_to_respawn
player.ticks_to_respawn = nil -- Respawn now
if sync_logistics then
serialized.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player)
end
if sync_controller then
if sync_inventories then
serialized.inventories = serialize.serialize_inventories(player, character_inventories)
elseif player.character then
-- Without the controller the death is synced by uploading the respawned character
serialized.character = serialize.serialize_character(player.character)
end
-- Go back to waiting for respawn
@@ -890,7 +1055,7 @@ function serialize.serialize_player(player, failed_deserialization, components)
end
-- Serialize non-character inventories
if sync_controller and sync_inventories and (
if sync_inventories and (
player.controller_type == defines.controllers.god
or v2_remote_controller and player.physical_controller_type == defines.controllers.god
) then
@@ -994,7 +1159,7 @@ end
--- @param serialized SerializedPlayerData
--- @param components SyncComponents
--- @param local_controller LocalControllerState? Controller to restore when the controller is not synced
--- @return FailedDeserializationPlayerData?
--- @return FailedDeserializationPlayerData?, LuaInventory? Items which did not fit, see spill_items
function serialize.deserialize_player(player, serialized, components, local_controller)
local failed_deserialization = {}
local sync_controller = syncs(components, "controller")
@@ -1008,15 +1173,18 @@ function serialize.deserialize_player(player, serialized, components, local_cont
local state = local_controller
if restore_controller then
state = { controller = serialized.controller, ticks_to_respawn = serialized.ticks_to_respawn }
elseif state and state.controller == "ghost" and sync_inventories and serialized.character then
-- Respawn now, a ghost would drop the synced character and upload an empty one after respawning
state = { controller = "character" }
end
-- Items can come from a character, a god or a ghost and go to whatever controller the player ends up with
local inventories = serialized.character and serialized.character.inventories or serialized.inventories
local queue = serialized.crafting_queue
local spill
local target_controller = state and defines.controllers[state.controller]
local to_ghost = state and state.controller == "ghost"
if state and (
player.controller_type ~= target_controller
or restore_controller and state.controller == "ghost"
or to_ghost and (restore_controller or sync_inventories or sync_logistics)
) then
if state.controller == "character" then
-- Create a character but do not destroy an existing one
@@ -1030,11 +1198,13 @@ function serialize.deserialize_player(player, serialized, components, local_cont
-- The force first, so the character and the logistic groups are on the restored player's force
player.force = serialized.force
local character = ensure_character(player)
if restore_controller and sync_logistics and serialized.personal_logistic_slots then
if sync_logistics and serialized.personal_logistic_slots then
serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots)
end
if restore_controller and sync_inventories and serialized.inventories then
serialize.deserialize_inventories(player, serialized.inventories, character_inventories)
if sync_inventories then
spill = serialize.place_items(
serialize.item_destinations(player), inventories, queue and queue.ingredients
)
end
if state.ticks_to_respawn then
player.ticks_to_respawn = state.ticks_to_respawn
@@ -1089,21 +1259,26 @@ function serialize.deserialize_player(player, serialized, components, local_cont
end
end
-- Deserialize character, the stored player may not have had one when the controller is not synced
if player.character then
if sync_inventories and serialized.character then
serialize.deserialize_character(player.character, serialized.character)
-- Deserialize items, a ghost got them while it had a character above
local character = player.character
if sync_inventories and not to_ghost then
local stats = character and serialized.character
if stats then
serialize.deserialize_character(character, stats)
end
if sync_logistics then
serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots)
local craft = character and queue
spill = serialize.place_items(
serialize.item_destinations(player), inventories, not craft and queue and queue.ingredients or nil
)
if stats then
serialize.deserialize_character_grid(character, stats)
end
if craft then
serialize.deserialize_crafting_queue(player, queue)
end
end
-- Deserialize non-character inventories
if restore_controller and sync_inventories and serialized.inventories
and player.controller_type == defines.controllers.god
then
serialize.deserialize_inventories(player, serialized.inventories, { main = defines.inventory.god_main })
if character and sync_logistics then
serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots)
end
-- Deserialize quick bar (named hotbar in old data)
@@ -1111,18 +1286,13 @@ function serialize.deserialize_player(player, serialized, components, local_cont
serialize.deserialize_quick_bar(player, serialized.quick_bar or serialized.hotbar)
end
-- Deserialize crafting queue
if sync_inventories and player.character and serialized.crafting_queue then
serialize.deserialize_crafting_queue(player, serialized.crafting_queue)
end
-- 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)
end
return next(failed_deserialization) and failed_deserialization or nil
return next(failed_deserialization) and failed_deserialization or nil, spill
end
return serialize