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 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-10-17 19:12:46 +00:00
1 parent 2529683c2b
commit 5634651de1
5 files changed
+48 -5

No files matched your search

+2
View File
@@ -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.
+8 -1
View File
@@ -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;
+4 -1
View File
@@ -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,
+2 -1
View File
@@ -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
+32 -2
View File
@@ -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 }));
});
});