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 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 -- 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 function serialize.deserialize_character(character, serialized) -- Deserialize character stats for _, key in pairs(character_stat_keys) do character[key] = serialized[key] 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 local grid = character.grid if grid and serialized.inhibit_movement_bonus ~= nil then grid.inhibit_movement_bonus = serialized.inhibit_movement_bonus end 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 local serialized = nil -- 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 if not serialized then serialized = {} end 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 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 get added with name only - this avoids overwriting existing groups on the server sec.group = section.group else -- Unnamed groups get added with filters sec.filters = section.filters end end end else for i, slot in pairs(serialized) do if slot ~= nil then player.set_personal_slogistic_slot(tonumber(i), slot) end 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 } --- @alias QuickBarSlotEncoded QuickBarSlotEncoded.name | QuickBarSlotEncoded.filter --- @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 --- @return QuickBarSlotEncoded | nil function serialize.serialize_quick_bar_slot(slot) 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 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 --- @return QuickBarSlot | ItemWithQualityID | nil function serialize.deserialize_quick_bar_slot(entry) 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 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 -- entry.t == "f" is only remaining case for 2.0 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 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) 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) if not serialized then return end local width = v2_1_quick_bar_api and player.quick_bar_width for i = 1, 100 do local entry = serialized[tostring(i)] if entry ~= nil then local slot = serialize.deserialize_quick_bar_slot(entry) if slot ~= nil then 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 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 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 --- @param player LuaPlayer --- @param failed_deserialization FailedDeserializationPlayerData --- @return SerializedPlayerData function serialize.serialize_player(player, failed_deserialization) --- @type SerializedPlayerData local serialized = { generation = 0, -- Gets replaced later controller = controller_to_name[player.controller_type], name = player.name, color = player.color, chat_color = player.chat_color, tag = player.tag, force = player.force.name, cheat_mode = player.cheat_mode, flashlight = player.is_flashlight_enabled(), shortcuts = serialize.serialize_shortcuts(player), game_view_settings = serialize.serialize_game_view_settings(player), ticks_to_respawn = player.ticks_to_respawn, } -- In 2.0 we want to sync the physical controller to ignore remote view if v2_remote_controller then serialized.controller = controller_to_name[player.physical_controller_type] 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 serialized.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player) serialized.inventories = serialize.serialize_inventories(player, character_inventories) -- 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 end -- Serialize character if player.character then serialized.character = serialize.serialize_character(player.character) serialized.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player) 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 serialized.inventories = serialize.serialize_inventories(player, { main = defines.inventory.god_main }) end -- Serialize quick bar serialized.quick_bar = serialize.serialize_quick_bar(player) -- Serialize crafting queue if player.character then serialized.crafting_queue = serialize.serialize_crafting_queue(player) end -- Serialize recipe notifications if recipe_notifications_api then serialized.recipe_notifications = serialize.serialize_crafting_notifications(player, failed_deserialization.recipe_notifications) end return serialized 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 --- @return FailedDeserializationPlayerData? function serialize.deserialize_player(player, serialized) local failed_deserialization = {} local target_controller = defines.controllers[serialized.controller] if player.controller_type ~= target_controller or serialized.controller == "ghost" then if serialized.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 -- Ghost state stores hidden logistic and filters which are only accessible in the character controller local character = ensure_character(player) if serialized.personal_logistic_slots then serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots) 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 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 serialized.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 player.color = serialized.color player.chat_color = serialized.chat_color player.tag = serialized.tag player.force = serialized.force player.cheat_mode = serialized.cheat_mode if serialized.flashlight then player.enable_flashlight() else player.disable_flashlight() end if serialized.shortcuts then serialize.deserialize_shortcuts(player, serialized.shortcuts) end if serialized.game_view_settings then serialize.deserialize_game_view_settings(player, serialized.game_view_settings) end -- Deserialize character if player.character then serialize.deserialize_character(player.character, serialized.character) serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots) end -- Deserialize non-character inventories if player.controller_type == defines.controllers.god then serialize.deserialize_inventories(player, serialized.inventories, { main = defines.inventory.god_main }) end -- Deserialize quick bar (named hotbar in old data) serialize.deserialize_quick_bar(player, serialized.quick_bar or serialized.hotbar) -- Deserialize crafting queue if player.character and serialized.crafting_queue then serialize.deserialize_crafting_queue(player, serialized.crafting_queue) end -- Deserialize recipe notifications if recipe_notifications_api and serialized.recipe_notifications then failed_deserialization.recipe_notifications = serialize.deserialize_crafting_notifications(player, serialized.recipe_notifications) end return next(failed_deserialization) and failed_deserialization or nil end return serialize