Files
factorio-clusterio/plugins/player_sync/module/serialize.lua
T
bbassieandClaude Opus 5.5 feb3253bec Restore each imported appearance field on its own
An import with color but no chat_color or tag assigned nil to the missing
LuaPlayer properties and failed restoration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:53:36 +00:00

1390 lines
45 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
-- Characters are serialized into a table with the following fields:
-- character_crafting_speed_modifier
-- character_mining_speed_modifier
-- character_additional_mining_categories
-- character_running_speed_modifier
-- character_build_distance_bonus
-- character_item_drop_distance_bonus
-- character_reach_distance_bonus
-- character_resource_reach_distance_bonus
-- character_item_pickup_distance_bonus
-- character_loot_pickup_distance_bonus
-- character_inventory_slots_bonus
-- character_trash_slot_count_bonus
-- character_maximum_following_robot_count_bonus
-- character_health_bonus
-- character_personal_logistic_requests_enabled
-- allow_dispatching_robots
-- inhibit_movement_bonus (optional, from the armor equipment grid)
-- inventories: table of character inventory name to inventory content
function serialize.serialize_character(character)
local serialized = { }
-- Serialize character stats
for _, key in pairs(character_stat_keys) do
serialized[key] = character[key]
end
-- Serialize character inventories
serialized.inventories = serialize.serialize_inventories(character, character_inventories)
-- Serialize armor grid state
local grid = character.grid
if grid then
serialized.inhibit_movement_bonus = grid.inhibit_movement_bonus
end
return serialized
end
--- Restore the stats of a character, the inventories are restored with place_items
function serialize.deserialize_character(character, serialized)
for _, key in pairs(character_stat_keys) do
-- Missing from imports and from data saved by another Factorio version
if serialized[key] ~= nil then
character[key] = serialized[key]
end
end
end
--- Restore the armor grid state, the grid exists after the armor inventory is restored
function serialize.deserialize_character_grid(character, serialized)
local grid = character.grid
if grid and serialized.inhibit_movement_bonus ~= nil then
grid.inhibit_movement_bonus = serialized.inhibit_movement_bonus
end
end
-- Inventories items which do not fit into the inventory they came from are inserted into, in order
local overflow_order = { "main", "guns", "ammo", "armor" }
--- Copy of a serialized item entry without the slot index, repeat count and filter
--- @param entry table
--- @return table
local function stack_entry(entry)
local stack = {}
for key, value in pairs(entry) do
stack[key] = value
end
stack.s = nil
stack.r = nil
stack.f = nil
return stack
end
--- Split a serialized inventory into the slots which fit into an inventory of size and the stacks of those which do not
--- @param serialized table
--- @param size number
--- @return table fitting, table[] rest
local function split_inventory(serialized, size)
local fitting = { b = serialized.b, i = {} }
local rest = {}
local last = 0
for _, entry in ipairs(serialized.i) do
local first = entry.s or last + 1
last = first + (entry.r or 0)
if last <= size then
table.insert(fitting.i, entry)
else
if first <= size then
local part = stack_entry(entry)
part.s = first
part.r = size > first and size - first or nil
part.f = entry.f
table.insert(fitting.i, part)
end
if entry.n or entry.e then
for _ = math.max(first, size + 1), last do
table.insert(rest, stack_entry(entry))
end
end
end
end
return fitting, rest
end
local stack_definition_keys = { n = true, c = true, q = true, h = true, p = true, d = true, a = true }
--- Insert as much of a serialized stack as possible into an inventory, reducing its count by what was inserted
--- @param inventory LuaInventory
--- @param stack table
--- @return boolean placed true if all of it was inserted
local function insert_stack(inventory, stack)
local plain = true
for key in pairs(stack) do
plain = plain and stack_definition_keys[key] or false
end
-- Stacks without labels, grids, inventories or export strings can be merged with what is in the inventory
if plain then
local inserted = inventory.insert({
name = stack.n, count = stack.c, quality = stack.q, health = stack.h,
spoil_percent = stack.p, durability = stack.d, ammo = stack.a,
})
stack.c = stack.c - inserted
return stack.c <= 0
end
-- Export strings have no name, and 1.1 does not take nil for it
local slot
if stack.n then
slot = inventory.find_empty_stack(stack.n)
else
slot = inventory.find_empty_stack()
end
if not slot or stack.n and not slot.can_set_stack({ name = stack.n, count = stack.c, quality = stack.q }) then
return false
end
clusterio_serialize.deserialize_item_stack(slot, stack)
return slot.valid_for_read
end
--- Inventories the items of a player go into, by the names used in serialized data
--- @param player LuaPlayer
--- @return table<string, LuaInventory>
function serialize.item_destinations(player)
local destinations = {}
local character = player.character
if character then
for name, index in pairs(character_inventories) do
destinations[name] = character.get_inventory(index)
end
elseif player.controller_type == defines.controllers.god
or v2_remote_controller and player.physical_controller_type == defines.controllers.god
then
destinations.main = player.get_inventory(defines.inventory.god_main) --[[@as LuaInventory]]
end
return destinations
end
--- Replace the items in destinations with the serialized inventories and loose items
---
--- Each inventory goes slot for slot into the destination of the same name as far as it fits, the slots past its
--- size are inserted where there is space. Returns the serialized stacks which did not fit, see spill_items.
--- @param destinations table<string, LuaInventory>
--- @param inventories table<string, table>?
--- @param items { name: string, count: integer, quality: string? }[]?
--- @return table[] leftovers
function serialize.place_items(destinations, inventories, items)
for _, destination in pairs(destinations) do
destination.clear()
end
-- Every inventory is restored before inserting into them, as restoring a slot replaces what is in it
local pending = {}
for name, serialized in pairs(inventories or {}) do
local destination = destinations[name]
local fitting, rest = split_inventory(serialized, destination and #destination or 0)
if destination then
clusterio_serialize.deserialize_inventory(destination, fitting)
end
for _, stack in ipairs(rest) do
table.insert(pending, { from = destination, stack = stack })
end
end
for _, item in pairs(items or {}) do
table.insert(pending, { stack = { n = item.name, c = item.count, q = item.quality } })
end
local leftovers = {}
for _, entry in ipairs(pending) do
local stack = entry.stack
local placed = entry.from and insert_stack(entry.from, stack)
for _, name in ipairs(overflow_order) do
placed = placed or destinations[name] and insert_stack(destinations[name], stack)
end
if not placed then
table.insert(leftovers, stack)
end
end
return leftovers
end
--- Put serialized stacks which did not fit into a corpse below the player
--- @param player LuaPlayer
--- @param stacks table[]?
function serialize.spill_items(player, stacks)
if not stacks or not next(stacks) then
return
end
-- Plain stacks can hold more than a stack's worth
local slots = 0
for _, stack in ipairs(stacks) do
local prototype = stack.n and compat.prototypes.item[stack.n]
slots = slots + (prototype and math.ceil((stack.c or 1) / prototype.stack_size) or 1)
end
local corpse = player.surface.create_entity({
name = "character-corpse",
position = player.position,
inventory_size = slots,
player_index = player.index,
})
if not corpse then
log("ERROR: Unable to create a corpse for " .. player.name .. ", voiding " .. #stacks .. " stacks of items")
return
end
local inventory = assert(corpse.get_inventory(defines.inventory.character_corpse))
for _, stack in ipairs(stacks) do
if not insert_stack(inventory, stack) then
log("ERROR: Unable to place " .. (stack.n or "an exported item") .. " into the corpse of " .. player.name)
end
end
player.print(
"Some of your synced items do not fit into your inventory here and have been placed in a corpse below you."
)
end
-- Personal logistic slots is a table mapping string indexes to a table with the following fields:
-- name
-- min
-- max
function serialize.serialize_personal_logistic_slots(player)
-- Check if logistics technology is researched
local force = player.force
if not force.technologies["logistic-robotics"] or not force.technologies["logistic-robotics"].researched then
return nil
end
if v2_logistic_api then
local logistic_point = player.get_requester_point()
if logistic_point == nil then
return nil
end
local serialized = {
enabled = logistic_point.enabled,
trash_not_requested = logistic_point.trash_not_requested,
sections = {},
}
for i = 1, logistic_point.sections_count do
local section = logistic_point.get_section(i)
serialized.sections[i] = {
group = section.group,
active = section.active,
multiplier = section.multiplier,
filters = section.filters,
}
end
return serialized
end
-- Empty when there are no requests, nil means the player has nowhere to hold them
local serialized = {}
-- Serialize personal logistic slots
local last_valid = 0
for i = 1, 65536 do
local slot = player.get_personal_logistic_slot(i)
if slot.name then
last_valid = i
serialized[tostring(i)] = {
name = slot.name,
min = slot.min,
max = slot.max,
}
-- Stop after 100 empty slots
elseif last_valid + 100 <= i then
break
end
end
return serialized
end
function serialize.deserialize_personal_logistic_slots(player, serialized)
if not serialized then
return
end
-- Check if logistics technology is researched
local force = player.force
if not force.technologies["logistic-robotics"] or not force.technologies["logistic-robotics"].researched then
return
end
-- Load personal logistic slots
if v2_logistic_api then
local logistic_point = player.get_requester_point()
if logistic_point == nil then
return
end
-- Remove old sections up to section_count
for i = logistic_point.sections_count, 1, -1 do
logistic_point.remove_section(i)
end
-- Data from before 2.0 maps slot indices to requests, migrate it to a section
if next(serialized) == nil then
return
elseif serialized.sections == nil then
local section = logistic_point.add_section()
for i, slot in pairs(serialized) do
section.set_slot(tonumber(i), {
value = {
name = slot.name,
quality = "normal",
},
min = slot.min,
max = slot.max,
})
end
else
-- Regular 2.0+ deserialization
logistic_point.enabled = serialized.enabled
logistic_point.trash_not_requested = serialized.trash_not_requested
for i, section in pairs(serialized.sections) do
local sec = logistic_point.add_section()
sec.active = section.active
sec.multiplier = section.multiplier
if section.group ~= "" then -- "" is the default group name, which is truthy
-- Named groups that have filters on the server get added with name only - this avoids
-- overwriting existing groups. Missing or empty groups get the filters of the player.
local existing = force.get_logistic_group(section.group)
sec.group = section.group
if not existing or #existing.filters == 0 then
sec.filters = section.filters
end
else
-- Unnamed groups get added with filters
sec.filters = section.filters
end
end
end
else
local last_valid = 0
for i = 1, 65536 do
if player.get_personal_logistic_slot(i).name then
last_valid = i
player.clear_personal_logistic_slot(i)
elseif last_valid + 100 <= i then
break
end
end
for i, slot in pairs(serialized) do
player.set_personal_logistic_slot(tonumber(i), slot)
end
end
end
-- name is a custom type where quality is "normal" and comparator is "=" (most common case)
--- @alias QuickBarSlotEncoded.name string
--- @alias QuickBarSlotEncoded.filter { t: "f", n: string, q: string, c: string }
--- b is the slot indices inside blueprint books, from the book in slot s down to the item, absent for a top-level item
--- @alias QuickBarSlotEncoded.item { t: "i", n: string, s: integer, b: integer[]? }
--- @alias QuickBarSlotEncoded.record { t: "r", p: integer[], y: string, l: string? }
--- @alias QuickBarSlotEncoded QuickBarSlotEncoded.name | QuickBarSlotEncoded.filter | QuickBarSlotEncoded.item | QuickBarSlotEncoded.record
--- Inventories of the player that can hold the item a quick bar slot points at
--- @param player LuaPlayer
local function get_quick_bar_inventories(player)
return player.character and character_inventories or { main = defines.inventory.god_main }
end
--- Path of indices from the player's blueprint library down through books to the record
--- @param records table<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 epoch 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 components SyncComponents Components this was serialized with
--- @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
--- @alias SyncComponent
--- | "controller"
--- | "force"
--- | "appearance"
--- | "inventories"
--- | "logistics"
--- | "quick_bar"
--- | "settings"
--- | "recipe_notifications"
--- Components which are synced, nil when all of them are
--- @alias SyncComponents table<SyncComponent, boolean>?
--- @param components SyncComponents
--- @param name SyncComponent
--- @return boolean
local function syncs(components, name)
return components == nil or components[name] == true
end
serialize.syncs = syncs
--- @type SyncComponent[]
local component_names = {
"controller", "force", "appearance", "inventories", "logistics", "quick_bar", "settings", "recipe_notifications",
}
--- True if the same components are synced in both
--- @param a SyncComponents
--- @param b SyncComponents
--- @return boolean
function serialize.same_components(a, b)
for _, name in ipairs(component_names) do
if syncs(a, name) ~= syncs(b, name) then
return false
end
end
return true
end
--- True if a component synced in new_components was not synced in old_components
--- @param old_components SyncComponents
--- @param new_components SyncComponents
--- @return boolean
function serialize.newly_synced(old_components, new_components)
for _, name in ipairs(component_names) do
if syncs(new_components, name) and not syncs(old_components, name) then
return true
end
end
return false
end
--- @param player LuaPlayer
--- @return boolean
local function waiting_to_respawn(player)
return player.controller_type == defines.controllers.ghost and player.ticks_to_respawn ~= nil
end
--- Call fn with a player waiting to respawn temporarily respawned
---
--- For the waiting to respawn state the inventory logistic requests and filters are hidden on the player, and are
--- only accessible in the character controller.
--- @param player LuaPlayer
--- @param fn fun()
local function with_respawned(player, fn)
local ticks_to_respawn = player.ticks_to_respawn
player.ticks_to_respawn = nil -- Respawn now
fn()
-- Go back to waiting for respawn
local character = player.character
player.ticks_to_respawn = ticks_to_respawn
if character and character.valid then
character.destroy()
end
end
--- @param player LuaPlayer
--- @param failed_deserialization FailedDeserializationPlayerData
--- @param components SyncComponents
--- @return SerializedPlayerData
function serialize.serialize_player(player, failed_deserialization, components)
local sync_controller = syncs(components, "controller")
local sync_inventories = syncs(components, "inventories")
local sync_logistics = syncs(components, "logistics")
--- @type SerializedPlayerData
local serialized = {
generation = 0, -- Gets replaced later
name = player.name,
components = components,
}
if sync_controller then
serialized.controller = controller_to_name[player.controller_type]
serialized.cheat_mode = player.cheat_mode
serialized.ticks_to_respawn = player.ticks_to_respawn
-- In 2.0 we want to sync the physical controller to ignore remote view
if v2_remote_controller then
serialized.controller = controller_to_name[player.physical_controller_type]
end
end
if syncs(components, "appearance") then
serialized.color = player.color
serialized.chat_color = player.chat_color
serialized.tag = player.tag
end
if syncs(components, "force") then
serialized.force = player.force.name
end
if syncs(components, "settings") then
serialized.flashlight = player.is_flashlight_enabled()
serialized.shortcuts = serialize.serialize_shortcuts(player)
serialized.game_view_settings = serialize.serialize_game_view_settings(player)
end
if (sync_inventories or sync_logistics) and waiting_to_respawn(player) then
with_respawned(player, function()
if sync_logistics then
serialized.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player)
end
if sync_inventories then
serialized.inventories = serialize.serialize_inventories(player, character_inventories)
end
end)
end
-- Serialize character
if player.character then
if sync_inventories then
serialized.character = serialize.serialize_character(player.character)
end
if sync_logistics then
serialized.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player)
end
end
-- Serialize non-character inventories
if sync_inventories and (
player.controller_type == defines.controllers.god
or v2_remote_controller and player.physical_controller_type == defines.controllers.god
) then
serialized.inventories = serialize.serialize_inventories(player, { main = defines.inventory.god_main })
end
-- Serialize quick bar
if syncs(components, "quick_bar") then
serialized.quick_bar = serialize.serialize_quick_bar(player)
end
-- Serialize crafting queue
if sync_inventories and player.character then
serialized.crafting_queue = serialize.serialize_crafting_queue(player)
end
-- Serialize recipe notifications
if recipe_notifications_api and syncs(components, "recipe_notifications") then
serialized.recipe_notifications = serialize.serialize_crafting_notifications(player, failed_deserialization.recipe_notifications)
end
return serialized
end
--- The controller a player has on this instance, restored in place of the synced one when it is not synced
---
--- Logistic requests and the flashlight are kept on the character, which sync code may replace.
--- @class LocalControllerState
--- @field controller string?
--- @field ticks_to_respawn number?
--- @field personal_logistic_slots table?
--- @field flashlight boolean?
--- @param player LuaPlayer
--- @param components SyncComponents
--- @return LocalControllerState
function serialize.serialize_local_controller(player, components)
local controller_type = v2_remote_controller and player.physical_controller_type or player.controller_type
--- @type LocalControllerState
local state = {
controller = controller_to_name[controller_type],
ticks_to_respawn = player.ticks_to_respawn,
}
if player.character then
state.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player)
state.flashlight = player.is_flashlight_enabled()
elseif not syncs(components, "logistics") and waiting_to_respawn(player) then
with_respawned(player, function()
state.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player)
end)
end
return state
end
--- Find a surface characters can exist on, used when the player is on a space platform surface
--- @param platform LuaSpacePlatform
--- @return LuaSurface
local function find_planet_surface(platform)
local location = platform.space_location
local planet = location and game.planets[location.name]
if planet and planet.surface then
return planet.surface
end
for _, surface in pairs(game.surfaces) do
if not surface.platform then
return surface
end
end
error("No surface found which can hold a character")
end
--- Ensure a player has a character, works from any controller type
--- @param player LuaPlayer
--- @return LuaEntity
local function ensure_character(player)
-- Do nothing if the player has a valid character
local character = player.character
if character and character.valid then
return character
end
-- Exit remote view before switching controllers, this can fail if the player is on a platform
if v2_exit_remote_view and player.controller_type == defines.controllers.remote then
player.exit_remote_view()
end
-- Switch to god controller if create_character would fail
if player.controller_type ~= defines.controllers.god then
player.set_controller{ type = defines.controllers.god }
end
-- Characters can not be created on platform surfaces, restore_position will move them back into the hub
local surface = player.surface
if v2_space_platform and surface.platform then
local planet_surface = find_planet_surface(surface.platform)
local force = player.force --[[@as LuaForce]]
player.teleport(force.get_spawn_position(planet_surface), planet_surface)
end
-- Create and return the character
if not player.create_character() then
error(string.format(
"Failed to create character (controller: %s, physical: %s, surface: %s, connected: %s, driving: %s)",
controller_to_name[player.controller_type],
v2_remote_controller and controller_to_name[player.physical_controller_type] or "n/a",
player.surface.name, tostring(player.connected), tostring(player.driving)
))
end
return assert(player.character)
end
--- @class FailedDeserializationPlayerData
--- @field recipe_notifications string[]?
--- @param player LuaPlayer
--- @param serialized SerializedPlayerData
--- @param components SyncComponents
--- @param local_controller LocalControllerState? Controller to restore when the controller is not synced
--- @return FailedDeserializationPlayerData?, table[]? Serialized stacks which did not fit, see spill_items
function serialize.deserialize_player(player, serialized, components, local_controller)
local failed_deserialization = {}
local sync_controller = syncs(components, "controller")
local sync_inventories = syncs(components, "inventories")
local sync_logistics = syncs(components, "logistics")
-- Stored data from instances which do not sync a component lacks its fields
local restore_controller = sync_controller and serialized.controller ~= nil
--- @type LocalControllerState?
local state = local_controller
if restore_controller then
state = { controller = serialized.controller, ticks_to_respawn = serialized.ticks_to_respawn }
end
-- A player in remote view keeps their physical controller
local current_controller = v2_remote_controller and player.physical_controller_type or player.controller_type
local target_controller = state and defines.controllers[state.controller]
local to_ghost = state and state.controller == "ghost"
local change_controller = state and (
current_controller ~= target_controller
or to_ghost and (restore_controller or sync_inventories or sync_logistics)
)
-- Items can come from a character, a god or a ghost and go to whatever controller the player ends up with
local items = serialized
local place = sync_inventories
if change_controller and not sync_inventories then
-- The local items are kept, but they have to move to the new controller
items = serialize.serialize_player(player, {}, { inventories = true })
place = true
end
local inventories = items.character and items.character.inventories or items.inventories
local queue = items.crafting_queue
local spill
local logistic_slots = serialized.personal_logistic_slots
if not sync_logistics then
logistic_slots = local_controller and local_controller.personal_logistic_slots
end
local flashlight = serialized.flashlight
if not syncs(components, "settings") then
flashlight = local_controller and local_controller.flashlight
end
if state and change_controller then
if state.controller == "character" then
-- Create a character but do not destroy an existing one
ensure_character(player)
if player.controller_type ~= target_controller then
player.set_controller{ type = target_controller }
end
elseif state.controller == "ghost" then
-- Ghost state stores hidden logistic and filters which are only accessible in the character controller
-- The force first, so the character and the logistic groups are on the restored player's force
if syncs(components, "force") and serialized.force then
player.force = serialized.force
end
local character = ensure_character(player)
serialize.deserialize_personal_logistic_slots(player, logistic_slots)
if place then
spill = serialize.place_items(
serialize.item_destinations(player), inventories, queue and queue.ingredients
)
end
if state.ticks_to_respawn then
player.ticks_to_respawn = state.ticks_to_respawn
else
-- We have to set ticks to respawn to save the hidden state into the player but we
-- can't unset tick_to_respawn by setting it back to nil as that triggers a respawn.
player.ticks_to_respawn = 0
player.set_controller{ type = defines.controllers.god }
player.set_controller{ type = defines.controllers.ghost }
end
if character and character.valid then
character.destroy()
end
elseif state.controller ~= "remote" then
-- All other controllers should not have a character
local character = player.character
if character and character.valid then
character.destroy()
end
if player.controller_type ~= target_controller then
player.set_controller{ type = target_controller }
end
else
error("Remote should not be 'serialized.controller'")
end
end
if syncs(components, "appearance") then
-- Imports may supply any subset of these
if serialized.color then player.color = serialized.color end
if serialized.chat_color then player.chat_color = serialized.chat_color end
if serialized.tag then player.tag = serialized.tag end
end
if syncs(components, "force") and serialized.force then
player.force = serialized.force
end
if restore_controller and serialized.cheat_mode ~= nil then
player.cheat_mode = serialized.cheat_mode
end
if flashlight == true then
player.enable_flashlight()
elseif flashlight == false then
player.disable_flashlight()
end
if syncs(components, "settings") then
if serialized.shortcuts then
serialize.deserialize_shortcuts(player, serialized.shortcuts)
end
if serialized.game_view_settings then
serialize.deserialize_game_view_settings(player, serialized.game_view_settings)
end
end
-- Deserialize items, a ghost got them while it had a character above
local character = player.character
if place and not to_ghost then
local stats = character and items.character
if stats then
serialize.deserialize_character(character, stats)
end
local craft = character and queue
spill = serialize.place_items(
serialize.item_destinations(player), inventories, not craft and queue and queue.ingredients or nil
)
if stats then
serialize.deserialize_character_grid(character, stats)
end
if craft then
serialize.deserialize_crafting_queue(player, queue)
end
end
if character then
serialize.deserialize_personal_logistic_slots(player, logistic_slots)
end
-- Deserialize quick bar (named hotbar in old data)
if syncs(components, "quick_bar") then
serialize.deserialize_quick_bar(player, serialized.quick_bar or serialized.hotbar)
end
-- Deserialize recipe notifications
if recipe_notifications_api and syncs(components, "recipe_notifications") and serialized.recipe_notifications then
failed_deserialization.recipe_notifications =
serialize.deserialize_crafting_notifications(player, serialized.recipe_notifications)
end
return next(failed_deserialization) and failed_deserialization or nil, spill
end
return serialize