Apply a prefetched copy on join without downloading

player_sync.prefetch_inventory holds a copy sent ahead in active_downloads
and finished_downloads, for a player who is offline or doesn't exist on
the destination, with the components its first chunk names. Chunk 1
refuses a player who is online, a download in progress, a stream of the
same or a newer generation and a finished copy of a newer one; a finished
copy of the same generation is replaced. At the acquire reply a copy is
used only when the reply is acquired with its generation and its
components are the ones the scenario syncs now. The player is then
switched to spectator as a download does and the copy is applied, at once
or at its last chunk. Otherwise the copy is dropped and the join
continues as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ILLISISandClaude Opus 5.5 committed 2026-10-20 12:23:38 +11:00
1 parent 18afae4686
commit fe416da26c
2 files changed
+133 -32

No files matched your search

@@ -8,18 +8,22 @@ local compat = require("modules/clusterio/compat")
--- @param components string? JSON of the components the instance sent the data with, given with the first part
local function download_inventory(player_name, data, number, total, components)
local script_data = get_script_data()
local record = script_data.active_downloads[player_name]
local player = game.get_player(player_name)
if player == nil then
-- A prefetched copy (player_sync.prefetch_inventory) may be for a player who doesn't exist here yet
if player == nil and not (record and record.prefetch) then
rcon.print("Player " .. player_name .. " does not exist")
return
end
local script_data = get_script_data()
local record = script_data.active_downloads[player_name]
if record == nil then
rcon.print("No active download is in progress for " .. player_name)
return
end
-- For the linter, which can't narrow through the check above. A prefetch record never reaches a line that indexes
-- player while it is nil: it has no restart, never gets total 0, and shown below is false for it.
--- @cast player LuaPlayer
local player_record = script_data.players[player_name]
-- The components changed after the download was started
@@ -60,30 +64,40 @@ local function download_inventory(player_name, data, number, total, components)
end
record.data = record.data .. data
-- A prefetched copy shows progress, and is applied, only once the acquire reply has confirmed it for the player
-- online here (player_sync.sync_player). A copy that finishes before then waits in finished_downloads for that reply.
local shown = not record.prefetch or (record.confirmed and player.connected)
-- Show progress in console
-- player.print("Downloaded "..(number + 1).."/"..total.." parts")
if number ~= total then
-- Show progress in GUI
progress_dialog.display(player, number, total)
if shown then
progress_dialog.display(player, number, total)
end
-- Update activity to indicate this session is still going
record.last_active = game.ticks_played
return
end
-- Download is complete
progress_dialog.remove(player)
-- Download is complete. The dialog goes even when this copy isn't shown now: it may have been shown before a leave
if player then
progress_dialog.remove(player)
end
-- Remove download session and store finished download
script_data.active_downloads[player_name] = nil
script_data.finished_downloads[player_name] = record
local ticks = game.ticks_played - record.started
player.print("Imported player data in " .. ticks .. " ticks")
log("Imported player data for " .. player_name .. " in " .. ticks .. " ticks")
if shown then
player.print("Imported player data in " .. ticks .. " ticks")
end
local kind = record.prefetch and "Prefetched" or "Imported"
log(kind .. " player data for " .. player_name .. " in " .. ticks .. " ticks")
if player.connected then
if shown and player.connected then
player_sync.finish_download(player, record)
end
end
+110 -23
View File
@@ -73,6 +73,36 @@ local function remove_inventory_bonus_raise(script_data, player_name)
end
end
-- Turn a synced player into a spectator while their player data is downloaded or applied, if this instance syncs
-- inventories, and keep where their body stood in record for restore_position. The old body goes, with its crafting
-- queue. Until the data is applied, the player's state isn't persisted.
local function spectate(player, player_record, record)
-- Indicate the current state shouldn't be persisted
player_record.sync_in_progress = true
if player_record.sync and serialize.syncs(record.components, "inventories") then
-- Store original position to teleport back to. Only once per record: a rejoin while a prefetched copy is held
-- spectates again, and the spectator's position then isn't where the body stood
if record.position == nil then
if v2_remote_controller then
record.surface = player.physical_surface
record.position = player.physical_position
else
record.surface = player.surface
record.position = player.position
end
if player.driving then
record.vehicle = player.vehicle
end
end
save_local_controller(player, player_record, record.components)
local character = player.character
player.set_controller({ type = defines.controllers.spectator })
if character ~= nil then character.destroy() end
end
end
player_sync = {}
-- This function is called by instance.js as /sc player_sync.download_inventory("danielv", Escaped JSON string, package_number, total_packages_count) with data from the controller
@@ -321,6 +351,19 @@ function player_sync.sync_player(acquire_response)
local script_data = get_script_data()
-- Epoch is bumped when an admin replaces or deletes the stored data
local epoch = acquire_response.epoch or 0
-- A prefetched copy (player_sync.prefetch_inventory) is used only if this reply acquires the player at the copy's
-- generation and the copy was sent with the components synced here now. Otherwise it goes, and the join carries on
-- as if it had never come: today's download or failure dialog, never the restart path below.
for _, downloads in ipairs({ script_data.finished_downloads, script_data.active_downloads }) do
local record = downloads[acquire_response.player_name]
if record and record.prefetch and not (
acquire_response.status == "acquired" and record.generation == acquire_response.generation
and serialize.same_components(record.components, script_data.components)
) then
downloads[acquire_response.player_name] = nil
end
end
local finished_record = script_data.finished_downloads[acquire_response.player_name]
if finished_record then
if
@@ -328,6 +371,14 @@ function player_sync.sync_player(acquire_response)
and finished_record.generation == acquire_response.generation
and (finished_record.epoch or 0) == epoch
then
if finished_record.prefetch then
-- As a download does: the body left here goes, and the copy makes a new character where it stood
spectate(player, script_data.players[player.name], finished_record)
if player_sync.finish_download(player, finished_record) then
log("Applied prefetched player data for " .. player.name)
end
return
end
player_sync.finish_download(player, finished_record)
return
end
@@ -345,6 +396,13 @@ function player_sync.sync_player(acquire_response)
and download_record.generation == acquire_response.generation
and (download_record.epoch or 0) == epoch
then
if download_record.prefetch then
-- The copy is still arriving: spectate as a download does, and its last chunk applies it
spectate(player, script_data.players[player.name], download_record)
progress_dialog.display(player, 0, 1)
download_record.confirmed = true
return
end
game.print("Continuing active download")
return
end
@@ -430,6 +488,7 @@ function player_sync.sync_player(acquire_response)
end
end
--- @return boolean? applied true when the player data was deserialized
function player_sync.finish_download(player, finished_record)
local script_data = get_script_data()
script_data.finished_downloads[player.name] = nil
@@ -451,6 +510,7 @@ function player_sync.finish_download(player, finished_record)
start_sync(player_record, finished_record.components)
player_record.generation = finished_record.generation
player_record.epoch = finished_record.epoch
return true
end
-- Download inventory from controller
@@ -510,6 +570,8 @@ player_sync.events[defines.events.on_pre_player_left_game] = function(event)
-- If player has an active download release our acquisition but let the download continue
if script_data.active_downloads[player.name] then
-- A prefetched copy is confirmed per join: the next join's acquire reply confirms it again
script_data.active_downloads[player.name].confirmed = nil
clusterio_api.send_json("player_sync_release", { player_name = player.name })
return
end
@@ -589,29 +651,7 @@ function player_sync.initiate_inventory_download(player, player_record, generati
recipe_notifications = recipe_notifications,
})
-- Indicate the current state shouldn't be persisted
player_record.sync_in_progress = true
-- If this is a synced player turn them into a spectator while the
-- player data is downloading
if player_record.sync and serialize.syncs(record.components, "inventories") then
-- Store original position to teleport back to
if v2_remote_controller then
record.surface = player.physical_surface
record.position = player.physical_position
else
record.surface = player.surface
record.position = player.position
end
if player.driving then
record.vehicle = player.vehicle
end
save_local_controller(player, player_record, record.components)
local character = player.character
player.set_controller({ type = defines.controllers.spectator })
if character ~= nil then character.destroy() end
end
spectate(player, player_record, record)
progress_dialog.display(player, 0, 1)
end
@@ -658,4 +698,51 @@ function player_sync.check_active_uploads()
end
end
-- Called by instance.ts as /sc player_sync.prefetch_inventory('name', generation, 'chunk', number, total) with a copy
-- of a player's stored data that the controller sent ahead (PrefetchEvent) while they switch to this server. It holds
-- the copy for a player who is offline here or doesn't exist yet. It prints only when it refuses or ignores a chunk,
-- which stops the host sending the rest.
--- @param components string? JSON of the components the instance stripped the copy by, given with the first chunk
function player_sync.prefetch_inventory(player_name, generation, data, number, total, components)
local script_data = get_script_data()
if number == 1 then
local player = game.get_player(player_name)
local active = script_data.active_downloads[player_name]
local finished = script_data.finished_downloads[player_name]
if player and player.connected then
rcon.print("Refused prefetch for " .. player_name .. ": online here, their join downloads")
return
end
if active and not active.prefetch then
-- download_inventory has no generation check, so its remaining chunks would land in the copy
rcon.print("Refused prefetch for " .. player_name .. ": a download is in progress")
return
end
-- Two copies can reach here out of order (applyRecipeNotificationDelta awaits zlib). A held finished copy of the
-- same generation is replaced: it can be one the controller lost (a crash before its save), and the newer copy
-- of the same number is what the controller holds now
if (active and active.generation >= generation) or (finished and finished.generation > generation) then
rcon.print("Refused prefetch for " .. player_name .. ": generation " .. generation .. " is not newer")
return
end
script_data.finished_downloads[player_name] = nil
script_data.active_downloads[player_name] = {
started = game.ticks_played,
last_active = game.ticks_played,
generation = generation,
data = "",
prefetch = true,
components = components and compat.json_to_table(components),
}
end
local record = script_data.active_downloads[player_name]
if not (record and record.prefetch and record.generation == generation) then
-- Refused at chunk 1, dropped by the acquire reply or the inactivity check, or replaced by a newer copy
rcon.print("Ignored prefetch chunk " .. number .. " for " .. player_name)
return
end
download_inventory(player_name, data, number, total)
end
return player_sync