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. 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. 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. 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. 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. 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, TechnologyProgress,
QueueUpdateEvent, QueueUpdateEvent,
QueueEvent, QueueEvent,
QueueChange,
QueueSyncRequest, QueueSyncRequest,
} from "./messages.js"; } from "./messages.js";
@@ -74,6 +75,59 @@ async function saveQueue(controllerConfig: lib.ControllerConfig, queue: string[]
await lib.atomicOverwriteFile(filePath, JSON.stringify(queue, null, "\t")); 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) { export default async function(context: ControllerPluginContext) {
const { controller, logger, plugin } = context; const { controller, logger, plugin } = context;
const technologies = await loadTechnologies(controller.config, logger); 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])) { if (newQueue.length === queue.length && newQueue.every((name, i) => name === queue[i])) {
return; return;
} }
const changes = queueChanges(queue, newQueue);
queue = newQueue; queue = newQueue;
queueDirty = true; queueDirty = true;
controller.sendTo("allInstances", new QueueEvent(queue, player, instanceName(src))); controller.sendTo("allInstances", new QueueEvent(queue, player, instanceName(src), changes));
} }
function broadcastProgress() { function broadcastProgress() {
+7 -2
View File
@@ -8,6 +8,7 @@ import {
SyncTechnologiesRequest, SyncTechnologiesRequest,
QueueUpdateEvent, QueueUpdateEvent,
QueueEvent, QueueEvent,
QueueChange,
QueueSyncRequest, QueueSyncRequest,
} from "./messages.js"; } from "./messages.js";
@@ -22,6 +23,7 @@ type IpcFinished = {
level: number, level: number,
}; };
type IpcQueue = { queue: unknown, player?: string }; type IpcQueue = { queue: unknown, player?: string };
type ApplyQueue = { queue: string[], instance?: string, player?: string, changes?: QueueChange[] };
export default async function(context: InstancePluginContext) { export default async function(context: InstancePluginContext) {
const { instance, host, logger, plugin } = context; const { instance, host, logger, plugin } = context;
@@ -49,7 +51,7 @@ export default async function(context: InstancePluginContext) {
return Array.isArray(v) ? v : []; 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); 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)) { if (!syncStarted || !["starting", "running"].includes(instance.status)) {
return; return;
} }
let data: { queue: string[], instance?: string, player?: string } = { queue: event.queue }; let data: ApplyQueue = { queue: event.queue };
if (event.instance !== null) { if (event.instance !== null) {
data.instance = event.instance; data.instance = event.instance;
} }
if (event.player !== null) { if (event.player !== null) {
data.player = event.player; data.player = event.player;
} }
if (event.changes?.length) {
data.changes = event.changes;
}
queueEventsApplied += 1; queueEventsApplied += 1;
await applyQueue(data); 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 { export class QueueEvent {
declare ["constructor"]: typeof QueueEvent; declare ["constructor"]: typeof QueueEvent;
static type = "event" as const; static type = "event" as const;
@@ -171,6 +194,8 @@ export class QueueEvent {
public queue: string[], public queue: string[],
public player: string | null, public player: string | null,
public instance: 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()), "queue": Type.Array(Type.String()),
"player": Type.Union([Type.String(), Type.Null()]), "player": Type.Union([Type.String(), Type.Null()]),
"instance": 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 { 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
end end
--- @param names string[] --- localised_name of a levelled technology includes its current level, so build the name with the given level
--- @return string --- @param tech LuaTechnology
local function format_queue(names) --- @param level number
if #names == 0 then --- @return LocalisedString
return "(empty)" local function technology_label(tech, level)
if tech.name:find("-%d+$") then
return {"", {"technology-name." .. tech.name:gsub("-%d+$", "")}, " ", level}
end end
local tags = {} return tech.localised_name
for _, name in ipairs(names) do end
table.insert(tags, "[technology=" .. name .. "]")
--- @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 end
return table.concat(tags, " ") return parts
end end
--- @param player LuaPlayer --- @param player LuaPlayer
@@ -392,11 +418,7 @@ function research_sync.research_technology(name, level)
tech.level = level tech.level = level
tech.researched = true tech.researched = true
if tech.name:find("-%d+$") then game.print {"", "Researched ", technology_label(tech, level)}
game.print {"", "Researched ", {"technology-name." .. tech.name:gsub("-%d+$", "")}, " ", level}
else
game.print {"", "Researched ", {"technology-name." .. tech.name}}
end
game.play_sound { path = "utility/research_completed" } game.play_sound { path = "utility/research_completed" }
end end
script_data.ignore_research_finished = false script_data.ignore_research_finished = false
@@ -424,19 +446,28 @@ function research_sync.queue_state()
})) }))
end 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) function research_sync.apply_queue(data)
if not research_queue_api then if not research_queue_api then
return return
end end
local msg = compat.json_to_table(data) --[[@as table]] local msg = compat.json_to_table(data) --[[@as table]]
local queue = msg.queue or {} local queue = msg.queue or {}
if msg.instance then if msg.instance and msg.changes then
local list = format_queue(queue) -- "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 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 else
game.print("The research queue was changed on " .. msg.instance .. ": " .. list) game.print("The research queue was changed on " .. msg.instance)
end end
end end
+55 -2
View File
@@ -4,10 +4,10 @@ import path from "node:path";
import * as lib from "@clusterio/lib"; import * as lib from "@clusterio/lib";
import * as mock from "../../../test/mock.js"; 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 { plugin as info } from "../dist/node/index.js";
import { import {
ContributionEvent, FinishedEvent, QueueEvent, QueueSyncRequest, QueueUpdateEvent, ContributionEvent, FinishedEvent, QueueChange, QueueEvent, QueueSyncRequest, QueueUpdateEvent,
SyncTechnologiesRequest, TechnologySync, SyncTechnologiesRequest, TechnologySync,
} from "../dist/node/messages.js"; } from "../dist/node/messages.js";
@@ -58,6 +58,24 @@ describe("research_sync controller", function() {
assert.equal(sent[0][1].instance, "rs-1"); assert.equal(sent[0][1].instance, "rs-1");
assert.deepEqual(await sync(), ["a", "b"]); 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() { it("should not broadcast an unchanged update", async function() {
await update(["a", "b"]); await update(["a", "b"]);
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() { it("should persist the queue across loads", async function() {
await update(["a", "b"]); await update(["a", "b"]);
await controller.hooks.save.invoke(); 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 loadInstancePlugin from "../dist/node/instance.js";
import { plugin as info } from "../dist/node/index.js"; import { plugin as info } from "../dist/node/index.js";
import { import {
ContributionEvent, FinishedEvent, QueueEvent, QueueSyncRequest, QueueUpdateEvent, SyncTechnologiesRequest, ContributionEvent, FinishedEvent, QueueChange, QueueEvent, QueueSyncRequest, QueueUpdateEvent,
TechnologySync, SyncTechnologiesRequest, TechnologySync,
} from "../dist/node/messages.js"; } from "../dist/node/messages.js";
describe("research_sync instance", function() { describe("research_sync instance", function() {
@@ -75,6 +75,11 @@ describe("research_sync instance", function() {
await handler()(new QueueEvent(["a"], "Bob", "rs-2")); await handler()(new QueueEvent(["a"], "Bob", "rs-2"));
assert.equal(lastCommand(), applyCommand({ queue: ["a"], instance: "rs-2", player: "Bob" })); 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() { it("should leave out a null player", async function() {
await handler()(new QueueEvent(["a"], null, "rs-2")); await handler()(new QueueEvent(["a"], null, "rs-2"));
assert.equal(lastCommand(), applyCommand({ queue: ["a"], instance: "rs-2" })); assert.equal(lastCommand(), applyCommand({ queue: ["a"], instance: "rs-2" }));