mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Merge pull request #1061 from bbassie/feature/inventory-sync-components
Allow inventory sync components to be turned off per instance
This commit is contained in:
14 files changed
+1076
-139
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -53,6 +53,12 @@ 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.
|
||||
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.
|
||||
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 +83,55 @@ 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.
|
||||
|
||||
Defaults to `true`.
|
||||
|
||||
### player_sync.sync_force
|
||||
|
||||
Sync the force the player is on.
|
||||
|
||||
Defaults to `true`.
|
||||
|
||||
### player_sync.sync_appearance
|
||||
|
||||
Sync the player color, chat color and tag.
|
||||
|
||||
Defaults to `true`.
|
||||
|
||||
### player_sync.sync_inventories
|
||||
|
||||
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`.
|
||||
|
||||
### 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`.
|
||||
@@ -0,0 +1,82 @@
|
||||
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 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"],
|
||||
recipe_notifications: ["recipe_notifications"],
|
||||
} as const;
|
||||
|
||||
export type Component = keyof typeof components;
|
||||
|
||||
function componentSet(isEnabled: (component: Component) => boolean) {
|
||||
return new Set((Object.keys(components) as Component[]).filter(isEnabled));
|
||||
}
|
||||
|
||||
/** Components synced on an instance. */
|
||||
export function enabledComponents(config: lib.InstanceConfig) {
|
||||
return componentSet(component => Boolean(config.get(`player_sync.sync_${component}`)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 scenarioComponents(sent: Record<string, boolean> | unknown[] | undefined) {
|
||||
if (sent === undefined) {
|
||||
return componentSet(() => true);
|
||||
}
|
||||
return componentSet(component => !Array.isArray(sent) && sent[component] === true);
|
||||
}
|
||||
|
||||
/** Remove the fields of components which are not synced. */
|
||||
export function stripPlayerData(playerData: IpcPlayerData, enabled: Set<Component>) {
|
||||
const fields = playerData as Record<string, unknown>;
|
||||
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 upload was not serialized with keep the stored fields.
|
||||
* Logistic requests are kept when the player has nowhere to hold them.
|
||||
*/
|
||||
export function mergePlayerData(stored: IpcPlayerData | undefined, uploaded: IpcPlayerData) {
|
||||
const { components: uploadComponents, ...merged } = uploaded as IpcPlayerData & Record<string, unknown>;
|
||||
const enabled = scenarioComponents(uploadComponents);
|
||||
function keep(keys: readonly string[]) {
|
||||
for (const key of keys) {
|
||||
const value = (stored as Record<string, unknown> | 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 (!uploaded.character && !uploaded.personal_logistic_slots) {
|
||||
keep(["personal_logistic_slots"]);
|
||||
}
|
||||
return merged as IpcPlayerData;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ControllerPluginContext, InstanceRecord } from "@clusterio/controller";
|
||||
import type { IpcPlayerData } from "./messages.js";
|
||||
import { mergePlayerData } from "./components.js";
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "path";
|
||||
@@ -165,7 +166,7 @@ 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));
|
||||
playerDatastoreDirty = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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, cheat mode " +
|
||||
"and god inventory. 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 color, chat color and tag.",
|
||||
type: "boolean",
|
||||
initialValue: true,
|
||||
},
|
||||
"player_sync.sync_inventories": {
|
||||
title: "Sync inventories",
|
||||
description:
|
||||
"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,
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { InstancePluginContext } from "@clusterio/host";
|
||||
import {
|
||||
AcquireRequest, AcquireResponse, ReleaseRequest, UploadRequest, DownloadRequest, DownloadResponse, IpcPlayerData,
|
||||
} from "./messages.js";
|
||||
import { type Component, enabledComponents, stripPlayerData } from "./components.js";
|
||||
|
||||
type IpcPlayerName = {
|
||||
player_name: string
|
||||
@@ -15,6 +16,11 @@ type IpcDownloadRequest = {
|
||||
recipe_notifications?: string,
|
||||
}
|
||||
|
||||
/** Components as an escaped JSON object for the scenario. */
|
||||
function componentsJson(enabled: Set<Component>) {
|
||||
return lib.escapeString(JSON.stringify(Object.fromEntries([...enabled].map(name => [name, true]))));
|
||||
}
|
||||
|
||||
type IpcAcquireResponse = {
|
||||
player_name: string,
|
||||
status: string,
|
||||
@@ -198,15 +204,20 @@ export default async function(context: InstancePluginContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
@@ -239,6 +250,20 @@ export default async function(context: InstancePluginContext) {
|
||||
),
|
||||
);
|
||||
|
||||
/** Tell the scenario which components are synced. */
|
||||
async function sendComponents() {
|
||||
const json = componentsJson(enabledComponents(instance.config));
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -4,23 +4,26 @@ 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<string, boolean>,
|
||||
game_view_settings?: Record<string, boolean>,
|
||||
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,
|
||||
recipe_notifications?: string,
|
||||
// Components the upload was serialized with, all when missing
|
||||
components?: Record<string, boolean> | unknown[],
|
||||
}
|
||||
|
||||
const jsonPlayerData = Type.Object({
|
||||
|
||||
@@ -2,9 +2,12 @@ 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 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")
|
||||
@@ -19,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)
|
||||
@@ -33,14 +41,19 @@ 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(record.components, "controller") or player_record.local_controller then
|
||||
ensure_character(player)
|
||||
end
|
||||
player_record.local_controller = nil
|
||||
|
||||
-- Restore player position and driving state
|
||||
restore_position(player, record)
|
||||
|
||||
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
|
||||
return
|
||||
end
|
||||
|
||||
@@ -131,6 +131,8 @@ 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
|
||||
-- Retry acquiring the player from the controller
|
||||
|
||||
@@ -24,11 +24,25 @@ local function is_in_cutscene(player)
|
||||
return player.controller_type == defines.controllers.cutscene
|
||||
end
|
||||
|
||||
-- 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
|
||||
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, components)
|
||||
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
|
||||
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,
|
||||
}
|
||||
@@ -51,6 +65,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 +97,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, player_record.components)
|
||||
serialized_player.generation = player_record.generation
|
||||
|
||||
return serialized_player
|
||||
@@ -95,11 +114,14 @@ 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, finished_record.components)
|
||||
|
||||
-- Stash temporary inventory if it exists, without inventory sync the current inventory is the one to keep
|
||||
local stashed_corpse
|
||||
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
|
||||
@@ -112,10 +134,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] = serialize.deserialize_player(player, serialized_player)
|
||||
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
|
||||
@@ -291,21 +317,29 @@ 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.controller_type == defines.controllers.character 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, 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
|
||||
|
||||
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)
|
||||
|
||||
@@ -318,8 +352,9 @@ function player_sync.sync_player(acquire_response)
|
||||
)
|
||||
end
|
||||
|
||||
player_record.sync = true
|
||||
start_sync(player_record, script_data.components)
|
||||
player_record.dirty = true
|
||||
player_record.sync_in_progress = false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -335,11 +370,13 @@ 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 = true
|
||||
player_record.sync_in_progress = false
|
||||
start_sync(player_record, finished_record.components)
|
||||
player_record.generation = finished_record.generation
|
||||
end
|
||||
|
||||
@@ -395,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
|
||||
@@ -447,14 +484,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 then
|
||||
if recipe_notifications_api and serialize.syncs(record.components, "recipe_notifications") then
|
||||
recipe_notifications = serialize.serialize_crafting_notifications(player)
|
||||
end
|
||||
|
||||
@@ -463,9 +501,12 @@ function player_sync.initiate_inventory_download(player, player_record, generati
|
||||
recipe_notifications = recipe_notifications,
|
||||
})
|
||||
|
||||
-- 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 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
|
||||
@@ -478,12 +519,10 @@ function player_sync.initiate_inventory_download(player, player_record, generati
|
||||
record.vehicle = player.vehicle
|
||||
end
|
||||
|
||||
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)
|
||||
|
||||
@@ -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,203 @@ 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 items which do not fit into the inventory they came from are inserted into, in order
|
||||
local overflow_order = { "main", "guns", "ammo", "armor" }
|
||||
|
||||
--- 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
|
||||
stack.s = nil
|
||||
stack.r = nil
|
||||
stack.f = nil
|
||||
return stack
|
||||
end
|
||||
|
||||
--- 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
|
||||
--- @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 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
|
||||
end
|
||||
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
|
||||
--- @param player LuaPlayer
|
||||
--- @return table<string, LuaInventory>
|
||||
function serialize.item_destinations(player)
|
||||
local destinations = {}
|
||||
local character = player.character
|
||||
if character then
|
||||
for name, index in pairs(character_inventories) do
|
||||
destinations[name] = character.get_inventory(index)
|
||||
end
|
||||
elseif player.controller_type == defines.controllers.god
|
||||
or v2_remote_controller and player.physical_controller_type == defines.controllers.god
|
||||
then
|
||||
destinations.main = player.get_inventory(defines.inventory.god_main) --[[@as LuaInventory]]
|
||||
end
|
||||
return destinations
|
||||
end
|
||||
|
||||
--- 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 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<string, LuaInventory>
|
||||
--- @param inventories table<string, table>?
|
||||
--- @param items { name: string, count: integer, quality: string? }[]?
|
||||
--- @return table[] leftovers
|
||||
function serialize.place_items(destinations, inventories, items)
|
||||
for _, destination in pairs(destinations) do
|
||||
destination.clear()
|
||||
end
|
||||
|
||||
-- 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]
|
||||
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
|
||||
|
||||
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
|
||||
if not placed then
|
||||
table.insert(leftovers, stack)
|
||||
end
|
||||
end
|
||||
return leftovers
|
||||
end
|
||||
|
||||
--- Put serialized stacks which did not fit into a corpse below the player
|
||||
--- @param player LuaPlayer
|
||||
--- @param stacks table[]?
|
||||
function serialize.spill_items(player, stacks)
|
||||
if not stacks or not next(stacks) then
|
||||
return
|
||||
end
|
||||
|
||||
-- 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 = slots,
|
||||
player_index = player.index,
|
||||
})
|
||||
if not corpse then
|
||||
log("ERROR: Unable to create a corpse for " .. player.name .. ", voiding " .. #stacks .. " stacks of items")
|
||||
return
|
||||
end
|
||||
|
||||
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:
|
||||
-- name
|
||||
-- min
|
||||
@@ -125,7 +296,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
|
||||
@@ -133,9 +305,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,
|
||||
@@ -175,7 +344,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, {
|
||||
@@ -210,11 +381,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
|
||||
|
||||
@@ -720,16 +898,17 @@ end
|
||||
--- @class SerializedPlayerData
|
||||
--- @field generation number
|
||||
--- @field name string
|
||||
--- @field controller string
|
||||
--- @field color Color
|
||||
--- @field chat_color Color
|
||||
--- @field tag string
|
||||
--- @field force string
|
||||
--- @field cheat_mode boolean
|
||||
--- @field flashlight boolean
|
||||
--- @field controller string?
|
||||
--- @field color Color?
|
||||
--- @field chat_color Color?
|
||||
--- @field tag string?
|
||||
--- @field force string?
|
||||
--- @field cheat_mode boolean?
|
||||
--- @field flashlight boolean?
|
||||
--- @field shortcuts table<string, boolean>?
|
||||
--- @field game_view_settings table<string, boolean>?
|
||||
--- @field ticks_to_respawn number
|
||||
--- @field ticks_to_respawn number?
|
||||
--- @field components SyncComponents Components this was serialized with
|
||||
--- @field character table<string, any>?
|
||||
--- @field inventories table<string, table>?
|
||||
--- @field hotbar table<string, string>?
|
||||
@@ -784,75 +963,204 @@ 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<SyncComponent, boolean>?
|
||||
|
||||
--- @param components SyncComponents
|
||||
--- @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
|
||||
--- @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
|
||||
--- @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
|
||||
--- @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,
|
||||
components = components,
|
||||
}
|
||||
|
||||
-- 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
|
||||
|
||||
-- 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
|
||||
player.ticks_to_respawn = nil -- Respawn now
|
||||
if syncs(components, "appearance") then
|
||||
serialized.color = player.color
|
||||
serialized.chat_color = player.chat_color
|
||||
serialized.tag = player.tag
|
||||
end
|
||||
|
||||
serialized.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player)
|
||||
serialized.inventories = serialize.serialize_inventories(player, character_inventories)
|
||||
if syncs(components, "force") then
|
||||
serialized.force = player.force.name
|
||||
end
|
||||
|
||||
-- Go back to waiting for respawn
|
||||
local character = player.character
|
||||
player.ticks_to_respawn = serialized.ticks_to_respawn
|
||||
if character and character.valid then
|
||||
character.destroy()
|
||||
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
|
||||
|
||||
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
|
||||
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_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
|
||||
---
|
||||
--- 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
|
||||
--- @param components SyncComponents
|
||||
--- @return LocalControllerState
|
||||
function serialize.serialize_local_controller(player, components)
|
||||
local controller_type = v2_remote_controller and player.physical_controller_type or player.controller_type
|
||||
--- @type LocalControllerState
|
||||
local state = {
|
||||
controller = controller_to_name[controller_type],
|
||||
ticks_to_respawn = player.ticks_to_respawn,
|
||||
}
|
||||
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
|
||||
--- @param platform LuaSpacePlatform
|
||||
--- @return LuaSurface
|
||||
@@ -915,32 +1223,77 @@ end
|
||||
|
||||
--- @param player LuaPlayer
|
||||
--- @param serialized SerializedPlayerData
|
||||
--- @return FailedDeserializationPlayerData?
|
||||
function serialize.deserialize_player(player, serialized)
|
||||
--- @param components SyncComponents
|
||||
--- @param local_controller LocalControllerState? Controller to restore when the controller is not synced
|
||||
--- @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")
|
||||
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
|
||||
|
||||
-- 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 (
|
||||
current_controller ~= target_controller
|
||||
or to_ghost and (restore_controller or sync_inventories or sync_logistics)
|
||||
)
|
||||
|
||||
-- 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)
|
||||
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
|
||||
if syncs(components, "force") and serialized.force then
|
||||
player.force = serialized.force
|
||||
end
|
||||
local character = ensure_character(player)
|
||||
if serialized.personal_logistic_slots then
|
||||
serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots)
|
||||
serialize.deserialize_personal_logistic_slots(player, logistic_slots)
|
||||
if place then
|
||||
spill = serialize.place_items(
|
||||
serialize.item_destinations(player), inventories, queue and queue.ingredients
|
||||
)
|
||||
end
|
||||
if 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 +1305,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,49 +1320,65 @@ 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
|
||||
if syncs(components, "appearance") and serialized.color then
|
||||
player.color = serialized.color
|
||||
player.chat_color = serialized.chat_color
|
||||
player.tag = serialized.tag
|
||||
end
|
||||
if syncs(components, "force") and serialized.force then
|
||||
player.force = serialized.force
|
||||
end
|
||||
if restore_controller and serialized.cheat_mode ~= nil then
|
||||
player.cheat_mode = serialized.cheat_mode
|
||||
end
|
||||
if flashlight == true then
|
||||
player.enable_flashlight()
|
||||
else
|
||||
elseif 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)
|
||||
if syncs(components, "settings") then
|
||||
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
|
||||
if player.character then
|
||||
serialize.deserialize_character(player.character, serialized.character)
|
||||
serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots)
|
||||
-- Deserialize items, a ghost got them while it had a character above
|
||||
local character = player.character
|
||||
if place and not to_ghost then
|
||||
local stats = character and items.character
|
||||
if stats then
|
||||
serialize.deserialize_character(character, stats)
|
||||
end
|
||||
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 player.controller_type == defines.controllers.god then
|
||||
serialize.deserialize_inventories(player, serialized.inventories, { main = defines.inventory.god_main })
|
||||
if character then
|
||||
serialize.deserialize_personal_logistic_slots(player, logistic_slots)
|
||||
end
|
||||
|
||||
-- Deserialize quick bar (named hotbar in old data)
|
||||
serialize.deserialize_quick_bar(player, serialized.quick_bar or serialized.hotbar)
|
||||
|
||||
-- Deserialize crafting queue
|
||||
if player.character and serialized.crafting_queue then
|
||||
serialize.deserialize_crafting_queue(player, serialized.crafting_queue)
|
||||
if syncs(components, "quick_bar") then
|
||||
serialize.deserialize_quick_bar(player, serialized.quick_bar or serialized.hotbar)
|
||||
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
|
||||
|
||||
return next(failed_deserialization) and failed_deserialization or nil
|
||||
return next(failed_deserialization) and failed_deserialization or nil, spill
|
||||
end
|
||||
|
||||
return serialize
|
||||
@@ -0,0 +1,161 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
components, enabledComponents, mergePlayerData, stripPlayerData, scenarioComponents,
|
||||
} 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 sync the controller without the inventories", function() {
|
||||
const enabled = enabledComponents(configWith(["inventories"]));
|
||||
assert.equal(enabled.has("controller"), true);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
it("should remove the god inventory with the inventories", function() {
|
||||
const playerData = structuredClone(stored);
|
||||
stripPlayerData(playerData, new Set(allComponents.filter(c => c !== "inventories")));
|
||||
assert.equal("inventories" in playerData, false);
|
||||
assert.equal(playerData.controller, "god");
|
||||
});
|
||||
});
|
||||
|
||||
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 = 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(scenarioComponents([]).size, 0);
|
||||
});
|
||||
it("should sync the controller without the inventories", function() {
|
||||
assert.deepEqual([...scenarioComponents({ controller: true })], ["controller"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergePlayerData()", function() {
|
||||
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 merged = mergePlayerData(stored, withComponents(uploaded, ["force", "appearance"]));
|
||||
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, withComponents(uploaded, ["force"]));
|
||||
assert.equal("force" in merged, false);
|
||||
assert.equal(merged.tag, "uploaded");
|
||||
});
|
||||
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.equal("inventories" in merged, false, "the god inventory moved to the character");
|
||||
});
|
||||
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;
|
||||
delete god.personal_logistic_slots;
|
||||
const merged = mergePlayerData(storedCharacter, withComponents(god, ["controller"]));
|
||||
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);
|
||||
});
|
||||
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, {});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,9 @@ 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 } 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 +51,42 @@ 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 it has no components", async function() {
|
||||
const playerData = { generation: 2, name: "test", force: "enemy", tag: "uploaded" };
|
||||
assert.deepEqual(await upload(playerData), playerData);
|
||||
});
|
||||
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", components }),
|
||||
{ generation: 2, name: "test", force: "player", tag: "uploaded" },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
@@ -100,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() {
|
||||
@@ -125,5 +130,85 @@ 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 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" });
|
||||
const expected = { generation: 1, name: "test", tag: "tag" };
|
||||
assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected, ["appearance"])]);
|
||||
});
|
||||
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(
|
||||
1, message.dst, message.src,
|
||||
new DownloadResponse({ generation: 1, name: "test", force: "enemy", tag: "tag" }),
|
||||
));
|
||||
});
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user