mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-02 23:55:22 -04:00
1102 lines
36 KiB
Lua
1102 lines
36 KiB
Lua
local SyncClient = require("src.sync.SyncClient")
|
|
local SyncState = require("src.sync.SyncState")
|
|
local SyncMods = require("src.sync.SyncMods")
|
|
|
|
local SyncEngine = {}
|
|
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"
|
|
local UNLINKED_STATUS = "Not set up"
|
|
|
|
local function unixSeconds(v)
|
|
local n = tonumber(v)
|
|
if not n or n ~= n or n <= 0 or n == math.huge or n == -math.huge then
|
|
return nil
|
|
end
|
|
return n
|
|
end
|
|
|
|
SyncEngine.unixSeconds = unixSeconds
|
|
|
|
local function saveApi()
|
|
return require("src.core.SaveData")
|
|
end
|
|
|
|
local function gameVersions()
|
|
return require("src.core.GameVersion").ORDER
|
|
end
|
|
|
|
local CART_PREFIX = "cart_"
|
|
|
|
local function cartOfScope(key)
|
|
if type(key) ~= "string" then return nil end
|
|
if key:sub(1, #CART_PREFIX) ~= CART_PREFIX then return nil end
|
|
local id = key:sub(#CART_PREFIX + 1)
|
|
if id == "" then return nil end
|
|
return id
|
|
end
|
|
|
|
local function safeCartId(id)
|
|
if type(id) ~= "string" or id == "" or #id > 64 then return nil end
|
|
if not id:match("^[%w_%-]+$") then return nil end
|
|
return id
|
|
end
|
|
|
|
local function blobCart(save)
|
|
local meta = type(save) == "table" and save.meta or nil
|
|
return safeCartId(type(meta) == "table" and meta.cartId or nil)
|
|
end
|
|
|
|
local function cartInstalled(options, cartId)
|
|
local reg = type(options) == "table" and options.carts or nil
|
|
if type(reg) == "table" and type(reg[cartId]) == "table" then return true end
|
|
local ok, CartStore = pcall(require, "src.carts.CartStore")
|
|
if not ok or type(CartStore) ~= "table" then return false end
|
|
local fs = love and love.filesystem
|
|
if type(fs) ~= "table" or type(fs.getInfo) ~= "function" then return false end
|
|
local okInfo, info = pcall(fs.getInfo, CartStore.fileFor(cartId))
|
|
return okInfo and info ~= nil
|
|
end
|
|
|
|
local function syncScopes()
|
|
local SaveData = saveApi()
|
|
local out = {}
|
|
for _, version in ipairs(gameVersions()) do
|
|
out[#out + 1] = { key = version, version = version }
|
|
end
|
|
local ok, ids = pcall(SaveData.cartsWithSlots)
|
|
if not ok or type(ids) ~= "table" then return out end
|
|
local okOpts, options = pcall(SaveData.loadOptions)
|
|
local reg = (okOpts and type(options) == "table"
|
|
and type(options.carts) == "table") and options.carts or {}
|
|
for _, id in ipairs(ids) do
|
|
local row = type(reg[id]) == "table" and reg[id] or nil
|
|
out[#out + 1] = { key = CART_PREFIX .. id, cart = id,
|
|
version = row and row.base or nil }
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function wireVersion(save, scope)
|
|
local GameVersion = require("src.core.GameVersion")
|
|
if not scope.cart and type(scope.version) == "string"
|
|
and GameVersion.VERSIONS[scope.version] then
|
|
return scope.version
|
|
end
|
|
local v = type(save) == "table" and save.version or nil
|
|
if type(v) == "string" and GameVersion.VERSIONS[v] then return v end
|
|
if type(scope.version) == "string" and GameVersion.VERSIONS[scope.version] then
|
|
return scope.version
|
|
end
|
|
return GameVersion.get()
|
|
end
|
|
|
|
local function slotForPlaythrough(options, version, playthroughId)
|
|
local root = (type(options) == "table" and type(options.playthroughIds) == "table")
|
|
and options.playthroughIds or {}
|
|
local byVersion = type(root[version]) == "table" and root[version] or nil
|
|
for slotId, id in pairs(byVersion or {}) do
|
|
if id == playthroughId then return version, slotId end
|
|
end
|
|
local bestKey, bestSlot
|
|
for key, byScope in pairs(root) do
|
|
if key ~= version and cartOfScope(key) and type(byScope) == "table" then
|
|
for slotId, id in pairs(byScope) do
|
|
if id == playthroughId and slotId ~= "legacy"
|
|
and (bestKey == nil or key < bestKey) then
|
|
bestKey, bestSlot = key, slotId
|
|
end
|
|
end
|
|
end
|
|
end
|
|
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 = {}
|
|
for _, scope in ipairs(syncScopes()) do
|
|
local slots = scope.cart and SaveData.listCartSlots(scope.cart)
|
|
or SaveData.listSlots(scope.version)
|
|
for _, slot in ipairs(slots) do
|
|
if slot.exists then
|
|
local source
|
|
if scope.cart then
|
|
source = SaveData.readCartSlotSource(scope.cart, slot.id)
|
|
else
|
|
source = SaveData.readSlotSource(scope.version, slot.id)
|
|
end
|
|
local save = source and SaveData.decode(source)
|
|
if type(save) == "table" then
|
|
local meta = type(save.meta) == "table" and save.meta or {}
|
|
local id
|
|
if scope.cart then
|
|
id = SaveData.cartSlotPlaythroughId(scope.cart, slot.id, save)
|
|
else
|
|
id = SaveData.slotPlaythroughId(scope.version, slot.id, save)
|
|
end
|
|
if id then
|
|
local name, summary = SaveData.slotSummary(save)
|
|
out[#out + 1] = {
|
|
version = wireVersion(save, scope),
|
|
cart = scope.cart,
|
|
slot = slot.id,
|
|
playthroughId = id,
|
|
blob = source,
|
|
meta = {
|
|
savedAt = unixSeconds(meta.savedAt)
|
|
or unixSeconds(save.savedAt),
|
|
sessionStart = unixSeconds(meta.sessionStart),
|
|
playthroughId = id,
|
|
format = meta.format,
|
|
engine = meta.engine,
|
|
playTime = tonumber(save.playTime),
|
|
summary = {
|
|
name = name,
|
|
badges = summary and summary.badges,
|
|
timeText = summary and summary.timeText,
|
|
dexCount = summary and summary.dexCount,
|
|
},
|
|
},
|
|
}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return out
|
|
end,
|
|
|
|
write = function(version, playthroughId, blob, mode)
|
|
local SaveData = saveApi()
|
|
local save = SaveData.decode(blob)
|
|
if type(save) ~= "table" then return nil, "the downloaded save is unreadable" end
|
|
save.version = save.version or version
|
|
local options = SaveData.loadOptions()
|
|
local scopeKey, slotId, created
|
|
if mode == "new" then
|
|
save.meta = type(save.meta) == "table" and save.meta or {}
|
|
save.meta.playthroughId = SaveData.newPlaythroughId()
|
|
else
|
|
scopeKey, slotId = slotForPlaythrough(options, version, playthroughId)
|
|
end
|
|
if not slotId then
|
|
local cartId = blobCart(save)
|
|
if cartId then
|
|
if not cartInstalled(options, cartId) then
|
|
return false, ("install the \"%s\" cart to receive its save")
|
|
:format(cartId)
|
|
end
|
|
scopeKey = CART_PREFIX .. cartId
|
|
else
|
|
scopeKey = version
|
|
end
|
|
end
|
|
local cart = cartOfScope(scopeKey)
|
|
if not slotId then
|
|
if cart then
|
|
slotId = SaveData.createCartSlot(cart)
|
|
else
|
|
slotId = SaveData.createSlot(version)
|
|
end
|
|
if not slotId then return nil, "could not make a save slot" end
|
|
created = true
|
|
end
|
|
local ok, err
|
|
if cart then
|
|
ok, err = SaveData.writeCartSlot(cart, slotId, save)
|
|
else
|
|
ok, err = SaveData.writeSlot(version, slotId, save)
|
|
end
|
|
if not ok then return nil, err or "could not write the save" end
|
|
options = SaveData.loadOptions()
|
|
options.playthroughIds = options.playthroughIds or {}
|
|
options.playthroughIds[scopeKey] = options.playthroughIds[scopeKey] or {}
|
|
options.playthroughIds[scopeKey][slotId] =
|
|
save.meta and save.meta.playthroughId or playthroughId
|
|
SaveData.saveOptions(options)
|
|
return slotId, created == true, cart
|
|
end,
|
|
}
|
|
end
|
|
|
|
function SyncEngine.overlaps(a, b)
|
|
if type(a) ~= "table" or type(b) ~= "table" then return false end
|
|
local aStart, aEnd = unixSeconds(a.sessionStart), unixSeconds(a.savedAt)
|
|
local bStart, bEnd = unixSeconds(b.sessionStart), unixSeconds(b.savedAt)
|
|
if not (aStart and aEnd and bStart and bEnd) then return false end
|
|
return aStart <= bEnd and bStart <= aEnd
|
|
end
|
|
|
|
-- Inline, under .meta, or under .remoteMeta, depending on the endpoint.
|
|
function SyncEngine.metaOf(row)
|
|
if type(row) ~= "table" then return nil end
|
|
if type(row.meta) == "table" then return row.meta end
|
|
if type(row.remoteMeta) == "table" then return row.remoteMeta end
|
|
return row
|
|
end
|
|
|
|
-- Gen 2 stores playTime as a table, so meta.playTime is nil on a Gold save
|
|
-- and summary.timeText is the only field that survives.
|
|
local function playedMinutes(meta)
|
|
if type(meta) ~= "table" then return nil end
|
|
local summary = type(meta.summary) == "table" and meta.summary or nil
|
|
local text = summary and summary.timeText
|
|
if type(text) == "string" then
|
|
local hours, minutes = text:match("^(%d+):(%d%d)$")
|
|
if hours then return tonumber(hours) * 60 + tonumber(minutes) end
|
|
end
|
|
local seconds = tonumber(meta.playTime)
|
|
if seconds and seconds > 0 then return math.floor(seconds / 60) end
|
|
return nil
|
|
end
|
|
|
|
-- Same minute of playtime means the same point in the playthrough, so there
|
|
-- is no fork. Unknown playtime on either side is NOT a match.
|
|
function SyncEngine.samePlaytime(a, b)
|
|
local left, right = playedMinutes(a), playedMinutes(b)
|
|
return left ~= nil and left == right
|
|
end
|
|
|
|
function SyncEngine.sameProgress(a, b)
|
|
if SyncEngine.samePlaytime(a, b) then return true end
|
|
if type(a) ~= "table" or type(b) ~= "table" then return false end
|
|
local left = type(a.summary) == "table" and a.summary or nil
|
|
local right = type(b.summary) == "table" and b.summary or nil
|
|
if not (left and right) then return false end
|
|
if type(left.name) ~= "string" or left.name == "" then return false end
|
|
return left.name == right.name and left.badges == right.badges
|
|
and left.timeText == right.timeText and left.dexCount == right.dexCount
|
|
end
|
|
|
|
function SyncEngine.displayMeta(meta)
|
|
if type(meta) ~= "table" then return meta end
|
|
local out = {}
|
|
for k, v in pairs(meta) do out[k] = v end
|
|
out.savedAt = unixSeconds(meta.savedAt)
|
|
out.sessionStart = unixSeconds(meta.sessionStart)
|
|
return out
|
|
end
|
|
|
|
function SyncEngine.new(opts)
|
|
opts = opts or {}
|
|
local eng = setmetatable({}, SyncEngine)
|
|
eng.fs = opts.fs
|
|
eng.state = opts.state or SyncState.load(eng.fs)
|
|
eng.client = opts.client or SyncClient.new({
|
|
baseUrl = opts.baseUrl, transport = opts.transport })
|
|
eng.saves = opts.saves or SyncEngine.defaultSaves()
|
|
eng.modDeps = opts.modDeps
|
|
eng.now = opts.now or os.time
|
|
eng.persist = opts.persist ~= false
|
|
eng.phase = "idle"
|
|
eng.error = nil
|
|
eng.conflicts = {}
|
|
eng.codes = SyncEngine.formatCodes(eng.state)
|
|
eng.modPlan = nil
|
|
eng.shareCode = nil
|
|
eng.clock = 0
|
|
eng.autoAt = SyncEngine.AUTO_INTERVAL
|
|
eng.queue = {}
|
|
eng.pending = nil
|
|
eng.uploadAt = nil
|
|
eng.client:setAuth(eng.state.account, eng.state.deviceToken)
|
|
eng.status = eng:defaultStatus()
|
|
return eng
|
|
end
|
|
|
|
function SyncEngine.shared(opts)
|
|
if SyncEngine._shared == nil then
|
|
local ok, eng = pcall(SyncEngine.new, opts or {})
|
|
SyncEngine._shared = (ok and type(eng) == "table") and eng or false
|
|
end
|
|
return SyncEngine._shared or nil
|
|
end
|
|
|
|
function SyncEngine.forgetShared()
|
|
SyncEngine._shared = nil
|
|
end
|
|
|
|
function SyncEngine:defaultStatus()
|
|
if not SyncState.linked(self.state) then return UNLINKED_STATUS end
|
|
return IDLE_STATUS
|
|
end
|
|
|
|
function SyncEngine:linked()
|
|
return SyncState.linked(self.state)
|
|
end
|
|
|
|
function SyncEngine:busy()
|
|
return self.pending ~= nil or #self.queue > 0 or self.modApply ~= nil
|
|
end
|
|
|
|
function SyncEngine:_persist()
|
|
if not self.persist then return end
|
|
SyncState.save(self.state, self.fs)
|
|
end
|
|
|
|
function SyncEngine:_fail(message)
|
|
self.phase = "error"
|
|
self.error = tostring(message or "sync failed")
|
|
self.status = "Sync failed: " .. self.error
|
|
self.queue = {}
|
|
self.pending = nil
|
|
end
|
|
|
|
function SyncEngine:_finish()
|
|
if #self.conflicts > 0 then
|
|
self.phase = "conflict"
|
|
local overlap = false
|
|
for _, row in ipairs(self.conflicts) do
|
|
if row.overlap then overlap = true end
|
|
end
|
|
self.status = overlap
|
|
and "These saves were played at the same time."
|
|
or "This save also changed on another device."
|
|
return
|
|
end
|
|
self.phase = "idle"
|
|
self.error = nil
|
|
self.state.lastSyncAt = self.now()
|
|
self.status = self:defaultStatus()
|
|
if type(self.skipped) == "table" and #self.skipped > 0 then
|
|
self.status = self.skipped[1]
|
|
end
|
|
self:_persist()
|
|
end
|
|
|
|
function SyncEngine:_request(handle, err, onOk, onErr)
|
|
if not handle then
|
|
self:_fail(err or "could not start the request")
|
|
return false
|
|
end
|
|
self.pending = { handle = handle, onOk = onOk, onErr = onErr }
|
|
return true
|
|
end
|
|
|
|
function SyncEngine:_enqueue(fn)
|
|
self.queue[#self.queue + 1] = fn
|
|
end
|
|
|
|
function SyncEngine:cancel()
|
|
if self.pending then self.client:release(self.pending.handle) end
|
|
self.pending = nil
|
|
self.queue = {}
|
|
self.modApply = nil
|
|
self.uploadAt = nil
|
|
if self.phase ~= "conflict" then
|
|
self.phase = "idle"
|
|
self.status = self:defaultStatus()
|
|
end
|
|
end
|
|
|
|
function SyncEngine:noteSaveWritten()
|
|
if not (self.state.enabled and self:linked()) then return end
|
|
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
|
|
local res = self.client:poll(self.pending.handle)
|
|
if res.status == "pending" then return end
|
|
local job = self.pending
|
|
self.pending = nil
|
|
self.client:release(job.handle)
|
|
if res.status == "ok" then
|
|
local ok, err = pcall(job.onOk, self, res)
|
|
if not ok then self:_fail(err) end
|
|
else
|
|
local handled = false
|
|
if job.onErr then
|
|
local ok, result = pcall(job.onErr, self, res)
|
|
if not ok then self:_fail(result) return end
|
|
handled = result == true
|
|
end
|
|
if not handled then self:_fail(res.err) end
|
|
end
|
|
end
|
|
if self.pending then return end
|
|
if self.modApply then
|
|
self:_stepModApply()
|
|
return
|
|
end
|
|
if self.uploadAt and self.clock >= self.uploadAt and not self:busy() then
|
|
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
|
|
steps = steps + 1
|
|
local task = table.remove(self.queue, 1)
|
|
local ok, err = pcall(task, self)
|
|
if not ok then self:_fail(err) return end
|
|
if not self.pending and #self.queue == 0 and self.phase ~= "error" then
|
|
self:_finish()
|
|
end
|
|
end
|
|
end
|
|
|
|
function SyncEngine.formatCodes(state)
|
|
if type(state) ~= "table" then return nil end
|
|
local a = SyncClient.formatCode(state.code1)
|
|
local b = SyncClient.formatCode(state.code2)
|
|
if not a or not b then return nil end
|
|
return { code1 = a, code2 = b }
|
|
end
|
|
|
|
function SyncEngine:createAccount(label)
|
|
if self:busy() then return false, "sync is busy" end
|
|
self.phase = "checking"
|
|
self.status = "Creating a sync account..."
|
|
self.error = nil
|
|
local handle, err = self.client:create(label)
|
|
return self:_request(handle, err, function(eng, res)
|
|
local data = res.data or {}
|
|
if type(data.account) ~= "string" or type(data.deviceToken) ~= "string" then
|
|
eng:_fail("the server sent an unexpected reply")
|
|
return
|
|
end
|
|
eng.state.code1 = SyncClient.normalizeCode(data.code1)
|
|
eng.state.code2 = SyncClient.normalizeCode(data.code2)
|
|
eng.codes = SyncEngine.formatCodes(eng.state) or {
|
|
code1 = tostring(data.code1 or ""),
|
|
code2 = tostring(data.code2 or ""),
|
|
}
|
|
eng.state.account = data.account
|
|
eng.state.deviceToken = data.deviceToken
|
|
eng.state.deviceId = type(data.device) == "string" and data.device or nil
|
|
eng.state.deviceLabel = label
|
|
eng.state.enabled = true
|
|
eng.client:setAuth(data.account, data.deviceToken)
|
|
eng.phase = "idle"
|
|
eng.status = "Sync account created"
|
|
eng:_persist()
|
|
eng:syncNow()
|
|
end)
|
|
end
|
|
|
|
function SyncEngine:linkDevice(code1, code2, label)
|
|
if self:busy() then return false, "sync is busy" end
|
|
local a = SyncClient.normalizeCode(code1)
|
|
local b = SyncClient.normalizeCode(code2)
|
|
if not a or not b then
|
|
self:_fail("both codes are 8 digits")
|
|
return false, "both codes are 8 digits"
|
|
end
|
|
self.phase = "checking"
|
|
self.status = "Linking this device..."
|
|
self.error = nil
|
|
local handle, err = self.client:link(a, b, label)
|
|
return self:_request(handle, err, function(eng, res)
|
|
local data = res.data or {}
|
|
if type(data.account) ~= "string" or type(data.deviceToken) ~= "string" then
|
|
eng:_fail("the server sent an unexpected reply")
|
|
return
|
|
end
|
|
eng.state.account = data.account
|
|
eng.state.deviceToken = data.deviceToken
|
|
eng.state.deviceId = type(data.device) == "string" and data.device or nil
|
|
eng.state.deviceLabel = label
|
|
eng.state.enabled = true
|
|
eng.state.code1, eng.state.code2 = a, b
|
|
eng.codes = SyncEngine.formatCodes(eng.state)
|
|
eng.client:setAuth(data.account, data.deviceToken)
|
|
eng.status = "This device is linked"
|
|
eng:_persist()
|
|
eng:syncNow()
|
|
end)
|
|
end
|
|
|
|
function SyncEngine:_forgetLocal()
|
|
self.state = SyncState.defaults()
|
|
self.client:clearAuth()
|
|
self.codes = nil
|
|
self.conflicts = {}
|
|
self.devices = nil
|
|
self.phase = "idle"
|
|
self.status = UNLINKED_STATUS
|
|
self:_persist()
|
|
end
|
|
|
|
function SyncEngine:unlink()
|
|
if not self:linked() then
|
|
self:_forgetLocal()
|
|
return true
|
|
end
|
|
if self:busy() then self:cancel() end
|
|
self.phase = "checking"
|
|
self.status = "Unlinking this device..."
|
|
self.error = nil
|
|
local handle, err = self.client:unlink(self.state.deviceId)
|
|
if not handle then
|
|
self:_forgetLocal()
|
|
return true
|
|
end
|
|
return self:_request(handle, err, function(eng)
|
|
eng:_forgetLocal()
|
|
end, function(eng)
|
|
eng:_forgetLocal()
|
|
return true
|
|
end)
|
|
end
|
|
|
|
function SyncEngine:unlinkDevice(deviceId)
|
|
if type(deviceId) ~= "string" or deviceId == "" then
|
|
return false, "no such device"
|
|
end
|
|
if not self:linked() then return false, "this device is not linked" end
|
|
if deviceId == self.state.deviceId then return self:unlink() end
|
|
if self:busy() then return false, "sync is busy" end
|
|
self.phase = "checking"
|
|
self.status = "Unlinking that device..."
|
|
self.error = nil
|
|
local handle, err = self.client:unlink(deviceId)
|
|
return self:_request(handle, err, function(eng)
|
|
eng.status = "That device was unlinked"
|
|
eng.phase = "idle"
|
|
eng:syncNow()
|
|
end)
|
|
end
|
|
|
|
function SyncEngine:reissueCodes()
|
|
if not self:linked() then return false, "this device is not linked" end
|
|
if self:busy() then return false, "sync is busy" end
|
|
self.phase = "checking"
|
|
self.status = "Fetching new sync codes..."
|
|
self.error = nil
|
|
local handle, err = self.client:reissueCodes()
|
|
return self:_request(handle, err, function(eng, res)
|
|
local data = res.data or {}
|
|
local a = SyncClient.normalizeCode(data.code1)
|
|
local b = SyncClient.normalizeCode(data.code2)
|
|
if not a or not b then
|
|
eng:_fail("the server sent an unexpected reply")
|
|
return
|
|
end
|
|
eng.state.code1, eng.state.code2 = a, b
|
|
eng.codes = SyncEngine.formatCodes(eng.state)
|
|
eng.phase = "idle"
|
|
eng.status = "New sync codes issued, the old pair no longer links"
|
|
eng:_persist()
|
|
end)
|
|
end
|
|
|
|
function SyncEngine:setEnabled(enabled)
|
|
self.state.enabled = enabled and true or false
|
|
self:_persist()
|
|
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.skipped = nil
|
|
self.state.pendingConflicts = {}
|
|
self.phase = "checking"
|
|
self.status = "Checking for changes..."
|
|
self.error = nil
|
|
local handle, err = self.client:fetchState()
|
|
return self:_request(handle, err, function(eng, res)
|
|
eng:_planFrom(res.data or {})
|
|
end)
|
|
end
|
|
|
|
function SyncEngine:_planFrom(remoteState)
|
|
self.devices = nil
|
|
if type(remoteState.devices) == "table" then
|
|
local list = {}
|
|
for _, row in ipairs(remoteState.devices) do
|
|
if type(row) == "table" and type(row.id) == "string" and row.id ~= "" then
|
|
list[#list + 1] = {
|
|
id = row.id,
|
|
label = type(row.label) == "string" and row.label ~= "" and row.label
|
|
or "device",
|
|
createdAt = tonumber(row.createdAt),
|
|
current = row.current == true or row.id == self.state.deviceId,
|
|
}
|
|
end
|
|
end
|
|
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))
|
|
local liveStamp = unixSeconds(entry.meta and entry.meta.savedAt)
|
|
local localChanged
|
|
if liveStamp == nil and stamp == nil then
|
|
localChanged = knownRev == nil
|
|
else
|
|
localChanged = liveStamp ~= stamp
|
|
end
|
|
local remoteRev = row and tonumber(row.rev)
|
|
local remoteChanged = row ~= nil and remoteRev ~= knownRev
|
|
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
|
|
self:_queueUpload(entry, key, true)
|
|
else
|
|
self:_addConflict(entry, key, row)
|
|
end
|
|
elseif localChanged then
|
|
self:_queueUpload(entry, key, false)
|
|
elseif remoteChanged and key ~= self.protectedKey then
|
|
self:_queueDownload(key, entry.version, entry.playthroughId, "replace")
|
|
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)
|
|
if version and id then
|
|
self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
|
|
end
|
|
end
|
|
end
|
|
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] = {
|
|
key = key,
|
|
version = entry.version,
|
|
playthroughId = entry.playthroughId,
|
|
slot = entry.slot,
|
|
entry = entry,
|
|
localMeta = SyncEngine.displayMeta(entry.meta),
|
|
remoteMeta = remoteMeta,
|
|
remoteRev = tonumber(row.rev),
|
|
overlap = SyncEngine.overlaps(entry.meta, remoteMeta),
|
|
}
|
|
local pending = self.state.pendingConflicts or {}
|
|
self.state.pendingConflicts = pending
|
|
for _, row in ipairs(pending) do
|
|
if row.key == key then return end
|
|
end
|
|
pending[#pending + 1] = {
|
|
key = key,
|
|
version = entry.version,
|
|
playthroughId = entry.playthroughId,
|
|
overlap = SyncEngine.overlaps(entry.meta, remoteMeta),
|
|
}
|
|
end
|
|
|
|
function SyncEngine:_queueUpload(entry, key, force)
|
|
self:_enqueue(function(eng)
|
|
eng.phase = "uploading"
|
|
eng.status = "Uploading saves..."
|
|
local handle, err = eng.client:putSave({
|
|
version = entry.version,
|
|
slot = entry.slot,
|
|
meta = entry.meta,
|
|
blob = entry.blob,
|
|
baseRev = SyncState.rev(eng.state, key),
|
|
force = force,
|
|
})
|
|
eng:_request(handle, err, function(e, res)
|
|
local data = res.data or {}
|
|
SyncState.setRev(e.state, key, tonumber(data.rev),
|
|
unixSeconds(entry.meta and entry.meta.savedAt))
|
|
e:_persist()
|
|
if not e:busy() then e:_finish() end
|
|
end, function(e, res)
|
|
if res.code == 409 then
|
|
local row = res.data or {}
|
|
-- Retried with force only once: a forced write that still 409s is a
|
|
-- real refusal, and retrying it would spin.
|
|
if not force
|
|
and SyncEngine.sameProgress(entry.meta, SyncEngine.metaOf(row)) then
|
|
e:_queueUpload(entry, key, true)
|
|
else
|
|
e:_addConflict(entry, key, row)
|
|
end
|
|
if not e:busy() then e:_finish() end
|
|
return true
|
|
end
|
|
return false
|
|
end)
|
|
end)
|
|
end
|
|
|
|
function SyncEngine:_queueDownload(key, version, playthroughId, mode, knownRev)
|
|
self:_enqueue(function(eng)
|
|
eng.phase = "downloading"
|
|
eng.status = "Downloading saves..."
|
|
local handle, err = eng.client:getSave(version, playthroughId)
|
|
eng:_request(handle, err, function(e, res)
|
|
local data = res.data or {}
|
|
if type(data.blob) ~= "string" or data.blob == "" then
|
|
e:_fail("the server sent no save data")
|
|
return
|
|
end
|
|
local slotId, detail, cartId =
|
|
e.saves.write(version, playthroughId, data.blob, mode)
|
|
if slotId == false then
|
|
e.skipped = e.skipped or {}
|
|
e.skipped[#e.skipped + 1] =
|
|
tostring(detail or "this save was skipped")
|
|
if not e:busy() then e:_finish() end
|
|
return
|
|
end
|
|
if not slotId then
|
|
e:_fail(detail or "could not write the downloaded save")
|
|
return
|
|
end
|
|
local created = detail == true
|
|
local meta = type(data.meta) == "table" and data.meta or {}
|
|
if mode ~= "new" then
|
|
SyncState.setRev(e.state, key, tonumber(data.rev) or knownRev,
|
|
unixSeconds(meta.savedAt))
|
|
end
|
|
e.lastDownloads = e.lastDownloads or {}
|
|
e.lastDownloads[#e.lastDownloads + 1] = {
|
|
version = version,
|
|
cart = cartId,
|
|
slot = slotId,
|
|
created = created,
|
|
device = type(meta.device) == "string" and meta.device ~= ""
|
|
and meta.device or nil,
|
|
}
|
|
e.changed = true
|
|
e:_persist()
|
|
if not e:busy() then e:_finish() end
|
|
end)
|
|
end)
|
|
end
|
|
|
|
function SyncEngine:resolveConflict(key, choice)
|
|
local index
|
|
for i, row in ipairs(self.conflicts) do
|
|
if row.key == key then index = i break end
|
|
end
|
|
if not index then return false, "no such conflict" end
|
|
local conflict = table.remove(self.conflicts, index)
|
|
local kept = {}
|
|
for _, row in ipairs(self.state.pendingConflicts or {}) do
|
|
if row.key ~= key then kept[#kept + 1] = row end
|
|
end
|
|
self.state.pendingConflicts = kept
|
|
|
|
if choice == "local" then
|
|
SyncState.setRev(self.state, key, conflict.remoteRev, nil)
|
|
self:_queueUpload(conflict.entry, key, true)
|
|
elseif choice == "remote" then
|
|
self:_queueDownload(key, conflict.version, conflict.playthroughId,
|
|
"replace", conflict.remoteRev)
|
|
elseif choice == "both" then
|
|
self:_queueDownload(key, conflict.version, conflict.playthroughId,
|
|
"new", conflict.remoteRev)
|
|
SyncState.setRev(self.state, key, conflict.remoteRev, nil)
|
|
self:_queueUpload(conflict.entry, key, true)
|
|
else
|
|
return false, "unknown resolution"
|
|
end
|
|
self.phase = "uploading"
|
|
self.status = "Applying your choice..."
|
|
return true
|
|
end
|
|
|
|
function SyncEngine:uploadMods(includeOptions)
|
|
if not self:linked() then return false, "this device is not linked" end
|
|
if self:busy() then return false, "sync is busy" end
|
|
local manifest = SyncMods.build(self.modDeps, includeOptions)
|
|
self.phase = "uploading"
|
|
self.status = includeOptions and "Uploading the mod list and options..."
|
|
or "Uploading the mod list..."
|
|
local handle, err = self.client:putMods(manifest)
|
|
return self:_request(handle, err, function(eng)
|
|
eng.phase = "idle"
|
|
eng.status = "Mod list synced"
|
|
end)
|
|
end
|
|
|
|
function SyncEngine:fetchModPlan()
|
|
if not self:linked() then return false, "this device is not linked" end
|
|
if self:busy() then return false, "sync is busy" end
|
|
self.phase = "downloading"
|
|
self.status = "Reading the mod list..."
|
|
local handle, err = self.client:getMods()
|
|
return self:_request(handle, err, function(eng, res)
|
|
local data = res.data or {}
|
|
local manifest = type(data.manifest) == "table" and data.manifest or data
|
|
eng:_takeModPlan(SyncMods.plan(manifest, eng.modDeps))
|
|
end)
|
|
end
|
|
|
|
function SyncEngine:shareMods(includeOptions)
|
|
if not self:linked() then return false, "this device is not linked" end
|
|
if self:busy() then return false, "sync is busy" end
|
|
local manifest = SyncMods.build(self.modDeps, includeOptions)
|
|
self.phase = "uploading"
|
|
self.status = includeOptions and "Sharing the mod list and options..."
|
|
or "Sharing the mod list..."
|
|
local handle, err = self.client:shareMods(manifest)
|
|
return self:_request(handle, err, function(eng, res)
|
|
local data = res.data or {}
|
|
eng.shareCode = type(data.code) == "string" and data.code or nil
|
|
eng.phase = "idle"
|
|
eng.status = eng.shareCode and ("Share code " .. eng.shareCode)
|
|
or "The server sent no share code"
|
|
end)
|
|
end
|
|
|
|
function SyncEngine:fetchShare(code)
|
|
if self:busy() then return false, "sync is busy" end
|
|
self.phase = "downloading"
|
|
self.status = "Fetching that mod list..."
|
|
local handle, err = self.client:fetchShare(code)
|
|
return self:_request(handle, err, function(eng, res)
|
|
local data = res.data or {}
|
|
local manifest = type(data.manifest) == "table" and data.manifest or data
|
|
eng:_takeModPlan(SyncMods.plan(manifest, eng.modDeps))
|
|
end)
|
|
end
|
|
|
|
function SyncEngine:_takeModPlan(plan)
|
|
self.modPlan = plan
|
|
self.phase = "idle"
|
|
if SyncMods.planHasOptions(plan) then
|
|
self.status = ("This list carries options for %d mods.")
|
|
:format(#plan.options)
|
|
elseif SyncMods.planEmpty(plan) then
|
|
self.status = "Mods already match"
|
|
else
|
|
self.status = "Mod changes ready to apply"
|
|
end
|
|
end
|
|
|
|
function SyncEngine:modOptionsAsk()
|
|
local plan = self.modPlan
|
|
if not SyncMods.planHasOptions(plan) then return nil end
|
|
if plan.applyOptions ~= nil then return nil end
|
|
return SyncMods.optionModIds(plan)
|
|
end
|
|
|
|
function SyncEngine:answerModOptions(importThem)
|
|
local plan = self.modPlan
|
|
if not SyncMods.planHasOptions(plan) then return false end
|
|
SyncMods.answerOptions(plan, importThem)
|
|
self.status = plan.applyOptions
|
|
and "Their mod options will be imported too"
|
|
or "Their mod options will be skipped"
|
|
return plan.applyOptions
|
|
end
|
|
|
|
function SyncEngine:applyModPlan(progress)
|
|
if not self.modPlan then return false, "no mod plan" end
|
|
if self.modApply then return false, "the mods are already being applied" end
|
|
self.modPlan.applyOptions = self.modPlan.applyOptions == true
|
|
local steps = SyncMods.steps(self.modPlan, self.modDeps)
|
|
if #steps == 0 then
|
|
self.modPlan = nil
|
|
self.status = "Mods already match"
|
|
if progress then progress(0, 0, nil, true) end
|
|
return true
|
|
end
|
|
self.modApply = { steps = steps, index = 0, failures = {},
|
|
progress = progress }
|
|
self.phase = "applying"
|
|
self.status = ("Applying mods... 0 of %d"):format(#steps)
|
|
return true
|
|
end
|
|
|
|
function SyncEngine:applyingMods()
|
|
return self.modApply ~= nil
|
|
end
|
|
|
|
function SyncEngine:_stepModApply()
|
|
local job = self.modApply
|
|
local step = job.steps[job.index + 1]
|
|
job.index = job.index + 1
|
|
local ok, res, why = pcall(step.run)
|
|
if not ok then
|
|
job.failures[#job.failures + 1] = tostring(res)
|
|
elseif not res then
|
|
job.failures[#job.failures + 1] = tostring(why or step.label)
|
|
end
|
|
local total = #job.steps
|
|
local done = job.index >= total
|
|
if not done then
|
|
self.status = ("Applying mods... %d of %d"):format(job.index, total)
|
|
if job.progress then
|
|
pcall(job.progress, job.index, total, step.label, false)
|
|
end
|
|
return
|
|
end
|
|
self.modApply = nil
|
|
self.modPlan = nil
|
|
self.phase = "idle"
|
|
if #job.failures > 0 then
|
|
self.status = "Some mods could not be applied: "
|
|
.. table.concat(job.failures, "; ")
|
|
else
|
|
self.status = "Mods applied"
|
|
end
|
|
if job.progress then
|
|
pcall(job.progress, job.index, total, step.label, true)
|
|
end
|
|
end
|
|
|
|
return SyncEngine
|