diff --git a/src/core/Game.lua b/src/core/Game.lua index b2c8e5bf..b4bb8d99 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -982,7 +982,11 @@ end -- parked the player until every direction was re-pressed (#799). function Game:focus(f) Input:reset() - if f then Input:reconcile() end + if f then + Input:reconcile() + local eng = self:syncEngine() + if eng then pcall(eng.noteResumed, eng) end + end TouchControls:reset() self:cancelPointers() end @@ -1002,6 +1006,8 @@ function Game:onResume() Input:reconcile() TouchControls:reset() self:cancelPointers() + local eng = self:syncEngine() + if eng then pcall(eng.noteResumed, eng) end -- Chip music may survive NX suspend as a duplicate stream; stop it and let -- the active screen re-cue on the next frame (hardware audio check: T19). -- Desktop/mobile window-visible flips must not kill overworld music. @@ -1209,18 +1215,26 @@ end function Game:syncEngine() if self._syncOff then return nil end - if self._syncEngineRef then return self._syncEngineRef end - local ok, SyncEngine = pcall(require, "src.sync.SyncEngine") - if not ok or type(SyncEngine) ~= "table" then - self._syncOff = true - return nil - end - local eng = SyncEngine.shared() + local eng = self._syncEngineRef if not eng then - self._syncOff = true - return nil + local ok, SyncEngine = pcall(require, "src.sync.SyncEngine") + if not ok or type(SyncEngine) ~= "table" then + self._syncOff = true + return nil + end + eng = SyncEngine.shared() + if not eng then + self._syncOff = true + return nil + end + self._syncEngineRef = eng + end + if type(eng.protectPlaythrough) == "function" then + local meta = self.save and self.save.meta + eng:protectPlaythrough( + (self.save and self.save.version) or require("src.core.GameVersion").get(), + type(meta) == "table" and meta.playthroughId or nil) end - self._syncEngineRef = eng return eng end diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 221226b4..2d5d9418 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -4554,7 +4554,7 @@ local function buildSyncHome(imp, m, eng) local linked = eng:linked() local codes = eng.codes local body = linked - and Strings("This device is linked. Saves and the mod list sync when the launcher opens and a few seconds after each save.") + and Strings("This device is linked. Saves sync when the launcher opens, a few seconds after each save, and every few minutes while the app is running.") or Strings(SYNC_HINT) local innerW = w - 2 * pad local hintH = Kit.wrapHeight("small", body, innerW, 5) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 9ce176ff..d5b4aecf 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1537,6 +1537,9 @@ function RomImporter:focus(f) self._modPress = nil return end + if type(self._sync) == "table" then + pcall(self._sync.noteResumed, self._sync) + end if not (f and self.android and self.workState ~= "working") then return end -- SAF create-document finished: GameActivity wrote export_done.flag. if love.filesystem.getInfo("export_done.flag", "file") then diff --git a/src/sync/SyncEngine.lua b/src/sync/SyncEngine.lua index 49c3298f..985ed415 100644 --- a/src/sync/SyncEngine.lua +++ b/src/sync/SyncEngine.lua @@ -7,6 +7,7 @@ SyncEngine.__index = SyncEngine SyncEngine.UPLOAD_DEBOUNCE = 5 SyncEngine.AUTO_INTERVAL = 300 +SyncEngine.RESUME_MIN_GAP = 60 SyncEngine.MAX_STEPS_PER_UPDATE = 8 local IDLE_STATUS = "Ready" @@ -133,6 +134,7 @@ function SyncEngine.new(opts) eng.modPlan = nil eng.shareCode = nil eng.clock = 0 + eng.autoAt = SyncEngine.AUTO_INTERVAL eng.queue = {} eng.pending = nil eng.uploadAt = nil @@ -258,6 +260,11 @@ function SyncEngine:update(dt) self.uploadAt = nil if self.state.enabled and self:linked() then self:syncNow() end end + if self.clock >= self.autoAt and not self:busy() + and (self.phase == "idle" or self.phase == "error") + and self.state.enabled and self:linked() then + self:syncNow() + end local steps = 0 while not self.pending and #self.queue > 0 and steps < SyncEngine.MAX_STEPS_PER_UPDATE do @@ -296,6 +303,7 @@ function SyncEngine:createAccount(label) eng.phase = "idle" eng.status = "Sync account created" eng:_persist() + eng:syncNow() end) end @@ -385,9 +393,24 @@ function SyncEngine:setEnabled(enabled) return self.state.enabled end +function SyncEngine:protectPlaythrough(version, playthroughId) + self.protectedKey = SyncState.key(version, playthroughId) +end + +function SyncEngine:noteResumed() + if not (self.state.enabled and self:linked()) then return end + if self:busy() or self.phase == "conflict" then return end + if self.now() - (tonumber(self.state.lastSyncAt) or 0) + < SyncEngine.RESUME_MIN_GAP then + return + end + self:syncNow() +end + function SyncEngine:syncNow() if not self:linked() then return false, "this device is not linked" end if self.pending then return false, "sync is busy" end + self.autoAt = self.clock + SyncEngine.AUTO_INTERVAL self.queue = {} self.conflicts = {} self.state.pendingConflicts = {} @@ -437,13 +460,13 @@ function SyncEngine:_planFrom(remoteState) self:_addConflict(entry, key, row) elseif localChanged then self:_queueUpload(entry, key, false) - elseif remoteChanged then + elseif remoteChanged and key ~= self.protectedKey then self:_queueDownload(key, entry.version, entry.playthroughId, "replace") end end end for key, row in pairs(remote) do - if not seen[key] then + if not seen[key] and key ~= self.protectedKey then local version, id = SyncState.splitKey(key) if version and id then self:_queueDownload(key, version, id, "replace", tonumber(row.rev)) diff --git a/tests/engine/sync_engine_test.lua b/tests/engine/sync_engine_test.lua index f6ee37fd..ddd3bc78 100644 --- a/tests/engine/sync_engine_test.lua +++ b/tests/engine/sync_engine_test.lua @@ -94,20 +94,26 @@ do local eng, transport = engine({ ["POST /sync/create"] = { code = 200, body = '{"account":"aa11","code1":"11112222","code2":"33334444","deviceToken":"tok"}' }, - }, {}, SyncState.defaults()) + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":1}' }, + }, { saveEntry("red", "abc", 500, 400) }, SyncState.defaults()) T.eq(eng:linked(), false, "a fresh engine is not linked") T.eq(eng.status, "Not set up", "and says so") eng:createAccount("laptop") - pump(eng, 3) + pump(eng) T.eq(eng:linked(), true, "creating an account links this device") T.eq(eng.state.account, "aa11", "and stores the account id") T.eq(eng.codes.code1, "1111-2222", "the first code is shown grouped") T.eq(eng.codes.code2, "3333-4444", "and so is the second") T.eq(eng.state.code1, nil, "codes never enter the persisted state") + T.eq(transport.sent[2].url, "http://sync.test/sync/state", + "creating the account starts a sync straight away") + T.eq(transport.sent[3].method, "PUT", + "so the saves that existed before setup are uploaded") + T.eq(SyncState.rev(eng.state, "red/abc"), 1, "and the served rev is remembered") T.eq(eng.phase, "idle", "and the engine settles") - T.eq(#transport.sent, 1, "one request was made") end do @@ -293,14 +299,105 @@ do T.eq(#transport.sent, 0, "with sync off an in-game save uploads nothing") end +do + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + }, {}) + eng:update(SyncEngine.AUTO_INTERVAL - 1) + T.eq(#transport.sent, 0, "an idle linked engine does not poll early") + eng:update(1) + T.eq(#transport.sent, 1, "after the auto interval it checks the server") + pump(eng) + T.eq(eng.phase, "idle", "and settles") + eng:update(SyncEngine.AUTO_INTERVAL - 10) + T.eq(#transport.sent, 1, "the next poll waits a whole interval again") + eng:update(10) + T.eq(#transport.sent, 2, "then fires") +end + +do + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + }, {}, SyncState.defaults()) + eng:update(SyncEngine.AUTO_INTERVAL * 2) + T.eq(#transport.sent, 0, "an unlinked engine never polls on its own") +end + +do + local calls = 0 + local eng = engine({ + ["GET /sync/state"] = function() + calls = calls + 1 + if calls == 1 then return { code = 500, body = '{"error":"down"}' } end + return { code = 200, body = '{"saves":{}}' } + end, + }, {}) + eng:syncNow() + pump(eng, 3) + T.eq(eng.phase, "error", "the first sync fails") + eng:update(SyncEngine.AUTO_INTERVAL) + pump(eng, 3) + T.eq(eng.phase, "idle", "the auto interval retries and recovers") +end + +do + local eng, transport = conflictEngine() + eng:syncNow() + pump(eng) + local sent = #transport.sent + eng:update(SyncEngine.AUTO_INTERVAL * 2) + T.eq(#transport.sent, sent, "a waiting conflict is never auto-synced over") + T.eq(eng.phase, "conflict", "the player still decides") +end + +do + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + }, {}) + eng:noteResumed() + T.eq(#transport.sent, 1, "regaining the app checks the server") + pump(eng) + eng:noteResumed() + T.eq(#transport.sent, 1, "but not twice in quick succession") +end + +do + local eng, transport = engine({}, {}, SyncState.defaults()) + eng:noteResumed() + T.eq(#transport.sent, 0, "an unlinked engine ignores a resume") +end + +do + local state = linkedState() + SyncState.setRev(state, "red/abc", 2, 500) + local eng, transport, saves = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"red/abc":{"rev":4,"meta":{"savedAt":900}},' .. + '"gold/xyz":{"rev":1,"meta":{"savedAt":900}}}}' }, + ["GET /sync/save"] = { code = 200, + body = '{"rev":1,"meta":{"savedAt":900},"blob":"return { player = {} }"}' }, + }, { saveEntry("red", "abc", 500, 400) }, state) + eng:protectPlaythrough("red", "abc") + eng:syncNow() + pump(eng) + T.eq(#saves.writes, 1, "only the save that is not being played downloads") + T.eq(saves.writes[1].version, "gold", "the other playthrough still arrives") + T.eq(SyncState.rev(eng.state, "red/abc"), 2, + "the live playthrough keeps its rev so the launcher can fetch it later") + T.eq(eng.phase, "idle", "and the sync settles") + eng:protectPlaythrough("red", nil) + T.eq(eng.protectedKey, nil, "no live playthrough means no protection") +end + do local eng = engine({ ["POST /sync/create"] = { code = 200, body = '{"account":"aa11","code1":"11112222","code2":"33334444",' .. '"deviceToken":"tok","device":"0a1b2c3d"}' }, + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, }, {}, SyncState.defaults()) eng:createAccount("laptop") - pump(eng, 3) + pump(eng) T.eq(eng.state.deviceId, "0a1b2c3d", "creating an account records the id the server gave this device") T.eq(SyncState.sanitize(eng.state).deviceId, "0a1b2c3d",