research_sync: follow the cluster research queue from the save, with a lab window checkbox and chat lines

The save module reports queue edits on a following tile, applies the cluster
queue (dropping unknown technologies), and offers a lab-window checkbox.
All new events are gated on Factorio 2.0+.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ILLISISandClaude Opus 5.5 committed 2026-10-18 21:34:35 +11:00
1 parent ead8ede42a
commit 667f1e3c0c
1 file changed
+210
+210
View File
@@ -38,6 +38,10 @@ end
--- @class ResearchSyncData
--- @field technologies table<string, ResearchSyncTechnology>
--- @field ignore_research_finished boolean
--- @field follow boolean? nil until first set; nil counts as following
--- @field ignore_queue_events boolean
--- @field queue_dirty boolean
--- @field queue_player string?
--- @param no_early_return boolean?
--- @return ResearchSyncData
@@ -53,6 +57,12 @@ local function get_script_data(no_early_return)
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
@@ -143,6 +153,117 @@ sync.on_nth_tick[79] = function(event)
end
end
--- @param force LuaForce
--- @return string[]
local function queue_names(force)
local names = {}
for _, tech in pairs(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 v2_tech_progress then
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]
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
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
if event.player_index then
local player = game.get_player(event.player_index) --[[@as LuaPlayer]]
script_data.queue_player = player.name
end
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).
sync.events[defines.events.on_tick] = function()
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"]
@@ -269,5 +390,94 @@ function research_sync.research_technology(name, level)
}
end
function research_sync.queue_state()
if not v2_tech_progress 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 v2_tech_progress 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 v2_tech_progress 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