From e3099e1e0595117cc6c6620811e3738be54da6f1 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sun, 18 Oct 2026 21:31:59 +1100 Subject: [PATCH 01/16] research_sync: cluster research queue on the controller - QueueUpdateEvent, QueueEvent and QueueSyncRequest messages. - The controller keeps one queue (max 7 names) in research_queue.json, saved with the other data. - An update that changes the queue replaces it and is broadcast as QueueEvent; an equal update is ignored. - QueueSyncRequest returns the queue and seeds it from the request when the queue is empty. - A finished technology (completing contribution, advancing FinishedEvent, technology sync) drops its first queue entry without a broadcast. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/research_sync/controller.ts | 70 +++++++++++++ plugins/research_sync/index.ts | 3 + plugins/research_sync/messages.ts | 73 ++++++++++++++ plugins/research_sync/test/controller.js | 119 +++++++++++++++++++++++ 4 files changed, 265 insertions(+) create mode 100644 plugins/research_sync/test/controller.js diff --git a/plugins/research_sync/controller.ts b/plugins/research_sync/controller.ts index ba198dc0..2878c0ec 100644 --- a/plugins/research_sync/controller.ts +++ b/plugins/research_sync/controller.ts @@ -13,6 +13,9 @@ import { TechnologySync, SyncTechnologiesRequest, TechnologyProgress, + QueueUpdateEvent, + QueueEvent, + QueueSyncRequest, } from "./messages.js"; type Technology = { @@ -49,12 +52,58 @@ async function saveTechnologies( await lib.safeOutputFile(filePath, JSON.stringify([...technologies.entries()], null, "\t")); } +const MAX_QUEUE = 7; + +async function loadQueue(controllerConfig: lib.ControllerConfig, logger: lib.Logger): Promise { + let filePath = path.join(controllerConfig.get("controller.database_directory"), "research_queue.json"); + logger.verbose(`Loading ${filePath}`); + try { + return JSON.parse(await fs.readFile(filePath, "utf8")); + } catch (err: any) { + if (err.code === "ENOENT") { + logger.verbose("Creating new research queue"); + return []; + } + throw err; + } +} + +async function saveQueue(controllerConfig: lib.ControllerConfig, queue: string[], logger: lib.Logger) { + let filePath = path.join(controllerConfig.get("controller.database_directory"), "research_queue.json"); + logger.verbose(`writing ${filePath}`); + await lib.safeOutputFile(filePath, JSON.stringify(queue, null, "\t")); +} + export default async function(context: ControllerPluginContext) { const { controller, logger, plugin } = context; const technologies = await loadTechnologies(controller.config, logger); let technologiesDirty = true; + let queue = await loadQueue(controller.config, logger); + let queueDirty = false; const progressToBroadcast = new Set(); + function instanceName(src: lib.Address) { + return controller.instances.get(src.id)?.config.get("instance.name") ?? String(src.id); + } + + function removeFinished(name: string) { + let index = queue.indexOf(name); + if (index !== -1) { + queue.splice(index, 1); + queueDirty = true; + } + } + + function setQueue(newQueue: string[], player: string | null, src: lib.Address) { + newQueue = newQueue.slice(0, MAX_QUEUE); + if (newQueue.length === queue.length && newQueue.every((name, i) => name === queue[i])) { + return; + } + queue = newQueue; + queueDirty = true; + controller.sendTo("allInstances", new QueueEvent(queue, player, instanceName(src))); + } + function broadcastProgress() { let techs = []; for (let name of progressToBroadcast) { @@ -109,6 +158,7 @@ export default async function(context: ControllerPluginContext) { tech.researched = true; tech.progress = null; progressToBroadcast.delete(name); + removeFinished(name); controller.sendTo("allInstances", new FinishedEvent(name, tech.level)); } @@ -118,6 +168,9 @@ export default async function(context: ControllerPluginContext) { controller.handle(FinishedEvent, async (event: FinishedEvent) => { let { name, level } = event; let tech = technologies.get(name); + if (!tech || tech.level < level || (tech.level === level && !tech.researched)) { + removeFinished(name); + } if (!tech || tech.level <= level) { controller.sendTo("allInstances", event); progressToBroadcast.delete(name); @@ -145,6 +198,7 @@ export default async function(context: ControllerPluginContext) { progressToBroadcast.add(name); } else if (researched || baseLevel(name) !== level) { controller.sendTo("allInstances", new FinishedEvent(name, level)); + removeFinished(name); } } else { @@ -156,6 +210,7 @@ export default async function(context: ControllerPluginContext) { // Send update if the unlocked level is greater if (level - Number(!researched) > tech.level - Number(!tech.researched)) { controller.sendTo("allInstances", new FinishedEvent(name, level - Number(!researched))); + removeFinished(name); } tech.level = level; tech.progress = progress; @@ -185,7 +240,22 @@ export default async function(context: ControllerPluginContext) { return result; }); + controller.handle(QueueUpdateEvent, async (event: QueueUpdateEvent, src: lib.Address) => { + setQueue(event.queue, event.player, src); + }); + + controller.handle(QueueSyncRequest, async (request: QueueSyncRequest, src: lib.Address): Promise => { + if (request.queue?.length && !queue.length) { + setQueue(request.queue, request.player, src); + } + return queue; + }); + controller.hooks.save.attach(plugin.name, async () => { + if (queueDirty) { + queueDirty = false; + await saveQueue(controller.config, queue, logger); + } if (technologiesDirty) { technologiesDirty = false; await saveTechnologies(controller.config, technologies, logger); diff --git a/plugins/research_sync/index.ts b/plugins/research_sync/index.ts index 33a0967a..a0d3e872 100644 --- a/plugins/research_sync/index.ts +++ b/plugins/research_sync/index.ts @@ -19,5 +19,8 @@ export const plugin: lib.PluginDeclaration = { messages.ProgressEvent, messages.FinishedEvent, messages.SyncTechnologiesRequest, + messages.QueueUpdateEvent, + messages.QueueEvent, + messages.QueueSyncRequest, ], }; diff --git a/plugins/research_sync/messages.ts b/plugins/research_sync/messages.ts index 99eff2a5..b10580c3 100644 --- a/plugins/research_sync/messages.ts +++ b/plugins/research_sync/messages.ts @@ -136,3 +136,76 @@ export class SyncTechnologiesRequest { static Response = lib.jsonArray(TechnologySync); } + +export class QueueUpdateEvent { + declare ["constructor"]: typeof QueueUpdateEvent; + static type = "event" as const; + static src = "instance" as const; + static dst = "controller" as const; + static plugin = "research_sync" as const; + + constructor( + public queue: string[], + public player: string | null, + ) { + } + + static jsonSchema = Type.Object({ + "queue": Type.Array(Type.String()), + "player": Type.Union([Type.String(), Type.Null()]), + }); + + static fromJSON(json: Static): QueueUpdateEvent { + return new this(json.queue, json.player); + } +} + +export class QueueEvent { + declare ["constructor"]: typeof QueueEvent; + static type = "event" as const; + static src = "controller" as const; + static dst = "instance" as const; + static plugin = "research_sync" as const; + + constructor( + public queue: string[], + public player: string | null, + public instance: string, + ) { + } + + static jsonSchema = Type.Object({ + "queue": Type.Array(Type.String()), + "player": Type.Union([Type.String(), Type.Null()]), + "instance": Type.String(), + }); + + static fromJSON(json: Static): QueueEvent { + return new this(json.queue, json.player, json.instance); + } +} + +export class QueueSyncRequest { + declare ["constructor"]: typeof QueueSyncRequest; + static type = "request" as const; + static src = "instance" as const; + static dst = "controller" as const; + static plugin = "research_sync" as const; + + constructor( + public queue: string[] | null, + public player: string | null, + ) { + } + + static jsonSchema = Type.Object({ + "queue": Type.Union([Type.Array(Type.String()), Type.Null()]), + "player": Type.Union([Type.String(), Type.Null()]), + }); + + static fromJSON(json: Static): QueueSyncRequest { + return new this(json.queue, json.player); + } + + static Response = lib.plainJson(Type.Array(Type.String())); +} diff --git a/plugins/research_sync/test/controller.js b/plugins/research_sync/test/controller.js new file mode 100644 index 00000000..dcbd2dfb --- /dev/null +++ b/plugins/research_sync/test/controller.js @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import * as lib from "@clusterio/lib"; +import * as mock from "../../../test/mock.js"; +import controllerEntrypoint from "../dist/node/controller.js"; +import { plugin as info } from "../dist/node/index.js"; +import { + ContributionEvent, FinishedEvent, QueueEvent, QueueSyncRequest, QueueUpdateEvent, + SyncTechnologiesRequest, TechnologySync, +} from "../dist/node/messages.js"; + +describe("research_sync controller", function() { + const databaseDir = path.join("temp", "test", "research_sync"); + let controller, sent; + + before(function() { + try { + lib.registerPluginMessages([info]); + } catch (err) { + // Already registered by the full test suite + } + }); + beforeEach(async function() { + await fs.rm(databaseDir, { recursive: true, force: true }); + await fs.mkdir(databaseDir, { recursive: true }); + controller = await load(); + }); + + async function load() { + const c = await mock.loadControllerPlugin(async (context) => { + context.controller.mockConfigEntries.set("controller.database_directory", databaseDir); + await controllerEntrypoint(context); + }, info); + sent = []; + c.sendTo = (dst, message) => { sent.push([dst, message]); }; + c.instances = new Map([[1, { config: { get: () => "rs-1" } }]]); + return c; + } + + const src = { id: 1 }; + const update = (queue, player = null) => mock.getHandler(controller, QueueUpdateEvent)( + new QueueUpdateEvent(queue, player), src + ); + const sync = (queue = null, player = null) => mock.getHandler(controller, QueueSyncRequest)( + new QueueSyncRequest(queue, player), src + ); + const queueEvents = () => sent.filter(([, m]) => m instanceof QueueEvent); + + it("should store and broadcast an update", async function() { + await update(["a", "b"], "Bob"); + assert.equal(sent.length, 1); + assert.equal(sent[0][0], "allInstances"); + assert.ok(sent[0][1] instanceof QueueEvent); + assert.deepEqual(sent[0][1].queue, ["a", "b"]); + assert.equal(sent[0][1].player, "Bob"); + assert.equal(sent[0][1].instance, "rs-1"); + assert.deepEqual(await sync(), ["a", "b"]); + }); + it("should not broadcast an unchanged update", async function() { + await update(["a", "b"]); + await update(["a", "b"]); + assert.equal(queueEvents().length, 1); + }); + it("should cap the queue at 7", async function() { + await update(["1", "2", "3", "4", "5", "6", "7", "8", "9"]); + assert.deepEqual(queueEvents()[0][1].queue, ["1", "2", "3", "4", "5", "6", "7"]); + assert.deepEqual(await sync(), ["1", "2", "3", "4", "5", "6", "7"]); + }); + it("should seed an empty queue from a sync request", async function() { + assert.deepEqual(await sync(["x"], "Bob"), ["x"]); + assert.equal(queueEvents().length, 1); + assert.deepEqual(queueEvents()[0][1].queue, ["x"]); + assert.equal(queueEvents()[0][1].player, "Bob"); + assert.equal(queueEvents()[0][1].instance, "rs-1"); + }); + it("should not replace a non-empty queue from a sync request", async function() { + await update(["a"]); + sent.length = 0; + assert.deepEqual(await sync(["x"]), ["a"]); + assert.equal(sent.length, 0); + assert.deepEqual(await sync(), ["a"]); + }); + it("should not broadcast when seeding with an empty queue", async function() { + assert.deepEqual(await sync([]), []); + assert.equal(sent.length, 0); + }); + + describe("finished technologies", function() { + it("should drop one entry when a contribution completes a technology", async function() { + await update(["m", "m", "x"]); + await mock.getHandler(controller, ContributionEvent)(new ContributionEvent("m", 3, 1), src); + assert.deepEqual(await sync(), ["m", "x"]); + }); + it("should drop one entry per advancing FinishedEvent only", async function() { + await update(["m", "m", "x"]); + const finished = mock.getHandler(controller, FinishedEvent); + await finished(new FinishedEvent("m", 3), src); + assert.deepEqual(await sync(), ["m", "x"]); + await finished(new FinishedEvent("m", 3), src); + assert.deepEqual(await sync(), ["m", "x"]); + }); + it("should drop an entry finished through a technology sync", async function() { + await update(["m", "x"]); + await mock.getHandler(controller, SyncTechnologiesRequest)( + new SyncTechnologiesRequest([new TechnologySync("m", 1, null, true)]), src + ); + assert.deepEqual(await sync(), ["x"]); + }); + }); + + it("should persist the queue across loads", async function() { + await update(["a", "b"]); + await controller.hooks.save.invoke(); + controller = await load(); + assert.deepEqual(await sync(), ["a", "b"]); + }); +}); From ead8ede42a9e80cc968bb68ad5350677a0486aff Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sun, 18 Oct 2026 21:33:16 +1100 Subject: [PATCH 02/16] research_sync: relay the cluster queue between the controller and the save Forward queue edits and follow requests from the save to the controller, and apply controller broadcasts and the start-up sync to the save. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/research_sync/instance.ts | 48 +++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/plugins/research_sync/instance.ts b/plugins/research_sync/instance.ts index 77200f7d..c79617c9 100644 --- a/plugins/research_sync/instance.ts +++ b/plugins/research_sync/instance.ts @@ -6,6 +6,9 @@ import { FinishedEvent, TechnologySync, SyncTechnologiesRequest, + QueueUpdateEvent, + QueueEvent, + QueueSyncRequest, } from "./messages.js"; // ./module/sync.lua @@ -35,6 +38,21 @@ export default async function(context: InstancePluginContext) { logger.error(`Unexpected error:\n${err.stack}`); } + // An empty Lua table serialises as {}, so anything that isn't an array is an empty queue + function asArray(v: unknown): string[] { + return Array.isArray(v) ? v : []; + } + + async function applyQueue(data: { queue: string[], instance?: string, player?: string }) { + await sendOrderedRcon(`/sc research_sync.apply_queue("${lib.escapeString(JSON.stringify(data))}")`, true); + } + + // queue null asks for the cluster queue without seeding it + async function followQueue(queue: string[] | null, player: string | null) { + let result = await instance.sendTo("controller", new QueueSyncRequest(queue, player)); + await applyQueue({ queue: result }); + } + async function researchContribution(tech: IpcContribution) { instance.sendTo("controller", new ContributionEvent(tech.name, tech.level, tech.contribution)); } @@ -50,6 +68,17 @@ export default async function(context: InstancePluginContext) { researchFinished(tech).catch(err => unexpectedError(err)); }); + instance.server.on("ipc-research_sync:queue", (data: { queue?: unknown, player?: string }) => { + try { + instance.sendTo("controller", new QueueUpdateEvent(asArray(data.queue), data.player ?? null)); + } catch (err: any) { + unexpectedError(err); + } + }); + instance.server.on("ipc-research_sync:follow", (data: { queue?: unknown, player?: string }) => { + followQueue(asArray(data.queue), data.player ?? null).catch(err => unexpectedError(err)); + }); + instance.handle(ProgressEvent, async (event: ProgressEvent) => { if (!syncStarted || !["starting", "running"].includes(instance.status)) { return; @@ -68,6 +97,19 @@ export default async function(context: InstancePluginContext) { ); }); + instance.handle(QueueEvent, async (event: QueueEvent) => { + if (!syncStarted || !["starting", "running"].includes(instance.status)) { + return; + } + let data: { queue: string[], instance: string, player?: string } = { + queue: event.queue, instance: event.instance, + }; + if (event.player !== null) { + data.player = event.player; + } + await applyQueue(data); + }); + instance.hooks.start.attach(plugin.name, async () => { let dumpJson = await sendOrderedRcon("/sc research_sync.dump_technologies()"); let techsToSend = []; @@ -102,5 +144,11 @@ export default async function(context: InstancePluginContext) { let syncJson = lib.escapeString(JSON.stringify(techsToSync)); await sendOrderedRcon(`/sc research_sync.sync_technologies("${syncJson}")`, true); } + + // A first start counts as turning following on, which seeds an empty cluster queue + let state = JSON.parse(await sendOrderedRcon("/sc research_sync.queue_state()")); + if (state.follow) { + await followQueue(state.first ? asArray(state.queue) : null, null); + } }); } From 667f1e3c0ce42ca66aa97ade0894424a36be7d80 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sun, 18 Oct 2026 21:34:35 +1100 Subject: [PATCH 03/16] 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) --- plugins/research_sync/module/sync.lua | 210 ++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) diff --git a/plugins/research_sync/module/sync.lua b/plugins/research_sync/module/sync.lua index 23bf6301..045e81ce 100644 --- a/plugins/research_sync/module/sync.lua +++ b/plugins/research_sync/module/sync.lua @@ -38,6 +38,10 @@ end --- @class ResearchSyncData --- @field technologies table --- @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 From 7175571d5b82e68103f7f5b2011a0cfec1524072 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sun, 18 Oct 2026 21:41:53 +1100 Subject: [PATCH 04/16] research_sync: gate the queue sync on Factorio 2.0.56 (on_research_queued) The last event of a tick now decides which player the queue edit is attributed to. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/research_sync/module/sync.lua | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/plugins/research_sync/module/sync.lua b/plugins/research_sync/module/sync.lua index 045e81ce..b3da483f 100644 --- a/plugins/research_sync/module/sync.lua +++ b/plugins/research_sync/module/sync.lua @@ -2,6 +2,8 @@ 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 = {} @@ -214,7 +216,7 @@ local function ensure_gui(player) frame["research_sync_follow"].state = get_script_data().follow ~= false end -if v2_tech_progress then +if queue_sync then sync.events[defines.events.on_player_created] = function(event) ensure_gui(game.get_player(event.player_index) --[[@as LuaPlayer]]) end @@ -236,10 +238,9 @@ if v2_tech_progress 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 + -- 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 @@ -391,7 +392,7 @@ function research_sync.research_technology(name, level) end function research_sync.queue_state() - if not v2_tech_progress then + if not queue_sync then rcon.print('{"follow":false}') return end @@ -409,7 +410,7 @@ end --- @param data string JSON {queue: string[], player?: string, instance?: string} function research_sync.apply_queue(data) - if not v2_tech_progress then + if not queue_sync then return end local msg = compat.json_to_table(data) --[[@as table]] @@ -450,7 +451,7 @@ end --- @param enabled boolean --- @param player_name string? function research_sync.set_follow(enabled, player_name) - if not v2_tech_progress then + if not queue_sync then return end local script_data = get_script_data() From 1f6e0519bdbdca439bb8c83bac42b080d66e3817 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sun, 18 Oct 2026 21:54:10 +1100 Subject: [PATCH 05/16] 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) --- README.md | 2 +- plugins/research_sync/README.md | 21 +++++++++++++++++++++ plugins/research_sync/instance.ts | 5 +++-- plugins/research_sync/module/sync.lua | 10 +++++++--- plugins/research_sync/test/controller.js | 9 +++++++++ plugins/research_sync/test/missing.js | 1 - 6 files changed, 41 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5955e302..52ac01bc 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Plugins add functionality to Factorio servers, Clusterio itself or both. These are the plugins supported and maintained by the Clusterio developers: - [Global Chat](/plugins/global_chat/README.md): share the in-game chat between servers. -- [Research Sync](/plugins/research_sync/README.md): synchronize research progress and technologies unlocked between servers. +- [Research Sync](/plugins/research_sync/README.md): synchronize research progress and technologies unlocked and the research queue between servers. - [Statistics Exporter](/plugins/statistics_exporter/README.md): collect in-game statistics from all the servers and makes it available to the Prometheus endpoint on the controller. - [Subspace Storage](https://github.com/clusterio/subspace_storage): Provide shared storage that can transport items between servers via teleport chests. - [Player Auth](/plugins/player_auth/README.md): Provides authentication to the cluster via logging into a Factorio server. diff --git a/plugins/research_sync/README.md b/plugins/research_sync/README.md index 46315762..0c0889a9 100644 --- a/plugins/research_sync/README.md +++ b/plugins/research_sync/README.md @@ -2,6 +2,27 @@ Plugin synchronizing research progress between instances and the cluster. +## Research queue + +Each server can follow one cluster research queue. +It is set from the normal research screen: queueing, moving or cancelling a technology there changes it for every following server. +So does a lab whose circuit "set research" picks a technology, and so can another mod. + +The "Follow the cluster research queue" checkbox on the lab window turns this on or off per server. +Turning it on replaces the server's queue with the cluster's, turning it off clears it. +It is on by default. +On the first start after installing or upgrading, the first server to start shares its queue as the cluster queue, and later servers take it. +Every change prints a chat line on every server. + +It needs Factorio 2.0.56 or later, older servers ignore the queue. +To turn it off without the GUI, run `/sc research_sync.set_follow(false)` on that server. + +Known gaps: + +- A script that assigns the queue directly to `{}` isn't seen. +- Two following servers whose labs want different research keep flipping the cluster queue, as two labs do on one server. +- A technology a server can't queue yet is dropped there, and an edit on that server then drops it for everyone. + ## Installation diff --git a/plugins/research_sync/instance.ts b/plugins/research_sync/instance.ts index c79617c9..38457c03 100644 --- a/plugins/research_sync/instance.ts +++ b/plugins/research_sync/instance.ts @@ -21,6 +21,7 @@ type IpcFinished = { name: string, level: number, }; +type IpcQueue = { queue: unknown, player?: string }; export default async function(context: InstancePluginContext) { const { instance, logger, plugin } = context; @@ -68,14 +69,14 @@ export default async function(context: InstancePluginContext) { researchFinished(tech).catch(err => unexpectedError(err)); }); - instance.server.on("ipc-research_sync:queue", (data: { queue?: unknown, player?: string }) => { + instance.server.on("ipc-research_sync:queue", (data: IpcQueue) => { try { instance.sendTo("controller", new QueueUpdateEvent(asArray(data.queue), data.player ?? null)); } catch (err: any) { unexpectedError(err); } }); - instance.server.on("ipc-research_sync:follow", (data: { queue?: unknown, player?: string }) => { + instance.server.on("ipc-research_sync:follow", (data: IpcQueue) => { followQueue(asArray(data.queue), data.player ?? null).catch(err => unexpectedError(err)); }); diff --git a/plugins/research_sync/module/sync.lua b/plugins/research_sync/module/sync.lua index b3da483f..81ab132d 100644 --- a/plugins/research_sync/module/sync.lua +++ b/plugins/research_sync/module/sync.lua @@ -40,7 +40,7 @@ end --- @class ResearchSyncData --- @field technologies table --- @field ignore_research_finished boolean ---- @field follow boolean? nil until first set; nil counts as following +--- @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? @@ -159,7 +159,7 @@ end --- @return string[] local function queue_names(force) local names = {} - for _, tech in pairs(force.research_queue) do + for _, tech in ipairs(force.research_queue) do table.insert(names, tech.name) end return names @@ -217,11 +217,13 @@ local function ensure_gui(player) 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]] @@ -229,6 +231,7 @@ if queue_sync then 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 @@ -247,7 +250,8 @@ if queue_sync then 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() + --- @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 diff --git a/plugins/research_sync/test/controller.js b/plugins/research_sync/test/controller.js index dcbd2dfb..b3cf0a9d 100644 --- a/plugins/research_sync/test/controller.js +++ b/plugins/research_sync/test/controller.js @@ -63,6 +63,15 @@ describe("research_sync controller", function() { await update(["a", "b"]); assert.equal(queueEvents().length, 1); }); + it("should broadcast a clear to an empty queue", async function() { + await update(["a"], "Bob"); + sent.length = 0; + await update([], null); + assert.equal(queueEvents().length, 1); + assert.deepEqual(queueEvents()[0][1].queue, []); + assert.equal(queueEvents()[0][1].player, null); + assert.deepEqual(await sync(null, null), []); + }); it("should cap the queue at 7", async function() { await update(["1", "2", "3", "4", "5", "6", "7", "8", "9"]); assert.deepEqual(queueEvents()[0][1].queue, ["1", "2", "3", "4", "5", "6", "7"]); diff --git a/plugins/research_sync/test/missing.js b/plugins/research_sync/test/missing.js index 4d585e2b..579cf2ed 100644 --- a/plugins/research_sync/test/missing.js +++ b/plugins/research_sync/test/missing.js @@ -2,4 +2,3 @@ // required here in order to track coverage for them. import "../dist/node/instance.js"; -import "../dist/node/controller.js"; From eb869dac5bebcf6f101b8b77857dd5a25a35d432 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sun, 18 Oct 2026 22:31:23 +1100 Subject: [PATCH 06/16] research_sync: README, the empty-cluster case on turning on, and which changes print where Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/research_sync/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/research_sync/README.md b/plugins/research_sync/README.md index 0c0889a9..99983fc0 100644 --- a/plugins/research_sync/README.md +++ b/plugins/research_sync/README.md @@ -9,10 +9,12 @@ It is set from the normal research screen: queueing, moving or cancelling a tech So does a lab whose circuit "set research" picks a technology, and so can another mod. The "Follow the cluster research queue" checkbox on the lab window turns this on or off per server. -Turning it on replaces the server's queue with the cluster's, turning it off clears it. +Turning it on replaces the server's queue with the cluster's, or, if the cluster queue is empty, shares the server's queue as the cluster queue. +Turning it off clears the server's queue. +Flipping it prints a chat line on that server. It is on by default. On the first start after installing or upgrading, the first server to start shares its queue as the cluster queue, and later servers take it. -Every change prints a chat line on every server. +Every change to the cluster queue prints a chat line on every server. It needs Factorio 2.0.56 or later, older servers ignore the queue. To turn it off without the GUI, run `/sc research_sync.set_follow(false)` on that server. From c70be25dc9ae09514ccfb30276e78ede4b2325b2 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sun, 18 Oct 2026 22:32:53 +1100 Subject: [PATCH 07/16] research_sync: the follow checkbox tooltip says all research progress is counted, whatever is selected Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/research_sync/module/sync.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/research_sync/module/sync.lua b/plugins/research_sync/module/sync.lua index 81ab132d..f894ed91 100644 --- a/plugins/research_sync/module/sync.lua +++ b/plugins/research_sync/module/sync.lua @@ -40,7 +40,7 @@ end --- @class ResearchSyncData --- @field technologies table --- @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 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? @@ -209,7 +209,8 @@ local function ensure_gui(player) 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.", + .. "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 From ed1e4ee3cb8d844aede365e509ae0dd8f31d8d48 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Mon, 19 Oct 2026 00:06:35 +1100 Subject: [PATCH 08/16] Fetch the cluster research queue again after a new controller session Test the research_sync instance plugin. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/research_sync/instance.ts | 23 ++++- plugins/research_sync/test/instance.js | 125 +++++++++++++++++++++++++ plugins/research_sync/test/missing.js | 4 - 3 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 plugins/research_sync/test/instance.js delete mode 100644 plugins/research_sync/test/missing.js diff --git a/plugins/research_sync/instance.ts b/plugins/research_sync/instance.ts index 38457c03..a7124a51 100644 --- a/plugins/research_sync/instance.ts +++ b/plugins/research_sync/instance.ts @@ -54,6 +54,13 @@ export default async function(context: InstancePluginContext) { await applyQueue({ queue: result }); } + async function syncQueue(seed: boolean) { + let state = JSON.parse(await sendOrderedRcon("/sc research_sync.queue_state()")); + if (state.follow) { + await followQueue(seed && state.first ? asArray(state.queue) : null, null); + } + } + async function researchContribution(tech: IpcContribution) { instance.sendTo("controller", new ContributionEvent(tech.name, tech.level, tech.contribution)); } @@ -147,9 +154,19 @@ export default async function(context: InstancePluginContext) { } // A first start counts as turning following on, which seeds an empty cluster queue - let state = JSON.parse(await sendOrderedRcon("/sc research_sync.queue_state()")); - if (state.follow) { - await followQueue(state.first ? asArray(state.queue) : null, null); + await syncQueue(true); + }); + + // QueueEvents sent while the controller session was down are lost, so take the cluster queue again. + // An edit made on this instance during the outage was never sent and is replaced: the cluster queue wins. + instance.hooks.controllerConnectionEvent.attach(plugin.name, async (event) => { + if (event !== "connect" || !syncStarted || !["starting", "running"].includes(instance.status)) { + return; + } + try { + await syncQueue(false); + } catch (err: any) { + unexpectedError(err); } }); } diff --git a/plugins/research_sync/test/instance.js b/plugins/research_sync/test/instance.js new file mode 100644 index 00000000..2abab6c8 --- /dev/null +++ b/plugins/research_sync/test/instance.js @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; + +import * as lib from "@clusterio/lib"; +import * as mock from "../../../test/mock.js"; +import loadInstancePlugin from "../dist/node/instance.js"; +import { plugin as info } from "../dist/node/index.js"; +import { + QueueEvent, QueueSyncRequest, QueueUpdateEvent, SyncTechnologiesRequest, +} from "../dist/node/messages.js"; + +describe("research_sync instance", function() { + let instance, sent; + const clusterQueue = ["a", "b"]; + + before(function() { + try { + lib.registerPluginMessages([info]); + } catch (err) { + // Already registered by the full test suite + } + }); + beforeEach(async function() { + ({ instance } = await mock.loadInstancePlugin(loadInstancePlugin, info)); + sent = []; + instance.sendTo = async (dst, message) => { + sent.push(message); + if (message instanceof SyncTechnologiesRequest) { + return []; + } + if (message instanceof QueueSyncRequest) { + return clusterQueue; + } + return undefined; + }; + instance.server.rconCommandResults.set("/sc research_sync.dump_technologies()", "[]"); + }); + + function queueState(state) { + instance.server.rconCommandResults.set("/sc research_sync.queue_state()", JSON.stringify(state)); + } + const applyCommand = data => `/sc research_sync.apply_queue("${lib.escapeString(JSON.stringify(data))}")`; + const lastCommand = () => instance.server.rconCommands[instance.server.rconCommands.length - 1]; + const queueSyncs = () => sent.filter(m => m instanceof QueueSyncRequest); + + describe("start", function() { + it("should seed the cluster queue on a first start", async function() { + queueState({ follow: true, first: true, queue: {} }); + await instance.hooks.start.invoke(); + assert.equal(queueSyncs().length, 1); + assert.deepEqual(queueSyncs()[0].queue, []); + assert.equal(lastCommand(), applyCommand({ queue: clusterQueue })); + }); + it("should only fetch the cluster queue after the first start", async function() { + queueState({ follow: true, first: false }); + await instance.hooks.start.invoke(); + assert.equal(queueSyncs()[0].queue, null); + }); + it("should not sync the queue when not following", async function() { + queueState({ follow: false }); + await instance.hooks.start.invoke(); + assert.equal(queueSyncs().length, 0); + }); + }); + + describe("QueueEvent", function() { + beforeEach(async function() { + queueState({ follow: false }); + await instance.hooks.start.invoke(); + instance.server.reset(); + }); + const handler = () => mock.getHandler(instance, QueueEvent); + + it("should apply the queue with the player", async function() { + await handler()(new QueueEvent(["a"], "Bob", "rs-2")); + assert.equal(lastCommand(), applyCommand({ queue: ["a"], instance: "rs-2", player: "Bob" })); + }); + it("should leave out a null player", async function() { + await handler()(new QueueEvent(["a"], null, "rs-2")); + assert.equal(lastCommand(), applyCommand({ queue: ["a"], instance: "rs-2" })); + }); + it("should ignore the event while stopped", async function() { + instance.status = "stopped"; + await handler()(new QueueEvent(["a"], null, "rs-2")); + assert.equal(instance.server.rconCommands.length, 0); + }); + }); + + describe("controller connection", function() { + beforeEach(async function() { + queueState({ follow: true, first: false }); + await instance.hooks.start.invoke(); + sent.length = 0; + instance.server.reset(); + queueState({ follow: true, first: false }); + }); + + it("should fetch the cluster queue again on a new session", async function() { + await instance.hooks.controllerConnectionEvent.invoke("connect"); + assert.equal(queueSyncs().length, 1); + assert.equal(queueSyncs()[0].queue, null); + assert.equal(lastCommand(), applyCommand({ queue: clusterQueue })); + }); + it("should do nothing on resume", async function() { + await instance.hooks.controllerConnectionEvent.invoke("resume"); + assert.equal(sent.length, 0); + }); + it("should do nothing while stopped", async function() { + instance.status = "stopped"; + await instance.hooks.controllerConnectionEvent.invoke("connect"); + assert.equal(sent.length, 0); + }); + it("should not throw when the fetch fails", async function() { + instance.sendTo = async () => { throw new Error("gone"); }; + await instance.hooks.controllerConnectionEvent.invoke("connect"); + }); + }); + + it("should forward queue changes from the game", async function() { + instance.server.emit("ipc-research_sync:queue", { queue: {}, player: "Bob" }); + assert.equal(sent.length, 1); + assert.ok(sent[0] instanceof QueueUpdateEvent); + assert.deepEqual(sent[0].queue, []); + assert.equal(sent[0].player, "Bob"); + }); +}); diff --git a/plugins/research_sync/test/missing.js b/plugins/research_sync/test/missing.js deleted file mode 100644 index 579cf2ed..00000000 --- a/plugins/research_sync/test/missing.js +++ /dev/null @@ -1,4 +0,0 @@ -// These are libraries that does not have any units test, they are -// required here in order to track coverage for them. - -import "../dist/node/instance.js"; From f7c78d9f742715b350989bd48f8906b573e04ce5 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Mon, 19 Oct 2026 00:06:35 +1100 Subject: [PATCH 09/16] Remove one research queue entry per level a technology sync unlocks Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/research_sync/controller.ts | 18 ++++++++++++------ plugins/research_sync/test/controller.js | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/plugins/research_sync/controller.ts b/plugins/research_sync/controller.ts index 2878c0ec..add05517 100644 --- a/plugins/research_sync/controller.ts +++ b/plugins/research_sync/controller.ts @@ -86,9 +86,13 @@ export default async function(context: ControllerPluginContext) { return controller.instances.get(src.id)?.config.get("instance.name") ?? String(src.id); } - function removeFinished(name: string) { - let index = queue.indexOf(name); - if (index !== -1) { + // Removes one entry per level that finished + function removeFinished(name: string, levels = 1) { + for (let i = 0; i < levels; i++) { + let index = queue.indexOf(name); + if (index === -1) { + return; + } queue.splice(index, 1); queueDirty = true; } @@ -198,7 +202,8 @@ export default async function(context: ControllerPluginContext) { progressToBroadcast.add(name); } else if (researched || baseLevel(name) !== level) { controller.sendTo("allInstances", new FinishedEvent(name, level)); - removeFinished(name); + // One queue entry per level unlocked + removeFinished(name, Math.max(level - Number(!researched) - baseLevel(name) + 1, 1)); } } else { @@ -208,9 +213,10 @@ export default async function(context: ControllerPluginContext) { if (tech.level < level || researched) { // Send update if the unlocked level is greater - if (level - Number(!researched) > tech.level - Number(!tech.researched)) { + let unlocked = level - Number(!researched) - (tech.level - Number(!tech.researched)); + if (unlocked > 0) { controller.sendTo("allInstances", new FinishedEvent(name, level - Number(!researched))); - removeFinished(name); + removeFinished(name, unlocked); } tech.level = level; tech.progress = progress; diff --git a/plugins/research_sync/test/controller.js b/plugins/research_sync/test/controller.js index b3cf0a9d..83171d9a 100644 --- a/plugins/research_sync/test/controller.js +++ b/plugins/research_sync/test/controller.js @@ -119,6 +119,23 @@ describe("research_sync controller", function() { }); }); + describe("technology sync unlocking several levels", function() { + const techSync = (name, level, researched) => mock.getHandler(controller, SyncTechnologiesRequest)( + new SyncTechnologiesRequest([new TechnologySync(name, level, null, researched)]), src + ); + it("should drop one entry per level unlocked on a known technology", async function() { + await techSync("m", 4, true); + await update(["m", "m", "m", "x"]); + await techSync("m", 7, true); + assert.deepEqual(await sync(), ["x"]); + }); + it("should drop one entry per level unlocked on a new technology", async function() { + await update(["m", "m", "x"]); + await techSync("m", 2, true); + assert.deepEqual(await sync(), ["x"]); + }); + }); + it("should persist the queue across loads", async function() { await update(["a", "b"]); await controller.hooks.save.invoke(); From bf73afeab297200ca853fb1beaa8a68eadbb2416 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Mon, 19 Oct 2026 00:06:35 +1100 Subject: [PATCH 10/16] Describe when a server shares its research queue as the cluster queue Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/research_sync/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/research_sync/README.md b/plugins/research_sync/README.md index 99983fc0..bd6dc89b 100644 --- a/plugins/research_sync/README.md +++ b/plugins/research_sync/README.md @@ -13,7 +13,8 @@ Turning it on replaces the server's queue with the cluster's, or, if the cluster Turning it off clears the server's queue. Flipping it prints a chat line on that server. It is on by default. -On the first start after installing or upgrading, the first server to start shares its queue as the cluster queue, and later servers take it. +The first server to turn on with something queued shares its queue as the cluster queue, and servers that turn on after that take the cluster's. +Whenever the cluster queue is empty (for example, everything in it has finished), the next server that turns on shares its queue again. Every change to the cluster queue prints a chat line on every server. It needs Factorio 2.0.56 or later, older servers ignore the queue. From bedfe0873bbb640fa55752453d5723ba5e471b75 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Mon, 19 Oct 2026 08:54:04 +1100 Subject: [PATCH 11/16] Check the queue sync version in each handler Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/research_sync/module/sync.lua | 72 +++++++++++++++------------ 1 file changed, 41 insertions(+), 31 deletions(-) diff --git a/plugins/research_sync/module/sync.lua b/plugins/research_sync/module/sync.lua index f894ed91..8b1e87fb 100644 --- a/plugins/research_sync/module/sync.lua +++ b/plugins/research_sync/module/sync.lua @@ -217,21 +217,28 @@ local function ensure_gui(player) 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 queue_sync 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 queue_sync 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 + +-- These defines don't exist before 2.0.56, so they can only be indexed when queue_sync is set. 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 @@ -249,25 +256,28 @@ if queue_sync then 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) - 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, - }) +-- 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 queue_sync 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 = {} From 2035f5bc2c4109865ccf12368094a39ec786c3d0 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Mon, 19 Oct 2026 08:54:04 +1100 Subject: [PATCH 12/16] Send the research queue to followers after a finished technology is removed Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/research_sync/controller.ts | 35 +++++++++++++++++++----- plugins/research_sync/instance.ts | 7 +++-- plugins/research_sync/messages.ts | 4 +-- plugins/research_sync/test/controller.js | 28 +++++++++++++++++++ plugins/research_sync/test/instance.js | 4 +++ 5 files changed, 66 insertions(+), 12 deletions(-) diff --git a/plugins/research_sync/controller.ts b/plugins/research_sync/controller.ts index add05517..a617b2c1 100644 --- a/plugins/research_sync/controller.ts +++ b/plugins/research_sync/controller.ts @@ -86,16 +86,25 @@ export default async function(context: ControllerPluginContext) { return controller.instances.get(src.id)?.config.get("instance.name") ?? String(src.id); } - // Removes one entry per level that finished - function removeFinished(name: string, levels = 1) { + // Removes one entry per level that finished, returns whether any was removed + function removeFinished(name: string, levels = 1): boolean { + let removed = false; for (let i = 0; i < levels; i++) { let index = queue.indexOf(name); if (index === -1) { - return; + break; } queue.splice(index, 1); queueDirty = true; + removed = true; } + return removed; + } + + // Instances only advance the technology on a finish, so they need the queue without it. + // No player or instance: the finished research prints its own chat line. + function broadcastQueueSilently() { + controller.sendTo("allInstances", new QueueEvent(queue, null, null)); } function setQueue(newQueue: string[], player: string | null, src: lib.Address) { @@ -162,9 +171,12 @@ export default async function(context: ControllerPluginContext) { tech.researched = true; tech.progress = null; progressToBroadcast.delete(name); - removeFinished(name); + let removed = removeFinished(name); controller.sendTo("allInstances", new FinishedEvent(name, tech.level)); + if (removed) { + broadcastQueueSilently(); + } } technologiesDirty = true; }); @@ -172,8 +184,9 @@ export default async function(context: ControllerPluginContext) { controller.handle(FinishedEvent, async (event: FinishedEvent) => { let { name, level } = event; let tech = technologies.get(name); + let removed = false; if (!tech || tech.level < level || (tech.level === level && !tech.researched)) { - removeFinished(name); + removed = removeFinished(name); } if (!tech || tech.level <= level) { controller.sendTo("allInstances", event); @@ -181,6 +194,9 @@ export default async function(context: ControllerPluginContext) { technologies.set(name, { level, progress: null, researched: true }); technologiesDirty = true; } + if (removed) { + broadcastQueueSilently(); + } }); controller.handle(SyncTechnologiesRequest, async (request: SyncTechnologiesRequest): Promise => { @@ -192,6 +208,7 @@ export default async function(context: ControllerPluginContext) { return Number.parseInt(match[1], 10); } + let removed = false; for (let instanceTech of request.technologies) { let { name, level, progress, researched } = instanceTech; let tech = technologies.get(name); @@ -203,7 +220,8 @@ export default async function(context: ControllerPluginContext) { } else if (researched || baseLevel(name) !== level) { controller.sendTo("allInstances", new FinishedEvent(name, level)); // One queue entry per level unlocked - removeFinished(name, Math.max(level - Number(!researched) - baseLevel(name) + 1, 1)); + let levels = Math.max(level - Number(!researched) - baseLevel(name) + 1, 1); + removed = removeFinished(name, levels) || removed; } } else { @@ -216,7 +234,7 @@ export default async function(context: ControllerPluginContext) { let unlocked = level - Number(!researched) - (tech.level - Number(!tech.researched)); if (unlocked > 0) { controller.sendTo("allInstances", new FinishedEvent(name, level - Number(!researched))); - removeFinished(name, unlocked); + removed = removeFinished(name, unlocked) || removed; } tech.level = level; tech.progress = progress; @@ -237,6 +255,9 @@ export default async function(context: ControllerPluginContext) { } } progressRateLimiter.activate(); + if (removed) { + broadcastQueueSilently(); + } let result = []; for (let [name, tech] of technologies) { diff --git a/plugins/research_sync/instance.ts b/plugins/research_sync/instance.ts index a7124a51..7e7b5109 100644 --- a/plugins/research_sync/instance.ts +++ b/plugins/research_sync/instance.ts @@ -109,9 +109,10 @@ export default async function(context: InstancePluginContext) { if (!syncStarted || !["starting", "running"].includes(instance.status)) { return; } - let data: { queue: string[], instance: string, player?: string } = { - queue: event.queue, instance: event.instance, - }; + let data: { queue: string[], instance?: string, player?: string } = { queue: event.queue }; + if (event.instance !== null) { + data.instance = event.instance; + } if (event.player !== null) { data.player = event.player; } diff --git a/plugins/research_sync/messages.ts b/plugins/research_sync/messages.ts index b10580c3..58d5a38d 100644 --- a/plugins/research_sync/messages.ts +++ b/plugins/research_sync/messages.ts @@ -170,14 +170,14 @@ export class QueueEvent { constructor( public queue: string[], public player: string | null, - public instance: string, + public instance: string | null, ) { } static jsonSchema = Type.Object({ "queue": Type.Array(Type.String()), "player": Type.Union([Type.String(), Type.Null()]), - "instance": Type.String(), + "instance": Type.Union([Type.String(), Type.Null()]), }); static fromJSON(json: Static): QueueEvent { diff --git a/plugins/research_sync/test/controller.js b/plugins/research_sync/test/controller.js index 83171d9a..5fbbfb91 100644 --- a/plugins/research_sync/test/controller.js +++ b/plugins/research_sync/test/controller.js @@ -110,6 +110,31 @@ describe("research_sync controller", function() { await finished(new FinishedEvent("m", 3), src); assert.deepEqual(await sync(), ["m", "x"]); }); + it("should send the queue silently after a completion behind the current research", async function() { + await update(["automation", "m", "m"]); + sent.length = 0; + await mock.getHandler(controller, ContributionEvent)(new ContributionEvent("m", 3, 1), src); + assert.equal(queueEvents().length, 1); + assert.deepEqual(queueEvents()[0][1], new QueueEvent(["automation", "m"], null, null)); + }); + it("should send the queue after an advancing FinishedEvent only", async function() { + await update(["m", "x"]); + sent.length = 0; + const finished = mock.getHandler(controller, FinishedEvent); + await finished(new FinishedEvent("m", 3), src); + assert.equal(queueEvents().length, 1); + assert.deepEqual(queueEvents()[0][1], new QueueEvent(["x"], null, null)); + sent.length = 0; + await finished(new FinishedEvent("m", 3), src); + assert.equal(queueEvents().length, 0); + }); + it("should send no queue when the finished technology is not queued", async function() { + await update(["x"]); + sent.length = 0; + await mock.getHandler(controller, FinishedEvent)(new FinishedEvent("m", 3), src); + await mock.getHandler(controller, ContributionEvent)(new ContributionEvent("n", 3, 1), src); + assert.equal(queueEvents().length, 0); + }); it("should drop an entry finished through a technology sync", async function() { await update(["m", "x"]); await mock.getHandler(controller, SyncTechnologiesRequest)( @@ -126,8 +151,11 @@ describe("research_sync controller", function() { it("should drop one entry per level unlocked on a known technology", async function() { await techSync("m", 4, true); await update(["m", "m", "m", "x"]); + sent.length = 0; await techSync("m", 7, true); assert.deepEqual(await sync(), ["x"]); + assert.equal(queueEvents().length, 1); + assert.deepEqual(queueEvents()[0][1], new QueueEvent(["x"], null, null)); }); it("should drop one entry per level unlocked on a new technology", async function() { await update(["m", "m", "x"]); diff --git a/plugins/research_sync/test/instance.js b/plugins/research_sync/test/instance.js index 2abab6c8..0ef10c6e 100644 --- a/plugins/research_sync/test/instance.js +++ b/plugins/research_sync/test/instance.js @@ -78,6 +78,10 @@ describe("research_sync instance", function() { await handler()(new QueueEvent(["a"], null, "rs-2")); assert.equal(lastCommand(), applyCommand({ queue: ["a"], instance: "rs-2" })); }); + it("should leave out a null instance", async function() { + await handler()(new QueueEvent(["a"], null, null)); + assert.equal(lastCommand(), applyCommand({ queue: ["a"] })); + }); it("should ignore the event while stopped", async function() { instance.status = "stopped"; await handler()(new QueueEvent(["a"], null, "rs-2")); From 6126b30350b2b3c3c651fdbec4e9459bde4791e7 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Tue, 20 Oct 2026 00:02:35 +1100 Subject: [PATCH 13/16] Name the research queue version check after its API research_queue_api is compat.version_ge("2.0.56"), named like player_sync's version checks, so it doesn't read as a setting. Handlers check it at the top; only the three research queue events are registered behind it. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/research_sync/module/sync.lua | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/plugins/research_sync/module/sync.lua b/plugins/research_sync/module/sync.lua index 8b1e87fb..65809005 100644 --- a/plugins/research_sync/module/sync.lua +++ b/plugins/research_sync/module/sync.lua @@ -3,7 +3,7 @@ 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 research_queue_api = compat.version_ge("2.0.56") local sync = {} @@ -219,7 +219,7 @@ end --- @param event EventData.on_player_created sync.events[defines.events.on_player_created] = function(event) - if not queue_sync then + if not research_queue_api then return end ensure_gui(game.get_player(event.player_index) --[[@as LuaPlayer]]) @@ -228,7 +228,7 @@ sync.events[defines.events.on_player_joined_game] = sync.events[defines.events.o --- @param event EventData.on_gui_checked_state_changed sync.events[defines.events.on_gui_checked_state_changed] = function(event) - if not queue_sync then + if not research_queue_api then return end if event.element.name == "research_sync_follow" then @@ -237,8 +237,8 @@ sync.events[defines.events.on_gui_checked_state_changed] = function(event) end end --- These defines don't exist before 2.0.56, so they can only be indexed when queue_sync is set. -if queue_sync then +-- 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 @@ -261,7 +261,7 @@ 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 queue_sync then + if not research_queue_api then return end local script_data = get_script_data() @@ -407,7 +407,7 @@ function research_sync.research_technology(name, level) end function research_sync.queue_state() - if not queue_sync then + if not research_queue_api then rcon.print('{"follow":false}') return end @@ -425,7 +425,7 @@ end --- @param data string JSON {queue: string[], player?: string, instance?: string} function research_sync.apply_queue(data) - if not queue_sync then + if not research_queue_api then return end local msg = compat.json_to_table(data) --[[@as table]] @@ -466,7 +466,7 @@ end --- @param enabled boolean --- @param player_name string? function research_sync.set_follow(enabled, player_name) - if not queue_sync then + if not research_queue_api then return end local script_data = get_script_data() From 079740d991294322303287f01595220abc3031fc Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Tue, 20 Oct 2026 00:03:48 +1100 Subject: [PATCH 14/16] Say that the research queue is shared on turning following on, not on a restart Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/research_sync/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/research_sync/README.md b/plugins/research_sync/README.md index bd6dc89b..6ddab572 100644 --- a/plugins/research_sync/README.md +++ b/plugins/research_sync/README.md @@ -13,8 +13,9 @@ Turning it on replaces the server's queue with the cluster's, or, if the cluster Turning it off clears the server's queue. Flipping it prints a chat line on that server. It is on by default. -The first server to turn on with something queued shares its queue as the cluster queue, and servers that turn on after that take the cluster's. -Whenever the cluster queue is empty (for example, everything in it has finished), the next server that turns on shares its queue again. +The first server to turn following on with something queued shares its queue as the cluster queue, and servers that turn it on after that take the cluster's. +A server's first start counts as turning it on; a later restart takes the cluster queue, even an empty one. +Whenever the cluster queue is empty (for example, everything in it has finished), the next server that turns following on shares its queue again. Every change to the cluster queue prints a chat line on every server. It needs Factorio 2.0.56 or later, older servers ignore the queue. From fd39e2a171dae62428c6d2f3aa5ca9b7d82fc739 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Tue, 20 Oct 2026 00:05:19 +1100 Subject: [PATCH 15/16] List a finished technology put back by a stale edit as a known gap Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/research_sync/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/research_sync/README.md b/plugins/research_sync/README.md index 6ddab572..767ed5e6 100644 --- a/plugins/research_sync/README.md +++ b/plugins/research_sync/README.md @@ -26,6 +26,7 @@ Known gaps: - A script that assigns the queue directly to `{}` isn't seen. - Two following servers whose labs want different research keep flipping the cluster queue, as two labs do on one server. - A technology a server can't queue yet is dropped there, and an edit on that server then drops it for everyone. +- An edit made on a server in the moment before it hears that a technology finished can put that technology back in the cluster queue. Every server skips it, since the game won't queue finished research, and the next edit removes it. ## Installation From b10807b6d628b8a3e04c796111611cb067fd8c1b Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Tue, 20 Oct 2026 12:20:36 +1100 Subject: [PATCH 16/16] Keep a newer queue change over a queue reply it overtook A QueueEvent arriving in the same socket read as a QueueSyncRequest reply is applied before the reply, which then wrote the older queue over it. Skip the reply when a QueueEvent was applied while it was in flight. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/research_sync/instance.ts | 8 ++++++++ plugins/research_sync/test/instance.js | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/plugins/research_sync/instance.ts b/plugins/research_sync/instance.ts index 7e7b5109..02771abf 100644 --- a/plugins/research_sync/instance.ts +++ b/plugins/research_sync/instance.ts @@ -26,6 +26,7 @@ type IpcQueue = { queue: unknown, player?: string }; export default async function(context: InstancePluginContext) { const { instance, logger, plugin } = context; let syncStarted = false; + let queueEventsApplied = 0; // Commands are sent one at a time so they execute in order let rconQueue: Promise = Promise.resolve(); @@ -50,7 +51,13 @@ export default async function(context: InstancePluginContext) { // queue null asks for the cluster queue without seeding it async function followQueue(queue: string[] | null, player: string | null) { + let applied = queueEventsApplied; let result = await instance.sendTo("controller", new QueueSyncRequest(queue, player)); + // A QueueEvent handled meanwhile is at least as new as this reply, since the controller broadcasts every + // queue change in order with its replies. Arriving in the same read, it is applied before the reply. + if (queueEventsApplied !== applied) { + return; + } await applyQueue({ queue: result }); } @@ -116,6 +123,7 @@ export default async function(context: InstancePluginContext) { if (event.player !== null) { data.player = event.player; } + queueEventsApplied += 1; await applyQueue(data); }); diff --git a/plugins/research_sync/test/instance.js b/plugins/research_sync/test/instance.js index 0ef10c6e..844d16f2 100644 --- a/plugins/research_sync/test/instance.js +++ b/plugins/research_sync/test/instance.js @@ -117,6 +117,17 @@ describe("research_sync instance", function() { instance.sendTo = async () => { throw new Error("gone"); }; await instance.hooks.controllerConnectionEvent.invoke("connect"); }); + it("should not let the reply overwrite a newer QueueEvent delivered with it", async function() { + // As if the reply and the broadcast came in one socket read: the event is handled before the + // reply resolves the request's await + instance.sendTo = async (dst, message) => { + sent.push(message); + mock.getHandler(instance, QueueEvent)(new QueueEvent(["new"], null, "B")); + return ["old"]; + }; + await instance.hooks.controllerConnectionEvent.invoke("connect"); + assert.equal(lastCommand(), applyCommand({ queue: ["new"], instance: "B" })); + }); }); it("should forward queue changes from the game", async function() {