mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
The controller merged uploads using the uploading instance's current config. An upload serialized under different settings, for example one sent from the save right after an admin turned on sync_inventories and restarted the instance, was then merged as if it held a character and wiped the stored one. Uploads now carry the components they were serialized with, the controller merges by those and drops the key before storing. The scenario keeps the components a player's synced session started with and serializes their upload with them, so changing the options while a player is online takes effect the next time they join. A player whose session lacks a component that is now synced downloads again even if the generation matches. Downloads snapshot the components when they start, so a change during a download can't mix two sets. A failed acquire unsyncs the player again regardless of the inventories setting, as before this branch. The god and dead player inventories move from the inventories component to the controller one, matching what the scenario syncs, which removes the special case in mergePlayerData. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1127 lines
36 KiB
Lua
1127 lines
36 KiB
Lua
local compat = require("modules/clusterio/compat")
|
|
local clusterio_serialize = require("modules/clusterio/serialize")
|
|
local character_inventories = require("modules/player_sync/define_player_inventories")
|
|
local character_stat_keys = require("modules/player_sync/define_player_stat_keys")
|
|
local game_view_settings_keys = require("modules/player_sync/define_game_view_settings")
|
|
local serialize = {}
|
|
|
|
local v2_logistic_api = compat.version_ge("2.0.0")
|
|
local v2_storage_api = compat.version_ge("2.0.0")
|
|
local v2_remote_controller = compat.version_ge("2.0.0")
|
|
local v2_space_platform = compat.version_ge("2.0.0")
|
|
local v2_exit_remote_view = compat.version_ge("2.0.56")
|
|
local recipe_notifications_api = compat.version_ge("2.0.67")
|
|
local v2_0_quick_bar_api = compat.version_ge("2.0.0")
|
|
local v2_1_quick_bar_api = compat.version_ge("2.1.0")
|
|
|
|
function serialize.serialize_inventories(source, inventories)
|
|
local serialized = {}
|
|
|
|
for name, index in pairs(inventories) do
|
|
local inventory = source.get_inventory(index)
|
|
if inventory ~= nil then
|
|
serialized[name] = clusterio_serialize.serialize_inventory(inventory)
|
|
end
|
|
end
|
|
|
|
return serialized
|
|
end
|
|
|
|
function serialize.deserialize_inventories(destination, serialized, inventories)
|
|
for name, index in pairs(inventories) do
|
|
local inventory = destination.get_inventory(index)
|
|
if inventory ~= nil and serialized[name] ~= nil then
|
|
inventory.clear()
|
|
clusterio_serialize.deserialize_inventory(inventory, serialized[name])
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Characters are serialized into a table with the following fields:
|
|
-- character_crafting_speed_modifier
|
|
-- character_mining_speed_modifier
|
|
-- character_additional_mining_categories
|
|
-- character_running_speed_modifier
|
|
-- character_build_distance_bonus
|
|
-- character_item_drop_distance_bonus
|
|
-- character_reach_distance_bonus
|
|
-- character_resource_reach_distance_bonus
|
|
-- character_item_pickup_distance_bonus
|
|
-- character_loot_pickup_distance_bonus
|
|
-- character_inventory_slots_bonus
|
|
-- character_trash_slot_count_bonus
|
|
-- character_maximum_following_robot_count_bonus
|
|
-- character_health_bonus
|
|
-- character_personal_logistic_requests_enabled
|
|
-- allow_dispatching_robots
|
|
-- inhibit_movement_bonus (optional, from the armor equipment grid)
|
|
-- inventories: table of character inventory name to inventory content
|
|
function serialize.serialize_character(character)
|
|
local serialized = { }
|
|
|
|
-- Serialize character stats
|
|
for _, key in pairs(character_stat_keys) do
|
|
serialized[key] = character[key]
|
|
end
|
|
|
|
-- Serialize character inventories
|
|
serialized.inventories = serialize.serialize_inventories(character, character_inventories)
|
|
|
|
-- Serialize armor grid state
|
|
local grid = character.grid
|
|
if grid then
|
|
serialized.inhibit_movement_bonus = grid.inhibit_movement_bonus
|
|
end
|
|
|
|
return serialized
|
|
end
|
|
|
|
function serialize.deserialize_character(character, serialized)
|
|
-- Deserialize character stats
|
|
for _, key in pairs(character_stat_keys) do
|
|
character[key] = serialized[key]
|
|
end
|
|
|
|
-- Deserialize character inventories
|
|
serialize.deserialize_inventories(character, serialized.inventories, character_inventories)
|
|
|
|
-- Deserialize armor grid state, the grid exists after the armor inventory is restored
|
|
local grid = character.grid
|
|
if grid and serialized.inhibit_movement_bonus ~= nil then
|
|
grid.inhibit_movement_bonus = serialized.inhibit_movement_bonus
|
|
end
|
|
end
|
|
|
|
-- Personal logistic slots is a table mapping string indexes to a table with the following fields:
|
|
-- name
|
|
-- min
|
|
-- max
|
|
function serialize.serialize_personal_logistic_slots(player)
|
|
-- Check if logistics technology is researched
|
|
local force = player.force
|
|
if not force.technologies["logistic-robotics"] or not force.technologies["logistic-robotics"].researched then
|
|
return nil
|
|
end
|
|
|
|
if v2_logistic_api then
|
|
local logistic_point = player.get_requester_point()
|
|
if logistic_point == nil then
|
|
return nil
|
|
end
|
|
local serialized = {
|
|
enabled = logistic_point.enabled,
|
|
trash_not_requested = logistic_point.trash_not_requested,
|
|
sections = {},
|
|
}
|
|
for i = 1, logistic_point.sections_count do
|
|
local section = logistic_point.get_section(i)
|
|
serialized.sections[i] = {
|
|
group = section.group,
|
|
active = section.active,
|
|
multiplier = section.multiplier,
|
|
filters = section.filters,
|
|
}
|
|
end
|
|
return serialized
|
|
end
|
|
|
|
local serialized = nil
|
|
|
|
-- Serialize personal logistic slots
|
|
local last_valid = 0
|
|
for i = 1, 65536 do
|
|
local slot = player.get_personal_logistic_slot(i)
|
|
if slot.name then
|
|
last_valid = i
|
|
if not serialized then
|
|
serialized = {}
|
|
end
|
|
serialized[tostring(i)] = {
|
|
name = slot.name,
|
|
min = slot.min,
|
|
max = slot.max,
|
|
}
|
|
|
|
-- Stop after 100 empty slots
|
|
elseif last_valid + 100 <= i then
|
|
break
|
|
end
|
|
end
|
|
|
|
return serialized
|
|
end
|
|
|
|
function serialize.deserialize_personal_logistic_slots(player, serialized)
|
|
if not serialized then
|
|
return
|
|
end
|
|
|
|
-- Check if logistics technology is researched
|
|
local force = player.force
|
|
if not force.technologies["logistic-robotics"] or not force.technologies["logistic-robotics"].researched then
|
|
return
|
|
end
|
|
|
|
-- Load personal logistic slots
|
|
if v2_logistic_api then
|
|
local logistic_point = player.get_requester_point()
|
|
if logistic_point == nil then
|
|
return
|
|
end
|
|
|
|
-- Remove old sections up to section_count
|
|
for i = logistic_point.sections_count, 1, -1 do
|
|
logistic_point.remove_section(i)
|
|
end
|
|
|
|
-- If this is an array instead of a table, migrate to v2 format
|
|
if serialized[1] ~= nil then
|
|
local section = logistic_point.add_section()
|
|
for i, slot in pairs(serialized) do
|
|
section.set_slot(i, {
|
|
value = {
|
|
name = slot.name,
|
|
quality = "normal",
|
|
},
|
|
min = slot.min,
|
|
max = slot.max,
|
|
})
|
|
end
|
|
else
|
|
-- Regular 2.0+ deserialization
|
|
logistic_point.enabled = serialized.enabled
|
|
logistic_point.trash_not_requested = serialized.trash_not_requested
|
|
for i, section in pairs(serialized.sections) do
|
|
local sec = logistic_point.add_section()
|
|
sec.active = section.active
|
|
sec.multiplier = section.multiplier
|
|
if section.group ~= "" then -- "" is the default group name, which is truthy
|
|
-- Named groups that have filters on the server get added with name only - this avoids
|
|
-- overwriting existing groups. Missing or empty groups get the filters of the player.
|
|
local existing = force.get_logistic_group(section.group)
|
|
sec.group = section.group
|
|
if not existing or #existing.filters == 0 then
|
|
sec.filters = section.filters
|
|
end
|
|
else
|
|
-- Unnamed groups get added with filters
|
|
sec.filters = section.filters
|
|
end
|
|
end
|
|
end
|
|
else
|
|
for i, slot in pairs(serialized) do
|
|
if slot ~= nil then
|
|
player.set_personal_slogistic_slot(tonumber(i), slot)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- name is a custom type where quality is "normal" and comparator is "=" (most common case)
|
|
--- @alias QuickBarSlotEncoded.name string
|
|
--- @alias QuickBarSlotEncoded.filter { t: "f", n: string, q: string, c: string }
|
|
--- b is the slot indices inside blueprint books, from the book in slot s down to the item, absent for a top-level item
|
|
--- @alias QuickBarSlotEncoded.item { t: "i", n: string, s: integer, b: integer[]? }
|
|
--- @alias QuickBarSlotEncoded.record { t: "r", p: integer[], y: string, l: string? }
|
|
--- @alias QuickBarSlotEncoded QuickBarSlotEncoded.name | QuickBarSlotEncoded.filter | QuickBarSlotEncoded.item | QuickBarSlotEncoded.record
|
|
|
|
--- Inventories of the player that can hold the item a quick bar slot points at
|
|
--- @param player LuaPlayer
|
|
local function get_quick_bar_inventories(player)
|
|
return player.character and character_inventories or { main = defines.inventory.god_main }
|
|
end
|
|
|
|
--- Path of indices from the player's blueprint library down through books to the record
|
|
--- @param records table<integer, LuaRecord>
|
|
--- @param target LuaRecord
|
|
--- @return integer[]?
|
|
local function find_record_path(records, target)
|
|
for index, record in pairs(records) do
|
|
if record == target then
|
|
return { index }
|
|
end
|
|
if record.valid and record.type == "blueprint-book" then
|
|
local path = find_record_path(record.contents, target)
|
|
if path then
|
|
table.insert(path, 1, index)
|
|
return path
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
--- Path of slot indices to the item with the item number, descending into blueprint books
|
|
--- @param inventory LuaInventory
|
|
--- @param item_number integer
|
|
--- @return integer[]?
|
|
local function find_item_path(inventory, item_number)
|
|
for i = 1, #inventory do
|
|
local stack = inventory[i] --[[@as LuaItemStack]]
|
|
if stack.valid_for_read then
|
|
if stack.item_number == item_number then
|
|
return { i }
|
|
end
|
|
local inner = stack.is_blueprint_book and stack.get_inventory(defines.inventory.item_main)
|
|
local path = inner and find_item_path(inner, item_number)
|
|
if path then
|
|
table.insert(path, 1, i)
|
|
return path
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
--- @param filter ItemFilter
|
|
--- @return QuickBarSlotEncoded
|
|
local function serialize_filter(filter)
|
|
if filter.quality == "normal" and filter.comparator == "=" then
|
|
return filter.name --[[@as string]]
|
|
end
|
|
|
|
return {
|
|
t = "f",
|
|
n = filter.name --[[@as string]],
|
|
q = filter.quality --[[@as string]],
|
|
c = filter.comparator --[[@as string]],
|
|
}
|
|
end
|
|
|
|
--- @param slot QuickBarSlot | ItemFilter | LuaItemPrototype
|
|
--- @param player LuaPlayer
|
|
--- @return QuickBarSlotEncoded | nil
|
|
function serialize.serialize_quick_bar_slot(slot, player)
|
|
if v2_1_quick_bar_api then
|
|
-- 2.1 has a dedicated type for quick bar slots
|
|
--- @cast slot QuickBarSlot
|
|
if slot.type == "filter" then
|
|
return serialize_filter(slot.filter)
|
|
end
|
|
|
|
if slot.type == "item" then
|
|
-- A specific item (blueprint, spidertron remote) is stored as the inventory slot that holds it
|
|
local item_number = slot.item --[[@as LuaItem]].item_number
|
|
for name, inventory_id in pairs(get_quick_bar_inventories(player)) do
|
|
local inventory = player.get_inventory(inventory_id)
|
|
local path = inventory and find_item_path(inventory, item_number)
|
|
if path then
|
|
local top = table.remove(path, 1)
|
|
return { t = "i", n = name, s = top, b = path[1] and path or nil }
|
|
end
|
|
end
|
|
|
|
print("Warning: Could not find the item of quick bar slot of type 'item' in the inventories")
|
|
return nil
|
|
end
|
|
|
|
if slot.type == "record" then
|
|
-- ponytail: one library walk per record slot per upload, a huge library makes the leave tick longer
|
|
local record = slot.record --[[@as LuaRecord]]
|
|
local path = find_record_path(player.blueprints, record)
|
|
if path then
|
|
return { t = "r", p = path, y = record.type, l = record.label }
|
|
end
|
|
|
|
print("Warning: Quick bar slot record is not in the player's blueprint library")
|
|
return nil
|
|
end
|
|
|
|
print("Warning: Unsupported quick bar slot type '" .. slot.type .. "'")
|
|
return nil
|
|
end
|
|
|
|
if v2_0_quick_bar_api then
|
|
-- 2.0 gives us an item filter which supports quality
|
|
--- @cast slot -QuickBarSlot, -LuaItemPrototype
|
|
return serialize_filter(slot)
|
|
end
|
|
|
|
-- pre 2.0 quality did not exist
|
|
--- @cast slot LuaItemPrototype
|
|
return slot.name
|
|
end
|
|
|
|
--- @param entry string | QuickBarSlotEncoded
|
|
--- @param player LuaPlayer
|
|
--- @return QuickBarSlot | ItemWithQualityID | LuaItemStack | nil
|
|
function serialize.deserialize_quick_bar_slot(entry, player)
|
|
if v2_1_quick_bar_api then
|
|
-- Return a quick bar slot
|
|
if type(entry) == "string" then
|
|
return {
|
|
type = "filter",
|
|
filter = {
|
|
name = entry,
|
|
quality = "normal",
|
|
comparator = "=",
|
|
},
|
|
}
|
|
end
|
|
|
|
if entry.t == "f" then
|
|
return {
|
|
type = "filter",
|
|
filter = {
|
|
name = entry.n,
|
|
quality = entry.q,
|
|
comparator = entry.c,
|
|
},
|
|
} --[[@as QuickBarSlot]]
|
|
end
|
|
|
|
if entry.t == "i" then
|
|
-- The inventories have been restored, the stack points the slot at the item in it
|
|
local inventory_id = get_quick_bar_inventories(player)[entry.n]
|
|
local inventory = inventory_id and player.get_inventory(inventory_id)
|
|
local stack = inventory and entry.s <= #inventory and inventory[entry.s] or nil
|
|
-- Walk down through the books to a stack kept inside them
|
|
for _, index in ipairs(entry.b or {}) do
|
|
local inner = stack and stack.valid_for_read and stack.is_blueprint_book
|
|
and stack.get_inventory(defines.inventory.item_main)
|
|
stack = inner and index <= #inner and inner[index] or nil
|
|
end
|
|
if stack and stack.valid_for_read then
|
|
return stack
|
|
end
|
|
|
|
print("Warning: Could not find the item of quick bar slot in " .. entry.n .. " slot " .. entry.s)
|
|
return nil
|
|
end
|
|
|
|
if entry.t == "r" then
|
|
-- The record must still be what was stored, a shifted library must not set the wrong one
|
|
local records = player.blueprints
|
|
local record --[[@as LuaRecord?]]
|
|
for _, index in ipairs(entry.p) do
|
|
record = records[index]
|
|
if not (record and record.valid) then
|
|
record = nil
|
|
break
|
|
end
|
|
records = record.type == "blueprint-book" and record.contents or {}
|
|
end
|
|
if record and record.valid and record.type == entry.y and (record.label or "") == (entry.l or "") then
|
|
return { type = "record", record = record } --[[@as QuickBarSlot]]
|
|
end
|
|
|
|
print("Warning: Could not find the record of quick bar slot at library path " .. table.concat(entry.p, "/"))
|
|
return nil
|
|
end
|
|
|
|
print("Warning: Unsupported serialized quick bar slot type '" .. tostring(entry.t) .. "'")
|
|
return nil
|
|
end
|
|
|
|
if v2_0_quick_bar_api then
|
|
-- Return a ItemIDAndQualityIDPair (member of ItemWithQualityID)
|
|
if type(entry) == "string" then
|
|
return {
|
|
name = entry,
|
|
quality = "normal",
|
|
}
|
|
end
|
|
|
|
-- 2.1 entries (items, records) can not be represented on 2.0, clearing the slot
|
|
if entry.t ~= "f" then
|
|
return nil
|
|
end
|
|
--- @cast entry QuickBarSlotEncoded.filter
|
|
return {
|
|
name = entry.n,
|
|
quality = entry.q,
|
|
}
|
|
end
|
|
|
|
-- Return a string (member of ItemPrototypeIdentification)
|
|
-- Pre 2.0 this was the only method of encoding used, anything else is from a newer version
|
|
if type(entry) ~= "string" then
|
|
return nil
|
|
end
|
|
return entry --[[@as string]]
|
|
end
|
|
|
|
--- @param player LuaPlayer
|
|
--- @return table<string, QuickBarSlotEncoded>?
|
|
function serialize.serialize_quick_bar(player)
|
|
local serialized
|
|
local width = v2_1_quick_bar_api and player.quick_bar_width
|
|
|
|
for i = 1, 100 do
|
|
local slot
|
|
|
|
if v2_1_quick_bar_api then
|
|
local page = math.floor((i - 1) / width) + 1
|
|
local page_slot = ((i - 1) % width) + 1
|
|
slot = player.get_quick_bar_slot(page, page_slot)
|
|
else
|
|
--- @diagnostic disable-next-line: missing-parameter
|
|
slot = player.get_quick_bar_slot(i)
|
|
end
|
|
|
|
if slot ~= nil then
|
|
local entry = serialize.serialize_quick_bar_slot(slot, player)
|
|
if entry ~= nil then
|
|
serialized = serialized or {}
|
|
serialized[tostring(i)] = entry
|
|
end
|
|
end
|
|
end
|
|
|
|
return serialized
|
|
end
|
|
|
|
--- @param player LuaPlayer
|
|
--- @param serialized table<string, string | QuickBarSlotEncoded>?
|
|
function serialize.deserialize_quick_bar(player, serialized)
|
|
-- Every slot is set, and the ones not in the data are cleared, so a slot moved or cleared on the
|
|
-- previous server does not come back from this server's old copy
|
|
serialized = serialized or {}
|
|
local width = v2_1_quick_bar_api and player.quick_bar_width
|
|
for i = 1, 100 do
|
|
local entry = serialized[tostring(i)]
|
|
local slot = entry ~= nil and serialize.deserialize_quick_bar_slot(entry, player) or nil
|
|
if v2_1_quick_bar_api then
|
|
local page = math.floor((i - 1) / width) + 1
|
|
local page_slot = ((i - 1) % width) + 1
|
|
player.set_quick_bar_slot(page, page_slot, slot)
|
|
else
|
|
--- @diagnostic disable-next-line: param-type-mismatch
|
|
player.set_quick_bar_slot(i, slot)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Crafting queue is a table with the following fields:
|
|
-- crafting_queue
|
|
-- ingredients
|
|
function serialize.serialize_crafting_queue(player)
|
|
local crafting_queue = {}
|
|
|
|
-- Give player some more inventory space to avoid duplicating items
|
|
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus + 1000
|
|
|
|
-- Save current items
|
|
local inventory = player.character.get_main_inventory()
|
|
local old_items = inventory.get_contents()
|
|
local crafting_queue_progress = player.crafting_queue_progress
|
|
|
|
-- Cancel old crafts to get the items back
|
|
while player.crafting_queue_size > 0 do
|
|
local old_queue = player.crafting_queue
|
|
|
|
-- Cancel craft
|
|
player.cancel_crafting {
|
|
index = 1,
|
|
count = 1,
|
|
}
|
|
|
|
local rightmost_right_index = 0 -- 0 indexed since it is subtractive in a 1 indexed language
|
|
local new_queue = player.crafting_queue
|
|
while
|
|
new_queue ~= nil and
|
|
new_queue[#new_queue - rightmost_right_index] ~= nil and
|
|
new_queue[#new_queue - rightmost_right_index].count >= old_queue[#old_queue - rightmost_right_index].count
|
|
do
|
|
rightmost_right_index = rightmost_right_index + 1
|
|
end
|
|
local oldItem = old_queue[#old_queue - rightmost_right_index]
|
|
local newItem = nil
|
|
if new_queue ~= nil then
|
|
newItem = new_queue[#new_queue - rightmost_right_index]
|
|
end
|
|
|
|
-- Figure out how many items to add to queue
|
|
local added = oldItem.count
|
|
if newItem ~= nil then
|
|
added = oldItem.count - newItem.count
|
|
if oldItem.recipe ~= newItem.recipe then
|
|
log("ERROR: Old item "..oldItem.recipe.." is not equal "..newItem.recipe)
|
|
end
|
|
end
|
|
|
|
-- If the last item we added was of the same type, merge them in the queue
|
|
if #crafting_queue > 0 and crafting_queue[#crafting_queue].recipe == oldItem.recipe then
|
|
crafting_queue[#crafting_queue].count = crafting_queue[#crafting_queue].count + added
|
|
else
|
|
-- If the last item was of a different type, add a new item to the queue
|
|
table.insert(crafting_queue, {
|
|
recipe = oldItem.recipe,
|
|
count = added,
|
|
})
|
|
end
|
|
-- game.print("Saved craft "..oldItem.recipe)
|
|
end
|
|
|
|
local difference = {}
|
|
if v2_storage_api then
|
|
-- Find amount of items added and remove from inventory
|
|
local new_items = inventory.get_contents()
|
|
-- Build map of old counts by item name and quality
|
|
local old_counts = {}
|
|
for _, item in ipairs(old_items) do
|
|
local key = item.name .. ":" .. item.quality
|
|
old_counts[key] = (old_counts[key] or 0) + item.count
|
|
end
|
|
|
|
-- Compare with new items to find differences
|
|
for _, item in ipairs(new_items) do
|
|
local key = item.name .. ":" .. item.quality
|
|
local old_count = old_counts[key] or 0
|
|
local diff = item.count - old_count
|
|
|
|
if diff > 0 then
|
|
-- We don't have to worry about quality because quality can't be handcrafted
|
|
local ingredient = {
|
|
name = item.name,
|
|
count = diff
|
|
}
|
|
inventory.remove(ingredient)
|
|
table.insert(difference, ingredient)
|
|
end
|
|
end
|
|
else
|
|
-- Find amount of items added and remove from inventory
|
|
local new_items = inventory.get_contents()
|
|
for k,v in pairs(new_items) do
|
|
local old_count = old_items[k]
|
|
local diff = v
|
|
if old_count ~= nil then
|
|
diff = diff - old_count
|
|
end
|
|
if diff > 0 then
|
|
local ingredient = {
|
|
name = k,
|
|
count = diff,
|
|
}
|
|
inventory.remove(ingredient)
|
|
table.insert(difference, ingredient)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Remove extra inventory slots
|
|
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus - 1000
|
|
|
|
local serialized = {
|
|
crafting_queue = crafting_queue,
|
|
crafting_queue_progress = crafting_queue_progress,
|
|
ingredients = difference,
|
|
}
|
|
|
|
-- Restore the crafting queue that was just destructively serialized
|
|
serialize.deserialize_crafting_queue(player, serialized)
|
|
|
|
return serialized
|
|
end
|
|
|
|
function serialize.deserialize_crafting_queue(player, serialized)
|
|
local inventory = player.character.get_main_inventory()
|
|
|
|
-- Give player some more inventory space to avoid duplicating items
|
|
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus + 1000
|
|
|
|
-- Add items to inventory
|
|
for _, item in pairs(serialized.ingredients) do
|
|
inventory.insert(item)
|
|
end
|
|
|
|
-- Load crafting queue
|
|
for _, queueItem in pairs(serialized.crafting_queue) do
|
|
-- Start crafting (consume items)
|
|
player.begin_crafting {
|
|
count = queueItem.count,
|
|
recipe = queueItem.recipe,
|
|
-- silent = true, -- Fail silently if items are missing
|
|
}
|
|
end
|
|
|
|
-- Remove extra inventory slots
|
|
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus - 1000
|
|
-- Set progress of current craft
|
|
player.crafting_queue_progress = serialized.crafting_queue_progress
|
|
end
|
|
|
|
---@param player LuaPlayer
|
|
---@param failed string[]?
|
|
---@return string?
|
|
function serialize.serialize_crafting_notifications(player, failed)
|
|
-- Get all unlocked recipes
|
|
local recipes = {}
|
|
for _, recipe in pairs(player.force.recipes) do
|
|
if recipe.enabled and not recipe.hidden then
|
|
recipes[recipe.name] = true
|
|
end
|
|
end
|
|
|
|
-- Remove those with notifications
|
|
for _, recipe in pairs(player.get_recipe_notifications()) do
|
|
recipes[recipe.name] = nil
|
|
end
|
|
|
|
-- Convert to a list of names, including previously failed recipes if any
|
|
local recipe_names = {}
|
|
for _, name in ipairs(failed or {}) do
|
|
recipe_names[#recipe_names + 1] = name
|
|
end
|
|
for name in pairs(recipes) do
|
|
recipe_names[#recipe_names + 1] = name
|
|
end
|
|
|
|
return helpers.encode_string(helpers.table_to_json(recipe_names))
|
|
end
|
|
|
|
--- @class CraftingNotificationDelta
|
|
--- @field add string[]? Recipes cleared elsewhere that still have a notification here
|
|
--- @field remove string[]? Recipes with a notification elsewhere that are cleared here
|
|
|
|
--- @param player LuaPlayer
|
|
--- @param serialized string Encoded delta against the state sent with the download request
|
|
--- @return string[]? failed Recipe names that do not exist
|
|
function serialize.deserialize_crafting_notifications(player, serialized)
|
|
--- @type CraftingNotificationDelta
|
|
local delta = helpers.json_to_table(assert(helpers.decode_string(serialized)))
|
|
assert(type(delta) == "table", "wrong type decoded from json_to_table")
|
|
|
|
-- Recipes which can have a notification on this server
|
|
local enabled = {}
|
|
for _, recipe in pairs(player.force.recipes) do
|
|
if recipe.enabled and not recipe.hidden then
|
|
enabled[recipe.name] = true
|
|
end
|
|
end
|
|
|
|
-- Clear notifications the player has cleared elsewhere, this means "add seen notification"
|
|
local failed = {}
|
|
for _, recipe_name in pairs(delta.add or {}) do
|
|
if enabled[recipe_name] then
|
|
player.clear_recipe_notification(recipe_name)
|
|
else
|
|
failed[#failed + 1] = recipe_name -- Recipe does not exist on this server
|
|
end
|
|
end
|
|
|
|
-- Add notifications the player has elsewhere, this means "remove seen notification"
|
|
for _, recipe_name in pairs(delta.remove or {}) do
|
|
if enabled[recipe_name] then
|
|
player.add_recipe_notification(recipe_name)
|
|
end
|
|
end
|
|
|
|
return next(failed) and failed or nil
|
|
end
|
|
|
|
local controller_to_name = {}
|
|
for name, value in pairs(defines.controllers) do
|
|
controller_to_name[value] = name
|
|
end
|
|
|
|
--- @class SerializedPlayerData
|
|
--- @field generation number
|
|
--- @field name string
|
|
--- @field controller string?
|
|
--- @field color Color?
|
|
--- @field chat_color Color?
|
|
--- @field tag string?
|
|
--- @field force string?
|
|
--- @field cheat_mode boolean?
|
|
--- @field flashlight boolean?
|
|
--- @field shortcuts table<string, boolean>?
|
|
--- @field game_view_settings table<string, boolean>?
|
|
--- @field ticks_to_respawn number?
|
|
--- @field 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
|
|
|
|
--- Components which are synced, nil when all of them are
|
|
--- @alias SyncComponents table<string, boolean>?
|
|
|
|
--- @param components SyncComponents
|
|
--- @param name string
|
|
--- @return boolean
|
|
local function syncs(components, name)
|
|
return components == nil or components[name] == true
|
|
end
|
|
serialize.syncs = syncs
|
|
|
|
local component_names = {
|
|
"controller", "force", "appearance", "inventories", "logistics", "quick_bar", "settings", "recipe_notifications",
|
|
}
|
|
|
|
--- 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
|
|
--- @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
|
|
|
|
-- For the waiting to respawn state the inventory logistic requests and filters are hidden on the player
|
|
if sync_controller and (sync_inventories or sync_logistics)
|
|
and player.controller_type == defines.controllers.ghost and player.ticks_to_respawn
|
|
then
|
|
player.ticks_to_respawn = nil -- Respawn now
|
|
|
|
if sync_logistics then
|
|
serialized.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player)
|
|
end
|
|
if sync_inventories then
|
|
serialized.inventories = serialize.serialize_inventories(player, character_inventories)
|
|
end
|
|
|
|
-- Go back to waiting for respawn
|
|
local character = player.character
|
|
player.ticks_to_respawn = serialized.ticks_to_respawn
|
|
if character and character.valid then
|
|
character.destroy()
|
|
end
|
|
end
|
|
|
|
-- Serialize character
|
|
if player.character then
|
|
if sync_inventories then
|
|
serialized.character = serialize.serialize_character(player.character)
|
|
end
|
|
if sync_logistics then
|
|
serialized.personal_logistic_slots = serialize.serialize_personal_logistic_slots(player)
|
|
end
|
|
end
|
|
|
|
-- Serialize non-character inventories
|
|
if sync_controller and sync_inventories and (
|
|
player.controller_type == defines.controllers.god
|
|
or v2_remote_controller and player.physical_controller_type == defines.controllers.god
|
|
) then
|
|
serialized.inventories = serialize.serialize_inventories(player, { main = defines.inventory.god_main })
|
|
end
|
|
|
|
-- Serialize quick bar
|
|
if syncs(components, "quick_bar") then
|
|
serialized.quick_bar = serialize.serialize_quick_bar(player)
|
|
end
|
|
|
|
-- Serialize crafting queue
|
|
if sync_inventories and player.character then
|
|
serialized.crafting_queue = serialize.serialize_crafting_queue(player)
|
|
end
|
|
|
|
-- Serialize recipe notifications
|
|
if recipe_notifications_api and syncs(components, "recipe_notifications") then
|
|
serialized.recipe_notifications = serialize.serialize_crafting_notifications(player, failed_deserialization.recipe_notifications)
|
|
end
|
|
|
|
return serialized
|
|
end
|
|
|
|
--- The controller a player has on this instance, restored in place of the synced one when it is not synced
|
|
--- @class LocalControllerState
|
|
--- @field controller string?
|
|
--- @field ticks_to_respawn number?
|
|
|
|
--- @param player LuaPlayer
|
|
--- @return LocalControllerState
|
|
function serialize.serialize_local_controller(player)
|
|
local controller_type = v2_remote_controller and player.physical_controller_type or player.controller_type
|
|
return {
|
|
controller = controller_to_name[controller_type],
|
|
ticks_to_respawn = player.ticks_to_respawn,
|
|
}
|
|
end
|
|
|
|
--- Find a surface characters can exist on, used when the player is on a space platform surface
|
|
--- @param platform LuaSpacePlatform
|
|
--- @return LuaSurface
|
|
local function find_planet_surface(platform)
|
|
local location = platform.space_location
|
|
local planet = location and game.planets[location.name]
|
|
if planet and planet.surface then
|
|
return planet.surface
|
|
end
|
|
for _, surface in pairs(game.surfaces) do
|
|
if not surface.platform then
|
|
return surface
|
|
end
|
|
end
|
|
error("No surface found which can hold a character")
|
|
end
|
|
|
|
--- Ensure a player has a character, works from any controller type
|
|
--- @param player LuaPlayer
|
|
--- @return LuaEntity
|
|
local function ensure_character(player)
|
|
-- Do nothing if the player has a valid character
|
|
local character = player.character
|
|
if character and character.valid then
|
|
return character
|
|
end
|
|
|
|
-- Exit remote view before switching controllers, this can fail if the player is on a platform
|
|
if v2_exit_remote_view and player.controller_type == defines.controllers.remote then
|
|
player.exit_remote_view()
|
|
end
|
|
|
|
-- Switch to god controller if create_character would fail
|
|
if player.controller_type ~= defines.controllers.god then
|
|
player.set_controller{ type = defines.controllers.god }
|
|
end
|
|
|
|
-- Characters can not be created on platform surfaces, restore_position will move them back into the hub
|
|
local surface = player.surface
|
|
if v2_space_platform and surface.platform then
|
|
local planet_surface = find_planet_surface(surface.platform)
|
|
local force = player.force --[[@as LuaForce]]
|
|
player.teleport(force.get_spawn_position(planet_surface), planet_surface)
|
|
end
|
|
|
|
-- Create and return the character
|
|
if not player.create_character() then
|
|
error(string.format(
|
|
"Failed to create character (controller: %s, physical: %s, surface: %s, connected: %s, driving: %s)",
|
|
controller_to_name[player.controller_type],
|
|
v2_remote_controller and controller_to_name[player.physical_controller_type] or "n/a",
|
|
player.surface.name, tostring(player.connected), tostring(player.driving)
|
|
))
|
|
end
|
|
return assert(player.character)
|
|
end
|
|
|
|
--- @class FailedDeserializationPlayerData
|
|
--- @field recipe_notifications string[]?
|
|
|
|
--- @param player LuaPlayer
|
|
--- @param serialized SerializedPlayerData
|
|
--- @param components SyncComponents
|
|
--- @param local_controller LocalControllerState? Controller to restore when the controller is not synced
|
|
--- @return FailedDeserializationPlayerData?
|
|
function serialize.deserialize_player(player, serialized, components, local_controller)
|
|
local failed_deserialization = {}
|
|
local sync_controller = syncs(components, "controller")
|
|
local sync_inventories = syncs(components, "inventories")
|
|
local sync_logistics = syncs(components, "logistics")
|
|
|
|
-- Stored data from instances which do not sync a component lacks its fields
|
|
local restore_controller = sync_controller and serialized.controller ~= nil
|
|
|
|
--- @type LocalControllerState?
|
|
local state = local_controller
|
|
if restore_controller then
|
|
state = { controller = serialized.controller, ticks_to_respawn = serialized.ticks_to_respawn }
|
|
elseif state and state.controller == "ghost" and sync_inventories and serialized.character then
|
|
-- Respawn now, a ghost would drop the synced character and upload an empty one after respawning
|
|
state = { controller = "character" }
|
|
end
|
|
|
|
local target_controller = state and defines.controllers[state.controller]
|
|
if state and (
|
|
player.controller_type ~= target_controller
|
|
or restore_controller and state.controller == "ghost"
|
|
) then
|
|
if state.controller == "character" then
|
|
-- Create a character but do not destroy an existing one
|
|
ensure_character(player)
|
|
if player.controller_type ~= target_controller then
|
|
player.set_controller{ type = target_controller }
|
|
end
|
|
|
|
elseif state.controller == "ghost" then
|
|
-- Ghost state stores hidden logistic and filters which are only accessible in the character controller
|
|
-- The force first, so the character and the logistic groups are on the restored player's force
|
|
player.force = serialized.force
|
|
local character = ensure_character(player)
|
|
if restore_controller and sync_logistics and serialized.personal_logistic_slots then
|
|
serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots)
|
|
end
|
|
if restore_controller and sync_inventories and serialized.inventories then
|
|
serialize.deserialize_inventories(player, serialized.inventories, character_inventories)
|
|
end
|
|
if state.ticks_to_respawn then
|
|
player.ticks_to_respawn = state.ticks_to_respawn
|
|
else
|
|
-- We have to set ticks to respawn to save the hidden state into the player but we
|
|
-- can't unset tick_to_respawn by setting it back to nil as that triggers a respawn.
|
|
player.ticks_to_respawn = 0
|
|
player.set_controller{ type = defines.controllers.god }
|
|
player.set_controller{ type = defines.controllers.ghost }
|
|
end
|
|
if character and character.valid then
|
|
character.destroy()
|
|
end
|
|
|
|
elseif state.controller ~= "remote" then
|
|
-- All other controllers should not have a character
|
|
local character = player.character
|
|
if character and character.valid then
|
|
character.destroy()
|
|
end
|
|
if player.controller_type ~= target_controller then
|
|
player.set_controller{ type = target_controller }
|
|
end
|
|
|
|
else
|
|
error("Remote should not be 'serialized.controller'")
|
|
end
|
|
end
|
|
|
|
if syncs(components, "appearance") and serialized.color then
|
|
player.color = serialized.color
|
|
player.chat_color = serialized.chat_color
|
|
player.tag = serialized.tag
|
|
end
|
|
if syncs(components, "force") and serialized.force then
|
|
player.force = serialized.force
|
|
end
|
|
if restore_controller and serialized.cheat_mode ~= nil then
|
|
player.cheat_mode = serialized.cheat_mode
|
|
end
|
|
if syncs(components, "settings") then
|
|
if serialized.flashlight == true then
|
|
player.enable_flashlight()
|
|
elseif serialized.flashlight == false then
|
|
player.disable_flashlight()
|
|
end
|
|
if serialized.shortcuts then
|
|
serialize.deserialize_shortcuts(player, serialized.shortcuts)
|
|
end
|
|
if serialized.game_view_settings then
|
|
serialize.deserialize_game_view_settings(player, serialized.game_view_settings)
|
|
end
|
|
end
|
|
|
|
-- Deserialize character, the stored player may not have had one when the controller is not synced
|
|
if player.character then
|
|
if sync_inventories and serialized.character then
|
|
serialize.deserialize_character(player.character, serialized.character)
|
|
end
|
|
if sync_logistics then
|
|
serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots)
|
|
end
|
|
end
|
|
|
|
-- Deserialize non-character inventories
|
|
if restore_controller and sync_inventories and serialized.inventories
|
|
and player.controller_type == defines.controllers.god
|
|
then
|
|
serialize.deserialize_inventories(player, serialized.inventories, { main = defines.inventory.god_main })
|
|
end
|
|
|
|
-- Deserialize quick bar (named hotbar in old data)
|
|
if syncs(components, "quick_bar") then
|
|
serialize.deserialize_quick_bar(player, serialized.quick_bar or serialized.hotbar)
|
|
end
|
|
|
|
-- Deserialize crafting queue
|
|
if sync_inventories and player.character and serialized.crafting_queue then
|
|
serialize.deserialize_crafting_queue(player, serialized.crafting_queue)
|
|
end
|
|
|
|
-- Deserialize recipe notifications
|
|
if recipe_notifications_api and syncs(components, "recipe_notifications") and serialized.recipe_notifications then
|
|
failed_deserialization.recipe_notifications =
|
|
serialize.deserialize_crafting_notifications(player, serialized.recipe_notifications)
|
|
end
|
|
|
|
return next(failed_deserialization) and failed_deserialization or nil
|
|
end
|
|
|
|
return serialize
|