From 5634651de1d4f361ceaffa1be24f0bb0e3f91e54 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:12:46 +0000 Subject: [PATCH] Keep accepting uploads from instances without epochs A host running the plugin from before epochs never sends one, so after an import or delete every upload it made was discarded while the instance believed it succeeded. The Lua now always sends its epoch, and an upload without one is compared by generation against the stored data the way it was before, taking over the stored epoch. Those hosts can still overwrite an import with a copy they failed to upload, which the README now says. Co-Authored-By: Claude Opus 5.5 --- plugins/player_sync/README.md | 2 ++ plugins/player_sync/controller.ts | 9 +++++- plugins/player_sync/messages.ts | 5 +++- plugins/player_sync/module/player_sync.lua | 3 +- plugins/player_sync/test/controller.js | 34 ++++++++++++++++++++-- 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/plugins/player_sync/README.md b/plugins/player_sync/README.md index 718211a1..850f1724 100644 --- a/plugins/player_sync/README.md +++ b/plugins/player_sync/README.md @@ -50,6 +50,8 @@ An instance holding a copy from an earlier epoch throws it away the next time th After an import the instance downloads the new data. After a delete the next join turns whatever the player has on that instance into the new synced inventory. Deleted players stay in `player_sync.json` as an entry with `deleted: true` so their epoch survives a controller restart. +Hosts running a version of this plugin from before epochs keep comparing generations only, so they can still overwrite an import with a copy they failed to upload. +Update the plugin on every host to get the full guarantee. Both are refused while an instance holds the lock on the player, because the instance uploads its own copy when the player leaves and that would undo the change. Have the player leave first, or release the lock if the instance is stuck holding it. diff --git a/plugins/player_sync/controller.ts b/plugins/player_sync/controller.ts index b98570d4..2938ffa9 100644 --- a/plugins/player_sync/controller.ts +++ b/plugins/player_sync/controller.ts @@ -199,9 +199,16 @@ export default async function(context: ControllerPluginContext) { acquiredPlayers.delete(playerName); let stored = storedVersion(playerName); + if (playerData.epoch === undefined) { + // Instances from before epochs were added never send one, they keep the rules they had before + stored.generation = playerDatastore.get(playerName)?.generation ?? 0; + if (stored.epoch) { + playerData.epoch = stored.epoch; + } + } if (store && (playerData.epoch ?? 0) !== stored.epoch) { logger.warn( - `${instanceName} uploaded ${playerName} from epoch ${playerData.epoch ?? 0} while the stored ` + + `${instanceName} uploaded ${playerName} from epoch ${playerData.epoch} while the stored ` + `epoch is ${stored.epoch}, discarding the outdated copy` ); store = false; diff --git a/plugins/player_sync/messages.ts b/plugins/player_sync/messages.ts index 3557ac62..478bdafd 100644 --- a/plugins/player_sync/messages.ts +++ b/plugins/player_sync/messages.ts @@ -4,7 +4,10 @@ import { StringEnum, jsonArray } from "@clusterio/lib"; // .\module\serialize.lua:serialize.serialize_player() export type IpcPlayerData = { generation: number, - /** Bumped when the stored data is imported or deleted, invalidating copies held by instances */ + /** + * Bumped when the stored data is imported or deleted, invalidating copies held by instances. + * Instances from before epochs were added leave it out of uploads. + */ epoch?: number, name: string, controller?: string, diff --git a/plugins/player_sync/module/player_sync.lua b/plugins/player_sync/module/player_sync.lua index 1a01600d..8519127d 100644 --- a/plugins/player_sync/module/player_sync.lua +++ b/plugins/player_sync/module/player_sync.lua @@ -99,7 +99,8 @@ function player_sync.serialize_player(player, player_record) local failed_deserialization = get_script_data().failed_deserialization[player.name] or {} local serialized_player = serialize.serialize_player(player, failed_deserialization, player_record.components) serialized_player.generation = player_record.generation - serialized_player.epoch = player_record.epoch + -- Always sent so the controller can tell this instance supports epochs + serialized_player.epoch = player_record.epoch or 0 return serialized_player end diff --git a/plugins/player_sync/test/controller.js b/plugins/player_sync/test/controller.js index 114ed49f..6328dbac 100644 --- a/plugins/player_sync/test/controller.js +++ b/plugins/player_sync/test/controller.js @@ -93,7 +93,7 @@ describe("player_sync controller", function() { describe("player data tools", function() { function playerData(name, generation, extra = {}) { - return { generation, name, controller: "character", force: "player", ...extra }; + return { generation, epoch: 0, name, controller: "character", force: "player", ...extra }; } let controller; @@ -251,7 +251,37 @@ describe("player_sync controller", function() { }); it("should reject when the player is acquired", async function() { await assert.rejects(del("stored"), acquiredError); - assert((await saved()).has("stored")); + assert.deepEqual((await saved()).get("stored"), playerData("stored", 3)); + }); + }); + + describe("uploads without an epoch", function() { + // What instances running a version from before epochs send + function legacyData(generation) { + const data = playerData("stored", generation); + delete data.epoch; + return data; + } + beforeEach(async function() { + await handler(msg.ForceReleaseRequest)(new msg.ForceReleaseRequest("stored")); + }); + it("should store them by generation", async function() { + await upload(1, legacyData(3)); + await upload(1, legacyData(4)); + assert.deepEqual((await saved()).get("stored"), legacyData(4)); + }); + it("should keep accepting them after an import", async function() { + const request = new msg.SetPlayerDataRequest("stored", playerData("stored", 0)); + await handler(msg.SetPlayerDataRequest)(request); + await upload(1, legacyData(4)); + assert.equal((await saved()).get("stored").epoch, 1); + await upload(1, legacyData(5)); + assert.deepEqual((await saved()).get("stored"), playerData("stored", 5, { epoch: 1 })); + }); + it("should accept any generation after a delete", async function() { + await handler(msg.DeletePlayerDataRequest)(new msg.DeletePlayerDataRequest("stored")); + await upload(1, legacyData(1)); + assert.deepEqual((await saved()).get("stored"), playerData("stored", 1, { epoch: 1 })); }); });