package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.harness") love = love or require("tests.love_stub") local Json = require("src.link.Json") local SyncState = require("src.sync.SyncState") local SyncEngine = require("src.sync.SyncEngine") local function scripted(routes) local t = { sent = {}, routes = routes, handles = {} } function t:begin(req) self.sent[#self.sent + 1] = req local path = req.url:match("^[^?]*"):gsub("^http://sync%.test", "") local route = self.routes[req.method .. " " .. path] local reply if type(route) == "function" then reply = route(req, self) else reply = route end reply = reply or { code = 404, body = '{"error":"no route"}' } self.handles[#self.sent] = { status = "ok", code = reply.code or 200, body = reply.body or Json.encode(reply.data or {}), } return #self.sent end function t:poll(handle) return self.handles[handle] end function t:release() end return t end local function pump(eng, times) for _ = 1, (times or 24) do eng:update(0.05) end end local function linkedState() local state = SyncState.defaults() state.account = "aa11bb22cc33dd44" state.deviceToken = "tok" state.enabled = true return state end local function saveEntry(version, id, savedAt, sessionStart, slot) return { version = version, slot = slot or "slot1", playthroughId = id, blob = "return { player = { name = 'ASH' } }", meta = { savedAt = savedAt, sessionStart = sessionStart, playthroughId = id, summary = { name = "ASH", badges = 2 } }, } end local function fakeSaves(entries) 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 } return mode == "new" and "slot9" or "slot1" end, } end local function engine(routes, entries, state) local saves = fakeSaves(entries or {}) local transport = scripted(routes) local eng = SyncEngine.new({ baseUrl = "http://sync.test", transport = transport, state = state or linkedState(), saves = saves, persist = false, now = function() return 1700001000 end, }) return eng, transport, saves end do T.eq(SyncEngine.overlaps({ sessionStart = 10, savedAt = 20 }, { sessionStart = 15, savedAt = 30 }), true, "two sessions that ran over the same minutes overlap") T.eq(SyncEngine.overlaps({ sessionStart = 10, savedAt = 20 }, { sessionStart = 21, savedAt = 30 }), false, "a session that started after the other ended does not") T.eq(SyncEngine.overlaps({ savedAt = 20 }, { sessionStart = 1, savedAt = 30 }), false, "a save with no session start cannot claim an overlap") end do local eng, transport = engine({ ["POST /sync/create"] = { code = 200, body = '{"account":"aa11","code1":"11112222","code2":"33334444","deviceToken":"tok"}' }, ["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) 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, "11112222", "the codes ride the persisted state") T.eq(eng.state.code2, "33334444", "both of them") T.eq(SyncEngine.formatCodes(SyncState.sanitize(eng.state)).code1, "1111-2222", "so a relaunch can show them again") 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") end do local eng, transport = engine({ ["POST /sync/link"] = { code = 200, body = '{"account":"aa11","deviceToken":"tok"}' }, ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":1}' }, }, { saveEntry("red", "abc", 500, 400) }, SyncState.defaults()) eng:linkDevice("1111-2222", "3333 4444", "phone") pump(eng) T.eq(eng:linked(), true, "linking with both codes links the device") T.eq(eng.codes.code1, "1111-2222", "the codes typed to link are kept too") T.eq(eng.state.code2, "33334444", "as bare digits in the state") T.eq(transport.sent[2].url, "http://sync.test/sync/state", "and a sync starts immediately") T.eq(transport.sent[3].method, "PUT", "the local save the server has never seen is uploaded") T.eq(SyncState.rev(eng.state, "red/abc"), 1, "the served rev is remembered") T.eq(SyncState.stamp(eng.state, "red/abc"), 500, "along with the savedAt that was uploaded") T.eq(eng.phase, "idle", "and the engine settles") T.eq(eng.state.lastSyncAt, 1700001000, "the sync time is stamped") end do local eng, transport = engine({}, {}, SyncState.defaults()) eng:linkDevice("12", "34", "phone") T.eq(#transport.sent, 0, "a malformed code pair is refused locally") T.eq(eng.phase, "error", "and the engine reports the problem") T.check(eng.status:find("8 digits", 1, true) ~= nil, "with copy that says what a code is") end do local eng, transport, saves = engine({ ["GET /sync/state"] = { code = 200, body = '{"saves":{"gold/xyz":{"rev":4,"meta":{"savedAt":900}}}}' }, ["GET /sync/save"] = { code = 200, body = '{"rev":4,"meta":{"savedAt":900},"blob":"return { player = {} }"}' }, }, {}) eng:syncNow() pump(eng) T.eq(#saves.writes, 1, "the remote-only save is written locally") T.eq(saves.writes[1].version, "gold", "into the right game") T.eq(saves.writes[1].mode, "replace", "as that playthrough's slot") T.eq(SyncState.rev(eng.state, "gold/xyz"), 4, "and its rev is remembered") T.eq(eng.phase, "idle", "the engine settles") T.eq(transport.sent[2].url, "http://sync.test/sync/save?id=xyz&version=gold", "the download names the playthrough, not the slot") T.eq(eng.changed, true, "and the launcher is told a slot changed") T.eq(eng.lastDownloads and eng.lastDownloads[1].version, "gold", "naming the game") T.eq(eng.lastDownloads and eng.lastDownloads[1].slot, "slot1", "and the slot") end do local eng, _, saves = engine({ ["GET /sync/state"] = { code = 200, body = '{"saves":{"red/xyz":{"rev":2,"meta":{"savedAt":900}}}}' }, ["GET /sync/save"] = { code = 200, body = '{"rev":2,"meta":{"savedAt":900,"device":"Android"},' .. '"blob":"return { player = {} }"}' }, }, {}) saves.write = function() return "slot3", true end eng:syncNow() pump(eng) T.eq(eng.changed, true, "a download into a fresh slot flags the launcher") T.eq(eng.lastDownloads[1].slot, "slot3", "naming the slot") T.eq(eng.lastDownloads[1].created, true, "as one it created") T.eq(eng.lastDownloads[1].device, "Android", "and the device whose save it holds") end do local state = linkedState() SyncState.setRev(state, "red/abc", 1, 500) local eng, _, saves = engine({ ["GET /sync/state"] = { code = 200, body = '{"saves":{"red/abc":{"rev":5,"meta":{"savedAt":900}}}}' }, ["GET /sync/save"] = { code = 200, body = '{"rev":5,"meta":{"savedAt":900},"blob":"return { player = {} }"}' }, }, { saveEntry("red", "abc", 500, 400) }, state) eng:protectPlaythrough("red", "abc") eng:syncNow() pump(eng) T.eq(#saves.writes, 0, "while a playthrough is being played its moved rev is left alone") T.eq(eng.phase, "idle", "and that sync still settles") eng:protectPlaythrough(nil, nil) T.eq(eng.protectedKey, nil, "leaving the game drops the protection") eng:syncNow() pump(eng) T.eq(#saves.writes, 1, "so the launcher's next sync fetches the other device's progress") T.eq(saves.writes[1].mode, "replace", "into this playthrough's slot") T.eq(SyncState.rev(eng.state, "red/abc"), 5, "and adopts the served rev") local main = assert(io.open("main.lua")):read("*a") local body = main:match("local function returnToLauncher%(opts%)(.-)\nend\n") T.check(body ~= nil, "main.lua still has returnToLauncher") T.check(body and body:find("protectPlaythrough", 1, true) ~= nil, "and returning to the launcher clears the played key on the shared engine") end do local state = linkedState() SyncState.setRev(state, "red/abc", 7, 500) local eng, transport = engine({ ["GET /sync/state"] = { code = 200, body = '{"saves":{"red/abc":{"rev":7,"meta":{"savedAt":500}}}}' }, }, { saveEntry("red", "abc", 500, 400) }, state) eng:syncNow() pump(eng) T.eq(#transport.sent, 1, "an unchanged save is neither uploaded nor downloaded") T.eq(eng.phase, "idle", "and the sync ends idle") end -- Identical playtime is not a fork. The Gold prompt the player saw offered -- "GOLD - 8 badges - 3:46 - 9 seen" against "GOLD - 8 badges - 3:46 - 9 seen": -- the same minute of the same playthrough, two saves that differ only in -- their savedAt stamp. Nothing to choose between, so nothing to ask. do T.eq(SyncEngine.samePlaytime( { summary = { timeText = "3:46" } }, { summary = { timeText = "3:46" } }), true, "the same H:MM is the same point in the playthrough") T.eq(SyncEngine.samePlaytime( { summary = { timeText = "3:46" } }, { summary = { timeText = "3:47" } }), false, "one minute apart is a real fork") T.eq(SyncEngine.samePlaytime({ playTime = 13560 }, { playTime = 13599 }), true, "Gen 1's seconds count compares to the minute, not the second") T.eq(SyncEngine.samePlaytime({ playTime = 13560 }, { playTime = 13620 }), false, "and a whole minute apart still forks") -- Gen 2 stores playTime as a table, so meta.playTime is nil on a Gold -- save and only timeText survives; a missing time must never read as a -- match, or an unknowable conflict would be silently discarded. T.eq(SyncEngine.samePlaytime({}, {}), false, "two unknown playtimes are not a match") T.eq(SyncEngine.samePlaytime({ summary = { timeText = "3:46" } }, {}), false, "and neither is one unknown side") end do local state = linkedState() SyncState.setRev(state, "red/abc", 7, 500) local entry = saveEntry("red", "abc", 700, 650) entry.meta.summary.timeText = "3:46" local eng, transport = engine({ ["GET /sync/state"] = { code = 200, body = '{"saves":{"red/abc":{"rev":9,"meta":{"savedAt":760,' .. '"sessionStart":600,"summary":{"name":"ASH","timeText":"3:46"}}}}}' }, ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":10}' }, }, { entry }, state) eng:syncNow() pump(eng) T.eq(#eng.conflicts, 0, "matching playtime raises no conflict") T.eq(#eng.state.pendingConflicts, 0, "and leaves nothing pending") T.neq(eng.phase, "conflict", "so the player is never prompted") local put for _, req in ipairs(transport.sent) do if req.method == "PUT" then put = req end end T.check(put ~= nil, "this device's copy is pushed instead") T.check(put and put.body:find('"force":true', 1, true) ~= nil, "forced past the moved rev, since there is nothing to preserve") end local function conflictEngine() local state = linkedState() SyncState.setRev(state, "red/abc", 7, 500) return engine({ ["GET /sync/state"] = { code = 200, body = '{"saves":{"red/abc":{"rev":9,"meta":{"savedAt":760,' .. '"sessionStart":600,"summary":{"name":"BLUE","badges":4}}}}}' }, ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":10}' }, ["GET /sync/save"] = { code = 200, body = '{"rev":9,"meta":{"savedAt":760},"blob":"return { player = {} }"}' }, }, { saveEntry("red", "abc", 700, 650) }, state) end do local eng, transport = conflictEngine() eng:syncNow() pump(eng) T.eq(eng.phase, "conflict", "both sides changing is a conflict") T.eq(#eng.conflicts, 1, "one conflict is raised") T.eq(eng.conflicts[1].overlap, true, "the two sessions ran over the same minutes") T.eq(eng.status, "These saves were played at the same time.", "and the status is the wording the player was promised") T.eq(eng.conflicts[1].remoteMeta.summary.name, "BLUE", "the other device's save is summarized for the prompt") T.eq(#transport.sent, 1, "nothing is uploaded while the player decides") T.eq(#eng.state.pendingConflicts, 1, "the conflict survives in the state") end do local eng, transport = conflictEngine() eng:syncNow() pump(eng) eng:resolveConflict("red/abc", "local") pump(eng) local put = transport.sent[2] T.eq(put.method, "PUT", "keep this device uploads") T.eq(Json.decode(put.body).force, true, "with the force flag") T.eq(SyncState.rev(eng.state, "red/abc"), 10, "and adopts the new rev") T.eq(eng.phase, "idle", "the conflict is cleared") T.eq(#eng.state.pendingConflicts, 0, "and dropped from the state") end do local eng, transport, saves = conflictEngine() eng:syncNow() pump(eng) eng:resolveConflict("red/abc", "remote") pump(eng) T.eq(transport.sent[2].method, "GET", "keep the other device downloads") T.eq(#saves.writes, 1, "and writes it locally") T.eq(saves.writes[1].mode, "replace", "over this playthrough's slot") T.eq(SyncState.rev(eng.state, "red/abc"), 9, "adopting the remote rev") T.eq(eng.phase, "idle", "the conflict is cleared") end do local eng, transport, saves = conflictEngine() eng:syncNow() pump(eng) eng:resolveConflict("red/abc", "both") pump(eng) T.eq(#saves.writes, 1, "keep both imports the other save") T.eq(saves.writes[1].mode, "new", "into a new slot") local put = transport.sent[3] T.eq(put.method, "PUT", "and still uploads this device's save") T.eq(Json.decode(put.body).force, true, "forcing past the stale rev") T.eq(eng.phase, "idle", "the conflict is cleared") end do local eng = engine({ ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, ["PUT /sync/save"] = { code = 409, body = '{"conflict":true,"rev":3,"remoteMeta":{"savedAt":710,"sessionStart":600}}' }, }, { saveEntry("red", "abc", 700, 650) }) eng:syncNow() pump(eng) T.eq(eng.phase, "conflict", "a 409 on upload becomes a conflict, not an error") T.eq(eng.conflicts[1].overlap, true, "with the overlap worked out") end do local eng = engine({ ["GET /sync/state"] = function() return { code = 500, body = '{"error":"server on fire"}' } end, }, {}) eng:syncNow() pump(eng, 3) T.eq(eng.phase, "error", "a server error stops the sync") T.check(eng.status:find("server on fire", 1, true) ~= nil, "and shows what the server said") end do local eng, transport = engine({ ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":1}' }, }, { saveEntry("red", "abc", 500, 400) }) eng:noteSaveWritten() eng:update(1) T.eq(#transport.sent, 0, "an in-game save does not sync straight away") eng:update(SyncEngine.UPLOAD_DEBOUNCE) T.eq(#transport.sent, 1, "it syncs once the debounce has passed") pump(eng) T.eq(transport.sent[2].method, "PUT", "and the save goes up") end do local eng, transport = engine({}, { saveEntry("red", "abc", 500, 400) }) eng:setEnabled(false) eng:noteSaveWritten() eng:update(60) 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) 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", "and it survives being persisted") end do local linked = SyncState.defaults() linked.account, linked.deviceToken, linked.enabled = "aa11", "tok", true local eng, transport = engine({ ["POST /sync/codes"] = { code = 200, body = '{"account":"aa11","code1":"55556666","code2":"77778888"}' }, }, {}, linked) T.eq(eng.codes, nil, "a device linked before codes were kept has none to show") eng:reissueCodes() pump(eng) T.eq(transport.sent[1].url, "http://sync.test/sync/codes", "asking for codes posts to the codes endpoint") T.eq(eng.codes.code1, "5555-6666", "and the reissued pair is shown grouped") T.eq(eng.state.code2, "77778888", "and kept in the state") T.eq(eng.phase, "idle", "the engine settles") local unlinked = engine({}, {}, SyncState.defaults()) T.eq(unlinked:reissueCodes(), false, "an unlinked device cannot ask for codes") end do local eng = engine({ ["POST /sync/link"] = { code = 200, body = '{"account":"aa11","deviceToken":"tok","device":"beefcafe"}' }, ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, }, {}, SyncState.defaults()) eng:linkDevice("11112222", "33334444", "phone") pump(eng) T.eq(eng.state.deviceId, "beefcafe", "so does linking a second device") end do local state = linkedState() state.deviceId = "0a1b2c3d" local eng, transport = engine({ ["POST /sync/unlink"] = { code = 200, body = '{"ok":true,"devices":1}' }, }, {}, state) eng:unlink() T.eq(eng:linked(), true, "unlink waits for the server before forgetting") local sent = transport.sent[1] T.eq(sent.url, "http://sync.test/sync/unlink", "it asks the server first") T.eq(Json.decode(sent.body).device, "0a1b2c3d", "naming the device id the server knows, not the platform label") pump(eng, 3) T.eq(eng:linked(), false, "and only then drops the credentials") T.eq(eng.status, "Not set up", "reporting the device as unlinked") end do local state = linkedState() state.deviceId = "0a1b2c3d" local eng = engine({ ["POST /sync/unlink"] = { code = 500, body = '{"error":"nope"}' }, }, {}, state) eng:unlink() pump(eng, 3) T.eq(eng:linked(), false, "a server that refuses the revocation still unlinks this device") T.neq(eng.phase, "error", "so the player is not left stuck on a red status") end do local state = linkedState() state.deviceId = "0a1b2c3d" local transport = { begin = function() return 1 end, poll = function() return { status = "error", err = "fetch failed for http://sync.test/sync/unlink: " .. "curl: (28) timed out" } end, release = function() end, } local eng = SyncEngine.new({ baseUrl = "http://sync.test", transport = transport, state = state, saves = fakeSaves({}), persist = false, now = function() return 1700001000 end, }) eng:unlink() pump(eng, 3) T.eq(eng:linked(), false, "an unreachable server does not trap the device as linked") T.eq(eng.status, "Not set up", "and the modal says the device is unlinked") end do local state = linkedState() state.deviceId = "0a1b2c3d" local eng, transport = engine({ ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, ["POST /sync/unlink"] = { code = 200, body = '{"ok":true,"devices":1}' }, }, {}, state) eng.pending = { handle = 99 } T.eq(eng:busy(), true, "a request is in flight") eng:unlink() T.eq(transport.sent[1].url, "http://sync.test/sync/unlink", "unlink cancels it instead of refusing as busy") pump(eng, 3) T.eq(eng:linked(), false, "and the device is unlinked") end do local state = linkedState() state.deviceId = "0a1b2c3d" local eng = engine({ ["POST /sync/unlink"] = { code = 401, body = '{"error":"unauthorized"}' }, }, {}, state) eng:unlink() pump(eng, 3) T.eq(eng:linked(), false, "a token the server already revoked is dropped rather than stuck forever") end do local state = linkedState() state.deviceId = "0a1b2c3d" local eng, transport = engine({ ["POST /sync/unlink"] = { code = 200, body = '{"ok":true,"devices":1}' }, ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, }, {}, state) eng:unlinkDevice("99998888") T.eq(Json.decode(transport.sent[1].body).device, "99998888", "another device is revoked by its id") pump(eng, 3) T.eq(eng:linked(), true, "without logging this device out") end do local state = linkedState() state.deviceId = "0a1b2c3d" local eng = engine({ ["GET /sync/state"] = { code = 200, body = '{"saves":{},"devices":[{"id":"0a1b2c3d","label":"OS X","current":true},' .. '{"id":"99998888","label":"Android"}]}' }, }, {}, state) eng:syncNow() pump(eng) T.eq(#eng.devices, 2, "the linked devices are kept for the modal to show") T.eq(eng.devices[1].current, true, "this device is marked") T.eq(eng.devices[2].label, "Android", "and the others are named") end do local eng = conflictEngine() eng:syncNow() pump(eng) eng:syncNow() pump(eng) eng:syncNow() pump(eng) T.eq(#eng.state.pendingConflicts, 1, "syncing again over the same conflict does not stack up rows") T.eq(#eng.conflicts, 1, "and the prompt still has exactly one to answer") end do local eng = engine({}, {}) local order, seen = {}, {} eng.modDeps = { installed = function() return {} end, indexes = function() return {} end, addIndex = function(url) order[#order + 1] = "index" return { feed = url } end, findEntry = function() return nil end, install = function(entry) order[#order + 1] = "install:" .. entry.id return true end, setEnabled = function(id) order[#order + 1] = "enable:" .. id return true end, } eng.modPlan = { indexes = { "https://mods.example/i.json" }, toInstall = { { id = "beta", entry = { id = "beta" } } }, toEnable = { { id = "beta", version = "red" } }, missing = {}, } eng:applyModPlan(function(done, total, label, finished) seen[#seen + 1] = ("%d/%d %s"):format(done, total, tostring(finished)) end) T.eq(#order, 0, "starting an apply installs nothing on the spot") T.eq(eng:busy(), true, "the launcher can see it is working") eng:update(0.016) T.eq(#order, 1, "one step runs per frame, so the progress line can draw") eng:update(0.016) eng:update(0.016) T.eq(#order, 3, "until the whole plan has run") T.eq(order[3], "enable:beta", "in plan order") T.eq(eng.modApply, nil, "the job is done") T.eq(eng.modPlan, nil, "and the plan is spent") T.eq(eng.status, "Mods applied", "the status says so") T.eq(seen[#seen], "3/3 true", "and the last progress call reports the end") end do local eng = engine({}, {}) eng.modDeps = { installed = function() return {} end, indexes = function() return {} end, addIndex = function() return true end, findEntry = function() return nil end, install = function() return nil, "download failed" end, setEnabled = function() return true end, } eng.modPlan = { indexes = {}, toInstall = { { id = "beta", entry = { id = "beta" } } }, toEnable = {}, missing = {} } eng:applyModPlan() eng:update(0.016) T.eq(eng.modApply, nil, "a failing step still ends the job") T.check(eng.status:find("download failed", 1, true) ~= nil, "and the failure reaches the status line") end do local shared = {} local eng, transport = engine({ ["POST /sync/modshare"] = function(req) shared[#shared + 1] = Json.decode(req.body) return { code = 200, body = '{"code":"K7QW3M"}' } end, }, {}) eng.modDeps = { installed = function() return { { id = "alpha", version = "1.0.0", enabledByVersion = { red = true } } } end, indexes = function() return {} end, modOptions = function() return { alpha = { speed = 3 } } end, setOptions = function() return true end, } eng:shareMods(false) pump(eng) T.eq(shared[1].manifest.mods[1].options, nil, "a shared list can leave the player's options at home") T.eq(eng.shareCode, "K7QW3M", "and still mints a code") eng:shareMods(true) pump(eng) T.eq(shared[2].manifest.mods[1].options.speed, 3, "or carry the options that go with those mods") T.eq(#transport.sent, 2, "one request each") end do local eng = engine({ ["GET /sync/modshare"] = { code = 200, body = '{"code":"K7QW3M","manifest":{"rev":2,"indexes":[],"hasOptions":true,' .. '"mods":[{"id":"alpha","version":"1.0.0","enabledFor":["red"],' .. '"options":{"speed":1}}]}}' }, }, {}) local written = {} eng.modDeps = { installed = function() return { { id = "alpha", version = "1.0.0", enabledByVersion = { red = true } } } end, indexes = function() return {} end, findEntry = function() return nil end, addIndex = function() return true end, install = function() return true end, setEnabled = function() return true end, modOptions = function() return { alpha = { speed = 3 } } end, setOptions = function(id, values) written[id] = values return true end, } eng:fetchShare("K7QW3M") pump(eng) T.eq(#eng.modPlan.options, 1, "a fetched list reports the options it carries") T.eq(eng.modPlan.applyOptions, nil, "without deciding for the player") T.check(eng.status:find("options", 1, true) ~= nil, "and the status line says there is a question to answer") local ask = eng:modOptionsAsk() T.eq(ask and ask[1], "alpha", "the launcher can name the mods it would touch") eng:answerModOptions(false) T.eq(eng:modOptionsAsk(), nil, "answering closes the question") eng:applyModPlan() pump(eng) T.eq(next(written), nil, "declining leaves this device's options alone") eng:fetchShare("K7QW3M") pump(eng) eng:answerModOptions(true) eng:applyModPlan() pump(eng) T.eq(written.alpha.speed, 1, "accepting writes the sharer's values") end do local eng = engine({ ["GET /sync/modshare"] = { code = 200, body = '{"code":"K7QW3M","manifest":{"rev":2,"indexes":[],"hasOptions":true,' .. '"mods":[{"id":"alpha","version":"1.0.0","enabledFor":["red"],' .. '"options":{"speed":1}}]}}' }, }, {}) local written = {} eng.modDeps = { installed = function() return { { id = "alpha", version = "1.0.0", enabledByVersion = { red = true } } } end, indexes = function() return {} end, findEntry = function() return nil end, addIndex = function() return true end, install = function() return true end, setEnabled = function() return true end, modOptions = function() return { alpha = { speed = 3 } } end, setOptions = function(id, values) written[id] = values return true end, } eng:fetchShare("K7QW3M") pump(eng) eng:applyModPlan() pump(eng) T.eq(next(written), nil, "an apply that never asked imports nothing: silence is not consent") end do T.eq(SyncEngine.unixSeconds(0), nil, "a zero stamp is no stamp") T.eq(SyncEngine.unixSeconds(-5), nil, "and neither is a negative one") T.eq(SyncEngine.unixSeconds(1700000000), 1700000000, "a real stamp survives") T.eq(SyncEngine.displayMeta({ savedAt = 0, sessionStart = 0 }).savedAt, nil, "the prompt is never handed a stamp it would date 1969") T.eq(SyncEngine.overlaps({ sessionStart = 0, savedAt = 0 }, { sessionStart = 0, savedAt = 0 }), false, "two server-zeroed windows do not overlap in 1970") T.eq(SyncEngine.samePlaytime({ playTime = 0 }, { playTime = 0 }), false, "a server-zeroed playtime is unknown, not zero minutes") end do local SaveData = require("src.core.SaveData") local raw = "return { player = { name = 'GOLD' }, savedAt = 1700000500 }" local slots = { { id = "slot1", exists = true } } local realList, realRead, realDecode, realSummary, realOptions = SaveData.listSlots, SaveData.readSlotSource, SaveData.decode, SaveData.slotSummary, SaveData.loadOptions SaveData.listSlots = function(version) return version == "gold" and slots or {} end SaveData.readSlotSource = function() return raw end SaveData.decode = function() return { player = { name = "GOLD" }, savedAt = 1700000500 } end SaveData.slotSummary = function() return "GOLD", { badges = 8, timeText = "3:46", dexCount = 9 } end SaveData.loadOptions = function() return { playthroughIds = { gold = { slot1 = "xyz" } } } end local list = SyncEngine.defaultSaves().list() SaveData.listSlots, SaveData.readSlotSource, SaveData.decode, SaveData.slotSummary, SaveData.loadOptions = realList, realRead, realDecode, realSummary, realOptions T.eq(#list, 1, "the Gold slot is listed") T.eq(list[1].meta.savedAt, 1700000500, "with the stamp gen2/Save.lua actually writes") end do local state = linkedState() local entry = saveEntry("gold", "xyz", 1700000500, nil) entry.meta.summary = { name = "GOLD", badges = 8, timeText = "3:46", dexCount = 9 } local eng, transport = engine({ ["GET /sync/state"] = { code = 200, body = '{"saves":{"gold/xyz":{"rev":4,"meta":{"savedAt":0,' .. '"summary":{"name":"GOLD","badges":8,"timeText":"3:46",' .. '"dexCount":9}}}}}' }, ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":5}' }, }, { entry }, state) eng:syncNow() pump(eng) T.eq(#eng.conflicts, 0, "the same playthrough with an epoch-dated server row is not a fork") T.neq(eng.phase, "conflict", "so no duplicate-save prompt is raised") T.eq(SyncState.stamp(eng.state, "gold/xyz"), 1700000500, "and the upload leaves a stamp behind") local put for _, req in ipairs(transport.sent) do if req.method == "PUT" then put = req end end T.check(put ~= nil, "this device's copy is pushed instead") end do local state = linkedState() SyncState.setRev(state, "gold/xyz", 4, 1700000500) local entry = saveEntry("gold", "xyz", 1700000500, nil) local eng, transport = engine({ ["GET /sync/state"] = { code = 200, body = '{"saves":{"gold/xyz":{"rev":4,"meta":{"savedAt":0}}}}' }, }, { entry }, state) eng:syncNow() pump(eng) T.eq(#transport.sent, 1, "a Gold save at the rev it was uploaded at syncs no further") T.eq(eng.phase, "idle", "and the second boot is idle") end do local state = linkedState() SyncState.setRev(state, "gold/xyz", 4, nil) local entry = saveEntry("gold", "xyz", nil, nil) local eng, transport = engine({ ["GET /sync/state"] = { code = 200, body = '{"saves":{"gold/xyz":{"rev":4,"meta":{"savedAt":0}}}}' }, }, { entry }, state) eng:syncNow() pump(eng) T.eq(#transport.sent, 1, "an older Gold save with no stamp on either side is not dirty every boot") 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")