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";