package.path = "./?.lua;./?/init.lua;" .. package.path love = love or require("tests.love_stub") local before = os.getenv("POKEPORT_SYNC_ENGINE_FILE") if before then package.loaded["src.sync.SyncEngine"] = assert(loadfile(before))() end local Engine = require("src.sync.SyncEngine") local State = require("src.sync.SyncState") local SaveData = require("src.core.SaveData") local Serializer = require("src.core.SaveSerializer") local Client = require("src.sync.SyncClient") local Json = require("src.link.Json") local T = require("tests.harness").suite("H14 content conflict") local function copy(v) if type(v) ~= "table" then return v end local out = {}; for k, x in pairs(v) do out[k] = copy(x) end; return out end local function save(version, at) local gen = (version == "red" or version == "yellow") and 1 or (version == "gold" or version == "crystal") and 2 or 3 return { version = version, generation = gen, player = { name = "ASH" }, party = { { species = "PIKACHU", level = 5 } }, inventory = { POTION = 1 }, playTime = gen == 1 and 13561 or { hours = 3, minutes = 46, seconds = 1, frames = 0 }, pokedex = { owned = {}, caught = {} }, badges = {}, meta = { playthroughId = "same-id", savedAt = at, sessionStart = at - 100, mods = {}, modCount = 0, format = 1, engine = "fixture" } } end local function actualEntry(blob, version) local names = { "listSlots", "readSlotSource", "loadOptions", "cartsWithSlots" } local old = {}; for _, name in ipairs(names) do old[name] = SaveData[name] end SaveData.listSlots = function(v) return v == version and { { exists = true, id = "slot1" } } or {} end SaveData.readSlotSource = function() return blob end SaveData.loadOptions = function() return { playthroughIds = { [version] = { slot1 = "same-id" } } } end SaveData.cartsWithSlots = function() return {} end local ok, entries = pcall(Engine.defaultSaves().list) for _, name in ipairs(names) do SaveData[name] = old[name] end assert(ok, entries); assert(#entries == 1); return entries[1] end local function pump(e, n) for _ = 1, n or 30 do e:update(0.01) end end local function scenario(version, opts) opts = opts or {} local left = opts.left and copy(opts.left) or save(version, 1700000700) left.meta.savedAt, left.meta.sessionStart = 1700000700, 1700000600 local right = copy(left); right.meta.savedAt = 1700000760; right.meta.sessionStart = 1700000660 if opts.mutate then opts.mutate(right, left) end local localBlob = Serializer.encode(left) local remoteBlob = opts.raw or Serializer.encode(right) local entry = actualEntry(localBlob, version) local remoteMeta = copy(entry.meta); remoteMeta.savedAt = 1700000760 if opts.summary then opts.summary(remoteMeta.summary, entry.meta.summary) end local key = State.key(version, "same-id") local state = State.defaults(); state.account, state.deviceToken, state.enabled = "fixture", "token", true State.setRev(state, key, 7, 1700000500) local server = { rev = opts.race and 7 or 9, blob = remoteBlob, meta = remoteMeta } local transport = { sent = {}, handles = {}, gets = 0, puts = 0, hold = opts.hold } function transport:begin(req) self.sent[#self.sent + 1] = req local path = req.url:match("^[^?]*"):gsub("^http://memory%.test", "") local code, body = 200, {} if req.method == "GET" and path == "/sync/state" then body = { saves = { [key] = { rev = server.rev, meta = server.meta } } } elseif req.method == "GET" and path == "/sync/save" then self.gets = self.gets + 1 if opts.noHandle then return nil, "offline" end code = opts.getCode or 200 body = opts.getReply or { rev = opts.fetchRev or server.rev, meta = server.meta, blob = server.blob } if opts.afterGet then opts.afterGet(server, entry) end elseif req.method == "PUT" and path == "/sync/save" then self.puts = self.puts + 1 local payload = assert(Json.decode(req.body)) if opts.race and self.puts == 1 then server.rev = 9 end if opts.forceRefusal or (payload.baseRev ~= server.rev and not payload.force) then code, body = 409, { rev = server.rev, remoteMeta = server.meta } else server.blob, server.meta, server.rev = payload.blob, payload.meta, server.rev + 1 body = { rev = server.rev } end else error(req.method .. " " .. path) end local h = #self.sent self.handles[h] = { status = "ok", code = code, body = Json.encode(body), pause = (req.method == "GET" and path == "/sync/save") or (opts.holdPut and req.method == "PUT") } return h end function transport:poll(h) if self.hold and self.handles[h].pause then return { status = "pending" } end return self.handles[h] end function transport:release(h) self.released = h end local writes = {} local e = Engine.new({ state = state, transport = transport, baseUrl = "http://memory.test", saves = { list = function() return { entry } end, write = function(_, _, blob, mode) writes[#writes + 1] = { blob = blob, mode = mode }; return "slot1" end }, persist = false, now = function() return 1700001000 end }) e:syncNow(); pump(e) return { e = e, server = server, transport = transport, state = state, key = key, entry = entry, left = left, right = right, remoteBlob = remoteBlob, localBlob = localBlob, writes = writes } end local function noForce(r, label) local forced = false for _, req in ipairs(r.transport.sent) do if req.method == "PUT" and Json.decode(req.body).force then forced = true end end T.eq(forced, false, label .. " never forces automatically") end local function conflict(r, label, puts) T.eq(r.e.phase, "conflict", label .. " asks for a choice") T.eq(#r.e.conflicts, 1, label .. " keeps one conflict") T.eq(#r.state.pendingConflicts, 1, label .. " tracks the unresolved conflict") T.eq(r.server.blob, r.remoteBlob, label .. " preserves remote bytes") T.eq(r.transport.puts, puts or 0, label .. " stops automatic writes") T.eq(r.transport.gets, 1, label .. " fetches real remote contents") noForce(r, label) end local function equal(r, label, puts) T.eq(r.e.phase, "idle", label .. " finishes without a prompt") T.eq(#r.e.conflicts, 0, label .. " has no conflict") T.eq(r.server.blob, r.remoteBlob, label .. " preserves the remote serialized copy") T.eq(r.transport.puts, puts or 0, label .. " performs no replacement PUT") T.eq(r.transport.gets, 1, label .. " verifies remote contents") T.eq(State.rev(r.state, r.key), 9, label .. " adopts the verified revision") T.eq(State.stamp(r.state, r.key), 1700000700, label .. " remembers the compared local stamp") T.eq(#r.writes, 0, label .. " needs no local write") noForce(r, label) end for _, v in ipairs({ "red", "yellow", "gold", "crystal", "emerald" }) do conflict(scenario(v, { mutate = function(s) s.party[1].species = "EEVEE"; s.inventory.POTION = 2 end }), v .. " same-summary party/items") conflict(scenario(v, { mutate = function(s) s.inventory.POTION = 2 end, summary = function(s) s.name = "BLUE"; s.badges = 8 end }), v .. " same-minute different summary") conflict(scenario(v, { race = true, mutate = function(s) s.inventory.POTION = 2 end }), v .. " 409 content fork", 1) equal(scenario(v), v .. " write/session timestamps only") equal(scenario(v, { summary = function(s) s.name = "BLUE"; s.badges = 8 end }), v .. " equal contents despite stale summary") equal(scenario(v, { race = true }), v .. " 409 equal contents", 1) conflict(scenario(v, { mutate = function(s) if type(s.playTime) == "number" then s.playTime = s.playTime + 1 else s.playTime.seconds = 2 end end }), v .. " one gameplay second") end local function produced(version) local Version = require("src.core.GameVersion") local oldFS, oldVersion = love.filesystem, Version.get() local Space, Dataset, Extract, oldBundle, oldOverride, oldRoot, oldNativeRoot if version == "emerald" then local cacheRoot = os.getenv("POKEPORT_EMERALD_CACHE") local file = cacheRoot and io.open(cacheRoot .. "/scripts/scripts.lua", "rb") if not file then print("[skip] H14 actual-produced Emerald controls: POKEPORT_EMERALD_CACHE requires an actual Emerald gba cache") return nil end file:close() Space = require("src.core.game3.scripting.space") Dataset = require("src.core.game3.dataset") Extract = require("src.import.gba.extract_island1") oldBundle, oldOverride = Space.bundle, Dataset.cacheRootOverride oldRoot, oldNativeRoot = Extract.CACHE_ROOT, Extract.NATIVE_ROOT Space.bundle, Dataset.cacheRootOverride = nil, cacheRoot end local files = {} love.filesystem = { write = function(path, body) files[path] = body; return true end, read = function(path) return files[path] end, remove = function(path) files[path] = nil; return true end, getInfo = function(path) return files[path] and { type = "file" } or nil end, createDirectory = function() return true end, } SaveData.resetSlotState(); Version.set(version) local ok, value = pcall(function() local s, writer if version == "gold" or version == "silver" or version == "crystal" then local Gen2 = require("src.core.gen2.Save") s, writer = Gen2.newGame({ playerName = "ASH", trainerId = 1 }), Gen2.save else local Schema = require("src.core.game3.save_schema_firered") s = Schema.toSaveTable(Schema.newGame({ version = version, name = "ASH", trainerIdLower = 1, rngSeed = 1 })) if Space then T.check(Space.bundle and Space.bundle.fromCache == true, "Emerald actual producer executes the isolated imported bundle") T.check(Space.bundle.scripts.EventScript_ResetAllMapFlags ~= nil, "Emerald actual reset entrypoint comes from that bundle") end SaveData.setActiveSlot(version, assert(SaveData.createSlot(version))) writer = SaveData.save end s.meta = SaveData.buildMeta({}, { playthroughId = "same-id" }, 1700000600) T.check(writer(s), version .. " actual save writer succeeds in memory") local slot = SaveData.activeSlot(version) or "legacy" local blob = assert(SaveData.readSlotSource(version, slot)) local decoded = assert(SaveData.decode(blob)) T.eq(decoded.meta.playthroughId, "same-id", version .. " saved-file identity survives actual writer") if decoded.generation == 3 then T.eq(decoded.player, nil, version .. " native save has root name rather than Gen1 player") T.eq(decoded.name, "ASH", version .. " native root trainer name survives") T.check(type(decoded.bag) == "table", version .. " native pocket bag survives") else T.eq(decoded.player.name, "ASH", version .. " Gen2 player survives actual writer") T.check(type(decoded.savedAt) == "number", version .. " Gen2 writer stamps legacy top-level timestamp") end return decoded end) love.filesystem = oldFS; SaveData.resetSlotState(); Version.set(oldVersion) if Space then Space.bundle, Dataset.cacheRootOverride = oldBundle, oldOverride Extract.CACHE_ROOT, Extract.NATIVE_ROOT = oldRoot, oldNativeRoot Dataset.invalidateManifestCache() T.eq(Space.bundle, oldBundle, "Emerald producer restores the previous script bundle") T.eq(Dataset.cacheRootOverride, oldOverride, "Emerald producer restores the previous cache override") T.eq(Extract.CACHE_ROOT, oldRoot, "Emerald producer restores the previous extract root") T.eq(Extract.NATIVE_ROOT, oldNativeRoot, "Emerald producer restores the previous native root") T.eq(Version.get(), oldVersion, "Emerald producer restores the previous edition") end assert(ok, value); return value end for _, version in ipairs({ "gold", "silver", "crystal", "firered", "leafgreen", "emerald" }) do local value = produced(version) if value then equal(scenario(version, { left = value }), version .. " actual producer equality") equal(scenario(version, { left = value, race = true }), version .. " actual producer 409 equality", 1) conflict(scenario(version, { left = value, mutate = function(s) if s.generation == 3 then s.money = s.money + 1 else s.player.money = s.player.money + 1 end end }), version .. " actual producer same-summary money fork") conflict(scenario(version, { left = value, race = true, mutate = function(s) if s.generation == 3 then s.money = s.money + 1 else s.player.money = s.player.money + 1 end end }), version .. " actual producer 409 money fork", 1) end end do local nested = string.rep("{next=", 140) .. "{}" .. string.rep("}", 140) local raw = 'return {player={name="ASH"},future=' .. nested .. '}' T.eq(SaveData.decode(raw), nil, "actual restricted decoder rejects nesting beyond128") conflict(scenario("red", { raw = raw }), "excessively nested remote save") T.eq(SaveData.decode('return {player={name="ASH"},future=player}'), nil, "actual restricted decoder rejects references rather than producing cycles") end for _, change in ipairs({ { "unknown field", function(s) s.future = { value = 1 } end }, { "mods", function(s) s.meta.mods = { { id = "other", version = "1" } } end }, { "format", function(s) s.meta.format = 2 end }, { "engine", function(s) s.meta.engine = "other" end }, { "cart", function(s) s.meta.cartId = "other" end }, { "identity", function(s) s.meta.playthroughId = "other" end }, { "raw cartridge bytes", function(s) s.rawImport = "bytes" end }, { "nested savedAt", function(s) s.party[1].savedAt = 5 end }, { "top sessionStart", function(s) s.sessionStart = 5 end }, { "createdAt", function(s) s.meta.createdAt = 5 end }, { "typed value", function(s) s.inventory.POTION = "1" end }, }) do conflict(scenario("red", { mutate = change[2] }), change[1]) end equal(scenario("gold", { mutate = function(s, left) s.savedAt = 3; left.savedAt = 5 end }), "legacy Gen2 write timestamp") conflict(scenario("red", { mutate = function(s) s.party[1].species = "EEVEE" end, summary = function(a,b) a.timeText = nil; b.timeText = nil end }), "unknown displayed time") for _, raw in ipairs({ "return {}", "return {player=false}", "return {player={name='ASH'},meta=false}", "return os.execute('echo forbidden')", "return {player={name='ASH'}} trailing", "" }) do conflict(scenario("red", { raw = raw }), "invalid remote blob " .. #raw) end do local maximum = Client.MAX_BLOB; Client.MAX_BLOB = 1024 local r = scenario("red", { raw = string.rep(" ", Client.MAX_BLOB + 1) }) Client.MAX_BLOB = maximum conflict(r, "remote blob over the configured size boundary") end for _, opts in ipairs({ { noHandle = true }, { getCode = 503 }, { getReply = {} }, { fetchRev = 8 }, { fetchRev = 0 }, { fetchRev = 9.5 } }) do conflict(scenario("red", opts), "unverified remote response") end do local r = scenario("red", { raw = "return {inventory={POTION=1},badges={},pokedex={caught={},owned={}}," .. 'party={[1]={level=5,species="PIKACHU"}},player={name="ASH"},generation=1,version="red",playTime=13561,' .. 'meta={engine="fixture",format=1,modCount=0,mods={},playthroughId="same-id",savedAt=1700000760,sessionStart=1700000660}}' }) equal(r, "different serialization ordering") end for _, choice in ipairs({ "local", "remote", "both" }) do local r = scenario("red", { mutate = function(s) s.inventory.POTION = 2 end }) if #r.e.conflicts > 0 then T.check(r.e:resolveConflict(r.key, choice), choice .. " explicit choice remains available") pump(r.e) T.eq(r.e.phase, "idle", choice .. " finishes") if choice ~= "local" then T.eq(r.writes[1].blob, r.remoteBlob, choice .. " preserves exact remote data") T.eq(r.writes[1].mode, choice == "both" and "new" or "replace", choice .. " local write mode") end if choice ~= "remote" then T.eq(r.server.blob, r.localBlob, choice .. " uploads only after user selection") T.eq(Json.decode(r.transport.sent[#r.transport.sent].body).force, true, choice .. " explicit force remains") end else T.check(false, choice .. " divergent contents must first be presented") end end do local r = scenario("red", { hold = true }) T.eq(r.transport.gets, 1, "comparison can remain pending") local later = save("red", 1700000800); later.inventory.POTION = 3 r.entry.blob = Serializer.encode(later); r.entry.meta.savedAt = 1700000800 r.transport.hold = false; pump(r.e) T.eq(State.stamp(r.state, r.key), 1700000700, "late local save cannot change the compared snapshot stamp") r.e:syncNow(); pump(r.e) T.eq(r.transport.puts, 1, "later local save remains detectable on the next sync") T.eq(State.stamp(r.state, r.key), 1700000800, "later save is tracked only after its own ordinary upload") end do local opts = { holdPut = true } local r = scenario("red", opts) local nextSave = save("red", 1700000800); nextSave.inventory.POTION = 3 r.entry.blob = Serializer.encode(nextSave); r.entry.meta.savedAt = 1700000800 r.transport.hold = true; r.e:syncNow(); pump(r.e) T.eq(r.transport.puts, 1, "ordinary upload can remain pending") nextSave.inventory.POTION = 4; nextSave.meta.savedAt = 1700000900 r.entry.blob = Serializer.encode(nextSave); r.entry.meta.savedAt = 1700000900 r.transport.hold = false; pump(r.e) T.eq(State.stamp(r.state, r.key), 1700000800, "pending upload adopts only its own captured stamp") T.eq(Serializer.decode(r.server.blob).inventory.POTION, 3, "pending upload carries only its captured contents") r.e:syncNow(); pump(r.e) T.eq(r.transport.puts, 2, "save made during pending upload remains detectable") T.eq(State.stamp(r.state, r.key), 1700000900, "later snapshot gets its own revision stamp") end do local r = scenario("red", { afterGet = function(server) server.rev = 10; server.blob = Serializer.encode(save("red", 1700000900)) server.meta = copy(server.meta); server.meta.savedAt = 1700000900 end }) T.eq(State.rev(r.state, r.key), 9, "adopted revision is the one whose contents were fetched") T.eq(r.transport.puts, 0, "remote advancement after verification is never force-overwritten") r.e:syncNow(); pump(r.e) T.eq(#r.writes, 1, "the later remote revision remains detectable") T.eq(State.rev(r.state, r.key), 10, "next sync adopts the later downloaded revision") end do local r = scenario("red", { hold = true }) r.e:cancel(); r.transport.hold = false; pump(r.e) T.eq(State.rev(r.state, r.key), 7, "cancelled comparison cannot adopt a revision") T.eq(r.transport.puts, 0, "cancelled comparison cannot write") end do local r = scenario("red", { mutate = function(s) s.inventory.POTION = 2 end, forceRefusal = true }) if #r.e.conflicts > 0 then r.e:resolveConflict(r.key, "local"); pump(r.e) end T.eq(r.transport.puts, 1, "a refused explicit force is not retried") T.eq(r.transport.gets, 1, "a refused explicit force does not enter automatic equality handling") T.eq(r.e.phase, "conflict", "explicit force refusal remains a conflict") end T.finish()