Files
factorio-clusterio/plugins/research_sync/module/sync.lua
T
ILLISISandClaude Opus 5.5 1f6e0519bd research_sync: README for the cluster research queue, review fixes
Document the cluster research queue in the plugin README and the root
README. Review fixes: ipairs for queue order, event annotations on the new
Lua handlers, an IpcQueue type, a test for clearing the queue, and drop the
controller import from test/missing.js.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 21:54:10 +11:00

489 lines
13 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 queue_sync = 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; the checkbox shows ticked but nil does not 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.",
state = true,
}
end
frame["research_sync_follow"].state = get_script_data().follow ~= false
end
if queue_sync then
--- @param event EventData.on_player_created
sync.events[defines.events.on_player_created] = function(event)
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 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
--- @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
-- 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)
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
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
elseif tech_data[progressIndex] 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 queue_sync 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 queue_sync 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 queue_sync 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