mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
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:
1 parent
7cf9cf8d3e
commit
bd9c4a5fea
7 files changed
+209
-28
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
Reference in new issue
Block a user