diff --git a/docs/new-features.md b/docs/new-features.md index 773e498e..3362f6eb 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -17,6 +17,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen * **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders * **Pokédex diploma and printer image exports** +* **Deleting a synced save deletes it everywhere**, a slot removed in the launcher is dropped from the server and from every other linked device on its next sync * **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed * **Custom carts**, a named mod set saved from the mods tab and picked from a game's page, with its own shell colour, label art, save slots and export file * **Install required mods**, one press on a cart that will not start, fetching every pinned mod at the pinned version and refusing any archive whose hash is not the one the cart recorded diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index ab210735..79d95d64 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2670,6 +2670,12 @@ function RomImporter:_deleteSlot(scope, id) if self.workState == "working" then return end local SaveData = require("src.core.SaveData") local cart = cartOfScope(scope) + local eng = self:_syncEngine() + local syncKey + if eng and eng.saves and type(eng.saves.keyForSlot) == "function" then + local okKey, key = pcall(eng.saves.keyForSlot, scope, id) + syncKey = okKey and key or nil + end local ok, err if cart then ok, err = SaveData.deleteCartSlot(cart, id) @@ -2677,6 +2683,7 @@ function RomImporter:_deleteSlot(scope, id) ok, err = SaveData.deleteSlot(scope, id) end if ok then + if eng and syncKey then pcall(eng.noteSaveDeleted, eng, syncKey) end self:_refreshSlots(scope) self.saveNotice[scope] = { ok = true, text = "Deleted " .. tostring(id) .. "." } else @@ -4260,6 +4267,12 @@ function RomImporter:_syncNoteDownload(row) local ok, found = pcall(self._cartById, self, version, cart) what = (ok and type(found) == "table" and found.title) or cart end + if row.removed then + self.saveNotice[scope] = { ok = true, + text = ("Removed %s, deleted%s."):format(tostring(row.slot), + row.device and (" on " .. tostring(row.device)) or " on another device") } + return + end self.saveNotice[scope] = { ok = true, text = what and ("Downloaded a %s save%s into %s."):format( diff --git a/src/sync/SyncClient.lua b/src/sync/SyncClient.lua index 09d5eb4f..7ca31056 100644 --- a/src/sync/SyncClient.lua +++ b/src/sync/SyncClient.lua @@ -173,6 +173,11 @@ function SyncClient:getSave(version, id) { params = { version = version, id = id } }) end +function SyncClient:deleteSave(version, id, rev) + return self:send("DELETE", "/sync/save", nil, + { params = { version = version, id = id, rev = rev } }) +end + function SyncClient:putMods(manifest) return self:send("PUT", "/sync/mods", { manifest = manifest }) end diff --git a/src/sync/SyncEngine.lua b/src/sync/SyncEngine.lua index c18e841e..a1679f9f 100644 --- a/src/sync/SyncEngine.lua +++ b/src/sync/SyncEngine.lua @@ -117,8 +117,58 @@ local function slotForPlaythrough(options, version, playthroughId) return bestKey, bestSlot end +local function slotKey(scopeKey, slotId) + local SaveData = saveApi() + local cart = cartOfScope(scopeKey) + local source + if cart then + source = SaveData.readCartSlotSource(cart, slotId) + else + source = SaveData.readSlotSource(scopeKey, slotId) + end + local save = source and SaveData.decode(source) + if type(save) ~= "table" then return nil end + local id + local scope = { key = scopeKey, cart = cart, version = scopeKey } + if cart then + id = SaveData.cartSlotPlaythroughId(cart, slotId, save) + local okOpts, options = pcall(SaveData.loadOptions) + local reg = (okOpts and type(options) == "table" + and type(options.carts) == "table") and options.carts[cart] or nil + scope.version = type(reg) == "table" and reg.base or nil + else + id = SaveData.slotPlaythroughId(scopeKey, slotId, save) + end + if not id then return nil end + return SyncState.key(wireVersion(save, scope), id) +end + function SyncEngine.defaultSaves() return { + keyForSlot = slotKey, + + remove = function(version, playthroughId) + local SaveData = saveApi() + local options = SaveData.loadOptions() + local scopeKey, slotId = slotForPlaythrough(options, version, playthroughId) + if not slotId then return false, "no such save" end + local cart = cartOfScope(scopeKey) + local ok, err + if cart then + ok, err = SaveData.deleteCartSlot(cart, slotId) + else + ok, err = SaveData.deleteSlot(scopeKey, slotId) + end + if not ok then return nil, err or "could not delete the save" end + options = SaveData.loadOptions() + if type(options.playthroughIds) == "table" + and type(options.playthroughIds[scopeKey]) == "table" then + options.playthroughIds[scopeKey][slotId] = nil + SaveData.saveOptions(options) + end + return slotId, cart + end, + list = function() local SaveData = saveApi() local out = {} @@ -402,6 +452,22 @@ function SyncEngine:noteSaveWritten() self.uploadAt = self.clock + SyncEngine.UPLOAD_DEBOUNCE end +function SyncEngine:noteSaveDeleted(key) + if type(key) ~= "string" or key == "" then return false end + local rev = SyncState.rev(self.state, key) + SyncState.forget(self.state, key) + if rev == nil or not self:linked() then + self:_persist() + return false + end + SyncState.markDeleted(self.state, key, rev, self.now()) + self:_persist() + if self.state.enabled then + self.uploadAt = self.clock + SyncEngine.UPLOAD_DEBOUNCE + end + return true +end + function SyncEngine:update(dt) self.clock = self.clock + (tonumber(dt) or 0) if self.pending then @@ -650,12 +716,14 @@ function SyncEngine:_planFrom(remoteState) self.devices = list end local remote = type(remoteState.saves) == "table" and remoteState.saves or {} + local tombs = type(remoteState.deleted) == "table" and remoteState.deleted or {} local locals = self.saves.list() or {} local seen = {} for _, entry in ipairs(locals) do local key = SyncState.key(entry.version, entry.playthroughId) if key then seen[key] = true + SyncState.clearDeleted(self.state, key) local row = remote[key] local knownRev = SyncState.rev(self.state, key) local stamp = unixSeconds(SyncState.stamp(self.state, key)) @@ -668,7 +736,14 @@ function SyncEngine:_planFrom(remoteState) end local remoteRev = row and tonumber(row.rev) local remoteChanged = row ~= nil and remoteRev ~= knownRev - if not row then + local tomb = not row and type(tombs[key]) == "table" and tombs[key] or nil + local buried = tomb ~= nil and knownRev ~= nil + and (tonumber(tomb.rev) or 0) >= knownRev + and not (localChanged and liveStamp + and liveStamp > (unixSeconds(tomb.deletedAt) or 0)) + if buried then + self:_removeLocal(entry, key, tomb) + elseif not row then self:_queueUpload(entry, key, false) elseif localChanged and remoteChanged then if SyncEngine.sameProgress(entry.meta, SyncEngine.metaOf(row)) then @@ -683,6 +758,17 @@ function SyncEngine:_planFrom(remoteState) end end end + for key, pending in pairs(self.state.pendingDeletes or {}) do + local row = remote[key] + if not seen[key] then + if row and (tonumber(row.rev) or 0) > (tonumber(pending.rev) or 0) then + SyncState.clearDeleted(self.state, key) + else + seen[key] = true + self:_queueDelete(key, pending.rev) + end + end + end for key, row in pairs(remote) do if not seen[key] and key ~= self.protectedKey then local version, id = SyncState.splitKey(key) @@ -694,6 +780,42 @@ function SyncEngine:_planFrom(remoteState) if #self.queue == 0 then self:_finish() end end +function SyncEngine:_removeLocal(entry, key, tomb) + if key == self.protectedKey then return end + local slotId, cartId + if type(self.saves.remove) == "function" then + slotId, cartId = self.saves.remove(entry.version, entry.playthroughId) + end + SyncState.forget(self.state, key) + if slotId then + self.lastDownloads = self.lastDownloads or {} + self.lastDownloads[#self.lastDownloads + 1] = { + version = entry.version, + cart = cartId or entry.cart, + slot = slotId, + removed = true, + device = type(tomb.device) == "string" and tomb.device ~= "" + and tomb.device or nil, + } + self.changed = true + end +end + +function SyncEngine:_queueDelete(key, rev) + self:_enqueue(function(eng) + eng.phase = "uploading" + eng.status = "Removing deleted saves..." + local version, id = SyncState.splitKey(key) + local handle, err = eng.client:deleteSave(version, id, rev) + eng:_request(handle, err, function(e) + SyncState.clearDeleted(e.state, key) + SyncState.forget(e.state, key) + e:_persist() + if not e:busy() then e:_finish() end + end) + end) +end + function SyncEngine:_addConflict(entry, key, row) local remoteMeta = SyncEngine.displayMeta(SyncEngine.metaOf(row)) self.conflicts[#self.conflicts + 1] = { diff --git a/src/sync/SyncState.lua b/src/sync/SyncState.lua index b0c930c0..01cb9114 100644 --- a/src/sync/SyncState.lua +++ b/src/sync/SyncState.lua @@ -11,6 +11,7 @@ function SyncState.defaults() revs = {}, stamps = {}, pendingConflicts = {}, + pendingDeletes = {}, } end @@ -58,6 +59,14 @@ function SyncState.sanitize(raw) if type(key) == "string" and n then out.stamps[key] = n end end end + if type(raw.pendingDeletes) == "table" then + for key, row in pairs(raw.pendingDeletes) do + if type(key) == "string" and type(row) == "table" then + out.pendingDeletes[key] = { rev = num(row.rev) or 0, + deletedAt = num(row.deletedAt) or 0 } + end + end + end if type(raw.pendingConflicts) == "table" then for _, row in ipairs(raw.pendingConflicts) do if type(row) == "table" and str(row.key) then @@ -130,6 +139,17 @@ function SyncState.setRev(state, key, rev, savedAt) state.stamps[key] = num(savedAt) end +function SyncState.markDeleted(state, key, rev, deletedAt) + if type(state) ~= "table" or type(key) ~= "string" then return end + state.pendingDeletes = state.pendingDeletes or {} + state.pendingDeletes[key] = { rev = num(rev) or 0, deletedAt = num(deletedAt) or 0 } +end + +function SyncState.clearDeleted(state, key) + if type(state) ~= "table" or type(state.pendingDeletes) ~= "table" then return end + state.pendingDeletes[key] = nil +end + function SyncState.forget(state, key) if type(state) ~= "table" or type(key) ~= "string" then return end if type(state.revs) == "table" then state.revs[key] = nil end diff --git a/tests/engine/sync_engine_test.lua b/tests/engine/sync_engine_test.lua index b5ff576d..d821d44b 100644 --- a/tests/engine/sync_engine_test.lua +++ b/tests/engine/sync_engine_test.lua @@ -53,10 +53,21 @@ local function saveEntry(version, id, savedAt, sessionStart, slot) end local function fakeSaves(entries) - local writes = {} + local writes, removed = {}, {} return { writes = writes, + removed = removed, list = function() return entries end, + remove = function(version, id) + removed[#removed + 1] = { version = version, playthroughId = id } + for i, e in ipairs(entries) do + if e.version == version and e.playthroughId == id then + table.remove(entries, i) + break + end + end + return "slot1", nil + end, write = function(version, id, blob, mode) writes[#writes + 1] = { version = version, playthroughId = id, blob = blob, mode = mode } @@ -926,4 +937,99 @@ do T.eq(eng.phase, "idle", "so the launcher settles") end +-- ---- save deletion (#tombstones): a slot deleted here is deleted on the +-- server, and a server tombstone removes the local copy on every other device. +do + local state = linkedState() + SyncState.setRev(state, "red/abc", 3, 500) + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{"red/abc":{"rev":3}}}' }, + ["DELETE /sync/save"] = { code = 200, body = '{"ok":true,"key":"red/abc","rev":3}' }, + }, {}, state) + T.eq(eng:noteSaveDeleted("red/abc"), true, "deleting a synced save is noted") + T.eq(eng.state.pendingDeletes["red/abc"].rev, 3, "with the rev this device knew") + T.eq(SyncState.rev(eng.state, "red/abc"), nil, "and the rev is forgotten") + T.check(eng.uploadAt ~= nil, "a sync is scheduled") + eng:syncNow() + pump(eng) + local del = transport.sent[2] + T.eq(del.method, "DELETE", "the server is told to delete the save") + T.check(del.url:find("version=red", 1, true) and del.url:find("id=abc", 1, true), + "by version and playthrough id") + T.eq(eng.state.pendingDeletes["red/abc"], nil, "the pending delete is cleared") + T.eq(#transport.sent, 2, "and nothing is downloaded back") + T.eq(eng.phase, "idle", "the engine settles") +end + +do + local eng = engine({}, {}, SyncState.defaults()) + T.eq(eng:noteSaveDeleted("red/never"), false, + "deleting a save the server never had is a local matter") + T.eq(next(eng.state.pendingDeletes), nil, "and leaves no pending delete") +end + +do + local state = linkedState() + SyncState.setRev(state, "red/abc", 3, 500) + local eng, transport, saves = engine({ + ["GET /sync/state"] = { code = 200, body = + '{"saves":{},"deleted":{"red/abc":{"rev":3,"deletedAt":900,"device":"phone"}}}' }, + }, { saveEntry("red", "abc", 500, 400) }, state) + eng:syncNow() + pump(eng) + T.eq(#saves.removed, 1, "a tombstone for an unchanged local save removes it") + T.eq(saves.removed[1].playthroughId, "abc", "the right one") + T.eq(SyncState.rev(eng.state, "red/abc"), nil, "and forgets its rev") + T.eq(eng.changed, true, "the launcher is told the slots changed") + T.eq(eng.lastDownloads[1].removed, true, "with a removal row") + T.eq(eng.lastDownloads[1].device, "phone", "naming the device that deleted it") + T.eq(#transport.sent, 1, "nothing is uploaded or downloaded") + T.eq(eng.phase, "idle", "the engine settles") +end + +do + local state = linkedState() + SyncState.setRev(state, "red/abc", 3, 500) + local eng, transport, saves = engine({ + ["GET /sync/state"] = { code = 200, body = + '{"saves":{},"deleted":{"red/abc":{"rev":3,"deletedAt":900}}}' }, + ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":4}' }, + }, { saveEntry("red", "abc", 1000, 950) }, state) + eng:syncNow() + pump(eng) + T.eq(#saves.removed, 0, "a save played after the delete is kept") + T.eq(transport.sent[2] and transport.sent[2].method, "PUT", "and uploaded, reviving it") + T.eq(SyncState.rev(eng.state, "red/abc"), 4, "at the server's next rev") +end + +do + local eng, transport, saves = engine({ + ["GET /sync/state"] = { code = 200, body = + '{"saves":{},"deleted":{"red/abc":{"rev":3,"deletedAt":900}}}' }, + ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":4}' }, + }, { saveEntry("red", "abc", 500, 400) }, linkedState()) + eng:syncNow() + pump(eng) + T.eq(#saves.removed, 0, "a tombstone never removes a save this device did not sync") + T.eq(transport.sent[2] and transport.sent[2].method, "PUT", "it is uploaded as usual") +end + +do + local state = linkedState() + SyncState.markDeleted(state, "red/abc", 3, 900) + local eng, transport, saves = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{"red/abc":{"rev":5}}}' }, + ["GET /sync/save"] = { code = 200, body = + '{"key":"red/abc","rev":5,"meta":{"savedAt":1200},"blob":"return {}"}' }, + }, {}, state) + eng:syncNow() + pump(eng) + T.eq(eng.state.pendingDeletes["red/abc"], nil, + "a pending delete yields to a save another device wrote afterwards") + T.eq(#saves.writes, 1, "which is downloaded") + for _, req in ipairs(transport.sent) do + T.check(req.method ~= "DELETE", "and never deleted") + end +end + T.finish("sync_engine") diff --git a/tests/engine/sync_save_identity_test.lua b/tests/engine/sync_save_identity_test.lua index 9448f5da..782a55a6 100644 --- a/tests/engine/sync_save_identity_test.lua +++ b/tests/engine/sync_save_identity_test.lua @@ -249,4 +249,29 @@ love.filesystem = realFS SaveData.resetSlotState() GameVersion.set("red") +do + fresh() + local provider = SyncEngine.defaultSaves() + local slotId = SaveData.createSlot("red") + SaveData.setActiveSlot("red", slotId) + local save = SaveData.newGame() + save.player.name = "ASH" + save.meta = SaveData.buildMeta({}, save.meta, os.time() - 60) + SaveData.save(save) + local id = provider.list()[1].playthroughId + T.eq(provider.keyForSlot("red", slotId), "red/" .. id, + "keyForSlot answers the sync key the launcher deletes under") + T.eq(provider.keyForSlot("red", "slot9"), nil, "and nil for an empty slot") + + local gone, cart = provider.remove("red", id) + T.eq(gone, slotId, "remove deletes the slot that carries the playthrough") + T.eq(cart, nil, "a plain version slot has no cart") + T.eq(#SaveData.listSlots("red"), 0, "so the slot is gone") + local ids = SaveData.loadOptions().playthroughIds + T.eq(((ids or {}).red or {})[slotId], nil, + "and its identity mapping with it") + T.eq(#provider.list(), 0, "nothing is left to sync") + T.eq(provider.remove("red", id), false, "removing it again finds nothing") +end + T.finish("sync_save_identity") diff --git a/tests/engine/sync_state_test.lua b/tests/engine/sync_state_test.lua index 518b3b9a..5fcbc65b 100644 --- a/tests/engine/sync_state_test.lua +++ b/tests/engine/sync_state_test.lua @@ -121,4 +121,16 @@ end love.filesystem = realFS +do + local state = SyncState.defaults() + SyncState.markDeleted(state, "red/abc", 3, 900) + local back = SyncState.sanitize(state) + T.eq(back.pendingDeletes["red/abc"].rev, 3, "a pending delete survives sanitize") + T.eq(back.pendingDeletes["red/abc"].deletedAt, 900, "with its time") + SyncState.clearDeleted(back, "red/abc") + T.eq(next(back.pendingDeletes), nil, "and can be cleared") + T.eq(next(SyncState.sanitize({ pendingDeletes = { [7] = {}, x = "no" } }) + .pendingDeletes), nil, "malformed pending deletes are dropped") +end + T.finish("sync_state")