From bd9c4a5fea8e818565821671a37a35b7e8d9b8f1 Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:51:42 +0100 Subject: [PATCH] Merge pull request #1188 from bbassie/fix/research-queue-message Print only what changed in the research queue chat line --- plugins/research_sync/README.md | 3 +- plugins/research_sync/controller.ts | 57 +++++++++++++++++++- plugins/research_sync/instance.ts | 9 +++- plugins/research_sync/messages.ts | 33 +++++++++++- plugins/research_sync/module/sync.lua | 69 +++++++++++++++++------- plugins/research_sync/test/controller.js | 57 +++++++++++++++++++- plugins/research_sync/test/instance.js | 9 +++- 7 files changed, 209 insertions(+), 28 deletions(-) diff --git a/plugins/research_sync/README.md b/plugins/research_sync/README.md index 767ed5e6..e4ed6720 100644 --- a/plugins/research_sync/README.md +++ b/plugins/research_sync/README.md @@ -16,7 +16,8 @@ It is on by default. 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. +Every change to the cluster queue prints a chat line on every server naming the technologies added, removed or moved, with the level each queued entry researches. +Entries dropped because their research finished only print the "Researched" line. 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. diff --git a/plugins/research_sync/controller.ts b/plugins/research_sync/controller.ts index a4e02a47..f390c3cb 100644 --- a/plugins/research_sync/controller.ts +++ b/plugins/research_sync/controller.ts @@ -15,6 +15,7 @@ import { TechnologyProgress, QueueUpdateEvent, QueueEvent, + QueueChange, QueueSyncRequest, } from "./messages.js"; @@ -74,6 +75,59 @@ async function saveQueue(controllerConfig: lib.ControllerConfig, queue: string[] await lib.atomicOverwriteFile(filePath, JSON.stringify(queue, null, "\t")); } +/** + * Lists the entries removed, added and moved between two queues. An entry is identified by its name and how many + * entries for the same name come before it, so extra levels of an infinite technology count as added or removed + * at the end. Moved entries are those outside the longest common subsequence of the kept entries. + */ +export function queueChanges(oldQueue: string[], newQueue: string[]): QueueChange[] { + function keyed(queue: string[]) { + const seen = new Map(); + return queue.map((name, index) => { + const offset = seen.get(name) ?? 0; + seen.set(name, offset + 1); + return { name, offset, position: index + 1, key: `${offset}:${name}` }; + }); + } + const oldEntries = keyed(oldQueue); + const newEntries = keyed(newQueue); + const oldKeys = new Set(oldEntries.map(e => e.key)); + const newKeys = new Set(newEntries.map(e => e.key)); + const keptOld = oldEntries.filter(e => newKeys.has(e.key)); + const keptNew = newEntries.filter(e => oldKeys.has(e.key)); + + // lcs[i][j] is the length of the longest common subsequence of keptOld[i..] and keptNew[j..] + const lcs = Array.from({ length: keptOld.length + 1 }, () => new Array(keptNew.length + 1).fill(0)); + for (let i = keptOld.length - 1; i >= 0; i--) { + for (let j = keptNew.length - 1; j >= 0; j--) { + lcs[i][j] = keptOld[i].key === keptNew[j].key + ? lcs[i + 1][j + 1] + 1 + : Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + } + const inPlace = new Set(); + for (let i = 0, j = 0; i < keptOld.length && j < keptNew.length;) { + if (keptOld[i].key === keptNew[j].key) { + inPlace.add(keptOld[i].key); + i += 1; + j += 1; + } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { + i += 1; + } else { + j += 1; + } + } + + const change = (type: QueueChange["type"], e: { name: string, offset: number, position: number }) => ( + new QueueChange(type, e.name, e.offset, e.position) + ); + return [ + ...oldEntries.filter(e => !newKeys.has(e.key)).map(e => change("removed", e)), + ...newEntries.filter(e => !oldKeys.has(e.key)).map(e => change("added", e)), + ...keptNew.filter(e => !inPlace.has(e.key)).map(e => change("moved", e)), + ]; +} + export default async function(context: ControllerPluginContext) { const { controller, logger, plugin } = context; const technologies = await loadTechnologies(controller.config, logger); @@ -112,9 +166,10 @@ export default async function(context: ControllerPluginContext) { if (newQueue.length === queue.length && newQueue.every((name, i) => name === queue[i])) { return; } + const changes = queueChanges(queue, newQueue); queue = newQueue; queueDirty = true; - controller.sendTo("allInstances", new QueueEvent(queue, player, instanceName(src))); + controller.sendTo("allInstances", new QueueEvent(queue, player, instanceName(src), changes)); } function broadcastProgress() { diff --git a/plugins/research_sync/instance.ts b/plugins/research_sync/instance.ts index 464e91ea..95406481 100644 --- a/plugins/research_sync/instance.ts +++ b/plugins/research_sync/instance.ts @@ -8,6 +8,7 @@ import { SyncTechnologiesRequest, QueueUpdateEvent, QueueEvent, + QueueChange, QueueSyncRequest, } from "./messages.js"; @@ -22,6 +23,7 @@ type IpcFinished = { level: number, }; type IpcQueue = { queue: unknown, player?: string }; +type ApplyQueue = { queue: string[], instance?: string, player?: string, changes?: QueueChange[] }; export default async function(context: InstancePluginContext) { const { instance, host, logger, plugin } = context; @@ -49,7 +51,7 @@ export default async function(context: InstancePluginContext) { return Array.isArray(v) ? v : []; } - async function applyQueue(data: { queue: string[], instance?: string, player?: string }) { + async function applyQueue(data: ApplyQueue) { await sendOrderedRcon(`/sc research_sync.apply_queue("${lib.escapeString(JSON.stringify(data))}")`, true); } @@ -177,13 +179,16 @@ 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 }; + let data: ApplyQueue = { queue: event.queue }; if (event.instance !== null) { data.instance = event.instance; } if (event.player !== null) { data.player = event.player; } + if (event.changes?.length) { + data.changes = event.changes; + } queueEventsApplied += 1; await applyQueue(data); }); diff --git a/plugins/research_sync/messages.ts b/plugins/research_sync/messages.ts index 58d5a38d..31882b8f 100644 --- a/plugins/research_sync/messages.ts +++ b/plugins/research_sync/messages.ts @@ -160,6 +160,29 @@ export class QueueUpdateEvent { } } +export class QueueChange { + constructor( + public type: "added" | "removed" | "moved", + public name: string, + /** Entries for the same technology before this one, the level above the first queued level */ + public offset: number, + /** 1-based position in the new queue, or in the old queue for removed */ + public position: number, + ) { + } + + static jsonSchema = Type.Object({ + "type": Type.Union([Type.Literal("added"), Type.Literal("removed"), Type.Literal("moved")]), + "name": Type.String(), + "offset": Type.Integer(), + "position": Type.Integer(), + }); + + static fromJSON(json: Static): QueueChange { + return new this(json.type, json.name, json.offset, json.position); + } +} + export class QueueEvent { declare ["constructor"]: typeof QueueEvent; static type = "event" as const; @@ -171,6 +194,8 @@ export class QueueEvent { public queue: string[], public player: string | null, public instance: string | null, + /** What changed, null for a silent update or an older controller */ + public changes: QueueChange[] | null = null, ) { } @@ -178,10 +203,16 @@ export class QueueEvent { "queue": Type.Array(Type.String()), "player": Type.Union([Type.String(), Type.Null()]), "instance": Type.Union([Type.String(), Type.Null()]), + "changes": Type.Optional(Type.Union([Type.Array(QueueChange.jsonSchema), Type.Null()])), }); static fromJSON(json: Static): QueueEvent { - return new this(json.queue, json.player, json.instance); + return new this( + json.queue, + json.player, + json.instance, + json.changes ? json.changes.map(c => QueueChange.fromJSON(c)) : null, + ); } } diff --git a/plugins/research_sync/module/sync.lua b/plugins/research_sync/module/sync.lua index 3b675c43..f6087464 100644 --- a/plugins/research_sync/module/sync.lua +++ b/plugins/research_sync/module/sync.lua @@ -177,17 +177,43 @@ local function write_queue(force, names) end end ---- @param names string[] ---- @return string -local function format_queue(names) - if #names == 0 then - return "(empty)" +--- localised_name of a levelled technology includes its current level, so build the name with the given level +--- @param tech LuaTechnology +--- @param level number +--- @return LocalisedString +local function technology_label(tech, level) + if tech.name:find("-%d+$") then + return {"", {"technology-name." .. tech.name:gsub("-%d+$", "")}, " ", level} end - local tags = {} - for _, name in ipairs(names) do - table.insert(tags, "[technology=" .. name .. "]") + return tech.localised_name +end + +--- @class ResearchSyncQueueChange +--- @field type "added"|"removed"|"moved" +--- @field name string +--- @field offset integer +--- @field position integer + +--- @param changes ResearchSyncQueueChange[] +--- @return LocalisedString[] +local function format_changes(changes) + local force = game.forces["player"] + local parts = {} + for i, change in ipairs(changes) do + local tech = force.technologies[change.name] + local label + if tech then + label = technology_label(tech, tech.level + change.offset) + else + label = change.name + end + local part = {"", i > 1 and ", " or "", change.type, " ", label} + if change.type == "moved" then + table.insert(part, " to position " .. change.position) + end + table.insert(parts, part) end - return table.concat(tags, " ") + return parts end --- @param player LuaPlayer @@ -392,11 +418,7 @@ function research_sync.research_technology(name, level) 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.print {"", "Researched ", technology_label(tech, level)} game.play_sound { path = "utility/research_completed" } end script_data.ignore_research_finished = false @@ -424,19 +446,28 @@ function research_sync.queue_state() })) end ---- @param data string JSON {queue: string[], player?: string, instance?: string} +--- @param data string JSON {queue: string[], player?: string, instance?: string, changes?: ResearchSyncQueueChange[]} 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.instance and msg.changes then + -- "Bob added X on 0_1", or "Research queue on 0_1: added X" + local text = {"", msg.player and msg.player .. " " or "Research queue on " .. msg.instance .. ": "} + for _, part in ipairs(format_changes(msg.changes)) do + table.insert(text, part) + end if msg.player then - game.print(msg.player .. " changed the research queue on " .. msg.instance .. ": " .. list) + table.insert(text, " on " .. msg.instance) + end + game.print(text) + elseif msg.instance then + if msg.player then + game.print(msg.player .. " changed the research queue on " .. msg.instance) else - game.print("The research queue was changed on " .. msg.instance .. ": " .. list) + game.print("The research queue was changed on " .. msg.instance) end end diff --git a/plugins/research_sync/test/controller.js b/plugins/research_sync/test/controller.js index 69584a15..fadf395a 100644 --- a/plugins/research_sync/test/controller.js +++ b/plugins/research_sync/test/controller.js @@ -4,10 +4,10 @@ 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 controllerEntrypoint, { queueChanges } from "../dist/node/controller.js"; import { plugin as info } from "../dist/node/index.js"; import { - ContributionEvent, FinishedEvent, QueueEvent, QueueSyncRequest, QueueUpdateEvent, + ContributionEvent, FinishedEvent, QueueChange, QueueEvent, QueueSyncRequest, QueueUpdateEvent, SyncTechnologiesRequest, TechnologySync, } from "../dist/node/messages.js"; @@ -58,6 +58,24 @@ describe("research_sync controller", function() { assert.equal(sent[0][1].instance, "rs-1"); assert.deepEqual(await sync(), ["a", "b"]); }); + it("should send what changed with an update", async function() { + await update(["a", "b"], "Bob"); + await update(["b", "a", "c"], "Bob"); + assert.deepEqual(queueEvents().map(([, m]) => m.changes), [ + [new QueueChange("added", "a", 0, 1), new QueueChange("added", "b", 0, 2)], + [new QueueChange("added", "c", 0, 3), new QueueChange("moved", "a", 0, 2)], + ]); + }); + it("should send what changed when seeding from a sync request", async function() { + await sync(["x"], "Bob"); + assert.deepEqual(queueEvents()[0][1].changes, [new QueueChange("added", "x", 0, 1)]); + }); + it("should send no changes when dropping a finished technology", async function() { + await update(["x", "y"]); + sent.length = 0; + await mock.getHandler(controller, FinishedEvent)(new FinishedEvent("x", 1), src); + assert.equal(queueEvents()[0][1].changes, null); + }); it("should not broadcast an unchanged update", async function() { await update(["a", "b"]); await update(["a", "b"]); @@ -213,6 +231,41 @@ describe("research_sync controller", function() { }); }); + describe("queueChanges()", function() { + const added = (name, offset, position) => new QueueChange("added", name, offset, position); + const removed = (name, offset, position) => new QueueChange("removed", name, offset, position); + const moved = (name, offset, position) => new QueueChange("moved", name, offset, position); + + it("should return nothing for equal queues", function() { + assert.deepEqual(queueChanges(["a", "b"], ["a", "b"]), []); + assert.deepEqual(queueChanges([], []), []); + }); + it("should list an entry added anywhere", function() { + assert.deepEqual(queueChanges(["a", "b"], ["a", "x", "b"]), [added("x", 0, 2)]); + assert.deepEqual(queueChanges([], ["a"]), [added("a", 0, 1)]); + }); + it("should list an entry removed with its old position", function() { + assert.deepEqual(queueChanges(["a", "x", "b"], ["a", "b"]), [removed("x", 0, 2)]); + assert.deepEqual(queueChanges(["a"], []), [removed("a", 0, 1)]); + }); + it("should list a moved entry with its new position", function() { + assert.deepEqual(queueChanges(["a", "b", "c", "d"], ["d", "a", "b", "c"]), [moved("d", 0, 1)]); + assert.deepEqual(queueChanges(["a", "b", "c", "d"], ["b", "c", "a", "d"]), [moved("a", 0, 3)]); + }); + it("should give repeated levels increasing offsets", function() { + assert.deepEqual(queueChanges(["m", "m"], ["m", "m", "m"]), [added("m", 2, 3)]); + assert.deepEqual(queueChanges([], ["m", "a", "m"]), [added("m", 0, 1), added("a", 0, 2), added("m", 1, 3)]); + }); + it("should remove the highest levels when a repeated technology shrinks", function() { + assert.deepEqual(queueChanges(["m", "m", "m"], ["m"]), [removed("m", 1, 2), removed("m", 2, 3)]); + }); + it("should list removed, then added, then moved", function() { + assert.deepEqual(queueChanges(["a", "b", "c"], ["c", "x", "a"]), [ + removed("b", 0, 2), added("x", 0, 2), moved("a", 0, 3), + ]); + }); + }); + it("should persist the queue across loads", async function() { await update(["a", "b"]); await controller.hooks.save.invoke(); diff --git a/plugins/research_sync/test/instance.js b/plugins/research_sync/test/instance.js index 64138a78..95d6f4c7 100644 --- a/plugins/research_sync/test/instance.js +++ b/plugins/research_sync/test/instance.js @@ -5,8 +5,8 @@ import * as mock from "../../../test/mock.js"; import loadInstancePlugin from "../dist/node/instance.js"; import { plugin as info } from "../dist/node/index.js"; import { - ContributionEvent, FinishedEvent, QueueEvent, QueueSyncRequest, QueueUpdateEvent, SyncTechnologiesRequest, - TechnologySync, + ContributionEvent, FinishedEvent, QueueChange, QueueEvent, QueueSyncRequest, QueueUpdateEvent, + SyncTechnologiesRequest, TechnologySync, } from "../dist/node/messages.js"; describe("research_sync instance", function() { @@ -75,6 +75,11 @@ describe("research_sync instance", function() { await handler()(new QueueEvent(["a"], "Bob", "rs-2")); assert.equal(lastCommand(), applyCommand({ queue: ["a"], instance: "rs-2", player: "Bob" })); }); + it("should pass on the changes", async function() { + const changes = [new QueueChange("added", "a", 0, 1)]; + await handler()(new QueueEvent(["a"], "Bob", "rs-2", changes)); + assert.equal(lastCommand(), applyCommand({ queue: ["a"], instance: "rs-2", player: "Bob", changes })); + }); 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" }));