mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 07:04:52 +09:00
The instance works out the recipe notification delta against the cleared list the player had when the download started, but the save applied it after the download restored player.force. A player whose force changed kept notifications they had cleared elsewhere, and recipes only the old force had dropped out of the failed list and were lost on the next upload. The download record now keeps the cleared list it sent. The save rebuilds the stored list from it and the delta, then merges that against the force the player has once the data is applied, as it did before #997. Nothing extra goes over RCON. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1464 lines
48 KiB
Lua
1464 lines
48 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 v2_inventory_bonus_equipment = compat.version_ge("2.0.0")
|
|
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
|
|
|
|
--- Index of the last slot used by a serialized inventory
|
|
local function last_slot_index(serialized)
|
|
local last = 0
|
|
for _, entry in ipairs(serialized.i) do
|
|
last = (entry.s or last + 1) + (entry.r or 0)
|
|
end
|
|
return last
|
|
end
|
|
|
|
--- Raise the inventory bonus of a character with its armor restored by the bonus of the armor's equipment which is
|
|
--- still to come, as far as a serialized main inventory needs it
|
|
--- @return integer raise Slots added to character_inventory_slots_bonus, removed again once the armor's bonus applies
|
|
local function raise_inventory_bonus(character, main)
|
|
local grid = character.grid --[[@as LuaEquipmentGrid]]
|
|
local equipment_bonus = 0
|
|
for _, equipment in pairs(grid.equipment) do
|
|
equipment_bonus = equipment_bonus + equipment.inventory_bonus
|
|
end
|
|
local delayed = equipment_bonus - grid.inventory_bonus
|
|
local raise = math.max(0, math.min(last_slot_index(main) - #character.get_main_inventory(), delayed))
|
|
character.character_inventory_slots_bonus = character.character_inventory_slots_bonus + raise
|
|
return raise
|
|
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? }[]?
|
|
--- @param after_armor fun()? Called once the armor is restored, before the other inventories
|
|
--- @return table[] leftovers
|
|
function serialize.place_items(destinations, inventories, items, after_armor)
|
|
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.
|
|
-- The armor first, its inventory bonus changes the size of the main inventory.
|
|
inventories = inventories or {}
|
|
local pending = {}
|
|
local function restore(name, serialized)
|
|
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
|
|
if inventories.armor then
|
|
restore("armor", inventories.armor)
|
|
end
|
|
if after_armor then
|
|
after_armor()
|
|
end
|
|
for name, serialized in pairs(inventories) do
|
|
if name ~= "armor" then
|
|
restore(name, serialized)
|
|
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
|
|
--- @return table<string, true> enabled Recipes which can have a notification on this server
|
|
--- @return table<string, true> cleared Enabled recipes without a notification
|
|
local function crafting_notification_state(player)
|
|
local enabled = {}
|
|
local cleared = {}
|
|
for _, recipe in pairs(player.force.recipes) do
|
|
if recipe.enabled and not recipe.hidden then
|
|
enabled[recipe.name] = true
|
|
cleared[recipe.name] = true
|
|
end
|
|
end
|
|
for _, recipe in pairs(player.get_recipe_notifications()) do
|
|
cleared[recipe.name] = nil
|
|
end
|
|
return enabled, cleared
|
|
end
|
|
|
|
---@param player LuaPlayer
|
|
---@param failed string[]?
|
|
---@return string?
|
|
function serialize.serialize_crafting_notifications(player, failed)
|
|
local _, cleared = crafting_notification_state(player)
|
|
|
|
-- 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(cleared) do
|
|
recipe_names[#recipe_names + 1] = name
|
|
end
|
|
|
|
return helpers.encode_string(helpers.table_to_json(recipe_names))
|
|
end
|
|
|
|
--- @class CraftingNotificationDelta
|
|
--- @field add string[]? Stored recipes missing from the cleared list sent with the download request
|
|
--- @field remove string[]? Recipes in the sent list that are not stored
|
|
--- @field full boolean? add is the whole stored list, sent without this save's (a prefetched copy)
|
|
|
|
--- @param player LuaPlayer
|
|
--- @param serialized string Encoded delta against the cleared list sent with the download request
|
|
--- @param sent string? That list, as sent. Without it the delta applies to the player's current list
|
|
--- @return string[]? failed Recipe names that do not exist
|
|
function serialize.deserialize_crafting_notifications(player, serialized, sent)
|
|
--- @type CraftingNotificationDelta
|
|
local delta = helpers.json_to_table(assert(helpers.decode_string(serialized)))
|
|
assert(type(delta) == "table", "wrong type decoded from json_to_table")
|
|
local enabled, cleared_here = crafting_notification_state(player)
|
|
|
|
-- Rebuild the stored list, then merge it against the force the player has now
|
|
local stored = {}
|
|
if not delta.full then
|
|
if sent then
|
|
local sent_names = helpers.json_to_table(assert(helpers.decode_string(sent)))
|
|
assert(type(sent_names) == "table", "wrong type decoded from json_to_table")
|
|
for _, recipe_name in pairs(sent_names) do
|
|
stored[recipe_name] = true
|
|
end
|
|
else
|
|
for recipe_name in pairs(cleared_here) do
|
|
stored[recipe_name] = true
|
|
end
|
|
end
|
|
for _, recipe_name in pairs(delta.remove or {}) do
|
|
stored[recipe_name] = nil
|
|
end
|
|
end
|
|
for _, recipe_name in pairs(delta.add or {}) do
|
|
stored[recipe_name] = true
|
|
end
|
|
|
|
local failed = {}
|
|
for recipe_name in pairs(stored) do
|
|
if not enabled[recipe_name] then
|
|
failed[#failed + 1] = recipe_name -- Recipe does not exist on this server
|
|
elseif not cleared_here[recipe_name] then
|
|
player.clear_recipe_notification(recipe_name)
|
|
end
|
|
end
|
|
for recipe_name in pairs(cleared_here) do
|
|
if not stored[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?
|
|
--- @field destination_instance_id integer? Only on a leave upload, for instance.ts, which strips it
|
|
|
|
--- @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
|
|
--- @param sent_recipe_notifications string? Cleared recipes sent with the download request
|
|
--- @return FailedDeserializationPlayerData?, table[]? Serialized stacks which did not fit, see spill_items
|
|
--- @return integer? inventory_bonus_raise See raise_inventory_bonus
|
|
function serialize.deserialize_player(player, serialized, components, local_controller, sent_recipe_notifications)
|
|
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
|
|
local inventory_bonus_raise
|
|
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
|
|
-- The bonus of the armor's equipment (toolbelts) only applies at the end of the tick, so once the armor is
|
|
-- restored the main inventory is raised by the part of that bonus still to come. Slots past it overflow and
|
|
-- are left over as without the raise. Only for armor with a grid, restoring it raises
|
|
-- on_player_armor_inventory_changed which removes the raise.
|
|
spill = serialize.place_items(
|
|
serialize.item_destinations(player), inventories, not craft and queue and queue.ingredients or nil,
|
|
function()
|
|
if v2_inventory_bonus_equipment and stats and inventories.main and character and character.grid then
|
|
inventory_bonus_raise = raise_inventory_bonus(character, inventories.main)
|
|
end
|
|
end
|
|
)
|
|
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, sent_recipe_notifications
|
|
)
|
|
end
|
|
|
|
return next(failed_deserialization) and failed_deserialization or nil, spill, inventory_bonus_raise
|
|
end
|
|
|
|
return serialize
|