CLOSES #1582, CLOSES #1583

This commit is contained in:
bryanthaboi
2026-08-20 12:43:04 -04:00
parent 1ac5b867bb
commit 1b659dab01
5 changed files with 155 additions and 18 deletions
+25 -11
View File
@@ -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
+1 -1
View File
@@ -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)
+3
View File
@@ -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
+25 -2
View File
@@ -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))
+101 -4
View File
@@ -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",