From 0e559cf000318ebb550180e18e9c4c4b2ffe2b94 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:18:34 +0000 Subject: [PATCH 01/14] Allow inventory sync components to be turned off per instance Adds an inventory_sync.sync_ 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 --- plugins/player_sync/README.md | 53 +++++ plugins/player_sync/components.ts | 86 +++++++ plugins/player_sync/controller.ts | 8 +- plugins/player_sync/index.ts | 60 +++++ plugins/player_sync/instance.ts | 17 ++ plugins/player_sync/messages.ts | 15 +- .../player_sync/module/download_inventory.lua | 6 +- plugins/player_sync/module/player_sync.lua | 39 +++- plugins/player_sync/module/serialize.lua | 212 ++++++++++++------ plugins/player_sync/test/components.js | 112 +++++++++ plugins/player_sync/test/instance.js | 44 ++++ 11 files changed, 571 insertions(+), 81 deletions(-) create mode 100644 plugins/player_sync/components.ts create mode 100644 plugins/player_sync/test/components.js diff --git a/plugins/player_sync/README.md b/plugins/player_sync/README.md index b7245ade..47532e2f 100644 --- a/plugins/player_sync/README.md +++ b/plugins/player_sync/README.md @@ -53,6 +53,9 @@ The greatly simplified data flow is as follows: When the player leaves the inventory is uploaded if it's a synced inventory and the exclusive access the scenario script holds is released. Should an error occur during this process the player is given the option to use a temporary inventory instead, which will be merged back into the synced inventory the next time the sync succeeds on that instance. +Instances can leave parts of the player out of the sync with the `player_sync.sync_*` options below. +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. + 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. Overall, we are able to achieve a latency between 3 and infinite ticks from server join, depending on the size of the inventory. The major limiter is rcon transfer speeds with larger inventories, especially if they contain blueprints. @@ -77,3 +80,53 @@ Size in characters of the chunks the serialized inventory is split into when sen Smaller chunks take more commands to transfer an inventory but hold up the RCON command pipe for less time each. Defaults to `1000`. + +### player_sync.sync_controller + +Sync whether the player is a character, god or spectator, their respawn timer and cheat mode. +Switching controller can destroy the character holding the inventory, so this only takes effect while `player_sync.sync_inventories` is also enabled. + +Defaults to `true`. + +### player_sync.sync_force + +Sync the force the player is on. + +Defaults to `true`. + +### player_sync.sync_appearance + +Sync the player colour, chat colour and tag. + +Defaults to `true`. + +### player_sync.sync_inventories + +Sync the character and god inventories, the character bonuses and the crafting queue. +While the controller is not synced only the inventory of a character is synced, a player without a character keeps the inventory they have on the instance and does not change the stored one. + +Defaults to `true`. + +### player_sync.sync_logistics + +Sync the personal logistic requests. + +Defaults to `true`. + +### player_sync.sync_quick_bar + +Sync the quick bar. + +Defaults to `true`. + +### player_sync.sync_settings + +Sync the shortcut toggles, game view settings and flashlight. + +Defaults to `true`. + +### player_sync.sync_recipe_notifications + +Sync which new recipe notifications have been seen. + +Defaults to `true`. diff --git a/plugins/player_sync/components.ts b/plugins/player_sync/components.ts new file mode 100644 index 00000000..7cef0493 --- /dev/null +++ b/plugins/player_sync/components.ts @@ -0,0 +1,86 @@ +import type * as lib from "@clusterio/lib"; +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 = { + controller: ["controller", "ticks_to_respawn", "cheat_mode"], + force: ["force"], + appearance: ["color", "chat_color", "tag"], + inventories: ["character", "inventories", "crafting_queue"], + logistics: ["personal_logistic_slots"], + quick_bar: ["quick_bar", "hotbar"], + settings: ["flashlight", "shortcuts", "game_view_settings"], + recipe_notifications: ["recipe_notifications"], +} as const; + +export type Component = keyof typeof components; + +// Fields only a player with a character has +const characterFields = ["character", "crafting_queue", "personal_logistic_slots"] as const; + +/** + * Components synced on an instance. + * + * The controller is only synced together with the inventories, as + * switching controller can destroy the character holding them. + */ +export function enabledComponents(config: lib.InstanceConfig) { + const enabled = new Set(); + for (const component of Object.keys(components) as Component[]) { + if (config.get(`player_sync.sync_${component}`)) { + enabled.add(component); + } + } + if (!enabled.has("inventories")) { + enabled.delete("controller"); + } + return enabled; +} + +/** Remove the fields of components which are not synced. */ +export function stripPlayerData(playerData: IpcPlayerData, enabled: Set) { + const fields = playerData as Record; + for (const [component, keys] of Object.entries(components)) { + if (!enabled.has(component as Component)) { + for (const key of keys) { + delete fields[key]; + } + } + } + return playerData; +} + +/** + * Combine data uploaded by an instance with the stored data. + * + * Components the instance does not sync keep the stored fields. Without + * the controller synced the god and ghost inventories are left as the + * synced controller had them, and character fields are only taken from a + * player which has a character. + */ +export function mergePlayerData(stored: IpcPlayerData | undefined, uploaded: IpcPlayerData, enabled: Set) { + const merged: Record = { ...uploaded }; + function keep(keys: readonly string[]) { + for (const key of keys) { + const value = (stored as Record | undefined)?.[key]; + if (value !== undefined) { + merged[key] = value; + } else { + delete merged[key]; + } + } + } + + for (const [component, keys] of Object.entries(components)) { + if (!enabled.has(component as Component)) { + keep(keys); + } + } + if (!enabled.has("controller")) { + keep(["inventories"]); + if (!uploaded.character) { + keep(characterFields); + } + } + return merged as IpcPlayerData; +} diff --git a/plugins/player_sync/controller.ts b/plugins/player_sync/controller.ts index 3ce86f16..bdc8bbab 100644 --- a/plugins/player_sync/controller.ts +++ b/plugins/player_sync/controller.ts @@ -1,5 +1,6 @@ import type { ControllerPluginContext, InstanceRecord } from "@clusterio/controller"; import type { IpcPlayerData } from "./messages.js"; +import { enabledComponents, mergePlayerData } from "./components.js"; import fs from "node:fs/promises"; import path from "path"; @@ -137,7 +138,8 @@ export default async function(context: ControllerPluginContext) { controller.handle(msg.UploadRequest, async (request: msg.UploadRequest) => { let { instanceId, playerName, playerData } = request; - let instanceName = controller.instances.get(instanceId)!.config.get("instance.name"); + let instanceConfig = controller.instances.get(instanceId)!.config; + let instanceName = instanceConfig.get("instance.name"); let store = true; let acquisitionRecord = acquiredPlayers.get(playerName); if (!acquisitionRecord) { @@ -165,7 +167,9 @@ export default async function(context: ControllerPluginContext) { if (store) { logger.verbose(`Received player data for ${playerName} from ${instanceName}`); - playerDatastore.set(playerName, playerData); + playerDatastore.set( + playerName, mergePlayerData(oldPlayerData, playerData, enabledComponents(instanceConfig)) + ); playerDatastoreDirty = true; } }); diff --git a/plugins/player_sync/index.ts b/plugins/player_sync/index.ts index bc41900a..0b367f58 100644 --- a/plugins/player_sync/index.ts +++ b/plugins/player_sync/index.ts @@ -4,6 +4,14 @@ import * as messages from "./messages.js"; declare module "@clusterio/lib" { export interface InstanceConfigFields { "player_sync.rcon_chunk_size": number; + "player_sync.sync_controller": boolean; + "player_sync.sync_force": boolean; + "player_sync.sync_appearance": boolean; + "player_sync.sync_inventories": boolean; + "player_sync.sync_logistics": boolean; + "player_sync.sync_quick_bar": boolean; + "player_sync.sync_settings": boolean; + "player_sync.sync_recipe_notifications": boolean; } export interface ControllerConfigFields { "player_sync.player_lock_timeout": number; @@ -28,6 +36,58 @@ export const plugin: lib.PluginDeclaration = { type: "number", initialValue: 1000, }, + "player_sync.sync_controller": { + title: "Sync controller", + description: + "Sync whether the player is a character, god or spectator, their respawn timer and cheat " + + "mode. Only synced while inventories are synced too.", + type: "boolean", + initialValue: true, + }, + "player_sync.sync_force": { + title: "Sync force", + description: "Sync the force the player is on.", + type: "boolean", + initialValue: true, + }, + "player_sync.sync_appearance": { + title: "Sync appearance", + description: "Sync the player colour, chat colour and tag.", + type: "boolean", + initialValue: true, + }, + "player_sync.sync_inventories": { + title: "Sync inventories", + description: + "Sync the character and god inventories, character bonuses and crafting queue. Without the " + + "controller synced only the inventory of a character is synced.", + type: "boolean", + initialValue: true, + }, + "player_sync.sync_logistics": { + title: "Sync logistics", + description: "Sync the personal logistic requests.", + type: "boolean", + initialValue: true, + }, + "player_sync.sync_quick_bar": { + title: "Sync quick bar", + description: "Sync the quick bar.", + type: "boolean", + initialValue: true, + }, + "player_sync.sync_settings": { + title: "Sync settings", + description: "Sync the shortcut toggles, game view settings and flashlight.", + type: "boolean", + initialValue: true, + }, + "player_sync.sync_recipe_notifications": { + title: "Sync recipe notifications", + description: "Sync which new recipe notifications have been seen.", + type: "boolean", + initialValue: true, + }, }, controllerEntrypoint: "dist/node/controller.js", diff --git a/plugins/player_sync/instance.ts b/plugins/player_sync/instance.ts index 11e88561..e1f9a810 100644 --- a/plugins/player_sync/instance.ts +++ b/plugins/player_sync/instance.ts @@ -5,6 +5,7 @@ import type { InstancePluginContext } from "@clusterio/host"; import { AcquireRequest, AcquireResponse, ReleaseRequest, UploadRequest, DownloadRequest, DownloadResponse, IpcPlayerData, } from "./messages.js"; +import { enabledComponents, stripPlayerData } from "./components.js"; type IpcPlayerName = { player_name: string @@ -198,6 +199,7 @@ export default async function(context: InstancePluginContext) { return; } + stripPlayerData(response.playerData, enabledComponents(instance.config)); await applyRecipeNotificationDelta(response.playerData, request.recipe_notifications, logger); const chunkSize = instance.config.get("player_sync.rcon_chunk_size"); @@ -239,6 +241,21 @@ export default async function(context: InstancePluginContext) { ), ); + /** Tell the scenario which components are synced. */ + async function sendComponents() { + const enabled = Object.fromEntries([...enabledComponents(instance.config)].map(name => [name, true])); + const json = lib.escapeString(JSON.stringify(enabled)); + await instance.sendRcon(`/sc player_sync.set_components("${json}")`, true, plugin.name); + } + + instance.hooks.start.attach(plugin.name, sendComponents); + + instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field) => { + if (field.startsWith("player_sync.sync_") && instance.status === "running") { + await sendComponents(); + } + }); + instance.hooks.prepareControllerDisconnect.attach(plugin.name, async () => { disconnecting = true; }); diff --git a/plugins/player_sync/messages.ts b/plugins/player_sync/messages.ts index dc1151d0..4b56ba95 100644 --- a/plugins/player_sync/messages.ts +++ b/plugins/player_sync/messages.ts @@ -4,19 +4,20 @@ import { StringEnum } from "@clusterio/lib"; // .\module\serialize.lua:serialize.serialize_player() export type IpcPlayerData = { generation: number, - controller: string, name: string, - color: number[], - chat_color: number[], - tag: string, - force: string, - cheat_mode: boolean, - flashlight: boolean, + controller?: string, + color?: number[], + chat_color?: number[], + tag?: string, + force?: string, + cheat_mode?: boolean, + flashlight?: boolean, shortcuts?: Record, game_view_settings?: Record, ticks_to_respawn?: number, character?: any, inventories?: any, + quick_bar?: any, hotbar?: string[], personal_logistic_slots?: {name:string, min:number, max:number}[], crafting_queue?: any, diff --git a/plugins/player_sync/module/download_inventory.lua b/plugins/player_sync/module/download_inventory.lua index 44ddaec0..e1d06e12 100644 --- a/plugins/player_sync/module/download_inventory.lua +++ b/plugins/player_sync/module/download_inventory.lua @@ -2,6 +2,7 @@ local progress_dialog = require("modules/player_sync/gui/progress_dialog") local ensure_character = require("modules/player_sync/ensure_character") local restore_position = require("modules/player_sync/restore_position") local get_script_data = require("modules/player_sync/get_script_data") +local serialize = require("modules/player_sync/serialize") local function download_inventory(player_name, data, number, total) @@ -33,7 +34,10 @@ local function download_inventory(player_name, data, number, total) progress_dialog.remove(player) -- Give the player a character and pretend that's the synced player data - ensure_character(player) + if serialize.syncs(script_data.components, "inventories") then + ensure_character(player) + end + player_record.local_controller = nil -- Restore player position and driving state restore_position(player, record) diff --git a/plugins/player_sync/module/player_sync.lua b/plugins/player_sync/module/player_sync.lua index 473b6e5e..46d72a72 100644 --- a/plugins/player_sync/module/player_sync.lua +++ b/plugins/player_sync/module/player_sync.lua @@ -24,6 +24,18 @@ local function is_in_cutscene(player) return player.controller_type == defines.controllers.cutscene end +-- Returns true if the component is synced on this instance +local function syncs(name) + return serialize.syncs(get_script_data().components, name) +end + +-- Remember the controller the player had before sync code replaced it, restored when the controller is not synced +local function save_local_controller(player, player_record) + if not player_record.local_controller then + player_record.local_controller = serialize.serialize_local_controller(player) + end +end + -- Create player record for bookkeeping local function create_player(player, dirty) local script_data = get_script_data(true) -- no early return to support loading from single player @@ -51,6 +63,11 @@ 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 player_sync.download_inventory = download_inventory +-- This function is called by instance.js with the components synced on this instance +function player_sync.set_components(data) + get_script_data().components = assert(compat.json_to_table(data)) +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) @@ -78,7 +95,7 @@ function player_sync.serialize_player(player, player_record) end local failed_deserialization = get_script_data().failed_deserialization[player.name] or {} - local serialized_player = serialize.serialize_player(player, failed_deserialization) + local serialized_player = serialize.serialize_player(player, failed_deserialization, get_script_data().components) serialized_player.generation = player_record.generation return serialized_player @@ -95,11 +112,13 @@ function player_sync.deserialize_player(player, finished_record) return end - -- Stash temporary inventory if it exists - local stashed_corpse local script_data = get_script_data() local player_record = script_data.players[player.name] - if player_record.dirty and player.character then + local local_controller = player_record.local_controller or serialize.serialize_local_controller(player) + + -- Stash temporary inventory if it exists, without inventory sync the current inventory is the one to keep + local stashed_corpse + if syncs("inventories") and player_record.dirty and player.character then local character = player.character local surface = character.surface local position = character.position @@ -112,7 +131,9 @@ 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] = serialize.deserialize_player(player, serialized_player) + script_data.failed_deserialization[player.name] = + serialize.deserialize_player(player, serialized_player, script_data.components, local_controller) + player_record.local_controller = nil -- Restore player position and driving state restore_position(player, finished_record) @@ -291,8 +312,9 @@ function player_sync.sync_player(acquire_response) local player_record = script_data.players[acquire_response.player_name] if acquire_response.status ~= "acquired" then - if player_record.sync and not player_record.dirty then + if player_record.sync and not player_record.dirty and syncs("inventories") then if player.controller_type == defines.controllers.character then + save_local_controller(player, player_record) local character = player.character player.set_controller({ type = defines.controllers.spectator }) if character then character.destroy() end @@ -454,7 +476,7 @@ function player_sync.initiate_inventory_download(player, player_record, generati -- The plugin only sends back what differs from the current notification state local recipe_notifications - if recipe_notifications_api then + if recipe_notifications_api and syncs("recipe_notifications") then recipe_notifications = serialize.serialize_crafting_notifications(player) end @@ -465,7 +487,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 - if player_record.sync then + if player_record.sync and syncs("inventories") then -- Store original position to teleport back to if v2_remote_controller then record.surface = player.physical_surface @@ -478,6 +500,7 @@ function player_sync.initiate_inventory_download(player, player_record, generati record.vehicle = player.vehicle end + save_local_controller(player, player_record) local character = player.character player.set_controller({ type = defines.controllers.spectator }) if character ~= nil then character.destroy() end diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index bdd33474..6bd310b7 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -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? --- @field game_view_settings table? ---- @field ticks_to_respawn number +--- @field ticks_to_respawn number? --- @field character table? --- @field inventories table? --- @field hotbar table? @@ -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? + +--- @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 diff --git a/plugins/player_sync/test/components.js b/plugins/player_sync/test/components.js new file mode 100644 index 00000000..fc2db0c5 --- /dev/null +++ b/plugins/player_sync/test/components.js @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; + +import { components, enabledComponents, mergePlayerData, stripPlayerData } from "../dist/node/components.js"; + +const allComponents = Object.keys(components); + +function configWith(disabled = []) { + return { get: name => !disabled.includes(name.replace("player_sync.sync_", "")) }; +} + +const character = { inventories: { main: [] } }; +const stored = { + generation: 1, + name: "test", + controller: "god", + cheat_mode: true, + force: "player", + color: [1, 0, 0], + chat_color: [1, 0, 0], + tag: "stored", + inventories: { main: ["stored god item"] }, + quick_bar: ["stored-item"], + recipe_notifications: "stored", +}; +const uploaded = { + generation: 2, + name: "test", + controller: "character", + cheat_mode: false, + force: "enemy", + color: [0, 1, 0], + chat_color: [0, 1, 0], + tag: "uploaded", + character, + crafting_queue: [], + personal_logistic_slots: [], + quick_bar: ["uploaded-item"], + recipe_notifications: "uploaded", +}; + +describe("player_sync components", function() { + describe("enabledComponents()", function() { + it("should enable every component by default", function() { + assert.deepEqual([...enabledComponents(configWith())], allComponents); + }); + it("should leave out disabled components", function() { + const enabled = enabledComponents(configWith(["force", "quick_bar"])); + assert.equal(enabled.has("force"), false); + assert.equal(enabled.has("quick_bar"), false); + assert.equal(enabled.size, allComponents.length - 2); + }); + it("should not sync the controller without the inventories", function() { + const enabled = enabledComponents(configWith(["inventories"])); + assert.equal(enabled.has("controller"), false); + }); + }); + + describe("stripPlayerData()", function() { + it("should keep everything when all components are synced", function() { + const playerData = structuredClone(uploaded); + stripPlayerData(playerData, new Set(allComponents)); + assert.deepEqual(playerData, uploaded); + }); + it("should remove the fields of disabled components", function() { + const playerData = structuredClone(uploaded); + stripPlayerData(playerData, new Set(allComponents.filter(c => !["appearance", "inventories"].includes(c)))); + for (const key of ["color", "chat_color", "tag", "character", "crafting_queue"]) { + assert.equal(key in playerData, false, key); + } + assert.equal(playerData.force, "enemy"); + }); + }); + + describe("mergePlayerData()", function() { + it("should store the upload as is when all components are synced", function() { + assert.deepEqual(mergePlayerData(stored, uploaded, new Set(allComponents)), uploaded); + }); + it("should keep the stored fields of disabled components", function() { + const enabled = new Set(allComponents.filter(c => !["force", "appearance"].includes(c))); + const merged = mergePlayerData(stored, uploaded, enabled); + assert.equal(merged.force, "player"); + assert.deepEqual(merged.color, [1, 0, 0]); + assert.equal(merged.tag, "stored"); + assert.deepEqual(merged.quick_bar, ["uploaded-item"]); + assert.equal(merged.generation, 2); + }); + it("should drop disabled components without stored data", function() { + const merged = mergePlayerData(undefined, uploaded, new Set(allComponents.filter(c => c !== "force"))); + 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() { + const merged = mergePlayerData(stored, uploaded, new Set(allComponents.filter(c => c !== "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"); + }); + it("should keep the character data for a player without one when the controller is not synced", function() { + const storedCharacter = { ...stored, character, crafting_queue: ["stored"], personal_logistic_slots: [] }; + const god = { ...uploaded, controller: "god", inventories: { main: ["new god item"] } }; + delete god.character; + delete god.crafting_queue; + delete god.personal_logistic_slots; + const enabled = new Set(allComponents.filter(c => c !== "controller")); + const merged = mergePlayerData(storedCharacter, god, enabled); + assert.equal(merged.character, character); + assert.deepEqual(merged.crafting_queue, ["stored"]); + assert.deepEqual(merged.inventories, { main: ["stored god item"] }); + }); + }); +}); diff --git a/plugins/player_sync/test/instance.js b/plugins/player_sync/test/instance.js index a5360222..3cc5d9d5 100644 --- a/plugins/player_sync/test/instance.js +++ b/plugins/player_sync/test/instance.js @@ -6,6 +6,7 @@ import * as lib from "@clusterio/lib"; import * as mock from "../../../test/mock.js"; import loadInstancePlugin, { recipeNotificationDelta, applyRecipeNotificationDelta } from "../dist/node/instance.js"; import { DownloadResponse } from "../dist/node/messages.js"; +import { components } from "../dist/node/components.js"; import { plugin as info } from "../dist/node/index.js"; const inflate = util.promisify(zlib.inflate); @@ -84,6 +85,9 @@ describe("player_sync", function() { }); beforeEach(function() { instance.server.reset(); + for (const component of Object.keys(components)) { + instance.mockConfigEntries.set(`player_sync.sync_${component}`, true); + } }); function respondWith(playerData) { @@ -125,5 +129,45 @@ describe("player_sync", function() { const expected = { generation: 1, name: "test", recipe_notifications: await encode({ add: ["a"] }) }; assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]); }); + it("should leave out components the instance does not sync", async function() { + instance.mockConfigEntries.set("player_sync.sync_force", false); + instance.mockConfigEntries.set("player_sync.sync_recipe_notifications", false); + respondWith({ generation: 1, name: "test", force: "enemy", tag: "tag", recipe_notifications: "x" }); + await download({ player_name: "test" }); + const expected = { generation: 1, name: "test", tag: "tag" }; + assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]); + }); + }); + + describe("set_components", function() { + let instance; + before(async function() { + ({ instance } = await mock.loadInstancePlugin(loadInstancePlugin, info)); + }); + beforeEach(function() { + instance.server.reset(); + for (const component of Object.keys(components)) { + instance.mockConfigEntries.set(`player_sync.sync_${component}`, true); + } + }); + + it("should send the synced components on start", async function() { + instance.mockConfigEntries.set("player_sync.sync_quick_bar", false); + await instance.hooks.start.invoke(); + const enabled = Object.fromEntries( + Object.keys(components).filter(c => c !== "quick_bar").map(c => [c, true]) + ); + const json = lib.escapeString(JSON.stringify(enabled)); + assert.deepEqual( + instance.server.rconCommands, + [`/sc player_sync.set_components("${json}")`] + ); + }); + it("should send the synced components when one is changed", async function() { + await instance.hooks.instanceConfigFieldChanged.invoke("player_sync.rcon_chunk_size", 1, 2); + assert.equal(instance.server.rconCommands.length, 0); + await instance.hooks.instanceConfigFieldChanged.invoke("player_sync.sync_force", false, true); + assert.equal(instance.server.rconCommands.length, 1); + }); }); }); From c798b2a33fb8711ed2e4dabed52421371ec1e0e1 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:52:07 +0000 Subject: [PATCH 02/14] Keep the local controller intact when it is not synced With the controller off, a god or ghost player was still turned into a spectator while downloading, which threw away the local state the sync is meant to leave alone. Only spectate a player without a character when the controller is synced, and skip the ghost round trip when the ghost is the local state. A ghost on an instance that syncs inventories but not the controller never received the stored character, so after respawning it uploaded an empty one over it. Respawn such a ghost straight away when there is a stored character to give it. Clear the saved local controller when the player picks a temporary inventory after a failed download, otherwise a later download restores the stale controller over the temporary character. An empty download only gives a character when the controller is synced or the player was spectated. Co-Authored-By: Claude Opus 5.5 --- plugins/player_sync/README.md | 1 + plugins/player_sync/module/download_inventory.lua | 2 +- plugins/player_sync/module/gui/dialog_failed_download.lua | 1 + plugins/player_sync/module/player_sync.lua | 3 ++- plugins/player_sync/module/serialize.lua | 8 +++++++- 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/plugins/player_sync/README.md b/plugins/player_sync/README.md index 47532e2f..d0e7ff9e 100644 --- a/plugins/player_sync/README.md +++ b/plugins/player_sync/README.md @@ -104,6 +104,7 @@ Defaults to `true`. Sync the character and god inventories, the character bonuses and the crafting queue. While the controller is not synced only the inventory of a character is synced, a player without a character keeps the inventory they have on the instance and does not change the stored one. +A player waiting to respawn is respawned straight away if there is a synced character to give them. Defaults to `true`. diff --git a/plugins/player_sync/module/download_inventory.lua b/plugins/player_sync/module/download_inventory.lua index e1d06e12..120e346a 100644 --- a/plugins/player_sync/module/download_inventory.lua +++ b/plugins/player_sync/module/download_inventory.lua @@ -34,7 +34,7 @@ local function download_inventory(player_name, data, number, total) progress_dialog.remove(player) -- Give the player a character and pretend that's the synced player data - if serialize.syncs(script_data.components, "inventories") then + if serialize.syncs(script_data.components, "controller") or player_record.local_controller then ensure_character(player) end player_record.local_controller = nil diff --git a/plugins/player_sync/module/gui/dialog_failed_download.lua b/plugins/player_sync/module/gui/dialog_failed_download.lua index 2f369833..4859e40b 100644 --- a/plugins/player_sync/module/gui/dialog_failed_download.lua +++ b/plugins/player_sync/module/gui/dialog_failed_download.lua @@ -131,6 +131,7 @@ dialog_failed_download.events = { player.gui.screen.dialog_failed_download.destroy() player_sync.players[player.name].dirty = true player_sync.players[player.name].sync = false + player_sync.players[player.name].local_controller = nil elseif event.element.name == "player_sync_failed_download_retry" then -- Retry acquiring the player from the controller diff --git a/plugins/player_sync/module/player_sync.lua b/plugins/player_sync/module/player_sync.lua index 46d72a72..4f3b381b 100644 --- a/plugins/player_sync/module/player_sync.lua +++ b/plugins/player_sync/module/player_sync.lua @@ -487,7 +487,8 @@ 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 - if player_record.sync and syncs("inventories") then + -- Without the controller synced a player without a character has nothing synced to protect + if player_record.sync and syncs("inventories") and (syncs("controller") or player.character) then -- Store original position to teleport back to if v2_remote_controller then record.surface = player.physical_surface diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index 6bd310b7..b37ce71f 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -987,10 +987,16 @@ 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 local target_controller = state and defines.controllers[state.controller] - if state and (player.controller_type ~= target_controller or state.controller == "ghost") then + if state and ( + player.controller_type ~= target_controller + or restore_controller and state.controller == "ghost" + ) then if state.controller == "character" then -- Create a character but do not destroy an existing one ensure_character(player) From 8487c6d923b78d31ba1869c5d8320561fa7ea582 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:52:07 +0000 Subject: [PATCH 03/14] Test that uploads merge with the uploading instance's components Co-Authored-By: Claude Opus 5.5 --- plugins/player_sync/test/controller.js | 39 +++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/plugins/player_sync/test/controller.js b/plugins/player_sync/test/controller.js index d87c437e..ec2771d6 100644 --- a/plugins/player_sync/test/controller.js +++ b/plugins/player_sync/test/controller.js @@ -6,7 +6,7 @@ import * as lib from "@clusterio/lib"; import * as mock from "../../../test/mock.js"; import controllerEntrypoint from "../dist/node/controller.js"; import { plugin as info } from "../dist/node/index.js"; -import { DatabaseStatsRequest } from "../dist/node/messages.js"; +import { DatabaseStatsRequest, DownloadRequest, UploadRequest } from "../dist/node/messages.js"; describe("player_sync controller", function() { const databaseDir = path.join("temp", "test", "player_sync"); @@ -50,4 +50,41 @@ describe("player_sync controller", function() { assert.equal(stats.databaseEntries, 1); assert.equal(stats.largestEntry.name, "new"); }); + + describe("UploadRequest", function() { + let controller; + let disabled; + beforeEach(async function() { + disabled = []; + const stored = { generation: 1, name: "test", force: "player", tag: "stored" }; + await fs.writeFile(path.join(databaseDir, "player_sync.json"), JSON.stringify([["test", stored]])); + controller = await load(); + controller.instances = new Map([[1, { + config: { + get: name => { + if (name === "instance.name") { return "test instance"; } + return !disabled.includes(name.replace("player_sync.sync_", "")); + }, + }, + }]]); + }); + + async function upload(playerData) { + await mock.getHandler(controller, UploadRequest)({ instanceId: 1, playerName: "test", playerData }); + return (await mock.getHandler(controller, DownloadRequest)({ instanceId: 1, playerName: "test" })) + .playerData; + } + + it("should store the upload as is when everything is synced", async function() { + const playerData = { generation: 2, name: "test", force: "enemy", tag: "uploaded" }; + assert.deepEqual(await upload(playerData), playerData); + }); + it("should keep stored fields the uploading instance does not sync", async function() { + disabled = ["force"]; + assert.deepEqual( + await upload({ generation: 2, name: "test", tag: "uploaded" }), + { generation: 2, name: "test", force: "player", tag: "uploaded" }, + ); + }); + }); }); From b0e07ea73b84f651104111267e4c9d03d4e61178 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:10:09 +0000 Subject: [PATCH 04/14] Merge uploads by the components they were serialized with The controller merged uploads using the uploading instance's current config. An upload serialized under different settings, for example one sent from the save right after an admin turned on sync_inventories and restarted the instance, was then merged as if it held a character and wiped the stored one. Uploads now carry the components they were serialized with, the controller merges by those and drops the key before storing. The scenario keeps the components a player's synced session started with and serializes their upload with them, so changing the options while a player is online takes effect the next time they join. A player whose session lacks a component that is now synced downloads again even if the generation matches. Downloads snapshot the components when they start, so a change during a download can't mix two sets. A failed acquire unsyncs the player again regardless of the inventories setting, as before this branch. The god and dead player inventories move from the inventories component to the controller one, matching what the scenario syncs, which removes the special case in mergePlayerData. Co-Authored-By: Claude Opus 5.5 --- plugins/player_sync/README.md | 8 ++- plugins/player_sync/components.ts | 45 ++++++++++----- plugins/player_sync/controller.ts | 9 +-- plugins/player_sync/index.ts | 8 +-- plugins/player_sync/messages.ts | 2 + .../player_sync/module/download_inventory.lua | 3 +- plugins/player_sync/module/player_sync.lua | 39 ++++++++----- plugins/player_sync/module/serialize.lua | 19 +++++++ plugins/player_sync/test/components.js | 56 +++++++++++++++---- plugins/player_sync/test/controller.js | 10 ++-- plugins/player_sync/test/instance.js | 9 +++ 11 files changed, 150 insertions(+), 58 deletions(-) diff --git a/plugins/player_sync/README.md b/plugins/player_sync/README.md index d0e7ff9e..0cdcd142 100644 --- a/plugins/player_sync/README.md +++ b/plugins/player_sync/README.md @@ -55,6 +55,8 @@ Should an error occur during this process the player is given the option to use Instances can leave parts of the player out of the sync with the `player_sync.sync_*` options below. 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. 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. @@ -83,7 +85,7 @@ Defaults to `1000`. ### player_sync.sync_controller -Sync whether the player is a character, god or spectator, their respawn timer and cheat mode. +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. Defaults to `true`. @@ -102,8 +104,8 @@ Defaults to `true`. ### player_sync.sync_inventories -Sync the character and god inventories, the character bonuses and the crafting queue. -While the controller is not synced only the inventory of a character is synced, a player without a character keeps the inventory they have on the instance and does not change the stored one. +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 waiting to respawn is respawned straight away if there is a synced character to give them. Defaults to `true`. diff --git a/plugins/player_sync/components.ts b/plugins/player_sync/components.ts index 7cef0493..ec86d93b 100644 --- a/plugins/player_sync/components.ts +++ b/plugins/player_sync/components.ts @@ -3,10 +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 = { - controller: ["controller", "ticks_to_respawn", "cheat_mode"], + // inventories holds the god inventory and the hidden ghost inventory, both belong to the controller + controller: ["controller", "ticks_to_respawn", "cheat_mode", "inventories"], force: ["force"], appearance: ["color", "chat_color", "tag"], - inventories: ["character", "inventories", "crafting_queue"], + inventories: ["character", "crafting_queue"], logistics: ["personal_logistic_slots"], quick_bar: ["quick_bar", "hotbar"], settings: ["flashlight", "shortcuts", "game_view_settings"], @@ -19,15 +20,15 @@ export type Component = keyof typeof components; const characterFields = ["character", "crafting_queue", "personal_logistic_slots"] as const; /** - * Components synced on an instance. + * Build the set of synced components. * * The controller is only synced together with the inventories, as * switching controller can destroy the character holding them. */ -export function enabledComponents(config: lib.InstanceConfig) { +function componentSet(isEnabled: (component: Component) => boolean) { const enabled = new Set(); for (const component of Object.keys(components) as Component[]) { - if (config.get(`player_sync.sync_${component}`)) { + if (isEnabled(component)) { enabled.add(component); } } @@ -37,6 +38,23 @@ export function enabledComponents(config: lib.InstanceConfig) { return enabled; } +/** Components synced on an instance. */ +export function enabledComponents(config: lib.InstanceConfig) { + return componentSet(component => Boolean(config.get(`player_sync.sync_${component}`))); +} + +/** + * Components an upload was serialized with, all of them when not given. + * + * Lua may encode an empty table as an array, which reads as no components. + */ +export function uploadedComponents(uploaded: Record | unknown[] | undefined) { + if (uploaded === undefined) { + return componentSet(() => true); + } + return componentSet(component => !Array.isArray(uploaded) && uploaded[component] === true); +} + /** Remove the fields of components which are not synced. */ export function stripPlayerData(playerData: IpcPlayerData, enabled: Set) { const fields = playerData as Record; @@ -53,13 +71,13 @@ export function stripPlayerData(playerData: IpcPlayerData, enabled: Set) { - const merged: Record = { ...uploaded }; +export function mergePlayerData(stored: IpcPlayerData | undefined, uploaded: IpcPlayerData) { + const { components: uploadComponents, ...merged } = uploaded as IpcPlayerData & Record; + const enabled = uploadedComponents(uploadComponents); function keep(keys: readonly string[]) { for (const key of keys) { const value = (stored as Record | undefined)?.[key]; @@ -76,11 +94,8 @@ export function mergePlayerData(stored: IpcPlayerData | undefined, uploaded: Ipc keep(keys); } } - if (!enabled.has("controller")) { - keep(["inventories"]); - if (!uploaded.character) { - keep(characterFields); - } + if (!enabled.has("controller") && !uploaded.character) { + keep(characterFields); } return merged as IpcPlayerData; } diff --git a/plugins/player_sync/controller.ts b/plugins/player_sync/controller.ts index bdc8bbab..887b4b06 100644 --- a/plugins/player_sync/controller.ts +++ b/plugins/player_sync/controller.ts @@ -1,6 +1,6 @@ import type { ControllerPluginContext, InstanceRecord } from "@clusterio/controller"; import type { IpcPlayerData } from "./messages.js"; -import { enabledComponents, mergePlayerData } from "./components.js"; +import { mergePlayerData } from "./components.js"; import fs from "node:fs/promises"; import path from "path"; @@ -138,8 +138,7 @@ export default async function(context: ControllerPluginContext) { controller.handle(msg.UploadRequest, async (request: msg.UploadRequest) => { let { instanceId, playerName, playerData } = request; - let instanceConfig = controller.instances.get(instanceId)!.config; - let instanceName = instanceConfig.get("instance.name"); + let instanceName = controller.instances.get(instanceId)!.config.get("instance.name"); let store = true; let acquisitionRecord = acquiredPlayers.get(playerName); if (!acquisitionRecord) { @@ -167,9 +166,7 @@ export default async function(context: ControllerPluginContext) { if (store) { logger.verbose(`Received player data for ${playerName} from ${instanceName}`); - playerDatastore.set( - playerName, mergePlayerData(oldPlayerData, playerData, enabledComponents(instanceConfig)) - ); + playerDatastore.set(playerName, mergePlayerData(oldPlayerData, playerData)); playerDatastoreDirty = true; } }); diff --git a/plugins/player_sync/index.ts b/plugins/player_sync/index.ts index 0b367f58..ef8df6ef 100644 --- a/plugins/player_sync/index.ts +++ b/plugins/player_sync/index.ts @@ -39,8 +39,8 @@ export const plugin: lib.PluginDeclaration = { "player_sync.sync_controller": { title: "Sync controller", description: - "Sync whether the player is a character, god or spectator, their respawn timer and cheat " + - "mode. Only synced while inventories are synced too.", + "Sync whether the player is a character, god or spectator, their respawn timer, cheat mode " + + "and god inventory. Only synced while inventories are synced too.", type: "boolean", initialValue: true, }, @@ -59,8 +59,8 @@ export const plugin: lib.PluginDeclaration = { "player_sync.sync_inventories": { title: "Sync inventories", description: - "Sync the character and god inventories, character bonuses and crafting queue. Without the " + - "controller synced only the inventory of a character is synced.", + "Sync the character inventories, character bonuses and crafting queue. Without the " + + "controller synced a player without a character keeps their local inventory.", type: "boolean", initialValue: true, }, diff --git a/plugins/player_sync/messages.ts b/plugins/player_sync/messages.ts index 4b56ba95..993b56c4 100644 --- a/plugins/player_sync/messages.ts +++ b/plugins/player_sync/messages.ts @@ -22,6 +22,8 @@ export type IpcPlayerData = { personal_logistic_slots?: {name:string, min:number, max:number}[], crafting_queue?: any, recipe_notifications?: string, + // Components the upload was serialized with, all when missing + components?: Record | unknown[], } const jsonPlayerData = Type.Object({ diff --git a/plugins/player_sync/module/download_inventory.lua b/plugins/player_sync/module/download_inventory.lua index 120e346a..18f96828 100644 --- a/plugins/player_sync/module/download_inventory.lua +++ b/plugins/player_sync/module/download_inventory.lua @@ -34,7 +34,7 @@ local function download_inventory(player_name, data, number, total) progress_dialog.remove(player) -- Give the player a character and pretend that's the synced player data - if serialize.syncs(script_data.components, "controller") or player_record.local_controller then + if serialize.syncs(record.components, "controller") or player_record.local_controller then ensure_character(player) end player_record.local_controller = nil @@ -45,6 +45,7 @@ local function download_inventory(player_name, data, number, total) script_data.active_downloads[player_name] = nil player_record.dirty = player.connected player_record.sync = true + player_record.components = record.components player_record.generation = record.generation return end diff --git a/plugins/player_sync/module/player_sync.lua b/plugins/player_sync/module/player_sync.lua index 4f3b381b..5aefd033 100644 --- a/plugins/player_sync/module/player_sync.lua +++ b/plugins/player_sync/module/player_sync.lua @@ -24,9 +24,10 @@ local function is_in_cutscene(player) return player.controller_type == defines.controllers.cutscene end --- Returns true if the component is synced on this instance -local function syncs(name) - return serialize.syncs(get_script_data().components, name) +-- Remember the components a player's synced session uses, uploads are serialized with these +local function start_sync(player_record, components) + player_record.sync = true + player_record.components = components end -- Remember the controller the player had before sync code replaced it, restored when the controller is not synced @@ -95,7 +96,7 @@ function player_sync.serialize_player(player, player_record) end local failed_deserialization = get_script_data().failed_deserialization[player.name] or {} - local serialized_player = serialize.serialize_player(player, failed_deserialization, get_script_data().components) + local serialized_player = serialize.serialize_player(player, failed_deserialization, player_record.components) serialized_player.generation = player_record.generation return serialized_player @@ -118,7 +119,7 @@ function player_sync.deserialize_player(player, finished_record) -- Stash temporary inventory if it exists, without inventory sync the current inventory is the one to keep local stashed_corpse - if syncs("inventories") and player_record.dirty and player.character then + if serialize.syncs(finished_record.components, "inventories") and player_record.dirty and player.character then local character = player.character local surface = character.surface local position = character.position @@ -132,7 +133,7 @@ function player_sync.deserialize_player(player, finished_record) 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] = - serialize.deserialize_player(player, serialized_player, script_data.components, local_controller) + serialize.deserialize_player(player, serialized_player, finished_record.components, local_controller) player_record.local_controller = nil -- Restore player position and driving state @@ -312,8 +313,10 @@ function player_sync.sync_player(acquire_response) local player_record = script_data.players[acquire_response.player_name] if acquire_response.status ~= "acquired" then - if player_record.sync and not player_record.dirty and syncs("inventories") then - if player.controller_type == defines.controllers.character then + if player_record.sync and not player_record.dirty then + if serialize.syncs(player_record.components, "inventories") + and player.controller_type == defines.controllers.character + then save_local_controller(player, player_record) local character = player.character player.set_controller({ type = defines.controllers.spectator }) @@ -327,7 +330,11 @@ function player_sync.sync_player(acquire_response) if acquire_response.has_data - and (not player_record.sync or acquire_response.generation > player_record.generation) + and ( + not player_record.sync + or acquire_response.generation > player_record.generation + or serialize.newly_synced(player_record.components, script_data.components) + ) then player_sync.initiate_inventory_download(player, player_record, acquire_response.generation) @@ -340,7 +347,7 @@ function player_sync.sync_player(acquire_response) ) end - player_record.sync = true + start_sync(player_record, script_data.components) player_record.dirty = true end end @@ -361,7 +368,7 @@ function player_sync.finish_download(player, finished_record) end player_record.dirty = true - player_record.sync = true + start_sync(player_record, finished_record.components) player_record.generation = finished_record.generation end @@ -469,14 +476,15 @@ function player_sync.initiate_inventory_download(player, player_record, generati started = game.ticks_played, last_active = game.ticks_played, generation = generation, - data = "" + data = "", } local script_data = get_script_data() + record.components = script_data.components script_data.active_downloads[player.name] = record -- The plugin only sends back what differs from the current notification state local recipe_notifications - if recipe_notifications_api and syncs("recipe_notifications") then + if recipe_notifications_api and serialize.syncs(record.components, "recipe_notifications") then recipe_notifications = serialize.serialize_crafting_notifications(player) end @@ -488,7 +496,10 @@ 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 - if player_record.sync and syncs("inventories") and (syncs("controller") or player.character) then + local components = record.components + if player_record.sync and serialize.syncs(components, "inventories") + and (serialize.syncs(components, "controller") or player.character) + then -- Store original position to teleport back to if v2_remote_controller then record.surface = player.physical_surface diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index b37ce71f..ba8ad094 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -730,6 +730,7 @@ end --- @field shortcuts table? --- @field game_view_settings table? --- @field ticks_to_respawn number? +--- @field components SyncComponents Components this was serialized with --- @field character table? --- @field inventories table? --- @field hotbar table? @@ -795,6 +796,23 @@ local function syncs(components, name) end serialize.syncs = syncs +local component_names = { + "controller", "force", "appearance", "inventories", "logistics", "quick_bar", "settings", "recipe_notifications", +} + +--- True if a component synced in new_components was not synced in old_components +--- @param old_components SyncComponents +--- @param new_components SyncComponents +--- @return boolean +function serialize.newly_synced(old_components, new_components) + for _, name in ipairs(component_names) do + if syncs(new_components, name) and not syncs(old_components, name) then + return true + end + end + return false +end + --- @param player LuaPlayer --- @param failed_deserialization FailedDeserializationPlayerData --- @param components SyncComponents @@ -808,6 +826,7 @@ function serialize.serialize_player(player, failed_deserialization, components) local serialized = { generation = 0, -- Gets replaced later name = player.name, + components = components, } if sync_controller then diff --git a/plugins/player_sync/test/components.js b/plugins/player_sync/test/components.js index fc2db0c5..bb105cb2 100644 --- a/plugins/player_sync/test/components.js +++ b/plugins/player_sync/test/components.js @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; -import { components, enabledComponents, mergePlayerData, stripPlayerData } from "../dist/node/components.js"; +import { + components, enabledComponents, mergePlayerData, stripPlayerData, uploadedComponents, +} from "../dist/node/components.js"; const allComponents = Object.keys(components); @@ -69,15 +71,45 @@ describe("player_sync components", function() { } assert.equal(playerData.force, "enemy"); }); + it("should remove the god inventory with the controller", function() { + const playerData = structuredClone(stored); + stripPlayerData(playerData, new Set(allComponents.filter(c => c !== "controller"))); + assert.equal("inventories" in playerData, false); + assert.equal("controller" in playerData, false); + assert.equal(playerData.tag, "stored"); + }); + }); + + describe("uploadedComponents()", function() { + it("should sync everything when the upload has no components", function() { + assert.deepEqual([...uploadedComponents(undefined)], allComponents); + }); + it("should sync the components marked true", function() { + const enabled = uploadedComponents({ force: true, quick_bar: true, settings: false }); + assert.deepEqual([...enabled], ["force", "quick_bar"]); + }); + 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); + }); }); describe("mergePlayerData()", function() { - it("should store the upload as is when all components are synced", function() { - assert.deepEqual(mergePlayerData(stored, uploaded, new Set(allComponents)), uploaded); + function withComponents(data, disabled) { + const enabled = allComponents.filter(c => !disabled.includes(c)); + return { ...data, components: Object.fromEntries(enabled.map(c => [c, true])) }; + } + + it("should store the upload as is when it has no components", function() { + assert.deepEqual(mergePlayerData(stored, uploaded), uploaded); + }); + it("should not store the components of the upload", function() { + assert.deepEqual(mergePlayerData(stored, withComponents(uploaded, [])), uploaded); }); it("should keep the stored fields of disabled components", function() { - const enabled = new Set(allComponents.filter(c => !["force", "appearance"].includes(c))); - const merged = mergePlayerData(stored, uploaded, enabled); + const merged = mergePlayerData(stored, withComponents(uploaded, ["force", "appearance"])); assert.equal(merged.force, "player"); assert.deepEqual(merged.color, [1, 0, 0]); assert.equal(merged.tag, "stored"); @@ -85,27 +117,29 @@ describe("player_sync components", function() { assert.equal(merged.generation, 2); }); it("should drop disabled components without stored data", function() { - const merged = mergePlayerData(undefined, uploaded, new Set(allComponents.filter(c => c !== "force"))); + const merged = mergePlayerData(undefined, withComponents(uploaded, ["force"])); 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() { - const merged = mergePlayerData(stored, uploaded, new Set(allComponents.filter(c => c !== "controller"))); + 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"); }); it("should keep the character data for a player without one when the controller is not synced", function() { - const storedCharacter = { ...stored, character, crafting_queue: ["stored"], personal_logistic_slots: [] }; + 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; - delete god.personal_logistic_slots; - const enabled = new Set(allComponents.filter(c => c !== "controller")); - const merged = mergePlayerData(storedCharacter, god, enabled); + god.personal_logistic_slots = [{ name: "uploaded" }]; + 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"] }); }); }); diff --git a/plugins/player_sync/test/controller.js b/plugins/player_sync/test/controller.js index ec2771d6..b1da5e64 100644 --- a/plugins/player_sync/test/controller.js +++ b/plugins/player_sync/test/controller.js @@ -5,6 +5,7 @@ import path from "node:path"; import * as lib from "@clusterio/lib"; import * as mock from "../../../test/mock.js"; import controllerEntrypoint from "../dist/node/controller.js"; +import { components as allComponents } from "../dist/node/components.js"; import { plugin as info } from "../dist/node/index.js"; import { DatabaseStatsRequest, DownloadRequest, UploadRequest } from "../dist/node/messages.js"; @@ -75,14 +76,15 @@ describe("player_sync controller", function() { .playerData; } - it("should store the upload as is when everything is synced", async function() { + it("should store the upload as is when it has no components", async function() { const playerData = { generation: 2, name: "test", force: "enemy", tag: "uploaded" }; assert.deepEqual(await upload(playerData), playerData); }); - it("should keep stored fields the uploading instance does not sync", async function() { - disabled = ["force"]; + it("should merge by the payload's components and ignore the instance config", async function() { + disabled = ["appearance"]; + const components = Object.fromEntries(Object.keys(allComponents).map(c => [c, c !== "force"])); assert.deepEqual( - await upload({ generation: 2, name: "test", tag: "uploaded" }), + await upload({ generation: 2, name: "test", tag: "uploaded", components }), { generation: 2, name: "test", force: "player", tag: "uploaded" }, ); }); diff --git a/plugins/player_sync/test/instance.js b/plugins/player_sync/test/instance.js index 3cc5d9d5..e869666d 100644 --- a/plugins/player_sync/test/instance.js +++ b/plugins/player_sync/test/instance.js @@ -169,5 +169,14 @@ describe("player_sync", function() { await instance.hooks.instanceConfigFieldChanged.invoke("player_sync.sync_force", false, true); assert.equal(instance.server.rconCommands.length, 1); }); + it("should not send the components when the instance is not running", async function() { + instance.status = "stopped"; + try { + await instance.hooks.instanceConfigFieldChanged.invoke("player_sync.sync_force", false, true); + } finally { + instance.status = "running"; + } + assert.equal(instance.server.rconCommands.length, 0); + }); }); }); From 5adbb0283b3a0e5f4312d3d82a3fe24d1b9fb545 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:10:19 +0000 Subject: [PATCH 05/14] Sync a death on instances that don't sync the controller A player who died on an instance syncing inventories but not the controller uploaded no character, so the controller kept the stored one and the player got their items back on the next join while the corpse still held them. The scenario now respawns the ghost for a moment, as the controller synced path already does to read the hidden inventory, and uploads that character. Co-Authored-By: Claude Opus 5.5 --- plugins/player_sync/README.md | 2 +- plugins/player_sync/module/serialize.lua | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/plugins/player_sync/README.md b/plugins/player_sync/README.md index 0cdcd142..614074dc 100644 --- a/plugins/player_sync/README.md +++ b/plugins/player_sync/README.md @@ -106,7 +106,7 @@ Defaults to `true`. 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 waiting to respawn is respawned straight away if there is a synced character to give them. +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. Defaults to `true`. diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index ba8ad094..6351728a 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -857,21 +857,23 @@ 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_controller and (sync_inventories or sync_logistics) - and player.controller_type == defines.controllers.ghost and player.ticks_to_respawn - then + if sync_inventories 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_inventories then + if sync_controller 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 local character = player.character - player.ticks_to_respawn = serialized.ticks_to_respawn + player.ticks_to_respawn = ticks_to_respawn if character and character.valid then character.destroy() end From f3d67ed7d9da3abead26d8adf6eba7a13d25b768 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:10:19 +0000 Subject: [PATCH 06/14] Spell color the American way in the appearance option Co-Authored-By: Claude Opus 5.5 --- plugins/player_sync/README.md | 2 +- plugins/player_sync/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/player_sync/README.md b/plugins/player_sync/README.md index 614074dc..cfc59bc7 100644 --- a/plugins/player_sync/README.md +++ b/plugins/player_sync/README.md @@ -98,7 +98,7 @@ Defaults to `true`. ### player_sync.sync_appearance -Sync the player colour, chat colour and tag. +Sync the player color, chat color and tag. Defaults to `true`. diff --git a/plugins/player_sync/index.ts b/plugins/player_sync/index.ts index ef8df6ef..fe76dfba 100644 --- a/plugins/player_sync/index.ts +++ b/plugins/player_sync/index.ts @@ -52,7 +52,7 @@ export const plugin: lib.PluginDeclaration = { }, "player_sync.sync_appearance": { title: "Sync appearance", - description: "Sync the player colour, chat colour and tag.", + description: "Sync the player color, chat color and tag.", type: "boolean", initialValue: true, }, From aa06b9afe3bc5e15d316839b0d05db56c6f5b99f Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:25:47 +0000 Subject: [PATCH 07/14] 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 --- plugins/player_sync/README.md | 11 +- plugins/player_sync/components.ts | 33 +-- plugins/player_sync/module/player_sync.lua | 10 +- plugins/player_sync/module/serialize.lua | 264 +++++++++++++++++---- plugins/player_sync/test/components.js | 38 +-- 5 files changed, 256 insertions(+), 100 deletions(-) diff --git a/plugins/player_sync/README.md b/plugins/player_sync/README.md index cfc59bc7..1c8d9a83 100644 --- a/plugins/player_sync/README.md +++ b/plugins/player_sync/README.md @@ -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`. diff --git a/plugins/player_sync/components.ts b/plugins/player_sync/components.ts index ec86d93b..0c6469f5 100644 --- a/plugins/player_sync/components.ts +++ b/plugins/player_sync/components.ts @@ -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(); - 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; @@ -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; } diff --git a/plugins/player_sync/module/player_sync.lua b/plugins/player_sync/module/player_sync.lua index 5aefd033..124edc34 100644 --- a/plugins/player_sync/module/player_sync.lua +++ b/plugins/player_sync/module/player_sync.lua @@ -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 diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index 6351728a..b0db1272 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -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 +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 +--- @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 +--- @param inventories 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 diff --git a/plugins/player_sync/test/components.js b/plugins/player_sync/test/components.js index bb105cb2..53d96626 100644 --- a/plugins/player_sync/test/components.js +++ b/plugins/player_sync/test/components.js @@ -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); }); }); }); From 45fedf28a09c3772d6ebe466eec45c46471bb876 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:55:05 +0000 Subject: [PATCH 08/14] Strip downloads by the components the scenario started them with The instance read its config when the controller answered, so a sync_ field changed during the download could strip a component the scenario still expected, and the scenario treated it as empty. Co-Authored-By: Claude Opus 5.5 --- plugins/player_sync/components.ts | 10 +++++----- plugins/player_sync/instance.ts | 6 ++++-- plugins/player_sync/module/player_sync.lua | 1 + plugins/player_sync/test/components.js | 14 +++++++------- plugins/player_sync/test/instance.js | 13 +++++++++---- 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/plugins/player_sync/components.ts b/plugins/player_sync/components.ts index 0c6469f5..1e1c1b03 100644 --- a/plugins/player_sync/components.ts +++ b/plugins/player_sync/components.ts @@ -26,15 +26,15 @@ export function enabledComponents(config: lib.InstanceConfig) { } /** - * Components an upload was serialized with, all of them when not given. + * Components the scenario sent with an upload or download, all of them when not given. * * Lua may encode an empty table as an array, which reads as no components. */ -export function uploadedComponents(uploaded: Record | unknown[] | undefined) { - if (uploaded === undefined) { +export function scenarioComponents(sent: Record | unknown[] | undefined) { + if (sent === undefined) { return componentSet(() => true); } - return componentSet(component => !Array.isArray(uploaded) && uploaded[component] === true); + return componentSet(component => !Array.isArray(sent) && sent[component] === true); } /** Remove the fields of components which are not synced. */ @@ -58,7 +58,7 @@ export function stripPlayerData(playerData: IpcPlayerData, enabled: Set; - const enabled = uploadedComponents(uploadComponents); + const enabled = scenarioComponents(uploadComponents); function keep(keys: readonly string[]) { for (const key of keys) { const value = (stored as Record | undefined)?.[key]; diff --git a/plugins/player_sync/instance.ts b/plugins/player_sync/instance.ts index e1f9a810..008cb573 100644 --- a/plugins/player_sync/instance.ts +++ b/plugins/player_sync/instance.ts @@ -5,7 +5,7 @@ import type { InstancePluginContext } from "@clusterio/host"; import { AcquireRequest, AcquireResponse, ReleaseRequest, UploadRequest, DownloadRequest, DownloadResponse, IpcPlayerData, } from "./messages.js"; -import { enabledComponents, stripPlayerData } from "./components.js"; +import { enabledComponents, scenarioComponents, stripPlayerData } from "./components.js"; type IpcPlayerName = { player_name: string @@ -14,6 +14,8 @@ type IpcPlayerName = { type IpcDownloadRequest = { player_name: string, recipe_notifications?: string, + // Components the download was started with, all when missing + components?: Record | unknown[], } type IpcAcquireResponse = { @@ -199,7 +201,7 @@ export default async function(context: InstancePluginContext) { return; } - stripPlayerData(response.playerData, enabledComponents(instance.config)); + stripPlayerData(response.playerData, scenarioComponents(request.components)); await applyRecipeNotificationDelta(response.playerData, request.recipe_notifications, logger); const chunkSize = instance.config.get("player_sync.rcon_chunk_size"); diff --git a/plugins/player_sync/module/player_sync.lua b/plugins/player_sync/module/player_sync.lua index 124edc34..fed3783d 100644 --- a/plugins/player_sync/module/player_sync.lua +++ b/plugins/player_sync/module/player_sync.lua @@ -493,6 +493,7 @@ function player_sync.initiate_inventory_download(player, player_record, generati clusterio_api.send_json("player_sync_download", { player_name = player.name, recipe_notifications = recipe_notifications, + components = record.components, }) -- If this is a synced player turn them into a spectator while the diff --git a/plugins/player_sync/test/components.js b/plugins/player_sync/test/components.js index 53d96626..5f4f298a 100644 --- a/plugins/player_sync/test/components.js +++ b/plugins/player_sync/test/components.js @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { - components, enabledComponents, mergePlayerData, stripPlayerData, uploadedComponents, + components, enabledComponents, mergePlayerData, stripPlayerData, scenarioComponents, } from "../dist/node/components.js"; const allComponents = Object.keys(components); @@ -79,19 +79,19 @@ describe("player_sync components", function() { }); }); - describe("uploadedComponents()", function() { - it("should sync everything when the upload has no components", function() { - assert.deepEqual([...uploadedComponents(undefined)], allComponents); + describe("scenarioComponents()", function() { + it("should sync everything when no components are sent", function() { + assert.deepEqual([...scenarioComponents(undefined)], allComponents); }); it("should sync the components marked true", function() { - const enabled = uploadedComponents({ force: true, quick_bar: true, settings: false }); + const enabled = scenarioComponents({ force: true, quick_bar: true, settings: false }); assert.deepEqual([...enabled], ["force", "quick_bar"]); }); it("should sync nothing for an empty table encoded as an array", function() { - assert.equal(uploadedComponents([]).size, 0); + assert.equal(scenarioComponents([]).size, 0); }); it("should sync the controller without the inventories", function() { - assert.deepEqual([...uploadedComponents({ controller: true })], ["controller"]); + assert.deepEqual([...scenarioComponents({ controller: true })], ["controller"]); }); }); diff --git a/plugins/player_sync/test/instance.js b/plugins/player_sync/test/instance.js index e869666d..72e95886 100644 --- a/plugins/player_sync/test/instance.js +++ b/plugins/player_sync/test/instance.js @@ -129,14 +129,19 @@ describe("player_sync", function() { const expected = { generation: 1, name: "test", recipe_notifications: await encode({ add: ["a"] }) }; assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]); }); - it("should leave out components the instance does not sync", async function() { - instance.mockConfigEntries.set("player_sync.sync_force", false); - instance.mockConfigEntries.set("player_sync.sync_recipe_notifications", false); + it("should leave out components the download was not started with", async function() { respondWith({ generation: 1, name: "test", force: "enemy", tag: "tag", recipe_notifications: "x" }); - await download({ player_name: "test" }); + await download({ player_name: "test", components: { appearance: true } }); const expected = { generation: 1, name: "test", tag: "tag" }; assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]); }); + it("should ignore config changes made after the download was started", async function() { + instance.mockConfigEntries.set("player_sync.sync_force", false); + respondWith({ generation: 1, name: "test", force: "enemy", tag: "tag" }); + await download({ player_name: "test", components: { force: true } }); + const expected = { generation: 1, name: "test", force: "enemy" }; + assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]); + }); }); describe("set_components", function() { From 93ead85e5dde808f85f7e7a462713cd192873cec Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:55:05 +0000 Subject: [PATCH 09/14] Upload cleared logistic requests on Factorio 1.1 A character without requests serialized to nil, which the controller reads as a player with nowhere to hold them, so with inventories off the stored requests came back. Serialize an empty table instead, clear old slots before restoring on 1.1, and fix the set_personal_logistic_slot typo that made any 1.1 restore fail. Co-Authored-By: Claude Opus 5.5 --- plugins/player_sync/module/serialize.lua | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index b0db1272..1bbb64b7 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -291,7 +291,8 @@ function serialize.serialize_personal_logistic_slots(player) return serialized end - local serialized = nil + -- Empty when there are no requests, nil means the player has nowhere to hold them + local serialized = {} -- Serialize personal logistic slots local last_valid = 0 @@ -299,9 +300,6 @@ function serialize.serialize_personal_logistic_slots(player) local slot = player.get_personal_logistic_slot(i) if slot.name then last_valid = i - if not serialized then - serialized = {} - end serialized[tostring(i)] = { name = slot.name, min = slot.min, @@ -341,7 +339,9 @@ function serialize.deserialize_personal_logistic_slots(player, serialized) end -- If this is an array instead of a table, migrate to v2 format - if serialized[1] ~= nil then + if next(serialized) == nil then + return + elseif serialized[1] ~= nil then local section = logistic_point.add_section() for i, slot in pairs(serialized) do section.set_slot(i, { @@ -376,11 +376,18 @@ function serialize.deserialize_personal_logistic_slots(player, serialized) end end else - for i, slot in pairs(serialized) do - if slot ~= nil then - player.set_personal_slogistic_slot(tonumber(i), slot) + local last_valid = 0 + for i = 1, 65536 do + if player.get_personal_logistic_slot(i).name then + last_valid = i + player.clear_personal_logistic_slot(i) + elseif last_valid + 100 <= i then + break end end + for i, slot in pairs(serialized) do + player.set_personal_logistic_slot(tonumber(i), slot) + end end end From 905560d17b6e4406cdb4d3ddadca1529a6a87433 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:55:15 +0000 Subject: [PATCH 10/14] Keep local items and character state when replacing the character With sync_controller on and sync_inventories off, switching to a synced god, ghost or spectator destroyed the local character and its items. Serialize the local items first and place them in the new controller. Logistic requests and the flashlight live on the character, which the download replaces when inventories are synced. Save them with the local controller and restore them when their component is not synced. Co-Authored-By: Claude Opus 5.5 --- plugins/player_sync/README.md | 1 + plugins/player_sync/module/serialize.lua | 63 ++++++++++++++++-------- 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/plugins/player_sync/README.md b/plugins/player_sync/README.md index 1c8d9a83..8df6829e 100644 --- a/plugins/player_sync/README.md +++ b/plugins/player_sync/README.md @@ -108,6 +108,7 @@ 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. +With this off and the controller synced, the items the player has on the instance move to the synced controller the same way. Defaults to `true`. diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index 1bbb64b7..5b10ed4a 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -1088,17 +1088,24 @@ function serialize.serialize_player(player, failed_deserialization, components) end --- The controller a player has on this instance, restored in place of the synced one when it is not synced +--- +--- Logistic requests and the flashlight are kept on the character, which sync code may replace. --- @class LocalControllerState --- @field controller string? --- @field ticks_to_respawn number? +--- @field personal_logistic_slots table? +--- @field flashlight boolean? --- @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 + local character = player.character return { controller = controller_to_name[controller_type], ticks_to_respawn = player.ticks_to_respawn, + personal_logistic_slots = character and serialize.serialize_personal_logistic_slots(player), + flashlight = character and player.is_flashlight_enabled(), } end @@ -1182,17 +1189,35 @@ function serialize.deserialize_player(player, serialized, components, local_cont state = { controller = serialized.controller, ticks_to_respawn = serialized.ticks_to_respawn } 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 ( + local change_controller = state and ( player.controller_type ~= target_controller or to_ghost and (restore_controller or sync_inventories or sync_logistics) - ) then + ) + + -- Items can come from a character, a god or a ghost and go to whatever controller the player ends up with + local items = serialized + local place = sync_inventories + if change_controller and not sync_inventories then + -- The local items are kept, but they have to move to the new controller + items = serialize.serialize_player(player, {}, { inventories = true }) + place = true + end + local inventories = items.character and items.character.inventories or items.inventories + local queue = items.crafting_queue + local spill + + local logistic_slots = serialized.personal_logistic_slots + if not sync_logistics then + logistic_slots = local_controller and local_controller.personal_logistic_slots + end + local flashlight = serialized.flashlight + if not syncs(components, "settings") then + flashlight = local_controller and local_controller.flashlight + end + + if state and change_controller then if state.controller == "character" then -- Create a character but do not destroy an existing one ensure_character(player) @@ -1205,10 +1230,8 @@ 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 sync_logistics and serialized.personal_logistic_slots then - serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots) - end - if sync_inventories then + serialize.deserialize_personal_logistic_slots(player, logistic_slots) + if place then spill = serialize.place_items( serialize.item_destinations(player), inventories, queue and queue.ingredients ) @@ -1252,12 +1275,12 @@ function serialize.deserialize_player(player, serialized, components, local_cont if restore_controller and serialized.cheat_mode ~= nil then player.cheat_mode = serialized.cheat_mode end + if flashlight == true then + player.enable_flashlight() + elseif flashlight == false then + player.disable_flashlight() + 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 @@ -1268,8 +1291,8 @@ 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 - if sync_inventories and not to_ghost then - local stats = character and serialized.character + if place and not to_ghost then + local stats = character and items.character if stats then serialize.deserialize_character(character, stats) end @@ -1284,8 +1307,8 @@ function serialize.deserialize_player(player, serialized, components, local_cont serialize.deserialize_crafting_queue(player, queue) end end - if character and sync_logistics then - serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots) + if character then + serialize.deserialize_personal_logistic_slots(player, logistic_slots) end -- Deserialize quick bar (named hotbar in old data) From 923ba10d2a45e90d4d12b34fa08d65d45271eb76 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:01:22 +0000 Subject: [PATCH 11/14] Test the download race and cleared requests without inventories Co-Authored-By: Claude Opus 5.5 --- plugins/player_sync/test/components.js | 9 +++++++++ plugins/player_sync/test/instance.js | 11 ++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/plugins/player_sync/test/components.js b/plugins/player_sync/test/components.js index 5f4f298a..2acea5a5 100644 --- a/plugins/player_sync/test/components.js +++ b/plugins/player_sync/test/components.js @@ -148,5 +148,14 @@ describe("player_sync components", function() { const merged = mergePlayerData(storedSlots, withComponents(noSlots, [])); assert.equal("personal_logistic_slots" in merged, false); }); + it("should take cleared logistic requests from an upload without inventories", function() { + const storedSlots = { ...stored, personal_logistic_slots: [{ name: "stored" }] }; + // Factorio 1.1 serializes a character without requests as an empty table + const cleared = { ...uploaded, personal_logistic_slots: {} }; + delete cleared.character; + delete cleared.crafting_queue; + const merged = mergePlayerData(storedSlots, withComponents(cleared, ["inventories"])); + assert.deepEqual(merged.personal_logistic_slots, {}); + }); }); }); diff --git a/plugins/player_sync/test/instance.js b/plugins/player_sync/test/instance.js index 72e95886..93689029 100644 --- a/plugins/player_sync/test/instance.js +++ b/plugins/player_sync/test/instance.js @@ -135,9 +135,14 @@ describe("player_sync", function() { const expected = { generation: 1, name: "test", tag: "tag" }; assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]); }); - it("should ignore config changes made after the download was started", async function() { - instance.mockConfigEntries.set("player_sync.sync_force", false); - respondWith({ generation: 1, name: "test", force: "enemy", tag: "tag" }); + it("should ignore config changes made while the controller responds", async function() { + instance.connector.once("send", message => { + instance.mockConfigEntries.set("player_sync.sync_force", false); + instance.connector.emit("message", new lib.MessageResponse( + 1, message.dst, message.src, + new DownloadResponse({ generation: 1, name: "test", force: "enemy", tag: "tag" }), + )); + }); await download({ player_name: "test", components: { force: true } }); const expected = { generation: 1, name: "test", force: "enemy" }; assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]); From a1a57b8c97acef3e5826427f4ba93304bca8c98f Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:36:22 +0000 Subject: [PATCH 12/14] Place synced items from a list of stacks instead of script inventories place_items no longer creates script inventories for what does not fit. Each serialized inventory is split at the size of its destination: the slots that fit are restored slot for slot and the rest become serialized stacks, which are inserted into the free space of the same inventory and then main, guns, ammo and armor. What is still left over is returned as a list and spill_items puts it into a corpse sized to hold it. This keeps 1.0 out of reach of game.create_inventory. The armor-first fill order is gone, as the bonus slots from armor equipment only apply on the next tick anyway (#1106). Filters are no longer stripped for destinations, they are the same inventory type the data came from. deserialize_inventory concatenated the filter table in its error print on 2.0, which errored out the whole restore when an inventory did not support filters. Co-Authored-By: Claude Opus 5.5 --- packages/host/modules/clusterio/serialize.lua | 4 +- plugins/player_sync/module/serialize.lua | 241 +++++++++--------- 2 files changed, 125 insertions(+), 120 deletions(-) diff --git a/packages/host/modules/clusterio/serialize.lua b/packages/host/modules/clusterio/serialize.lua index 1d098cb7..88d58d50 100644 --- a/packages/host/modules/clusterio/serialize.lua +++ b/packages/host/modules/clusterio/serialize.lua @@ -292,10 +292,10 @@ function serialize.deserialize_inventory(inventory, serialized) if entry.f then local call_success, call_return = pcall(inventory.set_filter, index, entry.f) if not call_success then - print("Error: '" .. call_return .. "' thrown setting filter " .. entry.f) + print("Error: '" .. call_return .. "' thrown setting filter " .. serpent.line(entry.f)) elseif not call_return then - print("Error: Failed to set filter " .. entry.f) + print("Error: Failed to set filter " .. serpent.line(entry.f)) end end diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index 5b10ed4a..971671b7 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -81,36 +81,88 @@ function serialize.deserialize_character_grid(character, serialized) 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) +--- Copy of a serialized item entry without the slot index, repeat count and filter +--- @param entry table +--- @return table +local function stack_entry(entry) + local stack = {} + for key, value in pairs(entry) do + stack[key] = value end - return last + stack.s = nil + stack.r = nil + stack.f = nil + return stack end ---- Copy of a serialized inventory without slot filters, for inventories which do not support them +--- Split a serialized inventory into the slots which fit into an inventory of size and the stacks of those which do not --- @param serialized table ---- @return table -local function without_filters(serialized) - local copy = { i = {} } +--- @param size number +--- @return table fitting, table[] rest +local function split_inventory(serialized, size) + local fitting = { b = serialized.b, i = {} } + local rest = {} + local last = 0 for _, entry in ipairs(serialized.i) do - local item = {} - for key, value in pairs(entry) do - item[key] = value + local first = entry.s or last + 1 + last = first + (entry.r or 0) + if last <= size then + table.insert(fitting.i, entry) + else + if first <= size then + local part = stack_entry(entry) + part.s = first + part.r = size > first and size - first or nil + part.f = entry.f + table.insert(fitting.i, part) + end + if entry.n or entry.e then + for _ = math.max(first, size + 1), last do + table.insert(rest, stack_entry(entry)) + end + end end - item.f = nil - table.insert(copy.i, item) end - return copy + return fitting, rest +end + +local stack_definition_keys = { n = true, c = true, q = true, h = true, p = true, d = true, a = true } + +--- Insert as much of a serialized stack as possible into an inventory, reducing its count by what was inserted +--- @param inventory LuaInventory +--- @param stack table +--- @return boolean placed true if all of it was inserted +local function insert_stack(inventory, stack) + local plain = true + for key in pairs(stack) do + plain = plain and stack_definition_keys[key] or false + end + + -- Stacks without labels, grids, inventories or export strings can be merged with what is in the inventory + if plain then + local inserted = inventory.insert({ + name = stack.n, count = stack.c, quality = stack.q, health = stack.h, + spoil_percent = stack.p, durability = stack.d, ammo = stack.a, + }) + stack.c = stack.c - inserted + return stack.c <= 0 + end + + -- Export strings have no name, and 1.1 does not take nil for it + local slot + if stack.n then + slot = inventory.find_empty_stack(stack.n) + else + slot = inventory.find_empty_stack() + end + if not slot or stack.n and not slot.can_set_stack({ name = stack.n, count = stack.c, quality = stack.q }) then + return false + end + clusterio_serialize.deserialize_item_stack(slot, stack) + return slot.valid_for_read end --- Inventories the items of a player go into, by the names used in serialized data @@ -131,131 +183,84 @@ function serialize.item_destinations(player) return destinations end ---- Insert as much of a stack as possible, starting with the inventory it came from ---- @param stack LuaItemStack ---- @param destinations table ---- @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. +--- Each inventory goes slot for slot into the destination of the same name as far as it fits, the slots past its +--- size are inserted where there is space. Returns the serialized stacks which did not fit, see spill_items. --- @param destinations table --- @param inventories table? --- @param items { name: string, count: integer, quality: string? }[]? ---- @return LuaInventory? +--- @return table[] leftovers 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 + -- Every inventory is restored before inserting into them, as restoring a slot replaces what is in it + local pending = {} + for name, serialized in pairs(inventories or {}) do 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 }) + local fitting, rest = split_inventory(serialized, destination and #destination or 0) + if destination then + clusterio_serialize.deserialize_inventory(destination, fitting) end + for _, stack in ipairs(rest) do + table.insert(pending, { from = destination, stack = stack }) + end + end + for _, item in pairs(items or {}) do + table.insert(pending, { stack = { n = item.name, c = item.count, q = item.quality } }) 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 + local leftovers = {} + for _, entry in ipairs(pending) do + local stack = entry.stack + local placed = entry.from and insert_stack(entry.from, stack) + for _, name in ipairs(overflow_order) do + placed = placed or destinations[name] and insert_stack(destinations[name], stack) 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 + if not placed then + table.insert(leftovers, stack) end - inventory.destroy() end - return spill + return leftovers end ---- Put items which did not fit into a corpse below the player +--- Put serialized stacks 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 +--- @param stacks table[]? +function serialize.spill_items(player, stacks) + if not stacks or not next(stacks) then return end - spill.sort_and_merge() - local count = #spill - spill.count_empty_stacks() + + -- Plain stacks can hold more than a stack's worth + local slots = 0 + for _, stack in ipairs(stacks) do + local prototype = stack.n and compat.prototypes.item[stack.n] + slots = slots + (prototype and math.ceil((stack.c or 1) / prototype.stack_size) or 1) + end + local corpse = player.surface.create_entity({ name = "character-corpse", position = player.position, - inventory_size = count, + inventory_size = slots, 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") + if not corpse then + log("ERROR: Unable to create a corpse for " .. player.name .. ", voiding " .. #stacks .. " stacks of items") + return end - spill.destroy() + + local inventory = assert(corpse.get_inventory(defines.inventory.character_corpse)) + for _, stack in ipairs(stacks) do + if not insert_stack(inventory, stack) then + log("ERROR: Unable to place " .. (stack.n or "an exported item") .. " into the corpse of " .. player.name) + 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." + ) end -- Personal logistic slots is a table mapping string indexes to a table with the following fields: @@ -1173,7 +1178,7 @@ end --- @param serialized SerializedPlayerData --- @param components SyncComponents --- @param local_controller LocalControllerState? Controller to restore when the controller is not synced ---- @return FailedDeserializationPlayerData?, LuaInventory? Items which did not fit, see spill_items +--- @return FailedDeserializationPlayerData?, table[]? Serialized stacks 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") From ad779a8799a9c45d9e826ace96179d9ceb145b02 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:36:33 +0000 Subject: [PATCH 13/14] Keep a ghost's local requests and a remote view character's crafts serialize_local_controller now respawns a player waiting to respawn for a moment to read the logistic requests hidden in the ghost, when logistics are not synced. Without this the requests were lost when the download turned the ghost into a spectator. deserialize_player compares the physical controller with the one it restores. A player in remote view with a character was seen as changing controller, so with inventories off their crafting queue was serialized and queued a second time on the same character. The ghost branch only sets the force when the force is synced, it errored on the stripped field otherwise. Co-Authored-By: Claude Opus 5.5 --- plugins/player_sync/module/serialize.lua | 81 ++++++++++++++++-------- 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index 971671b7..4a20b3b9 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -991,6 +991,32 @@ function serialize.newly_synced(old_components, new_components) return false end +--- @param player LuaPlayer +--- @return boolean +local function waiting_to_respawn(player) + return player.controller_type == defines.controllers.ghost and player.ticks_to_respawn ~= nil +end + +--- Call fn with a player waiting to respawn temporarily respawned +--- +--- For the waiting to respawn state the inventory logistic requests and filters are hidden on the player, and are +--- only accessible in the character controller. +--- @param player LuaPlayer +--- @param fn fun() +local function with_respawned(player, fn) + local ticks_to_respawn = player.ticks_to_respawn + player.ticks_to_respawn = nil -- Respawn now + + fn() + + -- Go back to waiting for respawn + local character = player.character + player.ticks_to_respawn = ticks_to_respawn + if character and character.valid then + character.destroy() + end +end + --- @param player LuaPlayer --- @param failed_deserialization FailedDeserializationPlayerData --- @param components SyncComponents @@ -1034,26 +1060,15 @@ function serialize.serialize_player(player, failed_deserialization, components) 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 (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_inventories then - serialized.inventories = serialize.serialize_inventories(player, character_inventories) - end - - -- Go back to waiting for respawn - local character = player.character - player.ticks_to_respawn = ticks_to_respawn - if character and character.valid then - character.destroy() - end + if (sync_inventories or sync_logistics) and waiting_to_respawn(player) then + with_respawned(player, function() + 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 + end) end -- Serialize character @@ -1102,16 +1117,24 @@ end --- @field flashlight boolean? --- @param player LuaPlayer +--- @param components SyncComponents --- @return LocalControllerState -function serialize.serialize_local_controller(player) +function serialize.serialize_local_controller(player, components) local controller_type = v2_remote_controller and player.physical_controller_type or player.controller_type - local character = player.character - return { + --- @type LocalControllerState + local state = { controller = controller_to_name[controller_type], ticks_to_respawn = player.ticks_to_respawn, - personal_logistic_slots = character and serialize.serialize_personal_logistic_slots(player), - flashlight = character and player.is_flashlight_enabled(), } + if player.character then + state.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player) + state.flashlight = player.is_flashlight_enabled() + elseif not syncs(components, "logistics") and waiting_to_respawn(player) then + with_respawned(player, function() + state.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player) + end) + end + return state end --- Find a surface characters can exist on, used when the player is on a space platform surface @@ -1194,10 +1217,12 @@ function serialize.deserialize_player(player, serialized, components, local_cont state = { controller = serialized.controller, ticks_to_respawn = serialized.ticks_to_respawn } end + -- A player in remote view keeps their physical controller + local current_controller = v2_remote_controller and player.physical_controller_type or player.controller_type local target_controller = state and defines.controllers[state.controller] local to_ghost = state and state.controller == "ghost" local change_controller = state and ( - player.controller_type ~= target_controller + current_controller ~= target_controller or to_ghost and (restore_controller or sync_inventories or sync_logistics) ) @@ -1233,7 +1258,9 @@ function serialize.deserialize_player(player, serialized, components, local_cont 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 + if syncs(components, "force") and serialized.force then + player.force = serialized.force + end local character = ensure_character(player) serialize.deserialize_personal_logistic_slots(player, logistic_slots) if place then From 0504fe15ed44d2b3c803df91b814062ad629050e Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:36:40 +0000 Subject: [PATCH 14/14] Let the instance decide the components of a download The scenario no longer sends its components with the download request. The instance strips the download by its own config and sends the components it used with the first chunk. If they differ from the ones the scenario started the download with, because the config changed in between, the scenario restarts the download. A pending download is now marked with sync_in_progress instead of clearing dirty while spectating, and it is set for every download rather than only when inventories are synced. Uploads are skipped while it is set, so a player who leaves during a download with inventories off no longer uploads their stale state. SyncComponent names the components in the Lua annotations. Co-Authored-By: Claude Opus 5.5 --- plugins/player_sync/instance.ts | 20 ++++++---- .../player_sync/module/download_inventory.lua | 10 ++++- .../module/gui/dialog_failed_download.lua | 1 + plugins/player_sync/module/player_sync.lua | 27 ++++++++----- plugins/player_sync/module/serialize.lua | 28 ++++++++++++- plugins/player_sync/test/instance.js | 40 ++++++++++++++----- 6 files changed, 96 insertions(+), 30 deletions(-) diff --git a/plugins/player_sync/instance.ts b/plugins/player_sync/instance.ts index 008cb573..b4e6b978 100644 --- a/plugins/player_sync/instance.ts +++ b/plugins/player_sync/instance.ts @@ -5,7 +5,7 @@ import type { InstancePluginContext } from "@clusterio/host"; import { AcquireRequest, AcquireResponse, ReleaseRequest, UploadRequest, DownloadRequest, DownloadResponse, IpcPlayerData, } from "./messages.js"; -import { enabledComponents, scenarioComponents, stripPlayerData } from "./components.js"; +import { type Component, enabledComponents, stripPlayerData } from "./components.js"; type IpcPlayerName = { player_name: string @@ -14,8 +14,11 @@ type IpcPlayerName = { type IpcDownloadRequest = { player_name: string, recipe_notifications?: string, - // Components the download was started with, all when missing - components?: Record | unknown[], +} + +/** Components as an escaped JSON object for the scenario. */ +function componentsJson(enabled: Set) { + return lib.escapeString(JSON.stringify(Object.fromEntries([...enabled].map(name => [name, true])))); } type IpcAcquireResponse = { @@ -201,16 +204,20 @@ export default async function(context: InstancePluginContext) { return; } - stripPlayerData(response.playerData, scenarioComponents(request.components)); + const enabled = enabledComponents(instance.config); + stripPlayerData(response.playerData, enabled); await applyRecipeNotificationDelta(response.playerData, request.recipe_notifications, logger); const chunkSize = instance.config.get("player_sync.rcon_chunk_size"); const chunks = chunkify(chunkSize, JSON.stringify(response.playerData)); logger.verbose(`Sending inventory for ${playerName} in ${chunks.length} chunks`); + // The scenario restarts the download if it was started with other components + const components = componentsJson(enabled); for (let i = 0; i < chunks.length; i++) { const chunk = lib.escapeString(chunks[i]); + const extra = i === 0 ? `,'${components}'` : ""; await instance.sendRcon( - `/sc player_sync.download_inventory('${playerName}','${chunk}',${i + 1},${chunks.length})`, + `/sc player_sync.download_inventory('${playerName}','${chunk}',${i + 1},${chunks.length}${extra})`, true, plugin.name, ); @@ -245,8 +252,7 @@ export default async function(context: InstancePluginContext) { /** Tell the scenario which components are synced. */ async function sendComponents() { - const enabled = Object.fromEntries([...enabledComponents(instance.config)].map(name => [name, true])); - const json = lib.escapeString(JSON.stringify(enabled)); + const json = componentsJson(enabledComponents(instance.config)); await instance.sendRcon(`/sc player_sync.set_components("${json}")`, true, plugin.name); } diff --git a/plugins/player_sync/module/download_inventory.lua b/plugins/player_sync/module/download_inventory.lua index 18f96828..d6f6e54d 100644 --- a/plugins/player_sync/module/download_inventory.lua +++ b/plugins/player_sync/module/download_inventory.lua @@ -3,9 +3,11 @@ local ensure_character = require("modules/player_sync/ensure_character") local restore_position = require("modules/player_sync/restore_position") local get_script_data = require("modules/player_sync/get_script_data") local serialize = require("modules/player_sync/serialize") +local compat = require("modules/clusterio/compat") -local function download_inventory(player_name, data, number, total) +--- @param components string? JSON of the components the instance sent the data with, given with the first part +local function download_inventory(player_name, data, number, total, components) local player = game.get_player(player_name) if player == nil then rcon.print("Player " .. player_name .. " does not exist") @@ -20,6 +22,11 @@ local function download_inventory(player_name, data, number, total) end local player_record = script_data.players[player_name] + -- The components changed after the download was started + local sent_components = number == 1 and components and compat.json_to_table(components) + if sent_components and not serialize.same_components(record.components, sent_components) then + record.restart = true + end if record.restart then rcon.print("Restarting outdated download") player_sync.initiate_inventory_download(player, player_record, record.generation) @@ -44,6 +51,7 @@ local function download_inventory(player_name, data, number, total) script_data.active_downloads[player_name] = nil player_record.dirty = player.connected + player_record.sync_in_progress = false player_record.sync = true player_record.components = record.components player_record.generation = record.generation diff --git a/plugins/player_sync/module/gui/dialog_failed_download.lua b/plugins/player_sync/module/gui/dialog_failed_download.lua index 4859e40b..1884d007 100644 --- a/plugins/player_sync/module/gui/dialog_failed_download.lua +++ b/plugins/player_sync/module/gui/dialog_failed_download.lua @@ -131,6 +131,7 @@ dialog_failed_download.events = { player.gui.screen.dialog_failed_download.destroy() player_sync.players[player.name].dirty = true player_sync.players[player.name].sync = false + player_sync.players[player.name].sync_in_progress = false player_sync.players[player.name].local_controller = nil elseif event.element.name == "player_sync_failed_download_retry" then diff --git a/plugins/player_sync/module/player_sync.lua b/plugins/player_sync/module/player_sync.lua index fed3783d..6285c8fe 100644 --- a/plugins/player_sync/module/player_sync.lua +++ b/plugins/player_sync/module/player_sync.lua @@ -31,9 +31,9 @@ local function start_sync(player_record, components) end -- Remember the controller the player had before sync code replaced it, restored when the controller is not synced -local function save_local_controller(player, player_record) +local function save_local_controller(player, player_record, components) if not player_record.local_controller then - player_record.local_controller = serialize.serialize_local_controller(player) + player_record.local_controller = serialize.serialize_local_controller(player, components) end end @@ -42,6 +42,7 @@ local function create_player(player, dirty) local script_data = get_script_data(true) -- no early return to support loading from single player script_data.players[player.name] = { dirty = dirty, -- Player inventory has changes that should be persisted + sync_in_progress = false, -- Player data is being downloaded, the current state should not be persisted sync = false, -- Player inventory is synced from the controller generation = 0, } @@ -115,7 +116,8 @@ function player_sync.deserialize_player(player, finished_record) local script_data = get_script_data() local player_record = script_data.players[player.name] - local local_controller = player_record.local_controller or serialize.serialize_local_controller(player) + local local_controller = player_record.local_controller + or serialize.serialize_local_controller(player, finished_record.components) -- Stash temporary inventory if it exists, without inventory sync the current inventory is the one to keep local stashed_corpse @@ -315,17 +317,18 @@ function player_sync.sync_player(acquire_response) local player_record = script_data.players[acquire_response.player_name] if acquire_response.status ~= "acquired" then - if player_record.sync and not player_record.dirty then + if player_record.sync and (not player_record.dirty or player_record.sync_in_progress) then if serialize.syncs(player_record.components, "inventories") and player.controller_type == defines.controllers.character then - save_local_controller(player, player_record) + save_local_controller(player, player_record, player_record.components) local character = player.character player.set_controller({ type = defines.controllers.spectator }) if character then character.destroy() end end player_record.sync = false end + player_record.sync_in_progress = false dialog_failed_download.create(player, acquire_response) return end @@ -351,6 +354,7 @@ function player_sync.sync_player(acquire_response) start_sync(player_record, script_data.components) player_record.dirty = true + player_record.sync_in_progress = false end end @@ -366,10 +370,12 @@ function player_sync.finish_download(player, finished_record) -- Treat this as a temporary inventory so the incomplete state is not uploaded over the synced data player_record.dirty = true player_record.sync = false + player_record.sync_in_progress = false return end player_record.dirty = true + player_record.sync_in_progress = false start_sync(player_record, finished_record.components) player_record.generation = finished_record.generation end @@ -426,7 +432,7 @@ player_sync.events[defines.events.on_pre_player_left_game] = function(event) return end - if not player_record.dirty or not player_record.sync then + if not player_record.dirty or not player_record.sync or player_record.sync_in_progress then -- Nothing to upload, release the acquisition so other instances are not blocked clusterio_api.send_json("player_sync_release", { player_name = player.name }) return @@ -493,9 +499,11 @@ function player_sync.initiate_inventory_download(player, player_record, generati clusterio_api.send_json("player_sync_download", { player_name = player.name, recipe_notifications = recipe_notifications, - components = record.components, }) + -- Indicate the current state shouldn't be persisted + player_record.sync_in_progress = true + -- If this is a synced player turn them into a spectator while the -- player data is downloading if player_record.sync and serialize.syncs(record.components, "inventories") then @@ -511,13 +519,10 @@ function player_sync.initiate_inventory_download(player, player_record, generati record.vehicle = player.vehicle end - save_local_controller(player, player_record) + save_local_controller(player, player_record, record.components) local character = player.character player.set_controller({ type = defines.controllers.spectator }) if character ~= nil then character.destroy() end - - -- Indicate this state shouldn't be persisted - player_record.dirty = false end progress_dialog.display(player, 0, 1) diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index 4a20b3b9..7f9b3b2a 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -963,21 +963,45 @@ function serialize.deserialize_game_view_settings(player, serialized) end end +--- @alias SyncComponent +--- | "controller" +--- | "force" +--- | "appearance" +--- | "inventories" +--- | "logistics" +--- | "quick_bar" +--- | "settings" +--- | "recipe_notifications" + --- Components which are synced, nil when all of them are ---- @alias SyncComponents table? +--- @alias SyncComponents table? --- @param components SyncComponents ---- @param name string +--- @param name SyncComponent --- @return boolean local function syncs(components, name) return components == nil or components[name] == true end serialize.syncs = syncs +--- @type SyncComponent[] local component_names = { "controller", "force", "appearance", "inventories", "logistics", "quick_bar", "settings", "recipe_notifications", } +--- True if the same components are synced in both +--- @param a SyncComponents +--- @param b SyncComponents +--- @return boolean +function serialize.same_components(a, b) + for _, name in ipairs(component_names) do + if syncs(a, name) ~= syncs(b, name) then + return false + end + end + return true +end + --- True if a component synced in new_components was not synced in old_components --- @param old_components SyncComponents --- @param new_components SyncComponents diff --git a/plugins/player_sync/test/instance.js b/plugins/player_sync/test/instance.js index 93689029..269e87dc 100644 --- a/plugins/player_sync/test/instance.js +++ b/plugins/player_sync/test/instance.js @@ -104,9 +104,10 @@ describe("player_sync", function() { await lib.wait(1); } } - function downloadCommand(playerData) { + function downloadCommand(playerData, enabled = Object.keys(components)) { const json = lib.escapeString(JSON.stringify(playerData)); - return `/sc player_sync.download_inventory('test','${json}',1,1)`; + const sent = lib.escapeString(JSON.stringify(Object.fromEntries(enabled.map(c => [c, true])))); + return `/sc player_sync.download_inventory('test','${json}',1,1,'${sent}')`; } it("should send an empty download when the controller has no data", async function() { @@ -129,13 +130,16 @@ describe("player_sync", function() { const expected = { generation: 1, name: "test", recipe_notifications: await encode({ add: ["a"] }) }; assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]); }); - it("should leave out components the download was not started with", async function() { + it("should leave out components which are not synced", async function() { + for (const component of Object.keys(components)) { + instance.mockConfigEntries.set(`player_sync.sync_${component}`, component === "appearance"); + } respondWith({ generation: 1, name: "test", force: "enemy", tag: "tag", recipe_notifications: "x" }); - await download({ player_name: "test", components: { appearance: true } }); + await download({ player_name: "test" }); const expected = { generation: 1, name: "test", tag: "tag" }; - assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]); + assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected, ["appearance"])]); }); - it("should ignore config changes made while the controller responds", async function() { + it("should send the components it stripped by when the config changes during the download", async function() { instance.connector.once("send", message => { instance.mockConfigEntries.set("player_sync.sync_force", false); instance.connector.emit("message", new lib.MessageResponse( @@ -143,9 +147,27 @@ describe("player_sync", function() { new DownloadResponse({ generation: 1, name: "test", force: "enemy", tag: "tag" }), )); }); - await download({ player_name: "test", components: { force: true } }); - const expected = { generation: 1, name: "test", force: "enemy" }; - assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]); + await download({ player_name: "test" }); + const expected = { generation: 1, name: "test", tag: "tag" }; + const enabled = Object.keys(components).filter(c => c !== "force"); + assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected, enabled)]); + }); + it("should only send the components with the first chunk", async function() { + instance.mockConfigEntries.set("player_sync.rcon_chunk_size", 10); + try { + respondWith({ generation: 1, name: "test" }); + await download({ player_name: "test" }); + for (let i = 0; i < 50 && instance.server.rconCommands.length < 3; i++) { + await lib.wait(1); + } + const [first, ...rest] = instance.server.rconCommands; + assert.match(first, /,1,3,'.+'\)$/); + for (const command of rest) { + assert.match(command, /,\d,3\)$/); + } + } finally { + instance.mockConfigEntries.set("player_sync.rcon_chunk_size", 100000); + } }); });