Files
factorio-clusterio/plugins/player_sync/module/serialize.lua
T
bbassieandClaude Opus 5.5 c798b2a33f Keep the local controller intact when it is not synced
With the controller off, a god or ghost player was still turned into a spectator while downloading, which threw away the local state the sync is meant to leave alone. Only spectate a player without a character when the controller is synced, and skip the ghost round trip when the ghost is the local state.

A ghost on an instance that syncs inventories but not the controller never received the stored character, so after respawning it uploaded an empty one over it. Respawn such a ghost straight away when there is a stored character to give it.

Clear the saved local controller when the player picks a temporary inventory after a failed download, otherwise a later download restores the stale controller over the temporary character. An empty download only gives a character when the controller is synced or the player was spectated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:25:06 +00:00

1108 lines
36 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
--- Components which are synced, nil when all of them are
--- @alias SyncComponents table<string, boolean>?
--- @param components SyncComponents
--- @param name string
--- @return boolean
local function syncs(components, name)
return components == nil or components[name] == true
end
serialize.syncs = syncs
--- @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,
}
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
-- For the waiting to respawn state the inventory logistic requests and filters are hidden on the player
if sync_controller and (sync_inventories or sync_logistics)
and player.controller_type == defines.controllers.ghost and player.ticks_to_respawn
then
player.ticks_to_respawn = nil -- Respawn now
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
-- 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
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_controller and 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
--- @class LocalControllerState
--- @field controller string?
--- @field ticks_to_respawn number?
--- @param player LuaPlayer
--- @return LocalControllerState
function serialize.serialize_local_controller(player)
local controller_type = v2_remote_controller and player.physical_controller_type or player.controller_type
return {
controller = controller_to_name[controller_type],
ticks_to_respawn = player.ticks_to_respawn,
}
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?
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 }
elseif state and state.controller == "ghost" and sync_inventories and serialized.character then
-- Respawn now, a ghost would drop the synced character and upload an empty one after respawning
state = { controller = "character" }
end
local target_controller = state and defines.controllers[state.controller]
if state and (
player.controller_type ~= target_controller
or restore_controller and state.controller == "ghost"
) 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
player.force = serialized.force
local character = ensure_character(player)
if restore_controller and sync_logistics and serialized.personal_logistic_slots then
serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots)
end
if restore_controller and sync_inventories and serialized.inventories then
serialize.deserialize_inventories(player, serialized.inventories, character_inventories)
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 syncs(components, "settings") then
if serialized.flashlight == true then
player.enable_flashlight()
elseif serialized.flashlight == false then
player.disable_flashlight()
end
if serialized.shortcuts then
serialize.deserialize_shortcuts(player, serialized.shortcuts)
end
if serialized.game_view_settings then
serialize.deserialize_game_view_settings(player, serialized.game_view_settings)
end
end
-- Deserialize character, the stored player may not have had one when the controller is not synced
if player.character then
if sync_inventories and serialized.character then
serialize.deserialize_character(player.character, serialized.character)
end
if sync_logistics then
serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots)
end
end
-- Deserialize non-character inventories
if restore_controller and sync_inventories and serialized.inventories
and player.controller_type == defines.controllers.god
then
serialize.deserialize_inventories(player, serialized.inventories, { main = defines.inventory.god_main })
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 crafting queue
if sync_inventories and player.character and serialized.crafting_queue then
serialize.deserialize_crafting_queue(player, serialized.crafting_queue)
end
-- Deserialize recipe notifications
if recipe_notifications_api and syncs(components, "recipe_notifications") and serialized.recipe_notifications then
failed_deserialization.recipe_notifications =
serialize.deserialize_crafting_notifications(player, serialized.recipe_notifications)
end
return next(failed_deserialization) and failed_deserialization or nil
end
return serialize