Files
factorio-clusterio/plugins/player_sync/module/serialize.lua
T
ILLISIS d5d4eb7fef Sync quick bar slots that hold a specific item (#1098)
* Sync quick bar slots that hold a specific item

Quick bar slots of type item (a specific blueprint or blueprint book) were
dropped with a warning. Store the player inventory and slot that holds the item,
and after the inventories are restored point the quick bar slot at the stack in
that slot. Slots whose item is not in the inventories are dropped as before.
2026-10-16 20:40:59 +03:00

1016 lines
32 KiB
Lua

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 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
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 }
--- 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<integer, LuaRecord>
--- @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<string, QuickBarSlotEncoded>?
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<string, string | QuickBarSlotEncoded>?
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<string, boolean>?
--- @field game_view_settings table<string, boolean>?
--- @field ticks_to_respawn number
--- @field character table<string, any>?
--- @field inventories table<string, table>?
--- @field hotbar table<string, string>?
--- @field quick_bar table<string, QuickBarSlotEncoded>?
--- @field personal_logistic_slots table?
--- @field crafting_queue table?
--- @field recipe_notifications string?
--- @param player LuaPlayer
--- @return table<string, boolean>
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<string, boolean>
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<string, boolean>
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<string, boolean>
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
-- 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 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