diff --git a/plugins/player_sync/README.md b/plugins/player_sync/README.md index cfc59bc7..1c8d9a83 100644 --- a/plugins/player_sync/README.md +++ b/plugins/player_sync/README.md @@ -57,6 +57,7 @@ Instances can leave parts of the player out of the sync with the `player_sync.sy A part which is not synced is left as it is when the player joins, and the instance's copy of it is not uploaded, so the stored copy from other instances stays as it was. A player online when the options change keeps the old ones until they leave and join again, and a part that was turned on is downloaded when they join. Turning off `player_sync.sync_inventories` leaves the items players have on the instance in place while the stored inventory still holds them too. +Items follow the player to whatever controller they end up with, a character, god or ghost, and what does not fit is put into a corpse below them. Communication between the server and instance goes over stdout or rcon, depending on the size of the data. Communication between the instance and the controller goes over websockets. @@ -85,8 +86,7 @@ Defaults to `1000`. ### player_sync.sync_controller -Sync whether the player is a character, god or spectator, their respawn timer, cheat mode, the god inventory and the inventory filters of a dead player. -Switching controller can destroy the character holding the inventory, so this only takes effect while `player_sync.sync_inventories` is also enabled. +Sync whether the player is a character, god or spectator, their respawn timer and cheat mode. Defaults to `true`. @@ -104,9 +104,10 @@ Defaults to `true`. ### player_sync.sync_inventories -Sync the character inventories, the character bonuses and the crafting queue. -While the controller is not synced a player without a character keeps what they have on the instance and does not change the stored character. -A player who died on the instance uploads the character they will respawn with, and a player waiting to respawn when joining is respawned straight away if there is a synced character to give them. +Sync the items the player holds, the character bonuses and the crafting queue. +This covers the character inventories, the god inventory and the items a dead player will respawn with. +A spectator holds no items, so the synced items are put into a corpse when the player is a spectator on the instance. +A player without a character gets the ingredients of the crafting queue as items instead. Defaults to `true`. diff --git a/plugins/player_sync/components.ts b/plugins/player_sync/components.ts index ec86d93b..0c6469f5 100644 --- a/plugins/player_sync/components.ts +++ b/plugins/player_sync/components.ts @@ -3,11 +3,11 @@ import type { IpcPlayerData } from "./messages.js"; /** Parts of the player data an instance can choose to sync, and the fields of the player data holding them. */ export const components = { - // inventories holds the god inventory and the hidden ghost inventory, both belong to the controller - controller: ["controller", "ticks_to_respawn", "cheat_mode", "inventories"], + controller: ["controller", "ticks_to_respawn", "cheat_mode"], force: ["force"], appearance: ["color", "chat_color", "tag"], - inventories: ["character", "crafting_queue"], + // inventories holds the god inventory and the hidden ghost inventory + inventories: ["character", "crafting_queue", "inventories"], logistics: ["personal_logistic_slots"], quick_bar: ["quick_bar", "hotbar"], settings: ["flashlight", "shortcuts", "game_view_settings"], @@ -16,26 +16,8 @@ export const components = { export type Component = keyof typeof components; -// Fields only a player with a character has -const characterFields = ["character", "crafting_queue", "personal_logistic_slots"] as const; - -/** - * Build the set of synced components. - * - * The controller is only synced together with the inventories, as - * switching controller can destroy the character holding them. - */ function componentSet(isEnabled: (component: Component) => boolean) { - const enabled = new Set(); - for (const component of Object.keys(components) as Component[]) { - if (isEnabled(component)) { - enabled.add(component); - } - } - if (!enabled.has("inventories")) { - enabled.delete("controller"); - } - return enabled; + return new Set((Object.keys(components) as Component[]).filter(isEnabled)); } /** Components synced on an instance. */ @@ -72,8 +54,7 @@ export function stripPlayerData(playerData: IpcPlayerData, enabled: Set; @@ -94,8 +75,8 @@ export function mergePlayerData(stored: IpcPlayerData | undefined, uploaded: Ipc keep(keys); } } - if (!enabled.has("controller") && !uploaded.character) { - keep(characterFields); + if (!uploaded.character && !uploaded.personal_logistic_slots) { + keep(["personal_logistic_slots"]); } return merged as IpcPlayerData; } diff --git a/plugins/player_sync/module/player_sync.lua b/plugins/player_sync/module/player_sync.lua index 5aefd033..124edc34 100644 --- a/plugins/player_sync/module/player_sync.lua +++ b/plugins/player_sync/module/player_sync.lua @@ -132,12 +132,14 @@ function player_sync.deserialize_player(player, finished_record) -- Deserialize downloaded player data local serialized_player = compat.json_to_table(finished_record.data) assert(type(serialized_player) == "table", "wrong type for serialized_player") - script_data.failed_deserialization[player.name] = + local failed, spill = serialize.deserialize_player(player, serialized_player, finished_record.components, local_controller) + script_data.failed_deserialization[player.name] = failed player_record.local_controller = nil -- Restore player position and driving state restore_position(player, finished_record) + serialize.spill_items(player, spill) -- Transfer items from stashed inventory if stashed_corpse then @@ -495,11 +497,7 @@ function player_sync.initiate_inventory_download(player, player_record, generati -- If this is a synced player turn them into a spectator while the -- player data is downloading - -- Without the controller synced a player without a character has nothing synced to protect - local components = record.components - if player_record.sync and serialize.syncs(components, "inventories") - and (serialize.syncs(components, "controller") or player.character) - then + if player_record.sync and serialize.syncs(record.components, "inventories") then -- Store original position to teleport back to if v2_remote_controller then record.surface = player.physical_surface diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index 6351728a..b0db1272 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -27,16 +27,6 @@ function serialize.serialize_inventories(source, inventories) return serialized end -function serialize.deserialize_inventories(destination, serialized, inventories) - for name, index in pairs(inventories) do - local inventory = destination.get_inventory(index) - if inventory ~= nil and serialized[name] ~= nil then - inventory.clear() - clusterio_serialize.deserialize_inventory(inventory, serialized[name]) - end - end -end - -- Characters are serialized into a table with the following fields: -- character_crafting_speed_modifier -- character_mining_speed_modifier @@ -76,22 +66,198 @@ function serialize.serialize_character(character) return serialized end +--- Restore the stats of a character, the inventories are restored with place_items function serialize.deserialize_character(character, serialized) - -- Deserialize character stats for _, key in pairs(character_stat_keys) do character[key] = serialized[key] end +end - -- Deserialize character inventories - serialize.deserialize_inventories(character, serialized.inventories, character_inventories) - - -- Deserialize armor grid state, the grid exists after the armor inventory is restored +--- Restore the armor grid state, the grid exists after the armor inventory is restored +function serialize.deserialize_character_grid(character, serialized) local grid = character.grid if grid and serialized.inhibit_movement_bonus ~= nil then grid.inhibit_movement_bonus = serialized.inhibit_movement_bonus end end +-- Inventories by the names used in serialized data in the order they are filled, armor first as it adds slots to main +local fill_order = { "armor", "guns", "ammo", "main", "trash" } +-- Inventories items which do not fit into the inventory they came from are inserted into, in order +local overflow_order = { "main", "guns", "ammo", "armor" } + +--- Number of slots a serialized inventory uses +--- @param serialized table +--- @return number +local function serialized_size(serialized) + local last = 0 + for _, entry in ipairs(serialized.i) do + last = (entry.s or last + 1) + (entry.r or 0) + end + return last +end + +--- Copy of a serialized inventory without slot filters, for inventories which do not support them +--- @param serialized table +--- @return table +local function without_filters(serialized) + local copy = { i = {} } + for _, entry in ipairs(serialized.i) do + local item = {} + for key, value in pairs(entry) do + item[key] = value + end + item.f = nil + table.insert(copy.i, item) + end + return copy +end + +--- Inventories the items of a player go into, by the names used in serialized data +--- @param player LuaPlayer +--- @return table +function serialize.item_destinations(player) + local destinations = {} + local character = player.character + if character then + for name, index in pairs(character_inventories) do + destinations[name] = character.get_inventory(index) + end + elseif player.controller_type == defines.controllers.god + or v2_remote_controller and player.physical_controller_type == defines.controllers.god + then + destinations.main = player.get_inventory(defines.inventory.god_main) --[[@as LuaInventory]] + end + return destinations +end + +--- Insert as much of a stack as possible, starting with the inventory it came from +--- @param stack LuaItemStack +--- @param destinations table +--- @param from string? +local function insert_stack(stack, destinations, from) + local function insert(destination) + if destination and stack.valid_for_read then + local inserted = destination.insert(stack) + if inserted >= stack.count then + stack.clear() + elseif inserted > 0 then + stack.count = stack.count - inserted + end + end + end + if from then + insert(destinations[from]) + end + for _, name in ipairs(overflow_order) do + insert(destinations[name]) + end +end + +--- Replace the items in destinations with the serialized inventories and loose items +--- +--- Each inventory goes slot for slot into the destination of the same name when it fits, otherwise its items are +--- inserted where there is space. Returns a script inventory with what did not fit, to be put into a corpse. +--- @param destinations table +--- @param inventories table? +--- @param items { name: string, count: integer, quality: string? }[]? +--- @return LuaInventory? +function serialize.place_items(destinations, inventories, items) + for _, destination in pairs(destinations) do + destination.clear() + end + + local leftovers = {} + for _, name in ipairs(fill_order) do + local serialized = inventories and inventories[name] + local size = serialized and serialized_size(serialized) or 0 + local destination = destinations[name] + if serialized and destination and size <= #destination then + if not destination.supports_filters() then + serialized = without_filters(serialized) + end + clusterio_serialize.deserialize_inventory(destination, serialized) + elseif serialized and size > 0 then + -- Slot filters belong to the slot they were set on + local inventory = game.create_inventory(size) + clusterio_serialize.deserialize_inventory(inventory, without_filters(serialized)) + table.insert(leftovers, { from = name, inventory = inventory }) + end + end + + if items and next(items) then + local inventory = game.create_inventory(#items) + for _, item in pairs(items) do + local remaining = item.count + while remaining > 0 do + local inserted = inventory.insert({ name = item.name, count = remaining, quality = item.quality }) + remaining = remaining - inserted + if remaining > 0 then + if inserted == 0 and not inventory.is_full() then + log("ERROR: Unable to place " .. remaining .. " " .. item.name .. ", voiding it") + break + end + inventory.resize(#inventory * 2) + end + end + end + table.insert(leftovers, { inventory = inventory }) + end + + local spill + local spilled = 0 + for _, leftover in ipairs(leftovers) do + local inventory = leftover.inventory + for i = 1, #inventory do + local stack = inventory[i] + if stack.valid_for_read then + insert_stack(stack, destinations, leftover.from) + end + if stack.valid_for_read then + spill = spill or game.create_inventory(1) + if spilled == #spill then + spill.resize(#spill * 2) + end + spilled = spilled + 1 + spill[spilled].transfer_stack(stack) + end + end + inventory.destroy() + end + return spill +end + +--- Put items which did not fit into a corpse below the player +--- @param player LuaPlayer +--- @param spill LuaInventory? +function serialize.spill_items(player, spill) + if not spill then + return + end + spill.sort_and_merge() + local count = #spill - spill.count_empty_stacks() + local corpse = player.surface.create_entity({ + name = "character-corpse", + position = player.position, + inventory_size = count, + player_index = player.index, + }) + if corpse then + local inventory = assert(corpse.get_inventory(defines.inventory.character_corpse)) + for i = 1, #spill do + if spill[i].valid_for_read then + assert(inventory.find_empty_stack()).transfer_stack(spill[i]) + end + end + player.print( + "Some of your synced items do not fit into your inventory here and have been placed in a corpse below you." + ) + else + log("ERROR: Unable to create a corpse for " .. player.name .. ", voiding " .. count .. " stacks of items") + end + spill.destroy() +end + -- Personal logistic slots is a table mapping string indexes to a table with the following fields: -- name -- min @@ -857,18 +1023,17 @@ function serialize.serialize_player(player, failed_deserialization, components) end -- For the waiting to respawn state the inventory logistic requests and filters are hidden on the player - if sync_inventories and player.controller_type == defines.controllers.ghost and player.ticks_to_respawn then + if (sync_inventories or sync_logistics) + and player.controller_type == defines.controllers.ghost and player.ticks_to_respawn + then local ticks_to_respawn = player.ticks_to_respawn player.ticks_to_respawn = nil -- Respawn now if sync_logistics then serialized.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player) end - if sync_controller then + if sync_inventories then serialized.inventories = serialize.serialize_inventories(player, character_inventories) - elseif player.character then - -- Without the controller the death is synced by uploading the respawned character - serialized.character = serialize.serialize_character(player.character) end -- Go back to waiting for respawn @@ -890,7 +1055,7 @@ function serialize.serialize_player(player, failed_deserialization, components) end -- Serialize non-character inventories - if sync_controller and sync_inventories and ( + if sync_inventories and ( player.controller_type == defines.controllers.god or v2_remote_controller and player.physical_controller_type == defines.controllers.god ) then @@ -994,7 +1159,7 @@ end --- @param serialized SerializedPlayerData --- @param components SyncComponents --- @param local_controller LocalControllerState? Controller to restore when the controller is not synced ---- @return FailedDeserializationPlayerData? +--- @return FailedDeserializationPlayerData?, LuaInventory? Items which did not fit, see spill_items function serialize.deserialize_player(player, serialized, components, local_controller) local failed_deserialization = {} local sync_controller = syncs(components, "controller") @@ -1008,15 +1173,18 @@ function serialize.deserialize_player(player, serialized, components, local_cont local state = local_controller if restore_controller then state = { controller = serialized.controller, ticks_to_respawn = serialized.ticks_to_respawn } - elseif state and state.controller == "ghost" and sync_inventories and serialized.character then - -- Respawn now, a ghost would drop the synced character and upload an empty one after respawning - state = { controller = "character" } end + -- Items can come from a character, a god or a ghost and go to whatever controller the player ends up with + local inventories = serialized.character and serialized.character.inventories or serialized.inventories + local queue = serialized.crafting_queue + local spill + local target_controller = state and defines.controllers[state.controller] + local to_ghost = state and state.controller == "ghost" if state and ( player.controller_type ~= target_controller - or restore_controller and state.controller == "ghost" + or to_ghost and (restore_controller or sync_inventories or sync_logistics) ) then if state.controller == "character" then -- Create a character but do not destroy an existing one @@ -1030,11 +1198,13 @@ function serialize.deserialize_player(player, serialized, components, local_cont -- The force first, so the character and the logistic groups are on the restored player's force player.force = serialized.force local character = ensure_character(player) - if restore_controller and sync_logistics and serialized.personal_logistic_slots then + if sync_logistics and serialized.personal_logistic_slots then serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots) end - if restore_controller and sync_inventories and serialized.inventories then - serialize.deserialize_inventories(player, serialized.inventories, character_inventories) + if sync_inventories then + spill = serialize.place_items( + serialize.item_destinations(player), inventories, queue and queue.ingredients + ) end if state.ticks_to_respawn then player.ticks_to_respawn = state.ticks_to_respawn @@ -1089,21 +1259,26 @@ function serialize.deserialize_player(player, serialized, components, local_cont end end - -- Deserialize character, the stored player may not have had one when the controller is not synced - if player.character then - if sync_inventories and serialized.character then - serialize.deserialize_character(player.character, serialized.character) + -- Deserialize items, a ghost got them while it had a character above + local character = player.character + if sync_inventories and not to_ghost then + local stats = character and serialized.character + if stats then + serialize.deserialize_character(character, stats) end - if sync_logistics then - serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots) + local craft = character and queue + spill = serialize.place_items( + serialize.item_destinations(player), inventories, not craft and queue and queue.ingredients or nil + ) + if stats then + serialize.deserialize_character_grid(character, stats) + end + if craft then + serialize.deserialize_crafting_queue(player, queue) end end - - -- Deserialize non-character inventories - if restore_controller and sync_inventories and serialized.inventories - and player.controller_type == defines.controllers.god - then - serialize.deserialize_inventories(player, serialized.inventories, { main = defines.inventory.god_main }) + if character and sync_logistics then + serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots) end -- Deserialize quick bar (named hotbar in old data) @@ -1111,18 +1286,13 @@ function serialize.deserialize_player(player, serialized, components, local_cont serialize.deserialize_quick_bar(player, serialized.quick_bar or serialized.hotbar) end - -- Deserialize crafting queue - if sync_inventories and player.character and serialized.crafting_queue then - serialize.deserialize_crafting_queue(player, serialized.crafting_queue) - end - -- Deserialize recipe notifications if recipe_notifications_api and syncs(components, "recipe_notifications") and serialized.recipe_notifications then failed_deserialization.recipe_notifications = serialize.deserialize_crafting_notifications(player, serialized.recipe_notifications) end - return next(failed_deserialization) and failed_deserialization or nil + return next(failed_deserialization) and failed_deserialization or nil, spill end return serialize diff --git a/plugins/player_sync/test/components.js b/plugins/player_sync/test/components.js index bb105cb2..53d96626 100644 --- a/plugins/player_sync/test/components.js +++ b/plugins/player_sync/test/components.js @@ -51,9 +51,9 @@ describe("player_sync components", function() { assert.equal(enabled.has("quick_bar"), false); assert.equal(enabled.size, allComponents.length - 2); }); - it("should not sync the controller without the inventories", function() { + it("should sync the controller without the inventories", function() { const enabled = enabledComponents(configWith(["inventories"])); - assert.equal(enabled.has("controller"), false); + assert.equal(enabled.has("controller"), true); }); }); @@ -71,12 +71,11 @@ describe("player_sync components", function() { } assert.equal(playerData.force, "enemy"); }); - it("should remove the god inventory with the controller", function() { + it("should remove the god inventory with the inventories", function() { const playerData = structuredClone(stored); - stripPlayerData(playerData, new Set(allComponents.filter(c => c !== "controller"))); + stripPlayerData(playerData, new Set(allComponents.filter(c => c !== "inventories"))); assert.equal("inventories" in playerData, false); - assert.equal("controller" in playerData, false); - assert.equal(playerData.tag, "stored"); + assert.equal(playerData.controller, "god"); }); }); @@ -91,8 +90,8 @@ describe("player_sync components", function() { it("should sync nothing for an empty table encoded as an array", function() { assert.equal(uploadedComponents([]).size, 0); }); - it("should not sync the controller without the inventories", function() { - assert.equal(uploadedComponents({ controller: true }).has("controller"), false); + it("should sync the controller without the inventories", function() { + assert.deepEqual([...uploadedComponents({ controller: true })], ["controller"]); }); }); @@ -121,26 +120,33 @@ describe("player_sync components", function() { assert.equal("force" in merged, false); assert.equal(merged.tag, "uploaded"); }); - it("should take the character but not the controller when the controller is not synced", function() { + it("should take the items but not the controller when the controller is not synced", function() { const merged = mergePlayerData(stored, withComponents(uploaded, ["controller"])); assert.equal(merged.controller, "god"); assert.equal(merged.cheat_mode, true); assert.equal(merged.character, character); - assert.deepEqual(merged.inventories, { main: ["stored god item"] }, "god inventory is kept"); + assert.equal("inventories" in merged, false, "the god inventory moved to the character"); }); - it("should keep the character data for a player without one when the controller is not synced", function() { + it("should take the items of a player without a character", function() { const storedCharacter = { ...stored, character, crafting_queue: ["stored"], personal_logistic_slots: [{ name: "stored" }], }; const god = { ...uploaded, controller: "god", inventories: { main: ["new god item"] } }; delete god.character; delete god.crafting_queue; - god.personal_logistic_slots = [{ name: "uploaded" }]; + delete god.personal_logistic_slots; const merged = mergePlayerData(storedCharacter, withComponents(god, ["controller"])); - assert.equal(merged.character, character); - assert.deepEqual(merged.crafting_queue, ["stored"]); - assert.deepEqual(merged.personal_logistic_slots, [{ name: "stored" }], "logistics stay with the character"); - assert.deepEqual(merged.inventories, { main: ["stored god item"] }); + assert.equal("character" in merged, false); + assert.equal("crafting_queue" in merged, false); + assert.deepEqual(merged.inventories, { main: ["new god item"] }); + assert.deepEqual(merged.personal_logistic_slots, [{ name: "stored" }], "nowhere to hold logistics"); + }); + it("should drop logistic requests a character uploaded without", function() { + const storedSlots = { ...stored, personal_logistic_slots: [{ name: "stored" }] }; + const noSlots = { ...uploaded }; + delete noSlots.personal_logistic_slots; + const merged = mergePlayerData(storedSlots, withComponents(noSlots, [])); + assert.equal("personal_logistic_slots" in merged, false); }); }); });