local compat = require("modules/clusterio/compat") local clusterio_serialize = require("modules/clusterio/serialize") local character_inventories = require("modules/player_sync/define_player_inventories") local character_stat_keys = require("modules/player_sync/define_player_stat_keys") local game_view_settings_keys = require("modules/player_sync/define_game_view_settings") local serialize = {} local v2_logistic_api = compat.version_ge("2.0.0") local v2_storage_api = compat.version_ge("2.0.0") local v2_remote_controller = compat.version_ge("2.0.0") local v2_space_platform = compat.version_ge("2.0.0") local v2_exit_remote_view = compat.version_ge("2.0.56") local recipe_notifications_api = compat.version_ge("2.0.67") local v2_0_quick_bar_api = compat.version_ge("2.0.0") local v2_1_quick_bar_api = compat.version_ge("2.1.0") function serialize.serialize_inventories(source, inventories) local serialized = {} for name, index in pairs(inventories) do local inventory = source.get_inventory(index) if inventory ~= nil then serialized[name] = clusterio_serialize.serialize_inventory(inventory) end end return serialized end -- Characters are serialized into a table with the following fields: -- character_crafting_speed_modifier -- character_mining_speed_modifier -- character_additional_mining_categories -- character_running_speed_modifier -- character_build_distance_bonus -- character_item_drop_distance_bonus -- character_reach_distance_bonus -- character_resource_reach_distance_bonus -- character_item_pickup_distance_bonus -- character_loot_pickup_distance_bonus -- character_inventory_slots_bonus -- character_trash_slot_count_bonus -- character_maximum_following_robot_count_bonus -- character_health_bonus -- character_personal_logistic_requests_enabled -- allow_dispatching_robots -- inhibit_movement_bonus (optional, from the armor equipment grid) -- inventories: table of character inventory name to inventory content function serialize.serialize_character(character) local serialized = { } -- Serialize character stats for _, key in pairs(character_stat_keys) do serialized[key] = character[key] end -- Serialize character inventories serialized.inventories = serialize.serialize_inventories(character, character_inventories) -- Serialize armor grid state local grid = character.grid if grid then serialized.inhibit_movement_bonus = grid.inhibit_movement_bonus end return serialized end --- Restore the stats of a character, the inventories are restored with place_items function serialize.deserialize_character(character, serialized) for _, key in pairs(character_stat_keys) do character[key] = serialized[key] end end --- 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 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 --- @param inventories 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 -- max function serialize.serialize_personal_logistic_slots(player) -- Check if logistics technology is researched local force = player.force if not force.technologies["logistic-robotics"] or not force.technologies["logistic-robotics"].researched then return nil end if v2_logistic_api then local logistic_point = player.get_requester_point() if logistic_point == nil then return nil end local serialized = { enabled = logistic_point.enabled, trash_not_requested = logistic_point.trash_not_requested, sections = {}, } for i = 1, logistic_point.sections_count do local section = logistic_point.get_section(i) serialized.sections[i] = { group = section.group, active = section.active, multiplier = section.multiplier, filters = section.filters, } end return serialized end -- 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 for i = 1, 65536 do local slot = player.get_personal_logistic_slot(i) if slot.name then last_valid = i serialized[tostring(i)] = { name = slot.name, min = slot.min, max = slot.max, } -- Stop after 100 empty slots elseif last_valid + 100 <= i then break end end return serialized end function serialize.deserialize_personal_logistic_slots(player, serialized) if not serialized then return end -- Check if logistics technology is researched local force = player.force if not force.technologies["logistic-robotics"] or not force.technologies["logistic-robotics"].researched then return end -- Load personal logistic slots if v2_logistic_api then local logistic_point = player.get_requester_point() if logistic_point == nil then return end -- Remove old sections up to section_count for i = logistic_point.sections_count, 1, -1 do logistic_point.remove_section(i) end -- If this is an array instead of a table, migrate to v2 format 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, { value = { name = slot.name, quality = "normal", }, min = slot.min, max = slot.max, }) end else -- Regular 2.0+ deserialization logistic_point.enabled = serialized.enabled logistic_point.trash_not_requested = serialized.trash_not_requested for i, section in pairs(serialized.sections) do local sec = logistic_point.add_section() sec.active = section.active sec.multiplier = section.multiplier if section.group ~= "" then -- "" is the default group name, which is truthy -- Named groups that have filters on the server get added with name only - this avoids -- overwriting existing groups. Missing or empty groups get the filters of the player. local existing = force.get_logistic_group(section.group) sec.group = section.group if not existing or #existing.filters == 0 then sec.filters = section.filters end else -- Unnamed groups get added with filters sec.filters = section.filters end end end else 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 -- name is a custom type where quality is "normal" and comparator is "=" (most common case) --- @alias QuickBarSlotEncoded.name string --- @alias QuickBarSlotEncoded.filter { t: "f", n: string, q: string, c: string } --- b is the slot indices inside blueprint books, from the book in slot s down to the item, absent for a top-level item --- @alias QuickBarSlotEncoded.item { t: "i", n: string, s: integer, b: integer[]? } --- @alias QuickBarSlotEncoded.record { t: "r", p: integer[], y: string, l: string? } --- @alias QuickBarSlotEncoded QuickBarSlotEncoded.name | QuickBarSlotEncoded.filter | QuickBarSlotEncoded.item | QuickBarSlotEncoded.record --- Inventories of the player that can hold the item a quick bar slot points at --- @param player LuaPlayer local function get_quick_bar_inventories(player) return player.character and character_inventories or { main = defines.inventory.god_main } end --- Path of indices from the player's blueprint library down through books to the record --- @param records table --- @param target LuaRecord --- @return integer[]? local function find_record_path(records, target) for index, record in pairs(records) do if record == target then return { index } end if record.valid and record.type == "blueprint-book" then local path = find_record_path(record.contents, target) if path then table.insert(path, 1, index) return path end end end return nil end --- Path of slot indices to the item with the item number, descending into blueprint books --- @param inventory LuaInventory --- @param item_number integer --- @return integer[]? local function find_item_path(inventory, item_number) for i = 1, #inventory do local stack = inventory[i] --[[@as LuaItemStack]] if stack.valid_for_read then if stack.item_number == item_number then return { i } end local inner = stack.is_blueprint_book and stack.get_inventory(defines.inventory.item_main) local path = inner and find_item_path(inner, item_number) if path then table.insert(path, 1, i) return path end end end return nil end --- @param filter ItemFilter --- @return QuickBarSlotEncoded local function serialize_filter(filter) if filter.quality == "normal" and filter.comparator == "=" then return filter.name --[[@as string]] end return { t = "f", n = filter.name --[[@as string]], q = filter.quality --[[@as string]], c = filter.comparator --[[@as string]], } end --- @param slot QuickBarSlot | ItemFilter | LuaItemPrototype --- @param player LuaPlayer --- @return QuickBarSlotEncoded | nil function serialize.serialize_quick_bar_slot(slot, player) if v2_1_quick_bar_api then -- 2.1 has a dedicated type for quick bar slots --- @cast slot QuickBarSlot if slot.type == "filter" then return serialize_filter(slot.filter) end if slot.type == "item" then -- A specific item (blueprint, spidertron remote) is stored as the inventory slot that holds it local item_number = slot.item --[[@as LuaItem]].item_number for name, inventory_id in pairs(get_quick_bar_inventories(player)) do local inventory = player.get_inventory(inventory_id) local path = inventory and find_item_path(inventory, item_number) if path then local top = table.remove(path, 1) return { t = "i", n = name, s = top, b = path[1] and path or nil } end end print("Warning: Could not find the item of quick bar slot of type 'item' in the inventories") return nil end if slot.type == "record" then -- ponytail: one library walk per record slot per upload, a huge library makes the leave tick longer local record = slot.record --[[@as LuaRecord]] local path = find_record_path(player.blueprints, record) if path then return { t = "r", p = path, y = record.type, l = record.label } end print("Warning: Quick bar slot record is not in the player's blueprint library") return nil end print("Warning: Unsupported quick bar slot type '" .. slot.type .. "'") return nil end if v2_0_quick_bar_api then -- 2.0 gives us an item filter which supports quality --- @cast slot -QuickBarSlot, -LuaItemPrototype return serialize_filter(slot) end -- pre 2.0 quality did not exist --- @cast slot LuaItemPrototype return slot.name end --- @param entry string | QuickBarSlotEncoded --- @param player LuaPlayer --- @return QuickBarSlot | ItemWithQualityID | LuaItemStack | nil function serialize.deserialize_quick_bar_slot(entry, player) if v2_1_quick_bar_api then -- Return a quick bar slot if type(entry) == "string" then return { type = "filter", filter = { name = entry, quality = "normal", comparator = "=", }, } end if entry.t == "f" then return { type = "filter", filter = { name = entry.n, quality = entry.q, comparator = entry.c, }, } --[[@as QuickBarSlot]] end if entry.t == "i" then -- The inventories have been restored, the stack points the slot at the item in it local inventory_id = get_quick_bar_inventories(player)[entry.n] local inventory = inventory_id and player.get_inventory(inventory_id) local stack = inventory and entry.s <= #inventory and inventory[entry.s] or nil -- Walk down through the books to a stack kept inside them for _, index in ipairs(entry.b or {}) do local inner = stack and stack.valid_for_read and stack.is_blueprint_book and stack.get_inventory(defines.inventory.item_main) stack = inner and index <= #inner and inner[index] or nil end if stack and stack.valid_for_read then return stack end print("Warning: Could not find the item of quick bar slot in " .. entry.n .. " slot " .. entry.s) return nil end if entry.t == "r" then -- The record must still be what was stored, a shifted library must not set the wrong one local records = player.blueprints local record --[[@as LuaRecord?]] for _, index in ipairs(entry.p) do record = records[index] if not (record and record.valid) then record = nil break end records = record.type == "blueprint-book" and record.contents or {} end if record and record.valid and record.type == entry.y and (record.label or "") == (entry.l or "") then return { type = "record", record = record } --[[@as QuickBarSlot]] end print("Warning: Could not find the record of quick bar slot at library path " .. table.concat(entry.p, "/")) return nil end print("Warning: Unsupported serialized quick bar slot type '" .. tostring(entry.t) .. "'") return nil end if v2_0_quick_bar_api then -- Return a ItemIDAndQualityIDPair (member of ItemWithQualityID) if type(entry) == "string" then return { name = entry, quality = "normal", } end -- 2.1 entries (items, records) can not be represented on 2.0, clearing the slot if entry.t ~= "f" then return nil end --- @cast entry QuickBarSlotEncoded.filter return { name = entry.n, quality = entry.q, } end -- Return a string (member of ItemPrototypeIdentification) -- Pre 2.0 this was the only method of encoding used, anything else is from a newer version if type(entry) ~= "string" then return nil end return entry --[[@as string]] end --- @param player LuaPlayer --- @return table? function serialize.serialize_quick_bar(player) local serialized local width = v2_1_quick_bar_api and player.quick_bar_width for i = 1, 100 do local slot if v2_1_quick_bar_api then local page = math.floor((i - 1) / width) + 1 local page_slot = ((i - 1) % width) + 1 slot = player.get_quick_bar_slot(page, page_slot) else --- @diagnostic disable-next-line: missing-parameter slot = player.get_quick_bar_slot(i) end if slot ~= nil then local entry = serialize.serialize_quick_bar_slot(slot, player) if entry ~= nil then serialized = serialized or {} serialized[tostring(i)] = entry end end end return serialized end --- @param player LuaPlayer --- @param serialized table? function serialize.deserialize_quick_bar(player, serialized) -- Every slot is set, and the ones not in the data are cleared, so a slot moved or cleared on the -- previous server does not come back from this server's old copy serialized = serialized or {} local width = v2_1_quick_bar_api and player.quick_bar_width for i = 1, 100 do local entry = serialized[tostring(i)] local slot = entry ~= nil and serialize.deserialize_quick_bar_slot(entry, player) or nil if v2_1_quick_bar_api then local page = math.floor((i - 1) / width) + 1 local page_slot = ((i - 1) % width) + 1 player.set_quick_bar_slot(page, page_slot, slot) else --- @diagnostic disable-next-line: param-type-mismatch player.set_quick_bar_slot(i, slot) end end end -- Crafting queue is a table with the following fields: -- crafting_queue -- ingredients function serialize.serialize_crafting_queue(player) local crafting_queue = {} -- Give player some more inventory space to avoid duplicating items player.character_inventory_slots_bonus = player.character_inventory_slots_bonus + 1000 -- Save current items local inventory = player.character.get_main_inventory() local old_items = inventory.get_contents() local crafting_queue_progress = player.crafting_queue_progress -- Cancel old crafts to get the items back while player.crafting_queue_size > 0 do local old_queue = player.crafting_queue -- Cancel craft player.cancel_crafting { index = 1, count = 1, } local rightmost_right_index = 0 -- 0 indexed since it is subtractive in a 1 indexed language local new_queue = player.crafting_queue while new_queue ~= nil and new_queue[#new_queue - rightmost_right_index] ~= nil and new_queue[#new_queue - rightmost_right_index].count >= old_queue[#old_queue - rightmost_right_index].count do rightmost_right_index = rightmost_right_index + 1 end local oldItem = old_queue[#old_queue - rightmost_right_index] local newItem = nil if new_queue ~= nil then newItem = new_queue[#new_queue - rightmost_right_index] end -- Figure out how many items to add to queue local added = oldItem.count if newItem ~= nil then added = oldItem.count - newItem.count if oldItem.recipe ~= newItem.recipe then log("ERROR: Old item "..oldItem.recipe.." is not equal "..newItem.recipe) end end -- If the last item we added was of the same type, merge them in the queue if #crafting_queue > 0 and crafting_queue[#crafting_queue].recipe == oldItem.recipe then crafting_queue[#crafting_queue].count = crafting_queue[#crafting_queue].count + added else -- If the last item was of a different type, add a new item to the queue table.insert(crafting_queue, { recipe = oldItem.recipe, count = added, }) end -- game.print("Saved craft "..oldItem.recipe) end local difference = {} if v2_storage_api then -- Find amount of items added and remove from inventory local new_items = inventory.get_contents() -- Build map of old counts by item name and quality local old_counts = {} for _, item in ipairs(old_items) do local key = item.name .. ":" .. item.quality old_counts[key] = (old_counts[key] or 0) + item.count end -- Compare with new items to find differences for _, item in ipairs(new_items) do local key = item.name .. ":" .. item.quality local old_count = old_counts[key] or 0 local diff = item.count - old_count if diff > 0 then -- We don't have to worry about quality because quality can't be handcrafted local ingredient = { name = item.name, count = diff } inventory.remove(ingredient) table.insert(difference, ingredient) end end else -- Find amount of items added and remove from inventory local new_items = inventory.get_contents() for k,v in pairs(new_items) do local old_count = old_items[k] local diff = v if old_count ~= nil then diff = diff - old_count end if diff > 0 then local ingredient = { name = k, count = diff, } inventory.remove(ingredient) table.insert(difference, ingredient) end end end -- Remove extra inventory slots player.character_inventory_slots_bonus = player.character_inventory_slots_bonus - 1000 local serialized = { crafting_queue = crafting_queue, crafting_queue_progress = crafting_queue_progress, ingredients = difference, } -- Restore the crafting queue that was just destructively serialized serialize.deserialize_crafting_queue(player, serialized) return serialized end function serialize.deserialize_crafting_queue(player, serialized) local inventory = player.character.get_main_inventory() -- Give player some more inventory space to avoid duplicating items player.character_inventory_slots_bonus = player.character_inventory_slots_bonus + 1000 -- Add items to inventory for _, item in pairs(serialized.ingredients) do inventory.insert(item) end -- Load crafting queue for _, queueItem in pairs(serialized.crafting_queue) do -- Start crafting (consume items) player.begin_crafting { count = queueItem.count, recipe = queueItem.recipe, -- silent = true, -- Fail silently if items are missing } end -- Remove extra inventory slots player.character_inventory_slots_bonus = player.character_inventory_slots_bonus - 1000 -- Set progress of current craft player.crafting_queue_progress = serialized.crafting_queue_progress end ---@param player LuaPlayer ---@param failed string[]? ---@return string? function serialize.serialize_crafting_notifications(player, failed) -- Get all unlocked recipes local recipes = {} for _, recipe in pairs(player.force.recipes) do if recipe.enabled and not recipe.hidden then recipes[recipe.name] = true end end -- Remove those with notifications for _, recipe in pairs(player.get_recipe_notifications()) do recipes[recipe.name] = nil end -- Convert to a list of names, including previously failed recipes if any local recipe_names = {} for _, name in ipairs(failed or {}) do recipe_names[#recipe_names + 1] = name end for name in pairs(recipes) do recipe_names[#recipe_names + 1] = name end return helpers.encode_string(helpers.table_to_json(recipe_names)) end --- @class CraftingNotificationDelta --- @field add string[]? Recipes cleared elsewhere that still have a notification here --- @field remove string[]? Recipes with a notification elsewhere that are cleared here --- @param player LuaPlayer --- @param serialized string Encoded delta against the state sent with the download request --- @return string[]? failed Recipe names that do not exist function serialize.deserialize_crafting_notifications(player, serialized) --- @type CraftingNotificationDelta local delta = helpers.json_to_table(assert(helpers.decode_string(serialized))) assert(type(delta) == "table", "wrong type decoded from json_to_table") -- Recipes which can have a notification on this server local enabled = {} for _, recipe in pairs(player.force.recipes) do if recipe.enabled and not recipe.hidden then enabled[recipe.name] = true end end -- Clear notifications the player has cleared elsewhere, this means "add seen notification" local failed = {} for _, recipe_name in pairs(delta.add or {}) do if enabled[recipe_name] then player.clear_recipe_notification(recipe_name) else failed[#failed + 1] = recipe_name -- Recipe does not exist on this server end end -- Add notifications the player has elsewhere, this means "remove seen notification" for _, recipe_name in pairs(delta.remove or {}) do if enabled[recipe_name] then player.add_recipe_notification(recipe_name) end end return next(failed) and failed or nil end local controller_to_name = {} for name, value in pairs(defines.controllers) do controller_to_name[value] = name 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 shortcuts table? --- @field game_view_settings table? --- @field ticks_to_respawn number? --- @field components SyncComponents Components this was serialized with --- @field character table? --- @field inventories table? --- @field hotbar table? --- @field quick_bar table? --- @field personal_logistic_slots table? --- @field crafting_queue table? --- @field recipe_notifications string? --- @param player LuaPlayer --- @return table function serialize.serialize_shortcuts(player) local shortcuts = {} for name, prototype in pairs(compat.prototypes.shortcut) do if prototype.toggleable then shortcuts[name] = player.is_shortcut_toggled(name) end end return shortcuts end --- @param player LuaPlayer --- @param serialized table function serialize.deserialize_shortcuts(player, serialized) local shortcut_prototypes = compat.prototypes.shortcut for name, toggled in pairs(serialized) do local prototype = shortcut_prototypes[name] if prototype and prototype.toggleable then player.set_shortcut_toggled(name, toggled) end end end --- @param player LuaPlayer --- @return table function serialize.serialize_game_view_settings(player) local settings = player.game_view_settings local serialized = {} for _, key in pairs(game_view_settings_keys) do serialized[key] = settings[key] end return serialized end --- @param player LuaPlayer --- @param serialized table function serialize.deserialize_game_view_settings(player, serialized) local settings = player.game_view_settings for _, key in pairs(game_view_settings_keys) do if serialized[key] ~= nil then settings[key] = serialized[key] end 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? --- @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, 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 name = player.name, components = components, } if sync_controller then serialized.controller = controller_to_name[player.controller_type] serialized.cheat_mode = player.cheat_mode serialized.ticks_to_respawn = player.ticks_to_respawn -- In 2.0 we want to sync the physical controller to ignore remote view if v2_remote_controller then serialized.controller = controller_to_name[player.physical_controller_type] end end if syncs(components, "appearance") then serialized.color = player.color serialized.chat_color = player.chat_color serialized.tag = player.tag end if syncs(components, "force") then serialized.force = player.force.name end if syncs(components, "settings") then serialized.flashlight = player.is_flashlight_enabled() serialized.shortcuts = serialize.serialize_shortcuts(player) serialized.game_view_settings = serialize.serialize_game_view_settings(player) end 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 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 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 if syncs(components, "quick_bar") then serialized.quick_bar = serialize.serialize_quick_bar(player) end -- Serialize crafting queue if sync_inventories and player.character then serialized.crafting_queue = serialize.serialize_crafting_queue(player) end -- Serialize recipe notifications 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 local function find_planet_surface(platform) local location = platform.space_location local planet = location and game.planets[location.name] if planet and planet.surface then return planet.surface end for _, surface in pairs(game.surfaces) do if not surface.platform then return surface end end error("No surface found which can hold a character") end --- Ensure a player has a character, works from any controller type --- @param player LuaPlayer --- @return LuaEntity local function ensure_character(player) -- Do nothing if the player has a valid character local character = player.character if character and character.valid then return character end -- Exit remote view before switching controllers, this can fail if the player is on a platform if v2_exit_remote_view and player.controller_type == defines.controllers.remote then player.exit_remote_view() end -- Switch to god controller if create_character would fail if player.controller_type ~= defines.controllers.god then player.set_controller{ type = defines.controllers.god } end -- Characters can not be created on platform surfaces, restore_position will move them back into the hub local surface = player.surface if v2_space_platform and surface.platform then local planet_surface = find_planet_surface(surface.platform) local force = player.force --[[@as LuaForce]] player.teleport(force.get_spawn_position(planet_surface), planet_surface) end -- Create and return the character if not player.create_character() then error(string.format( "Failed to create character (controller: %s, physical: %s, surface: %s, connected: %s, driving: %s)", controller_to_name[player.controller_type], v2_remote_controller and controller_to_name[player.physical_controller_type] or "n/a", player.surface.name, tostring(player.connected), tostring(player.driving) )) end return assert(player.character) end --- @class FailedDeserializationPlayerData --- @field recipe_notifications string[]? --- @param player LuaPlayer --- @param serialized SerializedPlayerData --- @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") -- 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 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 if syncs(components, "force") and serialized.force then player.force = serialized.force end local character = ensure_character(player) serialize.deserialize_personal_logistic_slots(player, logistic_slots) if place then 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 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. player.ticks_to_respawn = 0 player.set_controller{ type = defines.controllers.god } player.set_controller{ type = defines.controllers.ghost } end if character and character.valid then character.destroy() end elseif state.controller ~= "remote" then -- All other controllers should not have a character local character = player.character if character and character.valid then character.destroy() end if player.controller_type ~= target_controller then player.set_controller{ type = target_controller } end else error("Remote should not be 'serialized.controller'") end end 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() elseif flashlight == false then player.disable_flashlight() end 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 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 if character then serialize.deserialize_personal_logistic_slots(player, logistic_slots) end -- Deserialize quick bar (named hotbar in old data) 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 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, spill end return serialize