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:
Cooldude2606 authored and GitHub committed 2026-10-17 19:45:19 +01:00
commit 5e2ecdd143
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
+58
View File
@@ -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`.
+82
View File
@@ -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;
}
+2 -1
View File
@@ -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;
}
});
+60
View File
@@ -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",
+26 -1
View File
@@ -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;
});
+10 -7
View File
@@ -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
+56 -17
View File
@@ -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)
+475 -106
View File
@@ -27,16 +27,6 @@ function serialize.serialize_inventories(source, inventories)
return serialized
end
function serialize.deserialize_inventories(destination, serialized, inventories)
for name, index in pairs(inventories) do
local inventory = destination.get_inventory(index)
if inventory ~= nil and serialized[name] ~= nil then
inventory.clear()
clusterio_serialize.deserialize_inventory(inventory, serialized[name])
end
end
end
-- Characters are serialized into a table with the following fields:
-- character_crafting_speed_modifier
-- character_mining_speed_modifier
@@ -76,22 +66,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
+161
View File
@@ -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, {});
});
});
});
+40 -1
View File
@@ -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" },
);
});
});
});
+87 -2
View File
@@ -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);
});
});
});