From 45fedf28a09c3772d6ebe466eec45c46471bb876 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:55:05 +0000 Subject: [PATCH] Strip downloads by the components the scenario started them with The instance read its config when the controller answered, so a sync_ field changed during the download could strip a component the scenario still expected, and the scenario treated it as empty. Co-Authored-By: Claude Opus 5.5 --- plugins/player_sync/components.ts | 10 +++++----- plugins/player_sync/instance.ts | 6 ++++-- plugins/player_sync/module/player_sync.lua | 1 + plugins/player_sync/test/components.js | 14 +++++++------- plugins/player_sync/test/instance.js | 13 +++++++++---- 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/plugins/player_sync/components.ts b/plugins/player_sync/components.ts index 0c6469f5..1e1c1b03 100644 --- a/plugins/player_sync/components.ts +++ b/plugins/player_sync/components.ts @@ -26,15 +26,15 @@ export function enabledComponents(config: lib.InstanceConfig) { } /** - * Components an upload was serialized with, all of them when not given. + * Components the scenario sent with an upload or download, all of them when not given. * * Lua may encode an empty table as an array, which reads as no components. */ -export function uploadedComponents(uploaded: Record | unknown[] | undefined) { - if (uploaded === undefined) { +export function scenarioComponents(sent: Record | unknown[] | undefined) { + if (sent === undefined) { return componentSet(() => true); } - return componentSet(component => !Array.isArray(uploaded) && uploaded[component] === true); + return componentSet(component => !Array.isArray(sent) && sent[component] === true); } /** Remove the fields of components which are not synced. */ @@ -58,7 +58,7 @@ export function stripPlayerData(playerData: IpcPlayerData, enabled: Set; - const enabled = uploadedComponents(uploadComponents); + const enabled = scenarioComponents(uploadComponents); function keep(keys: readonly string[]) { for (const key of keys) { const value = (stored as Record | undefined)?.[key]; diff --git a/plugins/player_sync/instance.ts b/plugins/player_sync/instance.ts index e1f9a810..008cb573 100644 --- a/plugins/player_sync/instance.ts +++ b/plugins/player_sync/instance.ts @@ -5,7 +5,7 @@ import type { InstancePluginContext } from "@clusterio/host"; import { AcquireRequest, AcquireResponse, ReleaseRequest, UploadRequest, DownloadRequest, DownloadResponse, IpcPlayerData, } from "./messages.js"; -import { enabledComponents, stripPlayerData } from "./components.js"; +import { enabledComponents, scenarioComponents, stripPlayerData } from "./components.js"; type IpcPlayerName = { player_name: string @@ -14,6 +14,8 @@ type IpcPlayerName = { type IpcDownloadRequest = { player_name: string, recipe_notifications?: string, + // Components the download was started with, all when missing + components?: Record | unknown[], } type IpcAcquireResponse = { @@ -199,7 +201,7 @@ export default async function(context: InstancePluginContext) { return; } - stripPlayerData(response.playerData, enabledComponents(instance.config)); + stripPlayerData(response.playerData, scenarioComponents(request.components)); await applyRecipeNotificationDelta(response.playerData, request.recipe_notifications, logger); const chunkSize = instance.config.get("player_sync.rcon_chunk_size"); diff --git a/plugins/player_sync/module/player_sync.lua b/plugins/player_sync/module/player_sync.lua index 124edc34..fed3783d 100644 --- a/plugins/player_sync/module/player_sync.lua +++ b/plugins/player_sync/module/player_sync.lua @@ -493,6 +493,7 @@ function player_sync.initiate_inventory_download(player, player_record, generati clusterio_api.send_json("player_sync_download", { player_name = player.name, recipe_notifications = recipe_notifications, + components = record.components, }) -- If this is a synced player turn them into a spectator while the diff --git a/plugins/player_sync/test/components.js b/plugins/player_sync/test/components.js index 53d96626..5f4f298a 100644 --- a/plugins/player_sync/test/components.js +++ b/plugins/player_sync/test/components.js @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { - components, enabledComponents, mergePlayerData, stripPlayerData, uploadedComponents, + components, enabledComponents, mergePlayerData, stripPlayerData, scenarioComponents, } from "../dist/node/components.js"; const allComponents = Object.keys(components); @@ -79,19 +79,19 @@ describe("player_sync components", function() { }); }); - describe("uploadedComponents()", function() { - it("should sync everything when the upload has no components", function() { - assert.deepEqual([...uploadedComponents(undefined)], allComponents); + describe("scenarioComponents()", function() { + it("should sync everything when no components are sent", function() { + assert.deepEqual([...scenarioComponents(undefined)], allComponents); }); it("should sync the components marked true", function() { - const enabled = uploadedComponents({ force: true, quick_bar: true, settings: false }); + const enabled = scenarioComponents({ force: true, quick_bar: true, settings: false }); assert.deepEqual([...enabled], ["force", "quick_bar"]); }); it("should sync nothing for an empty table encoded as an array", function() { - assert.equal(uploadedComponents([]).size, 0); + assert.equal(scenarioComponents([]).size, 0); }); it("should sync the controller without the inventories", function() { - assert.deepEqual([...uploadedComponents({ controller: true })], ["controller"]); + assert.deepEqual([...scenarioComponents({ controller: true })], ["controller"]); }); }); diff --git a/plugins/player_sync/test/instance.js b/plugins/player_sync/test/instance.js index e869666d..72e95886 100644 --- a/plugins/player_sync/test/instance.js +++ b/plugins/player_sync/test/instance.js @@ -129,14 +129,19 @@ describe("player_sync", function() { const expected = { generation: 1, name: "test", recipe_notifications: await encode({ add: ["a"] }) }; assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]); }); - it("should leave out components the instance does not sync", async function() { - instance.mockConfigEntries.set("player_sync.sync_force", false); - instance.mockConfigEntries.set("player_sync.sync_recipe_notifications", false); + it("should leave out components the download was not started with", async function() { respondWith({ generation: 1, name: "test", force: "enemy", tag: "tag", recipe_notifications: "x" }); - await download({ player_name: "test" }); + await download({ player_name: "test", components: { appearance: true } }); const expected = { generation: 1, name: "test", tag: "tag" }; assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]); }); + it("should ignore config changes made after the download was started", async function() { + instance.mockConfigEntries.set("player_sync.sync_force", false); + respondWith({ generation: 1, name: "test", force: "enemy", tag: "tag" }); + await download({ player_name: "test", components: { force: true } }); + const expected = { generation: 1, name: "test", force: "enemy" }; + assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]); + }); }); describe("set_components", function() {