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

+6 -5
View File
@@ -57,6 +57,7 @@ Instances can leave parts of the player out of the sync with the `player_sync.sy
A part which is not synced is left as it is when the player joins, and the instance's copy of it is not uploaded, so the stored copy from other instances stays as it was.
A player online when the options change keeps the old ones until they leave and join again, and a part that was turned on is downloaded when they join.
Turning off `player_sync.sync_inventories` leaves the items players have on the instance in place while the stored inventory still holds them too.
Items follow the player to whatever controller they end up with, a character, god or ghost, and what does not fit is put into a corpse below them.
Communication between the server and instance goes over stdout or rcon, depending on the size of the data.
Communication between the instance and the controller goes over websockets.
@@ -85,8 +86,7 @@ Defaults to `1000`.
### player_sync.sync_controller
Sync whether the player is a character, god or spectator, their respawn timer, cheat mode, the god inventory and the inventory filters of a dead player.
Switching controller can destroy the character holding the inventory, so this only takes effect while `player_sync.sync_inventories` is also enabled.
Sync whether the player is a character, god or spectator, their respawn timer and cheat mode.
Defaults to `true`.
@@ -104,9 +104,10 @@ Defaults to `true`.
### player_sync.sync_inventories
Sync the character inventories, the character bonuses and the crafting queue.
While the controller is not synced a player without a character keeps what they have on the instance and does not change the stored character.
A player who died on the instance uploads the character they will respawn with, and a player waiting to respawn when joining is respawned straight away if there is a synced character to give them.
Sync the items the player holds, the character bonuses and the crafting queue.
This covers the character inventories, the god inventory and the items a dead player will respawn with.
A spectator holds no items, so the synced items are put into a corpse when the player is a spectator on the instance.
A player without a character gets the ingredients of the crafting queue as items instead.
Defaults to `true`.
+7 -26
View File
@@ -3,11 +3,11 @@ import type { IpcPlayerData } from "./messages.js";
/** Parts of the player data an instance can choose to sync, and the fields of the player data holding them. */
export const components = {
// inventories holds the god inventory and the hidden ghost inventory, both belong to the controller
controller: ["controller", "ticks_to_respawn", "cheat_mode", "inventories"],
controller: ["controller", "ticks_to_respawn", "cheat_mode"],
force: ["force"],
appearance: ["color", "chat_color", "tag"],
inventories: ["character", "crafting_queue"],
// inventories holds the god inventory and the hidden ghost inventory
inventories: ["character", "crafting_queue", "inventories"],
logistics: ["personal_logistic_slots"],
quick_bar: ["quick_bar", "hotbar"],
settings: ["flashlight", "shortcuts", "game_view_settings"],
@@ -16,26 +16,8 @@ export const components = {
export type Component = keyof typeof components;
// Fields only a player with a character has
const characterFields = ["character", "crafting_queue", "personal_logistic_slots"] as const;
/**
* Build the set of synced components.
*
* The controller is only synced together with the inventories, as
* switching controller can destroy the character holding them.
*/
function componentSet(isEnabled: (component: Component) => boolean) {
const enabled = new Set<Component>();
for (const component of Object.keys(components) as Component[]) {
if (isEnabled(component)) {
enabled.add(component);
}
}
if (!enabled.has("inventories")) {
enabled.delete("controller");
}
return enabled;
return new Set((Object.keys(components) as Component[]).filter(isEnabled));
}
/** Components synced on an instance. */
@@ -72,8 +54,7 @@ export function stripPlayerData(playerData: IpcPlayerData, enabled: Set<Componen
* Combine data uploaded by an instance with the stored data.
*
* Components the upload was not serialized with keep the stored fields.
* Without the controller synced character fields are only taken from a
* player which has a character.
* Logistic requests are kept when the player has nowhere to hold them.
*/
export function mergePlayerData(stored: IpcPlayerData | undefined, uploaded: IpcPlayerData) {
const { components: uploadComponents, ...merged } = uploaded as IpcPlayerData & Record<string, unknown>;
@@ -94,8 +75,8 @@ export function mergePlayerData(stored: IpcPlayerData | undefined, uploaded: Ipc
keep(keys);
}
}
if (!enabled.has("controller") && !uploaded.character) {
keep(characterFields);
if (!uploaded.character && !uploaded.personal_logistic_slots) {
keep(["personal_logistic_slots"]);
}
return merged as IpcPlayerData;
}
+4 -6
View File
@@ -132,12 +132,14 @@ 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")
script_data.failed_deserialization[player.name] =
local failed, spill =
serialize.deserialize_player(player, serialized_player, finished_record.components, local_controller)
script_data.failed_deserialization[player.name] = failed
player_record.local_controller = nil
-- Restore player position and driving state
restore_position(player, finished_record)
serialize.spill_items(player, spill)
-- Transfer items from stashed inventory
if stashed_corpse then
@@ -495,11 +497,7 @@ function player_sync.initiate_inventory_download(player, player_record, generati
-- If this is a synced player turn them into a spectator while the
-- player data is downloading
-- Without the controller synced a player without a character has nothing synced to protect
local components = record.components
if player_record.sync and serialize.syncs(components, "inventories")
and (serialize.syncs(components, "controller") or player.character)
then
if player_record.sync and serialize.syncs(record.components, "inventories") then
-- Store original position to teleport back to
if v2_remote_controller then
record.surface = player.physical_surface
+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
+22 -16
View File
@@ -51,9 +51,9 @@ describe("player_sync components", function() {
assert.equal(enabled.has("quick_bar"), false);
assert.equal(enabled.size, allComponents.length - 2);
});
it("should not sync the controller without the inventories", function() {
it("should sync the controller without the inventories", function() {
const enabled = enabledComponents(configWith(["inventories"]));
assert.equal(enabled.has("controller"), false);
assert.equal(enabled.has("controller"), true);
});
});
@@ -71,12 +71,11 @@ describe("player_sync components", function() {
}
assert.equal(playerData.force, "enemy");
});
it("should remove the god inventory with the controller", function() {
it("should remove the god inventory with the inventories", function() {
const playerData = structuredClone(stored);
stripPlayerData(playerData, new Set(allComponents.filter(c => c !== "controller")));
stripPlayerData(playerData, new Set(allComponents.filter(c => c !== "inventories")));
assert.equal("inventories" in playerData, false);
assert.equal("controller" in playerData, false);
assert.equal(playerData.tag, "stored");
assert.equal(playerData.controller, "god");
});
});
@@ -91,8 +90,8 @@ describe("player_sync components", function() {
it("should sync nothing for an empty table encoded as an array", function() {
assert.equal(uploadedComponents([]).size, 0);
});
it("should not sync the controller without the inventories", function() {
assert.equal(uploadedComponents({ controller: true }).has("controller"), false);
it("should sync the controller without the inventories", function() {
assert.deepEqual([...uploadedComponents({ controller: true })], ["controller"]);
});
});
@@ -121,26 +120,33 @@ describe("player_sync components", function() {
assert.equal("force" in merged, false);
assert.equal(merged.tag, "uploaded");
});
it("should take the character but not the controller when the controller is not synced", function() {
it("should take the items but not the controller when the controller is not synced", function() {
const merged = mergePlayerData(stored, withComponents(uploaded, ["controller"]));
assert.equal(merged.controller, "god");
assert.equal(merged.cheat_mode, true);
assert.equal(merged.character, character);
assert.deepEqual(merged.inventories, { main: ["stored god item"] }, "god inventory is kept");
assert.equal("inventories" in merged, false, "the god inventory moved to the character");
});
it("should keep the character data for a player without one when the controller is not synced", function() {
it("should take the items of a player without a character", function() {
const storedCharacter = {
...stored, character, crafting_queue: ["stored"], personal_logistic_slots: [{ name: "stored" }],
};
const god = { ...uploaded, controller: "god", inventories: { main: ["new god item"] } };
delete god.character;
delete god.crafting_queue;
god.personal_logistic_slots = [{ name: "uploaded" }];
delete god.personal_logistic_slots;
const merged = mergePlayerData(storedCharacter, withComponents(god, ["controller"]));
assert.equal(merged.character, character);
assert.deepEqual(merged.crafting_queue, ["stored"]);
assert.deepEqual(merged.personal_logistic_slots, [{ name: "stored" }], "logistics stay with the character");
assert.deepEqual(merged.inventories, { main: ["stored god item"] });
assert.equal("character" in merged, false);
assert.equal("crafting_queue" in merged, false);
assert.deepEqual(merged.inventories, { main: ["new god item"] });
assert.deepEqual(merged.personal_logistic_slots, [{ name: "stored" }], "nowhere to hold logistics");
});
it("should drop logistic requests a character uploaded without", function() {
const storedSlots = { ...stored, personal_logistic_slots: [{ name: "stored" }] };
const noSlots = { ...uploaded };
delete noSlots.personal_logistic_slots;
const merged = mergePlayerData(storedSlots, withComponents(noSlots, []));
assert.equal("personal_logistic_slots" in merged, false);
});
});
});