Merge pull request #1188 from bbassie/fix/research-queue-message

Print only what changed in the research queue chat line
This commit is contained in:
Bastiaan authored and GitHub committed 2026-11-07 14:51:42 +00:00
1 parent 7cf9cf8d3e
commit bd9c4a5fea
7 files changed
+209 -28

No files matched your search

+2 -1
View File
@@ -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.
+56 -1
View File
@@ -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<string, number>();
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<number>(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<string>();
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() {
+7 -2
View File
@@ -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);
});
+32 -1
View File
@@ -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<typeof QueueChange.jsonSchema>): 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<typeof QueueEvent.jsonSchema>): 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,
);
}
}
+50 -19
View File
@@ -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
+55 -2
View File
@@ -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();
+7 -2
View File
@@ -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" }));