mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-04 00:20:01 -04:00
350 lines
18 KiB
Lua
350 lines
18 KiB
Lua
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()
|