From a959ff25a464598b4cd16c7b2ffdcb2a7622988b Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sat, 14 Nov 2026 14:57:30 +1100 Subject: [PATCH] Reject an upload for a player who is back, and take an empty reply as stored A player rejoining before the controller's reply was skipped by reject_upload, and the join then kept the copy the controller refused. The rejection now counts while the join still waits for its acquire reply, which the controller sends after it. A controller from before the upload response replies with nothing. That failed validation on a newer host, so its uploads were never confirmed. It is taken as stored, as before. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01JyNnVzRW4jPWaoWTxCyhM7 --- plugins/player_sync/messages.ts | 7 ++++--- plugins/player_sync/module/player_sync.lua | 7 ++++++- plugins/player_sync/test/plugin.js | 8 ++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/plugins/player_sync/messages.ts b/plugins/player_sync/messages.ts index 506b46df..ea6059ee 100644 --- a/plugins/player_sync/messages.ts +++ b/plugins/player_sync/messages.ts @@ -269,12 +269,13 @@ export class UploadResponse { ) { } - static jsonSchema = Type.Object({ - "stored": Type.Boolean(), + // No type: a controller from before this response replies with nothing, which counts as stored + static jsonSchema = Type.Unsafe<{ stored: boolean } | undefined>({ + properties: { "stored": { type: "boolean" } }, }); static fromJSON(json: Static) { - return new this(json.stored); + return new this(json?.stored ?? true); } } diff --git a/plugins/player_sync/module/player_sync.lua b/plugins/player_sync/module/player_sync.lua index 8c27fd02..472f7958 100644 --- a/plugins/player_sync/module/player_sync.lua +++ b/plugins/player_sync/module/player_sync.lua @@ -735,10 +735,15 @@ end -- too, and then downloads what this save sent. function player_sync.reject_upload(player_name, generation) local player = game.get_player(player_name) - if not player or player.connected then + if not player then return end local script_data = get_script_data() + -- A player who is back already still waits for the acquire reply, which the controller sent after this one, and + -- that join then downloads. Past that reply the join has decided and this is not about the copy here any more. + if player.connected and not script_data.players_waiting_for_acquire[player_name] then + return + end local player_record = script_data.players[player.name] if not player_record or player_record.generation ~= generation then return diff --git a/plugins/player_sync/test/plugin.js b/plugins/player_sync/test/plugin.js index 2bc67904..7284404d 100644 --- a/plugins/player_sync/test/plugin.js +++ b/plugins/player_sync/test/plugin.js @@ -60,6 +60,14 @@ describe("player_sync plugin", function() { assert(!validate({ instanceId: 1, playerName: "player", playerData: data }), `accepted ${deleted}`); } }); + it("should take the empty reply of an older controller as a stored upload", function() { + const validate = lib.compile(msg.UploadResponse.jsonSchema); + assert(validate(undefined)); + assert.equal(msg.UploadResponse.fromJSON(undefined).stored, true); + assert(validate({ stored: false })); + assert.equal(msg.UploadResponse.fromJSON({ stored: false }).stored, false); + assert(!validate({ stored: "no" })); + }); describe("SetPlayerDataRequest schema", function() { const validate = lib.compile(msg.SetPlayerDataRequest.jsonSchema); // Shaped like module/serialize.lua output