Files
factorio-clusterio/plugins/research_sync/module/sync.lua
T
bbassieandClaude Opus 5.5 5589609b1b Resync research after a new controller session
While the host has no controller session, events between the instance
and the controller are dropped in both directions, and research_sync
only synced technologies on instance start. A technology finished during
the outage stayed unresearched on the controller and on every other
instance until the instance restarted.

On a new session the instance now runs the same technology sync as on
start, before taking the cluster queue. Contributions made while the
controller is unreachable are summed per technology and sent on connect
or resume, since progress from several instances can't be rebuilt from a
state sync that keeps the highest progress. A finish made while the
connection was dropped triggers the sync on resume too.

sync_technologies now runs while the game is running, so it skips
writing progress to a technology that finished after the dump.

Fixes #1123

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:04:15 +00:00

501 lines
14 KiB
Lua

local clusterio_api = require("modules/clusterio/api")
local compat = require("modules/clusterio/compat")
local v2_tech_progress = compat.version_ge("2.0.0")
-- on_research_queued and player_index on moved/cancelled arrived in 2.0.56
local research_queue_api = compat.version_ge("2.0.56")
local sync = {}
--- @param tech LuaTechnology
local function get_technology_progress(tech)
if tech == tech.force.current_research then
return tech.force.research_progress
elseif v2_tech_progress then
return tech.saved_progress
else
--- @diagnostic disable-next-line
return tech.force.get_saved_technology_progress(tech.name)
end
end
--- @param tech LuaTechnology
--- @param progress number
local function set_technology_progress(tech, progress)
if tech == tech.force.current_research then
tech.force.research_progress = progress
elseif v2_tech_progress then
tech.saved_progress = progress
else
--- @diagnostic disable-next-line
tech.force.set_saved_technology_progress(tech.name, progress)
end
end
--- @class ResearchSyncTechnology
--- @field level uint
--- @field researched boolean?
--- @field progress double?
--- @class ResearchSyncData
--- @field technologies table<string, ResearchSyncTechnology>
--- @field ignore_research_finished boolean
--- @field follow boolean? nil until the first queue_state sets it: shown ticked, but doesn't send or apply
--- @field ignore_queue_events boolean
--- @field queue_dirty boolean
--- @field queue_player string?
--- @param no_early_return boolean?
--- @return ResearchSyncData
local function get_script_data(no_early_return)
local research_sync = compat.script_data.research_sync
if research_sync and not no_early_return then
return research_sync
end
research_sync = research_sync or {}
research_sync.technologies = research_sync.technologies or {}
if research_sync.ignore_research_finished == nil then
research_sync.ignore_research_finished = false
end
if research_sync.ignore_queue_events == nil then
research_sync.ignore_queue_events = false
end
if research_sync.queue_dirty == nil then
research_sync.queue_dirty = false
end
local force = game.forces["player"]
for _, tech in pairs(force.technologies) do
local progress = get_technology_progress(tech)
research_sync.technologies[tech.name] = {
level = tech.level,
researched = tech.researched,
progress = progress,
}
end
compat.script_data.research_sync = research_sync
return research_sync
end
sync.events = {}
sync.events[clusterio_api.events.on_server_startup] = function(event)
get_script_data(true)
end
--- @param tech LuaTechnology
local function get_contribution(tech)
local progress = get_technology_progress(tech)
if not progress then
return 0, nil
end
local script_data = get_script_data()
local prev_tech = script_data.technologies[tech.name]
if prev_tech.progress and prev_tech.level == tech.level then
return progress - prev_tech.progress, progress
else
return progress, progress
end
end
--- @param tech LuaTechnology
local function send_contribution(tech)
local contribution, progress = get_contribution(tech)
if contribution ~= 0 then
clusterio_api.send_json("research_sync:contribution", {
name = tech.name,
level = tech.level,
contribution = contribution,
})
local script_data = get_script_data()
script_data.technologies[tech.name].progress = progress
end
end
--- @param event EventData.on_research_started
sync.events[defines.events.on_research_started] = function(event)
local tech = event.last_research
if tech then
send_contribution(tech)
end
end
--- @param event EventData.on_research_finished
sync.events[defines.events.on_research_finished] = function(event)
local script_data = get_script_data()
if script_data.ignore_research_finished then
return
end
local tech = event.research
script_data.technologies[tech.name] = {
level = tech.level,
researched = tech.researched,
}
local level = tech.level
if not tech.researched then
level = level - 1
end
clusterio_api.send_json("research_sync:finished", {
name = tech.name,
level = level,
})
end
sync.on_nth_tick = {}
sync.on_nth_tick[79] = function(event)
local tech = game.forces["player"].current_research
if tech then
send_contribution(tech)
end
end
--- @param force LuaForce
--- @return string[]
local function queue_names(force)
local names = {}
for _, tech in ipairs(force.research_queue) do
table.insert(names, tech.name)
end
return names
end
--- @param force LuaForce
--- @param names string[]
local function write_queue(force, names)
local script_data = get_script_data()
script_data.ignore_queue_events = true
local ok, err = pcall(function() force.research_queue = names end)
script_data.ignore_queue_events = false
if not ok then
log("research_sync: failed to write research queue: " .. tostring(err))
end
end
--- @param names string[]
--- @return string
local function format_queue(names)
if #names == 0 then
return "(empty)"
end
local tags = {}
for _, name in ipairs(names) do
table.insert(tags, "[technology=" .. name .. "]")
end
return table.concat(tags, " ")
end
--- @param player LuaPlayer
local function ensure_gui(player)
local frame = player.gui.relative["research_sync_queue"]
if not frame then
frame = player.gui.relative.add {
type = "frame",
name = "research_sync_queue",
caption = "Research sync",
anchor = {
gui = defines.relative_gui_type.lab_gui,
position = defines.relative_gui_position.right,
},
}
frame.add {
type = "checkbox",
name = "research_sync_follow",
caption = "Follow the cluster research queue",
tooltip = "On: this server's research queue is replaced by the cluster's, "
.. "and changes here change it on every following server.\n"
.. "Off: this server's research queue is cleared and it picks its own research.\n"
.. "Either way, all research progress is counted, whatever technology is selected.",
state = true,
}
end
frame["research_sync_follow"].state = get_script_data().follow ~= false
end
--- @param event EventData.on_player_created
sync.events[defines.events.on_player_created] = function(event)
if not research_queue_api then
return
end
ensure_gui(game.get_player(event.player_index) --[[@as LuaPlayer]])
end
sync.events[defines.events.on_player_joined_game] = sync.events[defines.events.on_player_created]
--- @param event EventData.on_gui_checked_state_changed
sync.events[defines.events.on_gui_checked_state_changed] = function(event)
if not research_queue_api then
return
end
if event.element.name == "research_sync_follow" then
local player = game.get_player(event.player_index) --[[@as LuaPlayer]]
research_sync.set_follow(event.element.state, player.name)
end
end
-- The version check stays outside here: these defines don't exist before 2.0.56, so indexing them there would error.
if research_queue_api then
--- @param event EventData.on_research_queued|EventData.on_research_moved|EventData.on_research_cancelled
local function on_queue_event(event)
if event.force.name ~= "player" then
return
end
local script_data = get_script_data()
if script_data.follow ~= true or script_data.ignore_queue_events or script_data.ignore_research_finished then
return
end
script_data.queue_dirty = true
-- the last event of the tick decides who gets named
local player = event.player_index and game.get_player(event.player_index)
script_data.queue_player = player and player.name or nil
end
sync.events[defines.events.on_research_queued] = on_queue_event
sync.events[defines.events.on_research_moved] = on_queue_event
sync.events[defines.events.on_research_cancelled] = on_queue_event
end
-- One send per tick however many events fired (a lab raises several at once).
--- @param event EventData.on_tick
sync.events[defines.events.on_tick] = function(event)
if not research_queue_api then
return
end
local script_data = get_script_data()
if not script_data.queue_dirty then
return
end
script_data.queue_dirty = false
local player = script_data.queue_player
script_data.queue_player = nil
if script_data.follow ~= true then
return
end
clusterio_api.send_json("research_sync:queue", {
queue = queue_names(game.forces["player"]),
player = player,
})
end
research_sync = {}
function research_sync.dump_technologies()
local force = game.forces["player"]
local techs = {}
for _, tech in pairs(force.technologies) do
table.insert(techs, {
name = tech.name,
level = tech.level,
progress = get_technology_progress(tech),
researched = tech.researched,
})
end
if #techs == 0 then
rcon.print("[]")
else
rcon.print(compat.table_to_json(techs))
end
end
--- @param data string
function research_sync.sync_technologies(data)
local force = game.forces["player"]
local nameIndex = 1
local levelIndex = 2
local progressIndex = 3
local researchedIndex = 4
local script_data = get_script_data()
script_data.ignore_research_finished = true
for _, tech_data in pairs(compat.json_to_table(data) --[[@as table]]) do
local tech = force.technologies[tech_data[nameIndex]]
if tech and tech.level <= tech_data[levelIndex] then
local new_level = math.min(tech_data[levelIndex], tech.prototype.max_level)
if new_level ~= tech.level then
-- when the level of the current research changes the
-- progress is not automatically reset.
if force.current_research == tech then
force.research_progress = 0
end
tech.level = new_level
end
local progress
if tech_data[researchedIndex] then
if force.current_research == tech then
force.research_progress = 0
end
tech.researched = true
progress = nil
-- may have finished here since the dump this sync is answering
elseif tech_data[progressIndex] and not tech.researched then
send_contribution(tech)
progress = tech_data[progressIndex]
set_technology_progress(tech, progress)
else
progress = get_technology_progress(tech)
end
script_data.technologies[tech.name] = {
level = tech.level,
researched = tech.researched,
progress = progress,
}
end
end
script_data.ignore_research_finished = false
end
--- @param data string
function research_sync.update_progress(data)
local script_data = get_script_data()
local techs = compat.json_to_table(data) --[[@as table]]
local force = game.forces["player"]
for _, controllerTech in ipairs(techs) do
local tech = force.technologies[controllerTech.name]
if tech and tech.level == controllerTech.level then
send_contribution(tech)
set_technology_progress(tech, controllerTech.progress)
script_data.technologies[tech.name] = {
level = tech.level,
progress = controllerTech.progress
}
end
end
end
--- @param name string
--- @param level number
function research_sync.research_technology(name, level)
local force = game.forces["player"]
local tech = force.technologies[name]
if not tech or tech.level > level then
return
end
if level > tech.prototype.max_level then
level = tech.prototype.max_level
end
local script_data = get_script_data()
script_data.ignore_research_finished = true
if tech == force.current_research and tech.level == level then
force.research_progress = 1
elseif tech.level < level or tech.level == level and not tech.researched then
tech.level = level
tech.researched = true
if tech.name:find("-%d+$") then
game.print {"", "Researched ", {"technology-name." .. tech.name:gsub("-%d+$", "")}, " ", level}
else
game.print {"", "Researched ", {"technology-name." .. tech.name}}
end
game.play_sound { path = "utility/research_completed" }
end
script_data.ignore_research_finished = false
script_data.technologies[tech.name] = {
level = tech.level,
researched = tech.researched,
}
end
function research_sync.queue_state()
if not research_queue_api then
rcon.print('{"follow":false}')
return
end
local script_data = get_script_data()
local first = script_data.follow == nil
if first then
script_data.follow = true
end
rcon.print(compat.table_to_json({
follow = script_data.follow,
first = first,
queue = queue_names(game.forces["player"]),
}))
end
--- @param data string JSON {queue: string[], player?: string, instance?: string}
function research_sync.apply_queue(data)
if not research_queue_api then
return
end
local msg = compat.json_to_table(data) --[[@as table]]
local queue = msg.queue or {}
if msg.instance then
local list = format_queue(queue)
if msg.player then
game.print(msg.player .. " changed the research queue on " .. msg.instance .. ": " .. list)
else
game.print("The research queue was changed on " .. msg.instance .. ": " .. list)
end
end
if get_script_data().follow ~= true then
return
end
local force = game.forces["player"]
-- Names this tile has no technology for (a mod it lacks) would throw.
local known = {}
for _, name in ipairs(queue) do
if force.technologies[name] then
table.insert(known, name)
end
end
local current = queue_names(force)
local same = #known == #current
for i = 1, #known do
if known[i] ~= current[i] then
same = false
break
end
end
if not same then
write_queue(force, known)
end
end
--- @param enabled boolean
--- @param player_name string?
function research_sync.set_follow(enabled, player_name)
if not research_queue_api then
return
end
local script_data = get_script_data()
if script_data.follow == enabled then
return
end
script_data.follow = enabled
script_data.queue_dirty = false
script_data.queue_player = nil
for _, player in pairs(game.players) do
ensure_gui(player)
end
local state = enabled and "on" or "off"
if player_name then
game.print(player_name .. " turned " .. state .. " following the cluster research queue")
else
game.print("Following the cluster research queue was turned " .. state)
end
local force = game.forces["player"]
if enabled then
clusterio_api.send_json("research_sync:follow", { queue = queue_names(force), player = player_name })
else
write_queue(force, {})
end
end
return sync