Files
factorio-clusterio/plugins/inventory_sync/module/inventory_sync.lua
T
Hornwitser 90dacd98b2 Rework inventory_sync (#411)
Complete rework of the inventory synchronization to reduce the number of
times the inventory has to be downloaded and the possibilities for item
loss/duplication.

The basic concept is to acquire exclusive access to the player data
before downloading the data.  This prevents the player from logging in
to two servers at the same time and getting the synced inventory twice.
The acquisition will in most cases execute faster than the player can
provide input to the game on login, and if the player data on the master
isn't what was previously uploaded then the player inventory is deleted
and the player is turned into a spectator while the updated inventory is
being transferred into the game.  This model allows for the downloading
of the synced inventory to be skipped in the case the player joins the
same server they left from.

The synced data is expanded to include more than just the character and
inventory to properly handle sync of dead players.  If the player leaves
dead on one server this state will be saved and when the player joins
another server they continue off as dead.  The ghost, god, and spectator
controllers are supported, while the cutscene and editor controllers are
not.  For players in a cutscene when joining the acquisition will be
attempted immediately, but the scenario waits until the player is out of
the cutscene before acting upon the response to the acquisition.  For
players in the editor mode the scenario toggles them out of the mode
before syncing data.

Since uploads to the master can fail handling of this is also added.
The scenario will retry uploading the player data at exponentially
increasing intervals until it gets the message back that the upload
succeeded.  Since it's possible for servers to go down and stay down for
a long time the exclusive access held by these servers are released after
a timeout.  This means that a player could be playing and or have
uploaded newer data by the time the instance comes back online and tries
to upload the inventory, in this case the master will report the upload
succeeded but discard the data.  Instances may also upload old data if
they load back to an earlier save, which will also be discarded by the
master.

Other small improvements made by this rework:
- Add a retry button to the failed download dialog
- Update the existing progress dialog instead of destroying and
  recreating it every time.
- Automatically stuff the items from the temporary inventory into the
  synced inventory if they fit.
- Move player back to the position they started the download from if
  they were turned into a spectator for the download.
- Ensure the player has a character if the stored data is wiped.
- Add error checking of the RCON commands sent by the instance.
2021-09-07 15:49:53 +02:00

366 lines
13 KiB
Lua

--[[
Inventory Sync
When a player leaves the game, serialize the player state and upload it to the master.
When a player joins the game, request the player data from master and deserialize the player state from it.
]]
local clusterio_api = require("modules/clusterio/api")
local upload_inventory = require("modules/inventory_sync/upload_inventory")
local download_inventory = require("modules/inventory_sync/download_inventory")
local progress_dialog = require("modules/inventory_sync/gui/progress_dialog")
local dialog_failed_download = require("modules/inventory_sync/gui/dialog_failed_download")
-- Returns true if the player is currently in a cutscene
local function is_in_cutscene(player)
return player.controller_type == defines.controllers.cutscene
end
-- Create player record for bookeeping
local function create_player(player, dirty)
global.inventory_sync.players[player.name] = {
dirty = dirty, -- Player inventory has changes that should be persisted
sync = false, -- Player inventory is synced from the master
generation = 0,
}
end
-- Remove all stored player data by this module
local function remove_player(player)
global.inventory_sync.saved_crafting_queue[player.name] = nil
global.inventory_sync.players_waiting_for_acquire[player.name] = nil
global.inventory_sync.players_in_cutscene_to_sync[player.name] = nil
global.inventory_sync.active_downloads[player.name] = nil
global.inventory_sync.active_uploads[player.name] = nil
global.inventory_sync.players[player.name] = nil
end
inventory_sync = {}
-- This function is called by instance.js as /sc inventory_sync.download_inventory("danielv", Escaped JSON string, package_number, total_packages_count) with data from the master
inventory_sync.download_inventory = download_inventory
-- This function is called internally when a player leaves the game for their inventory to upload to the master
inventory_sync.upload_inventory = upload_inventory
inventory_sync.events = {}
inventory_sync.events[clusterio_api.events.on_server_startup] = function(event)
-- Set up global table
if not global.inventory_sync then
global.inventory_sync = {}
end
if not global.inventory_sync.saved_crafting_queue then
global.inventory_sync.saved_crafting_queue = {}
end
if not global.inventory_sync.players then
global.inventory_sync.players = {}
end
global.inventory_sync.players_waiting_for_acquire = {}
global.inventory_sync.players_in_cutscene_to_sync = {}
if not global.inventory_sync.active_downloads then
global.inventory_sync.active_downloads = {}
end
if not global.inventory_sync.active_uploads then
global.inventory_sync.active_uploads = {}
end
for _, player in pairs(game.players) do
if not global.inventory_sync.players[player.name] then
create_player(player, true)
end
end
end
-- Cleanup
inventory_sync.events[defines.events.on_pre_player_removed] = function(event)
remove_player(game.get_player(event.player_index))
end
inventory_sync.events[defines.events.on_player_created] = function(event)
create_player(game.get_player(event.player_index), false)
end
function inventory_sync.acquire(player)
global.inventory_sync.players_waiting_for_acquire[player.name] = {
start_tick = game.ticks_played,
}
clusterio_api.send_json("inventory_sync_acquire", {
player_name = player.name,
})
end
function inventory_sync.check_players_waiting_for_acquire()
for player_name, record in pairs(global.inventory_sync.players_waiting_for_acquire) do
-- Check if the acquire player request timed out
if game.ticks_played > record.start_tick + 600 then
inventory_sync.acquire_response(game.table_to_json({
status = "timeout",
player_name = player_name,
}))
end
end
end
function inventory_sync.acquire_response(data)
local response = game.json_to_table(data)
if not global.inventory_sync.players_waiting_for_acquire[response.player_name] then
clusterio_api.send_json("inventory_sync_release", { player_name = response.player_name })
return
end
global.inventory_sync.players_waiting_for_acquire[response.player_name] = nil
local player = game.get_player(response.player_name)
if is_in_cutscene(player) then
response.player = player
global.inventory_sync.players_in_cutscene_to_sync[response.player_name] = response
else
inventory_sync.sync_player(response)
end
end
function inventory_sync.check_players_in_cutscene()
for player_name, response in pairs(global.inventory_sync.players_in_cutscene_to_sync) do
if not is_in_cutscene(response.player) then
global.inventory_sync.players_in_cutscene_to_sync[player_name] = nil
inventory_sync.sync_player(response)
end
end
end
function inventory_sync.sync_player(acquire_response)
local player = game.get_player(acquire_response.player_name)
-- Editor mode is not supported.
if player.controller_type == defines.controllers.editor then
player.toggle_map_editor()
end
if player.controller_type == defines.controllers.editor then
log("ERROR: Inventory sync failed, unable to switch " .. player.name .. " out of editor mode")
player.print("ERROR: Inventory sync failed, unable to switch out of editor mode")
return
end
local download_record = global.inventory_sync.active_downloads[acquire_response.player_name]
if download_record then
-- Ignore the response if it affirms the current active download
if acquire_response.status == "acquired" and download_record.generation == acquire_response.generation then
game.print("Continuing active download")
return
end
-- This is the excaptional case where a long download started from
-- the previous time the player joined is still ongoing and we just
-- received a message saying that it's not valid any more.
if acquire_response.status == "error" or acquire_response.status == "timeout" then
-- Let the current download finish or fail on its own, we may end up
-- duplicating items or giving the player a stale inventory here
-- but it's better than throwing away all the downloaded data.
return
end
if acquire_response.status == "acquired" then
-- The stored data on the master server has changed since the
-- download was started. Restart it.
game.print("Restarting download")
download_record.restart = true
return
end
if acquire_response.status == "busy" then
-- Somehow another instance has claimed the player while we're
-- downloading it. Abort it and send failure to the player.
global.inventory_sync.active_downloads[acquire_response.player_name] = nil
end
end
local player_record = global.inventory_sync.players[acquire_response.player_name]
if acquire_response.status ~= "acquired" then
if player_record.sync and not player_record.dirty then
if player.controller_type == defines.controllers.character then
local character = player.character
player.set_controller({ type = defines.controllers.spectator })
character.destroy()
end
player_record.sync = false
end
dialog_failed_download.create(player, acquire_response)
return
end
if not player_record.sync or acquire_response.generation > player_record.generation then
inventory_sync.initiate_inventory_download(player, player_record, acquire_response.generation)
else
if acquire_response.generation == 0 then
player.print(
"Your inventory will be automatically synchronized between servers in this cluster. " ..
"To improve sync performance, please avoid storing large blueprints in your inventory.",
{0.75, 0.75, 1}
)
end
player_record.sync = true
player_record.dirty = true
end
end
-- Download inventory from master
inventory_sync.events[defines.events.on_player_joined_game] = function(event)
-- Send acquire request even if an active download is currently in plogress
local player = game.get_player(event.player_index)
inventory_sync.acquire(player, false)
-- Clear active upload if it existsi
global.inventory_sync.active_uploads[player.name] = nil
end
inventory_sync.on_nth_tick = {}
inventory_sync.on_nth_tick[33] = function(event)
-- Periodically check players currently in the process of syncing
inventory_sync.check_players_waiting_for_acquire()
inventory_sync.check_players_in_cutscene()
inventory_sync.check_active_downloads()
inventory_sync.check_active_uploads()
end
inventory_sync.events[defines.events.on_cutscene_waypoint_reached] = function(event)
inventory_sync.check_players_in_cutscene()
end
inventory_sync.events[defines.events.on_cutscene_cancelled] = function(event)
inventory_sync.check_players_in_cutscene()
end
-- Upload inventory when a player leaves the game. Triggers on restart after crash if player was online during crash.
inventory_sync.events[defines.events.on_pre_player_left_game] = function(event)
-- for some reason, on_pre_player_left_game gets called before on_server_startup so global isn't ready yet
if not global.inventory_sync then
log("ERROR: Global inventory sync not defined")
return
end
local player = game.get_player(event.player_index)
local player_record = global.inventory_sync.players[player.name]
-- If player has an active download release our acquisition but let the download continue
if global.inventory_sync.active_downloads[player.name] then
clusterio_api.send_json("inventory_sync_release", { player_name = player.name })
return
end
if not player_record.dirty or not player_record.sync then
return
end
player_record.generation = player_record.generation + 1
global.inventory_sync.active_uploads[player.name] = {
last_attempt = game.ticks_played,
timeout = math.random(600, 1200), -- Start with 10-20 seconds for the timeout
}
inventory_sync.upload_inventory(player, player_record)
end
-- Invoked by the instance code to confirm the master has received the uploaded data
function inventory_sync.confirm_upload(player_name, generation)
local player = game.get_player(player_name)
if not player or player.connected then
log("no player, or player connected")
return
end
local player_record = global.inventory_sync.players[player.name]
if not player_record or player_record.generation ~= generation then
return
end
if not player_record.dirty then
log("ERROR: Received upload confirmation for " .. player_name .. " without the dirty flag")
end
if not player_record.sync then
log("ERROR: Received upload confirmation for " .. player_name .. " without the sync flag")
end
log("Confirmed upload of " .. player_name)
global.inventory_sync.active_uploads[player_name] = nil
player_record.dirty = false
end
function inventory_sync.initiate_inventory_download(player, player_record, generation)
player.print("Initiating inventory download...")
local record = {
started = game.ticks_played,
last_active = game.ticks_played,
generation = generation,
data = ""
}
global.inventory_sync.active_downloads[player.name] = record
clusterio_api.send_json("inventory_sync_download", {
player_name = player.name
})
-- If this is a synced player turn them into a spectator while the
-- player data is downloading
if player_record.sync then
local character = player.character
player.set_controller({ type = defines.controllers.spectator })
if character ~= nil then character.destroy() end
-- Store original position to teleport back to
record.surface = player.surface
record.position = player.position
-- Indicate this state shouldn't be persisted
player_record.dirty = false
end
progress_dialog.display(player, 0, 1)
end
function inventory_sync.check_active_downloads()
-- Used for detecting the inventory download failing, like when the map is saved and
-- re-loaded in the middle of a download. The player will be returned to a playable state.
for player_name, record in pairs(global.inventory_sync.active_downloads) do
if record.last_active <= game.ticks_played - 600 then
global.inventory_sync.active_downloads[player_name] = nil
local player = game.get_player(player_name)
if player then
log("ERROR: Inventory download failed for " .. player_name)
if player.connected then
-- Show GUI with option to retry or abort download
dialog_failed_download.create(player, {
player_name = player_name,
status = "error",
message = "Inventory download failed",
})
end
end
end
end
end
function inventory_sync.check_active_uploads()
for player_name, record in pairs(global.inventory_sync.active_uploads) do
if record.last_attempt + record.timeout < game.ticks_played then
local player_record = global.inventory_sync.players[player_name]
local player = game.get_player(player_name)
if not player_record or not player then
global.inventory_sync.active_uploads[player_name] = nil
else
log("Retrying upload for " .. player_name)
record.last_attempt = game.ticks_played
record.timeout = math.random(record.timeout, record.timeout * 2)
inventory_sync.upload_inventory(player, player_record)
end
end
end
end
return inventory_sync