Allow inventory sync components to be turned off per instance

Adds an inventory_sync.sync_<component> instance config field for the
controller, force, appearance, inventories, logistics, quick bar,
settings and recipe notifications. All default to on, which keeps the
current behaviour.

A component which is off on an instance is left alone there and its
fields are not stored from that instance. The controller merges each
upload with the stored data using the uploading instance's config, the
instance strips the fields from downloads before sending them over RCON,
and the scenario is told the enabled set on start and when it changes so
it can skip serializing them and skip the steps which replace the
character.

The controller is only synced together with the inventories, as switching
controller can destroy the character holding them. Without the controller
synced the player gets back the controller they had on the instance, only
character inventories move between instances, and a player without a
character keeps their inventory.

Closes #968

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 e810020db5
commit 0e559cf000
11 files changed
+571 -81

No files matched your search

+149 -63
View File
@@ -720,16 +720,16 @@ end
--- @class SerializedPlayerData
--- @field generation number
--- @field name string
--- @field controller string
--- @field color Color
--- @field chat_color Color
--- @field tag string
--- @field force string
--- @field cheat_mode boolean
--- @field flashlight boolean
--- @field controller string?
--- @field color Color?
--- @field chat_color Color?
--- @field tag string?
--- @field force string?
--- @field cheat_mode boolean?
--- @field flashlight boolean?
--- @field shortcuts table<string, boolean>?
--- @field game_view_settings table<string, boolean>?
--- @field ticks_to_respawn number
--- @field ticks_to_respawn number?
--- @field character table<string, any>?
--- @field inventories table<string, table>?
--- @field hotbar table<string, string>?
@@ -784,37 +784,71 @@ function serialize.deserialize_game_view_settings(player, serialized)
end
end
--- Components which are synced, nil when all of them are
--- @alias SyncComponents table<string, boolean>?
--- @param components SyncComponents
--- @param name string
--- @return boolean
local function syncs(components, name)
return components == nil or components[name] == true
end
serialize.syncs = syncs
--- @param player LuaPlayer
--- @param failed_deserialization FailedDeserializationPlayerData
--- @param components SyncComponents
--- @return SerializedPlayerData
function serialize.serialize_player(player, failed_deserialization)
function serialize.serialize_player(player, failed_deserialization, components)
local sync_controller = syncs(components, "controller")
local sync_inventories = syncs(components, "inventories")
local sync_logistics = syncs(components, "logistics")
--- @type SerializedPlayerData
local serialized = {
generation = 0, -- Gets replaced later
controller = controller_to_name[player.controller_type],
name = player.name,
color = player.color,
chat_color = player.chat_color,
tag = player.tag,
force = player.force.name,
cheat_mode = player.cheat_mode,
flashlight = player.is_flashlight_enabled(),
shortcuts = serialize.serialize_shortcuts(player),
game_view_settings = serialize.serialize_game_view_settings(player),
ticks_to_respawn = player.ticks_to_respawn,
}
-- In 2.0 we want to sync the physical controller to ignore remote view
if v2_remote_controller then
serialized.controller = controller_to_name[player.physical_controller_type]
if sync_controller then
serialized.controller = controller_to_name[player.controller_type]
serialized.cheat_mode = player.cheat_mode
serialized.ticks_to_respawn = player.ticks_to_respawn
-- In 2.0 we want to sync the physical controller to ignore remote view
if v2_remote_controller then
serialized.controller = controller_to_name[player.physical_controller_type]
end
end
if syncs(components, "appearance") then
serialized.color = player.color
serialized.chat_color = player.chat_color
serialized.tag = player.tag
end
if syncs(components, "force") then
serialized.force = player.force.name
end
if syncs(components, "settings") then
serialized.flashlight = player.is_flashlight_enabled()
serialized.shortcuts = serialize.serialize_shortcuts(player)
serialized.game_view_settings = serialize.serialize_game_view_settings(player)
end
-- For the waiting to respawn state the inventory logistic requests and filters are hidden on the player
if player.controller_type == defines.controllers.ghost and player.ticks_to_respawn then
if sync_controller and (sync_inventories or sync_logistics)
and player.controller_type == defines.controllers.ghost and player.ticks_to_respawn
then
player.ticks_to_respawn = nil -- Respawn now
serialized.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player)
serialized.inventories = serialize.serialize_inventories(player, character_inventories)
if sync_logistics then
serialized.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player)
end
if sync_inventories then
serialized.inventories = serialize.serialize_inventories(player, character_inventories)
end
-- Go back to waiting for respawn
local character = player.character
@@ -826,33 +860,55 @@ function serialize.serialize_player(player, failed_deserialization)
-- Serialize character
if player.character then
serialized.character = serialize.serialize_character(player.character)
serialized.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player)
if sync_inventories then
serialized.character = serialize.serialize_character(player.character)
end
if sync_logistics then
serialized.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player)
end
end
-- Serialize non-character inventories
if player.controller_type == defines.controllers.god
or v2_remote_controller and player.physical_controller_type == defines.controllers.god
then
if sync_controller and sync_inventories and (
player.controller_type == defines.controllers.god
or v2_remote_controller and player.physical_controller_type == defines.controllers.god
) then
serialized.inventories = serialize.serialize_inventories(player, { main = defines.inventory.god_main })
end
-- Serialize quick bar
serialized.quick_bar = serialize.serialize_quick_bar(player)
if syncs(components, "quick_bar") then
serialized.quick_bar = serialize.serialize_quick_bar(player)
end
-- Serialize crafting queue
if player.character then
if sync_inventories and player.character then
serialized.crafting_queue = serialize.serialize_crafting_queue(player)
end
-- Serialize recipe notifications
if recipe_notifications_api then
if recipe_notifications_api and syncs(components, "recipe_notifications") then
serialized.recipe_notifications = serialize.serialize_crafting_notifications(player, failed_deserialization.recipe_notifications)
end
return serialized
end
--- The controller a player has on this instance, restored in place of the synced one when it is not synced
--- @class LocalControllerState
--- @field controller string?
--- @field ticks_to_respawn number?
--- @param player LuaPlayer
--- @return LocalControllerState
function serialize.serialize_local_controller(player)
local controller_type = v2_remote_controller and player.physical_controller_type or player.controller_type
return {
controller = controller_to_name[controller_type],
ticks_to_respawn = player.ticks_to_respawn,
}
end
--- Find a surface characters can exist on, used when the player is on a space platform surface
--- @param platform LuaSpacePlatform
--- @return LuaSurface
@@ -915,32 +971,46 @@ end
--- @param player LuaPlayer
--- @param serialized SerializedPlayerData
--- @param components SyncComponents
--- @param local_controller LocalControllerState? Controller to restore when the controller is not synced
--- @return FailedDeserializationPlayerData?
function serialize.deserialize_player(player, serialized)
function serialize.deserialize_player(player, serialized, components, local_controller)
local failed_deserialization = {}
local sync_controller = syncs(components, "controller")
local sync_inventories = syncs(components, "inventories")
local sync_logistics = syncs(components, "logistics")
local target_controller = defines.controllers[serialized.controller]
if player.controller_type ~= target_controller or serialized.controller == "ghost" then
if serialized.controller == "character" then
-- Stored data from instances which do not sync a component lacks its fields
local restore_controller = sync_controller and serialized.controller ~= nil
--- @type LocalControllerState?
local state = local_controller
if restore_controller then
state = { controller = serialized.controller, ticks_to_respawn = serialized.ticks_to_respawn }
end
local target_controller = state and defines.controllers[state.controller]
if state and (player.controller_type ~= target_controller or state.controller == "ghost") then
if state.controller == "character" then
-- Create a character but do not destroy an existing one
ensure_character(player)
if player.controller_type ~= target_controller then
player.set_controller{ type = target_controller }
end
elseif serialized.controller == "ghost" then
elseif state.controller == "ghost" then
-- Ghost state stores hidden logistic and filters which are only accessible in the character controller
-- 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 serialized.personal_logistic_slots then
if restore_controller and sync_logistics and serialized.personal_logistic_slots then
serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots)
end
if serialized.inventories then
if restore_controller and sync_inventories and serialized.inventories then
serialize.deserialize_inventories(player, serialized.inventories, character_inventories)
end
if serialized.ticks_to_respawn then
player.ticks_to_respawn = serialized.ticks_to_respawn
if state.ticks_to_respawn then
player.ticks_to_respawn = state.ticks_to_respawn
else
-- We have to set ticks to respawn to save the hidden state into the player but we
-- can't unset tick_to_respawn by setting it back to nil as that triggers a respawn.
@@ -952,7 +1022,7 @@ function serialize.deserialize_player(player, serialized)
character.destroy()
end
elseif serialized.controller ~= "remote" then
elseif state.controller ~= "remote" then
-- All other controllers should not have a character
local character = player.character
if character and character.valid then
@@ -967,44 +1037,60 @@ function serialize.deserialize_player(player, serialized)
end
end
player.color = serialized.color
player.chat_color = serialized.chat_color
player.tag = serialized.tag
player.force = serialized.force
player.cheat_mode = serialized.cheat_mode
if serialized.flashlight then
player.enable_flashlight()
else
player.disable_flashlight()
if syncs(components, "appearance") and serialized.color then
player.color = serialized.color
player.chat_color = serialized.chat_color
player.tag = serialized.tag
end
if serialized.shortcuts then
serialize.deserialize_shortcuts(player, serialized.shortcuts)
if syncs(components, "force") and serialized.force then
player.force = serialized.force
end
if serialized.game_view_settings then
serialize.deserialize_game_view_settings(player, serialized.game_view_settings)
if restore_controller and serialized.cheat_mode ~= nil then
player.cheat_mode = serialized.cheat_mode
end
if syncs(components, "settings") then
if serialized.flashlight == true then
player.enable_flashlight()
elseif serialized.flashlight == false then
player.disable_flashlight()
end
if serialized.shortcuts then
serialize.deserialize_shortcuts(player, serialized.shortcuts)
end
if serialized.game_view_settings then
serialize.deserialize_game_view_settings(player, serialized.game_view_settings)
end
end
-- Deserialize character
-- Deserialize character, the stored player may not have had one when the controller is not synced
if player.character then
serialize.deserialize_character(player.character, serialized.character)
serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots)
if sync_inventories and serialized.character then
serialize.deserialize_character(player.character, serialized.character)
end
if sync_logistics then
serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots)
end
end
-- Deserialize non-character inventories
if player.controller_type == defines.controllers.god then
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 })
end
-- Deserialize quick bar (named hotbar in old data)
serialize.deserialize_quick_bar(player, serialized.quick_bar or serialized.hotbar)
if syncs(components, "quick_bar") then
serialize.deserialize_quick_bar(player, serialized.quick_bar or serialized.hotbar)
end
-- Deserialize crafting queue
if player.character and serialized.crafting_queue then
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 serialized.recipe_notifications then
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