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); + }); }); });