Files
gen1recomp/tests/online_trade.lua
T
2026-09-24 05:17:32 -04:00

1087 lines
44 KiB
Lua

-- Launcher trade core (src/online/Trade.lua), ROM-free.
-- luajit tests/online_trade.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
if not _G.love then _G.love = require("tests.love_stub") end
local SaveData = require("src.core.SaveData")
local SaveSerializer = require("src.core.SaveSerializer")
local Stats = require("src.pokemon.Stats")
local Mon = require("src.battle.gen2.Mon")
local Trade = require("src.online.Trade")
-- ------- in-memory persistence
local files = {}
local failWrite = nil
local fs = {
getInfo = function(name) return files[name] and { type = "file" } or nil end,
read = function(name) return files[name] end,
write = function(name, body)
if failWrite and name == failWrite then
failWrite = nil
return false, "disk full"
end
files[name] = body
return true
end,
remove = function(name) files[name] = nil return true end,
createDirectory = function() return true end,
getDirectoryItems = function(dir)
local prefix, out, seen = dir .. "/", {}, {}
for name in pairs(files) do
if name:sub(1, #prefix) == prefix then
local head = name:sub(#prefix + 1):match("^[^/]+")
if head and not seen[head] then
seen[head] = true
out[#out + 1] = head
end
end
end
table.sort(out)
return out
end,
}
local realPortableFs = SaveData.portableFs
SaveData.portableFs = function() return fs end
-- ------- synthetic datasets
local GEN1 = {
pokemon = {
KADABRA = { name = "KADABRA", growthRate = "MEDIUM_SLOW",
baseStats = { hp = 40, attack = 35, defense = 30, speed = 105,
special = 120 },
evolutions = { { method = "TRADE", species = "ALAKAZAM" } } },
ALAKAZAM = { name = "ALAKAZAM", growthRate = "MEDIUM_SLOW",
baseStats = { hp = 55, attack = 50, defense = 45, speed = 120,
special = 135 },
evolutions = {} },
PIKACHU = { name = "PIKACHU", growthRate = "MEDIUM_FAST",
baseStats = { hp = 35, attack = 55, defense = 30, speed = 90,
special = 50 },
evolutions = {} },
},
moves = { TACKLE = { name = "TACKLE", pp = 35 },
PSYBEAM = { name = "PSYBEAM", pp = 20 } },
items = { POTION = { name = "POTION" } },
maps = { PALLET_TOWN = {} },
}
local GEN2 = {
pokemon = {
ONIX = { name = "ONIX",
baseStats = { hp = 35, attack = 45, defense = 160, speed = 70,
specialAttack = 30, specialDefense = 45 },
types = { "ROCK", "GROUND" },
evolutions = { { method = "EVOLVE_TRADE", into = "STEELIX",
item = "METAL_COAT" } } },
STEELIX = { name = "STEELIX",
baseStats = { hp = 75, attack = 85, defense = 200, speed = 30,
specialAttack = 55, specialDefense = 65 },
types = { "STEEL", "GROUND" }, evolutions = {} },
MACHOKE = { name = "MACHOKE",
baseStats = { hp = 80, attack = 100, defense = 70, speed = 45,
specialAttack = 50, specialDefense = 60 },
types = { "FIGHTING" },
evolutions = { { method = "EVOLVE_TRADE", into = "MACHAMP" } } },
MACHAMP = { name = "MACHAMP",
baseStats = { hp = 90, attack = 130, defense = 80, speed = 55,
specialAttack = 65, specialDefense = 85 },
types = { "FIGHTING" }, evolutions = {} },
TOTODILE = { name = "TOTODILE",
baseStats = { hp = 50, attack = 65, defense = 64, speed = 43,
specialAttack = 44, specialDefense = 48 },
types = { "WATER" }, evolutions = {} },
},
moves = { TACKLE = { name = "TACKLE", pp = 35 } },
items = { METAL_COAT = { name = "METAL COAT" },
LEFTOVERS = { name = "LEFTOVERS" },
FLOWER_MAIL = { name = "FLOWER MAIL" } },
}
-- ------- fixtures
local function gen1Mon(species, level, extra)
local def = GEN1.pokemon[species]
local dvs = { attack = 15, defense = 15, speed = 15, special = 15 }
dvs.hp = 15
local statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }
local stats = Stats.calc(def, level, dvs, statExp)
local mon = { species = species, level = level, exp = level * level * level,
dvs = dvs, statExp = statExp, stats = stats, hp = stats.hp,
moves = { { id = "TACKLE", pp = 35 } },
ot = "OAK", otId = 4242 }
for key, value in pairs(extra or {}) do mon[key] = value end
return mon
end
local function gen2Mon(species, level, extra)
local def = GEN2.pokemon[species]
local dvs = { attack = 15, defense = 15, speed = 15, special = 15 }
dvs.hp = Mon.hpDV(dvs)
local statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }
local stats = Mon.stats(def.baseStats, dvs, level, statExp)
local mon = { species = species, name = def.name, level = level,
experience = level * level * level, dvs = dvs,
statExp = statExp, stats = stats, hp = stats.hp,
maxHp = stats.hp, types = def.types,
moves = { { id = "TACKLE", pp = 35, maxPp = 35 } },
happiness = 70, pokerus = 0, caughtLevel = level,
ot = "SILVER", otId = 9 }
for key, value in pairs(extra or {}) do mon[key] = value end
return mon
end
local function gen1Save(name, party)
return { version = "red", format = 1,
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
party = party, boxes = {}, inventory = {},
pokedex = { seen = {}, owned = {} }, playTime = 0 }
end
local function gen2Save(name, party, mail, boxes)
return { version = "gold", generation = 2, format = 7, boxes = boxes,
player = { name = name, id = 9, badges = {}, kantoBadges = {} },
party = party, pokedex = { seen = {}, caught = {} },
scriptMem = {}, events = {}, mapScenes = {},
playerState = "normal", lastDexMode = "NEW",
mail = { party = mail or {}, box = {} },
playTime = { hours = 0, minutes = 0, seconds = 0, frames = 0 } }
end
local function put(version, slotId, save)
local path = "saves/" .. version .. "/" .. slotId .. ".lua"
files[path] = SaveSerializer.encode(save)
return path
end
local function open(version, slotId, data)
local handle, reason = Trade.openSlot(version, slotId, nil, { data = data })
assert(handle, tostring(reason))
return handle
end
local function decodeAt(path)
return SaveSerializer.decode(files[path])
end
-- ------- a live game is never traded into
do
local previous = package.loaded["src.core.Data"]
package.loaded["src.core.Data"] = { _pristineKeys = {} }
put("red", "slot1", gen1Save("RED", { gen1Mon("PIKACHU", 20) }))
local handle, reason = Trade.openSlot("red", "slot1")
T.eq(handle, nil, "a slot cannot be opened while a game is live")
T.eq(reason, "close the game first", "and the launcher says which")
package.loaded["src.core.Data"] = previous
end
-- ------- a positive host signal refuses even with no dataset loaded
do
files = {}
put("red", "slot1", gen1Save("RED", { gen1Mon("KADABRA", 30) }))
put("red", "slot2", gen1Save("BLUE", { gen1Mon("PIKACHU", 25) }))
local plan = Trade.plan({ from = open("red", "slot1", GEN1), fromIndex = 1,
to = open("red", "slot2", GEN1), toIndex = 1 })
T.check(plan ~= nil, "a plan is made while the launcher owns the machine")
Trade.hostIsLive = function() return true end
local handle, reason = Trade.openSlot("red", "slot1")
T.eq(handle, nil, "a booted game refuses to open a slot")
T.eq(reason, "close the game first", "and says why")
local ok, why = Trade.commit(plan)
T.eq(ok, false, "and refuses to commit one that was already planned")
T.eq(why, "close the game first", "with the same reason")
local inMount = Trade.withMountedData(GEN1, function()
return Trade.gameIsLive()
end)
T.eq(inMount, true, "a mount cannot talk a live game away")
Trade.hostIsLive = nil
T.eq(Trade.gameIsLive(), false, "with the signal gone the launcher is free")
end
-- ------- the panel's cross-generation path: plan and commit inside a mount
do
files = {}
local pathA = put("red", "slot1", gen1Save("RED", { gen1Mon("KADABRA", 30) }))
local pathB = put("gold", "slot1",
gen2Save("GOLD", { gen2Mon("TOTODILE", 20) }))
local beforeA, beforeB = files[pathA], files[pathB]
local a = open("red", "slot1", GEN1)
local b = open("gold", "slot1", GEN2)
local convert = function(packed, _, toGen)
if toGen == 2 then
return { species = "TOTODILE", level = packed.level,
experience = packed.exp, hp = 1, dvs = packed.dvs,
statExp = packed.statExp, moves = packed.moves,
ot = packed.ot, otId = packed.otId, happiness = 70 }
end
return { species = "PIKACHU", level = packed.level, exp = 8000,
dvs = packed.dvs, statExp = packed.statExp, moves = packed.moves,
ot = packed.ot, otId = packed.otId }
end
local previous = package.loaded["src.core.Data"]
package.loaded["src.core.Data"] = { _pristineKeys = {} }
local out, why = Trade.withMountedData(GEN1, function()
local plan, reason = Trade.plan({ from = a, fromIndex = 1, to = b,
toIndex = 1, convert = convert })
if not plan then return { ok = false, reason = reason } end
local ok, result = Trade.commit(plan)
return { ok = ok, reason = result, plan = plan }
end)
package.loaded["src.core.Data"] = previous
T.check(type(out) == "table", "the mounted run returns: " .. tostring(why))
T.check(out.plan ~= nil, "the preview plans inside the mount")
T.eq(out.ok, true,
"and the confirm commits inside the same mount: " .. tostring(out.reason))
T.check(files[pathA] ~= beforeA, "the Gen 1 save is rewritten")
T.check(files[pathB] ~= beforeB, "and so is the Gen 2 one")
T.eq(decodeAt(pathA).party[1].species, "PIKACHU",
"the converted mon lands in the Gen 1 party")
T.eq(decodeAt(pathB).party[1].species, "TOTODILE",
"and the other one in the Gen 2 party")
end
-- ------- Gen 1 plan preview, OT and traded semantics
do
files = {}
local pathA = put("red", "slot1",
gen1Save("RED", { gen1Mon("KADABRA", 30, { nickname = "ABRA-CAD" }) }))
local pathB = put("red", "slot2",
gen1Save("BLUE", { gen1Mon("PIKACHU", 25) }))
local a = open("red", "slot1", GEN1)
local b = open("red", "slot2", GEN1)
T.eq(a.path, pathA, "the handle knows the file it came from")
T.eq(a.generation, 1, "and the generation of the save")
local plan, reason = Trade.plan({ from = a, fromIndex = 1,
to = b, toIndex = 1 })
T.check(plan ~= nil, "two Gen 1 party picks make a plan: " .. tostring(reason))
T.eq(plan.get.species, "PIKACHU", "the giver receives the other mon")
T.eq(plan.get.traded, true, "a received mon is marked traded")
T.eq(plan.get.ot, "OAK", "and keeps the sender's OT name")
T.eq(plan.get.otId, 4242, "and the sender's OT id")
T.eq(plan.evolveA, nil, "PIKACHU has no trade evolution")
T.eq(plan.evolveB, "ALAKAZAM", "KADABRA evolves on the receiving side")
T.eq(plan.give.species, "ALAKAZAM", "so the receiver files the evolution")
T.eq(plan.give.nickname, "ABRA-CAD",
"a real nickname survives the evolution")
T.eq(plan.give.stats.special,
Stats.calc(GEN1.pokemon.ALAKAZAM, 30, plan.give.dvs, plan.give.statExp).special,
"with stats recomputed for the new species")
T.eq(files[pathA], SaveSerializer.encode(a.save),
"planning writes nothing to the giver's file")
T.eq(decodeAt(pathB).party[1].species, "PIKACHU",
"and nothing to the receiver's")
local warned = false
for _, row in ipairs(plan.warnings) do
if row.code == "evolve" and row.species == "ALAKAZAM" then warned = true end
end
T.check(warned, "the preview warns about the evolution")
end
-- ------- an un-nicknamed mon loses the old species name on evolving
do
files = {}
put("red", "slot1",
gen1Save("RED", { gen1Mon("KADABRA", 30, { nickname = "KADABRA" }) }))
put("red", "slot2", gen1Save("BLUE", { gen1Mon("PIKACHU", 25) }))
local plan = Trade.plan({ from = open("red", "slot1", GEN1), fromIndex = 1,
to = open("red", "slot2", GEN1), toIndex = 1 })
T.eq(plan.give.nickname, nil,
"a nickname equal to the old species name is dropped")
end
-- ------- eggs and mail are never traded
do
files = {}
put("red", "slot1",
gen1Save("RED", { gen1Mon("PIKACHU", 5, { isEgg = true }) }))
put("red", "slot2", gen1Save("BLUE", { gen1Mon("PIKACHU", 25) }))
local plan, reason = Trade.plan({ from = open("red", "slot1", GEN1),
fromIndex = 1, to = open("red", "slot2", GEN1), toIndex = 1 })
T.eq(plan, nil, "an EGG cannot be traded")
T.eq(reason, "an EGG can't be traded", "and says so")
files = {}
put("gold", "slot1", gen2Save("GOLD", { gen2Mon("TOTODILE", 20) },
{ [1] = { type = "FLOWER_MAIL", message = "HI", author = "GOLD",
authorId = 9, species = "TOTODILE" } }))
put("gold", "slot2", gen2Save("SILVER", { gen2Mon("MACHOKE", 30) }))
local mailPlan, mailReason = Trade.plan({
from = open("gold", "slot1", GEN2), fromIndex = 1,
to = open("gold", "slot2", GEN2), toIndex = 1 })
T.eq(mailPlan, nil, "a mon carrying MAIL cannot leave")
T.eq(mailReason, "take the MAIL first", "and the reason names the letter")
files = {}
put("gold", "slot1",
gen2Save("GOLD", { gen2Mon("TOTODILE", 20, { item = "FLOWER_MAIL" }) }))
put("gold", "slot2", gen2Save("SILVER", { gen2Mon("MACHOKE", 30) }))
local heldPlan = Trade.plan({ from = open("gold", "slot1", GEN2),
fromIndex = 1, to = open("gold", "slot2", GEN2), toIndex = 1 })
T.eq(heldPlan, nil, "a mail item in the held slot counts too")
end
-- ------- a cross-generation pick asks for the converter
do
files = {}
put("red", "slot1", gen1Save("RED", { gen1Mon("PIKACHU", 25) }))
put("gold", "slot1", gen2Save("GOLD", { gen2Mon("TOTODILE", 20) }))
local a = open("red", "slot1", GEN1)
local b = open("gold", "slot1", GEN2)
local plan, reason = Trade.plan({ from = a, fromIndex = 1,
to = b, toIndex = 1 })
T.eq(plan, nil, "a Gen 1 to Gen 2 pick is not this package's job")
T.eq(reason, "needs_conversion", "and names the missing step")
local converted = Trade.plan({ from = a, fromIndex = 1, to = b, toIndex = 1,
convert = function(packed, from, to)
if to == 2 then
return { species = "TOTODILE", level = packed.level,
experience = packed.exp, hp = 1,
dvs = packed.dvs, statExp = packed.statExp,
moves = packed.moves, ot = packed.ot, otId = packed.otId,
happiness = 70 }
end
return { species = "PIKACHU", level = packed.level, exp = 8000,
dvs = packed.dvs, statExp = packed.statExp,
moves = packed.moves, ot = packed.ot, otId = packed.otId }
end })
T.check(converted ~= nil, "a converter hook unblocks the same pick")
T.eq(converted.get.species, "PIKACHU", "the Gen 1 side rebuilds Gen 1")
T.eq(converted.give.species, "TOTODILE", "and the Gen 2 side rebuilds Gen 2")
end
-- ------- the same save is not two sides
do
files = {}
put("red", "slot1",
gen1Save("RED", { gen1Mon("PIKACHU", 25), gen1Mon("KADABRA", 30) }))
local a = open("red", "slot1", GEN1)
local b = open("red", "slot1", GEN1)
local plan, reason = Trade.plan({ from = a, fromIndex = 1,
to = b, toIndex = 2 })
T.eq(plan, nil, "one save cannot trade with itself")
T.eq(reason, "that's the same save", "and says so")
end
-- ------- commit writes both files, marks the dex, and backs up first
do
files = {}
local pathA = put("red", "slot1", gen1Save("RED", { gen1Mon("KADABRA", 30) }))
local pathB = put("red", "slot2", gen1Save("BLUE", { gen1Mon("PIKACHU", 25) }))
local beforeA, beforeB = files[pathA], files[pathB]
local a = open("red", "slot1", GEN1)
local b = open("red", "slot2", GEN1)
local plan = Trade.plan({ from = a, fromIndex = 1, to = b, toIndex = 1 })
local ok, handles, backups = Trade.commit(plan)
T.eq(ok, true, "the commit succeeds")
T.eq(#handles, 2, "and hands back both rewritten saves")
local diskA, diskB = decodeAt(pathA), decodeAt(pathB)
T.eq(diskA.party[1].species, "PIKACHU", "the giver's slot holds the new mon")
T.eq(diskB.party[1].species, "ALAKAZAM",
"the receiver's slot holds the evolved one")
T.eq(diskA.party[1].traded, true, "the received mon stays marked traded")
T.eq(diskA.pokedex.seen.PIKACHU, true, "the receiver's dex sees it")
T.eq(diskA.pokedex.owned.PIKACHU, true, "and owns it")
T.eq(diskB.pokedex.owned.ALAKAZAM, true,
"the evolved species is marked too")
T.eq(diskB.pokedex.owned.KADABRA, true,
"and so is the species that arrived")
T.eq(files[pathA .. ".tmp"], nil, "no write witness is left behind")
T.eq(files[pathB .. ".tmp"], nil, "on either side")
T.eq(#backups, 2, "both files were backed up before the write")
T.eq(files[backups[1]], beforeA, "the first backup is the old bytes")
T.eq(files[backups[2]], beforeB, "and so is the second")
T.eq(handles[1].party[1].species, "PIKACHU",
"the returned handle reads the new party")
end
-- ------- a failed second write restores the first
do
files = {}
local pathA = put("red", "slot1", gen1Save("RED", { gen1Mon("KADABRA", 30) }))
local pathB = put("red", "slot2", gen1Save("BLUE", { gen1Mon("PIKACHU", 25) }))
local beforeA, beforeB = files[pathA], files[pathB]
local plan = Trade.plan({ from = open("red", "slot1", GEN1), fromIndex = 1,
to = open("red", "slot2", GEN1), toIndex = 1 })
failWrite = pathB
local ok, reason = Trade.commit(plan)
failWrite = nil
T.eq(ok, false, "a half-written trade fails")
T.check(reason ~= nil, "with a reason: " .. tostring(reason))
T.eq(files[pathA], beforeA, "the first save is byte-identical to before")
T.eq(files[pathB], beforeB, "and so is the second")
T.eq(files[pathA .. ".tmp"], nil, "no witness survives the rollback")
T.eq(files[pathA .. ".bak"], nil, "and no half-made backup either")
end
-- ------- a save that will not validate is never written
do
files = {}
local bad = gen1Save("BLUE", { gen1Mon("PIKACHU", 25) })
bad.boxes = { { { species = "MISSINGNO", level = 5, moves = {} } } }
local pathA = put("red", "slot1", gen1Save("RED", { gen1Mon("KADABRA", 30) }))
local pathB = put("red", "slot2", bad)
local beforeA, beforeB = files[pathA], files[pathB]
local plan = Trade.plan({ from = open("red", "slot1", GEN1), fromIndex = 1,
to = open("red", "slot2", GEN1), toIndex = 1 })
local ok, reason = Trade.commit(plan)
T.eq(ok, false, "a save whose content this dataset cannot vouch for is refused")
T.eq(reason, "that save didn't validate", "and says which check failed")
T.eq(files[pathA], beforeA, "the other save is untouched")
T.eq(files[pathB], beforeB, "and so is the one that failed")
end
-- ------- Gen 2: trade evolutions, held items and the dex
do
files = {}
local pathA = put("gold", "slot1",
gen2Save("GOLD", { gen2Mon("ONIX", 30, { item = "METAL_COAT" }) }))
local pathB = put("gold", "slot2",
gen2Save("SILVER", { gen2Mon("MACHOKE", 30) }))
local a = open("gold", "slot1", GEN2)
local b = open("gold", "slot2", GEN2)
T.eq(a.generation, 2, "a Gold save reads as generation 2")
local plan, reason = Trade.plan({ from = a, fromIndex = 1,
to = b, toIndex = 1 })
T.check(plan ~= nil, "two Gen 2 picks make a plan: " .. tostring(reason))
T.eq(plan.evolveB, "STEELIX", "a METAL COAT trade evolves ONIX")
T.eq(plan.give.item, nil, "and the held item is consumed")
T.eq(plan.evolveA, "MACHAMP", "MACHOKE evolves on any trade")
T.eq(plan.get.traded, true, "the received Gen 2 mon is marked traded")
T.eq(plan.get.ot, "SILVER", "and keeps its OT")
T.eq(plan.get.happiness, 70, "and its happiness byte")
local used = false
for _, row in ipairs(plan.warnings) do
if row.code == "item_used" then used = true end
end
T.check(used, "the preview warns that the item is eaten")
local ok = Trade.commit(plan)
T.eq(ok, true, "the Gen 2 commit succeeds")
local diskA, diskB = decodeAt(pathA), decodeAt(pathB)
T.eq(diskA.party[1].species, "MACHAMP", "the evolved mon lands in the party")
T.eq(diskB.party[1].species, "STEELIX", "on both sides")
T.eq(diskA.pokedex.caught.MACHAMP, true, "a Gen 2 dex marks caught")
T.eq(diskA.pokedex.seen.MACHOKE, true, "and sees what arrived")
T.eq(diskB.pokedex.caught.STEELIX, true, "the other side too")
end
-- ------- an EVERSTONE stops a Gen 2 trade evolution
do
files = {}
put("gold", "slot1",
gen2Save("GOLD", { gen2Mon("MACHOKE", 30, { item = "EVERSTONE" }) }))
put("gold", "slot2", gen2Save("SILVER", { gen2Mon("TOTODILE", 20) }))
local data = {}
for key, value in pairs(GEN2) do data[key] = value end
data.items = { EVERSTONE = { name = "EVERSTONE" },
METAL_COAT = { name = "METAL COAT" } }
local plan = Trade.plan({ from = open("gold", "slot1", data), fromIndex = 1,
to = open("gold", "slot2", data), toIndex = 1 })
T.check(plan ~= nil, "the trade still happens")
T.eq(plan.evolveB, nil, "but an EVERSTONE holder does not evolve")
T.eq(plan.give.species, "MACHOKE", "and arrives unchanged")
T.eq(plan.give.item, "EVERSTONE", "still holding the stone")
end
-- ------- a Gen 1 pick can come out of the PC
do
files = {}
local b = gen1Save("BLUE", { gen1Mon("PIKACHU", 25) })
b.boxes = { {}, { gen1Mon("PIKACHU", 14) } }
local pathA = put("red", "slot1", gen1Save("RED", { gen1Mon("KADABRA", 30) }))
local pathB = put("red", "slot2", b)
local boxRef = { where = "box", box = 2, index = 1 }
local plan, reason = Trade.plan({ from = open("red", "slot1", GEN1),
fromIndex = 1, to = open("red", "slot2", GEN1), toIndex = boxRef })
T.check(plan ~= nil,
"a party pick trades against one in the PC: " .. tostring(reason))
T.eq(plan.get.level, 14, "the party side receives the boxed POKeMON")
T.eq(plan.give.species, "ALAKAZAM",
"and the box side still files the trade evolution")
local ok, handles = Trade.commit(plan)
T.eq(ok, true, "the commit goes through: " .. tostring(handles))
local diskA, diskB = decodeAt(pathA), decodeAt(pathB)
T.eq(diskA.party[1].level, 14, "the boxed mon lands in the other party")
T.eq(diskA.party[1].traded, true, "marked traded like any other")
T.eq(diskB.boxes[2][1].species, "ALAKAZAM",
"and the box slot itself takes the evolution")
T.eq(#diskB.boxes[2], 1, "the box keeps its length")
T.eq(diskB.party[1].level, 25, "the box side's party is untouched")
T.eq(diskB.pokedex.owned.ALAKAZAM, true, "a box pick marks the dex")
T.eq(diskA.pokedex.owned.PIKACHU, true, "on both sides")
T.eq(handles[2].boxes[2][1].species, "ALAKAZAM",
"and the returned handle reads the rewritten boxes")
end
-- ------- box to box, with neither party touched
do
files = {}
local a = gen1Save("RED", { gen1Mon("PIKACHU", 25) })
a.boxes = { { gen1Mon("KADABRA", 30) } }
local b = gen1Save("BLUE", { gen1Mon("PIKACHU", 25) })
b.boxes = { {}, {}, { gen1Mon("PIKACHU", 7), gen1Mon("PIKACHU", 9) } }
local pathA = put("red", "slot1", a)
local pathB = put("red", "slot2", b)
local plan, reason = Trade.plan({
from = open("red", "slot1", GEN1),
fromIndex = { where = "box", box = 1, index = 1 },
to = open("red", "slot2", GEN1),
toIndex = { where = "box", box = 3, index = 2 } })
T.check(plan ~= nil, "two PC picks make a plan: " .. tostring(reason))
T.eq(Trade.commit(plan), true, "and commit")
local diskA, diskB = decodeAt(pathA), decodeAt(pathB)
T.eq(diskA.boxes[1][1].level, 9, "the first box slot holds what arrived")
T.eq(diskB.boxes[3][2].species, "ALAKAZAM", "and the second the evolution")
T.eq(diskB.boxes[3][1].level, 7, "the box's other slots are left alone")
T.eq(diskA.party[1].level, 25, "neither party moved")
T.eq(diskB.party[1].level, 25, "on either side")
end
-- ------- a boxed Gen 2 mon has no MAIL by definition
do
files = {}
local letter = { type = "FLOWER_MAIL", message = "HI", author = "GOLD",
authorId = 9, species = "TOTODILE" }
local pathA = put("gold", "slot1",
gen2Save("GOLD", { gen2Mon("TOTODILE", 20) }, { [1] = letter },
{ { gen2Mon("MACHOKE", 30, { item = "FLOWER_MAIL" }) } }))
local pathB = put("gold", "slot2",
gen2Save("SILVER", { gen2Mon("TOTODILE", 22) }))
local blocked = Trade.plan({ from = open("gold", "slot1", GEN2),
fromIndex = 1, to = open("gold", "slot2", GEN2), toIndex = 1 })
T.eq(blocked, nil, "the party mon is still held back by its letter")
local plan, reason = Trade.plan({ from = open("gold", "slot1", GEN2),
fromIndex = { where = "box", box = 1, index = 1 },
to = open("gold", "slot2", GEN2), toIndex = 1 })
T.check(plan ~= nil,
"the boxed one trades even so: " .. tostring(reason))
T.eq(plan.give.species, "MACHAMP",
"a Gen 2 box pick evolves on arrival like any other")
T.eq(Trade.commit(plan), true, "and commits")
local diskA, diskB = decodeAt(pathA), decodeAt(pathB)
T.eq(diskA.boxes[1][1].level, 22, "the box slot takes what came back")
T.eq(diskB.party[1].species, "MACHAMP", "and the party the evolution")
T.eq(diskA.mail.party[1].message, "HI",
"the party's letter is left where it was")
T.eq(diskA.party[1].species, "TOTODILE", "and so is the mon holding it")
T.eq(diskA.pokedex.caught.TOTODILE, true, "a Gen 2 box pick marks caught")
end
-- ------- a box source rolls back like any other
do
files = {}
local b = gen1Save("BLUE", { gen1Mon("PIKACHU", 25) })
b.boxes = { { gen1Mon("PIKACHU", 14) } }
local pathA = put("red", "slot1", gen1Save("RED", { gen1Mon("KADABRA", 30) }))
local pathB = put("red", "slot2", b)
local beforeA, beforeB = files[pathA], files[pathB]
local plan = Trade.plan({ from = open("red", "slot1", GEN1), fromIndex = 1,
to = open("red", "slot2", GEN1),
toIndex = { where = "box", box = 1, index = 1 } })
failWrite = pathB
local ok, reason = Trade.commit(plan)
failWrite = nil
T.eq(ok, false, "a failed write on the box side fails the trade")
T.check(reason ~= nil, "with a reason: " .. tostring(reason))
T.eq(files[pathA], beforeA, "the party save is byte-identical to before")
T.eq(files[pathB], beforeB, "and so is the one with the box")
local bad = gen1Save("BLUE", { gen1Mon("PIKACHU", 25) })
bad.boxes = { { gen1Mon("PIKACHU", 14) },
{ { species = "MISSINGNO", level = 5, moves = {} } } }
files = {}
local badA = put("red", "slot1", gen1Save("RED", { gen1Mon("KADABRA", 30) }))
local badB = put("red", "slot2", bad)
local beforeBadA, beforeBadB = files[badA], files[badB]
local badPlan = Trade.plan({ from = open("red", "slot1", GEN1),
fromIndex = 1, to = open("red", "slot2", GEN1),
toIndex = { where = "box", box = 1, index = 1 } })
local badOk, badWhy = Trade.commit(badPlan)
T.eq(badOk, false, "a box trade into a save that will not validate is refused")
T.eq(badWhy, "that save didn't validate", "with the same reason as a party one")
T.eq(files[badA], beforeBadA, "and neither file is touched")
T.eq(files[badB], beforeBadB, "on either side")
end
-- ------- an incoming mon can be filed into a box
do
files = {}
local b = gen1Save("BLUE", { gen1Mon("PIKACHU", 25) })
b.boxes = { {}, { gen1Mon("PIKACHU", 14) } }
local pathB = put("red", "slot2", b)
local plan, reason = Trade.planIncoming({
to = open("red", "slot2", GEN1),
toIndex = { where = "box", box = 2, index = 1 },
record = gen1Mon("KADABRA", 30) })
T.check(plan ~= nil, "planIncoming takes a box ref: " .. tostring(reason))
T.eq(plan.give.level, 14, "the mon that was in that box slot is the giver")
T.eq(plan.get.species, "ALAKAZAM", "the arrival still evolves")
T.eq(Trade.commit(plan), true, "and the one-sided commit lands")
local disk = decodeAt(pathB)
T.eq(disk.boxes[2][1].species, "ALAKAZAM", "in the box slot it was aimed at")
T.eq(disk.party[1].level, 25, "leaving the party alone")
local nope, why = Trade.planIncoming({ to = open("red", "slot2", GEN1),
toIndex = { where = "box", box = 9, index = 4 },
record = gen1Mon("PIKACHU", 5) })
T.eq(nope, nil, "an empty box slot is not a trade target")
T.eq(why, "that's not in the PC", "and the reason names the PC")
end
-- ------- a remote trade through the relay's commit barrier
local Relay = require("tests.online_trade_fakerelay")
local function remotePair(version, slotA, slotB, data, opts)
local relay = Relay.new(opts)
local rsA, rsB = relay:join(0, "A"), relay:join(1, "B")
local remoteA = Trade.remote(open(version, slotA, data), rsA)
local remoteB = Trade.remote(open(version, slotB, data), rsB)
return relay, remoteA, remoteB, rsA, rsB
end
local function negotiate(remoteA, remoteB)
remoteA:start()
remoteB:start()
remoteA:update()
remoteB:update()
remoteA:pick(1)
remoteB:pick(1)
remoteA:update()
remoteB:update()
remoteA:confirm(true)
remoteB:confirm(true)
remoteA:update()
remoteB:update()
end
do
files = {}
local pathA = put("red", "slot1", gen1Save("RED", { gen1Mon("PIKACHU", 25) }))
local pathB = put("red", "slot2",
gen1Save("BLUE", { gen1Mon("KADABRA", 30) }))
local relay, remoteA, remoteB = remotePair("red", "slot1", "slot2", GEN1)
T.check(remoteA ~= nil and remoteB ~= nil, "both ends open an adapter")
T.eq(remoteA.game.save, remoteA.handle.save,
"the stub game the session reads is the handle's own save")
remoteA:start()
remoteB:start()
remoteA:update()
remoteB:update()
T.eq(remoteA:stage(), "picking", "both sides have the other's party")
remoteA:pick(1)
remoteB:pick(1)
remoteA:update()
remoteB:update()
local before = files[pathA]
remoteA:confirm(true)
remoteB:confirm(true)
remoteA:update()
remoteB:update()
T.eq(remoteA:stage(), "commit_wait", "the negotiation completes into the barrier")
T.eq(remoteB:stage(), "commit_wait", "on both ends")
T.eq(relay.commits, 1, "the relay saw two equal digests")
T.eq(files[pathA], before, "nothing is written before trade_commit arrives")
local early = remoteA:commit()
T.eq(early, false, "commit refuses before the relay committed")
remoteA:update()
remoteB:update()
T.eq(remoteA:stage(), "committed", "trade_commit writes seat 0")
T.eq(remoteB:stage(), "committed", "and seat 1")
T.eq(remoteA:commit(), true, "commit reports the result it already has")
T.eq(decodeAt(pathA).party[1].species, "ALAKAZAM",
"the KADABRA arrives evolved")
T.eq(decodeAt(pathB).party[1].species, "PIKACHU",
"and the PIKACHU crosses the other way")
T.eq(decodeAt(pathA).party[1].traded, true, "marked traded")
T.eq(decodeAt(pathB).pokedex.owned.PIKACHU, true, "with the dex marked")
remoteA:close()
remoteB:close()
end
do
files = {}
local pathA = put("red", "slot1", gen1Save("RED", { gen1Mon("PIKACHU", 25) }))
local pathB = put("red", "slot2",
gen1Save("BLUE", { gen1Mon("KADABRA", 30) }))
local diskA, diskB = files[pathA], files[pathB]
local relay, remoteA, remoteB = remotePair("red", "slot1", "slot2", GEN1)
local realPrepare = Trade.prepare
Trade.prepare = function(plan)
if plan.sides[1].handle == remoteB.handle then return false, "that save didn't validate" end
return realPrepare(plan)
end
negotiate(remoteA, remoteB)
remoteA:update()
remoteB:update()
Trade.prepare = realPrepare
T.eq(remoteB:stage(), "cancelled", "a save that won't build backs out before the confirm")
T.eq(remoteB.session.error, "that save didn't validate", "and says why")
T.eq(relay.commits, 0, "so the relay never commits")
T.eq(files[pathA], diskA, "and seat 0 writes nothing")
T.eq(files[pathB], diskB, "nor seat 1")
end
do
files = {}
local pathA = put("red", "slot1", gen1Save("RED", { gen1Mon("PIKACHU", 25) }))
local pathB = put("red", "slot2",
gen1Save("BLUE", { gen1Mon("KADABRA", 30) }))
local diskA, diskB = files[pathA], files[pathB]
local relay, remoteA, remoteB, rsA = remotePair("red", "slot1", "slot2", GEN1)
remoteA:start()
remoteB:start()
remoteA:update()
remoteB:update()
remoteA:pick(1)
remoteB:pick(1)
remoteA:update()
remoteB:update()
remoteB:confirm(true)
remoteA:update()
T.eq(remoteA:stage(), "confirming", "seat 0 still deciding")
rsA.close()
remoteB:update()
T.eq(relay.aborts, 0, "no digest ever reached the relay")
T.eq(remoteB:stage(), "cancelled", "the other trainer leaving calls it off")
T.eq(remoteB.session.error, "the other trainer left", "and says so")
T.eq(files[pathA], diskA, "a drop before commit writes nothing on seat 0")
T.eq(files[pathB], diskB, "or seat 1")
end
do
files = {}
local pathA = put("red", "slot1", gen1Save("RED", { gen1Mon("PIKACHU", 25) }))
local pathB = put("red", "slot2",
gen1Save("BLUE", { gen1Mon("KADABRA", 30) }))
local diskA, diskB = files[pathA], files[pathB]
local relay, remoteA, remoteB = remotePair("red", "slot1", "slot2", GEN1, {
tamper = function(seat, digest)
if seat == 1 then return ("f"):rep(16) end
return digest
end })
negotiate(remoteA, remoteB)
remoteA:update()
remoteB:update()
T.eq(relay.aborts, 1, "unequal digests abort at the relay")
T.eq(remoteA:stage(), "cancelled", "seat 0 cancels")
T.eq(remoteA.session.error, "digest", "naming the digest")
T.eq(files[pathA], diskA, "and writes nothing")
T.eq(files[pathB], diskB, "on either side")
end
do
files = {}
local pathA = put("red", "slot1", gen1Save("RED", { gen1Mon("PIKACHU", 25) }))
local pathB = put("red", "slot2",
gen1Save("BLUE", { gen1Mon("KADABRA", 30) }))
local _, remoteA, remoteB, _, rsB = remotePair("red", "slot1", "slot2", GEN1)
negotiate(remoteA, remoteB)
rsB.online = false
remoteA:update()
T.eq(remoteA:stage(), "committed", "seat 0 commits while seat 1 is dropped")
remoteB:update()
T.eq(remoteB:stage(), "commit_wait", "seat 1 waits through the drop")
rsB.online = true
remoteB:update()
T.eq(remoteB:stage(), "committed", "and commits on the replay")
T.eq(decodeAt(pathA).party[1].species, "ALAKAZAM", "both files are written")
T.eq(decodeAt(pathB).party[1].species, "PIKACHU", "after a drop past the commit")
end
-- ------- a remote Gen 2 trade uses the Gen 2 codec
do
files = {}
local pathA = put("gold", "slot1",
gen2Save("GOLD", { gen2Mon("TOTODILE", 20, { item = "LEFTOVERS" }) }))
local pathB = put("gold", "slot2",
gen2Save("SILVER", { gen2Mon("MACHOKE", 30) }))
local _, remoteA, remoteB = remotePair("gold", "slot1", "slot2", GEN2)
negotiate(remoteA, remoteB)
T.eq(remoteB:stage(), "commit_wait", "the Gen 2 negotiation reaches the barrier")
remoteA:update()
remoteB:update()
T.eq(remoteA:stage(), "committed", "and each side commits")
T.eq(remoteB:stage(), "committed", "its own save")
T.eq(decodeAt(pathA).party[1].species, "MACHAMP",
"the MACHOKE arrives evolved")
T.eq(decodeAt(pathB).party[1].species, "TOTODILE",
"and the TOTODILE crosses back")
T.eq(decodeAt(pathB).party[1].item, "LEFTOVERS",
"still holding what it left with")
remoteA:close()
remoteB:close()
end
local LT = require("src.core.game3.link.trade")
local Protocol = require("src.link.Protocol")
local realWithDataset = Trade.withDataset
local function restart(relay)
Trade._resolver, Trade._scanned = nil, false
Trade._applied, Trade._journalPaths = {}, {}
LT.outcomeClient = relay and relay:outcomeClient() or nil
Trade.withDataset = function(version, fn)
local data = version == "gold" and GEN2 or GEN1
local ok, result = pcall(fn, data)
if not ok then return nil, tostring(result) end
return result
end
return LT.outcomeClient
end
local function settle(steps)
for _ = 1, steps or 40 do
if Trade.pumpPending(1000) == true then return true end
end
return false
end
local function journalAt(path)
local body = files[Trade.journalPath(path)]
local data = body and SaveData.decode(body) or nil
return data and data.entries or nil
end
local function backupsOf(path)
local n = 0
for name in pairs(files) do
if name:sub(1, #path + 11) == path .. ".trade-bak-" then n = n + 1 end
end
return n
end
do
files = {}
restart(nil)
local pathA = put("red", "slot1", gen1Save("RED", { gen1Mon("PIKACHU", 25) }))
local pathB = put("red", "slot2",
gen1Save("BLUE", { gen1Mon("KADABRA", 30) }))
local relay, remoteA, remoteB, _, rsB = remotePair("red", "slot1", "slot2", GEN1)
negotiate(remoteA, remoteB)
local jB = journalAt(pathB)
T.eq(jB and #jB, 1, "seat 1 journals the trade before its confirm leaves")
T.eq(jB and jB[1].room, relay.room, "keyed by the relay room")
T.eq(jB and jB[1].digest, remoteB.digest, "and the digest it confirmed")
T.eq(jB and jB[1].slotId, "slot2", "naming the save it belongs to")
T.eq(jB and jB[1].sent.species, "KADABRA", "with the mon it sends")
T.eq(jB and jB[1].record.species, "PIKACHU", "and the one it gets")
T.check(journalAt(pathA) ~= nil, "seat 0 journals too")
rsB.online = false
remoteA:update()
T.eq(remoteA:stage(), "committed", "seat 0 commits")
T.eq(journalAt(pathA), nil, "and drops its journal after the save")
remoteB:close()
T.eq(decodeAt(pathB).party[1].species, "KADABRA",
"seat 1 lost the link for good before trade_commit")
local client = restart(relay)
client.failNext = 1
Trade.pumpPending(0)
Trade.pumpPending(0)
T.eq(#client.requests, 1, "the next launcher start asks the ledger")
T.eq(client.requests[1].path, "/trade/outcome", "on the outcome route")
T.eq(client.requests[1].room, relay.room, "for that room")
T.eq(client.requests[1].digest, remoteB.digest, "and that digest")
T.eq(client.requests[1].noAuth, true, "without an account")
T.eq(decodeAt(pathB).party[1].species, "KADABRA", "an unreachable server changes nothing")
T.check(journalAt(pathB) ~= nil, "and keeps the journal for a retry")
T.eq(settle(), true, "the retry settles it")
T.check(#client.requests >= 2, "after asking again")
local disk = decodeAt(pathB)
T.eq(disk.party[1].species, "PIKACHU", "the committed trade is applied to seat 1's save")
T.eq(disk.party[1].traded, true, "as a traded mon")
T.eq(disk.pokedex.owned.PIKACHU, true, "with the dex marked")
T.eq(journalAt(pathB), nil, "and the journal is dropped")
T.eq(backupsOf(pathB), 1, "the old file is backed up first")
T.eq(decodeAt(pathA).party[1].species, "ALAKAZAM", "seat 0 keeps what it got")
restart(relay)
T.eq(settle(), false, "nothing is left to resolve")
T.eq(decodeAt(pathB).party[1].species, "PIKACHU", "and nothing is applied twice")
end
do
files = {}
restart(nil)
local pathA = put("red", "slot1", gen1Save("RED", { gen1Mon("PIKACHU", 25) }))
local pathB = put("red", "slot2",
gen1Save("BLUE", { gen1Mon("KADABRA", 30) }))
local relay, remoteA, remoteB, _, rsB = remotePair("red", "slot1", "slot2", GEN1, {
tamper = function(seat, digest)
if seat == 0 then return ("f"):rep(16) end
return digest
end })
negotiate(remoteA, remoteB)
T.eq(relay.aborts, 1, "the relay aborts the round")
rsB.online = false
remoteA:update()
T.eq(journalAt(pathA), nil, "a seat that hears trade_abort drops its journal")
local before = files[pathB]
remoteB:close()
T.check(journalAt(pathB) ~= nil, "a seat that never heard it keeps one")
local client = restart(relay)
T.eq(settle(), true, "the ledger settles it")
T.eq(#client.requests, 1, "in one ask")
T.eq(files[pathB], before, "an aborted trade leaves the save alone")
T.eq(journalAt(pathB), nil, "and drops the journal")
end
do
files = {}
restart(nil)
put("red", "slot1", gen1Save("RED", { gen1Mon("PIKACHU", 25) }))
local pathB = put("red", "slot2",
gen1Save("BLUE", { gen1Mon("KADABRA", 30) }))
put("red", "slot3", gen1Save("GREEN", { gen1Mon("PIKACHU", 9) }))
local held = true
local relay, remoteA, remoteB, _, rsB = remotePair("red", "slot1", "slot2", GEN1, {
tamper = function(seat, digest)
if held and seat == 0 then return nil end
return digest
end })
negotiate(remoteA, remoteB)
T.eq(relay.commits + relay.aborts, 0, "seat 0's confirm is still in flight")
rsB.online = false
remoteB:close()
local client = restart(relay)
Trade.pumpPending(0)
Trade.pumpPending(0)
T.eq(client.requests[1] and client.requests[1].digest, remoteB.digest, "the ledger is asked")
T.eq(relay:outcome(relay.room, remoteB.digest), "open", "and says the round is open")
T.eq(decodeAt(pathB).party[1].species, "KADABRA", "so nothing is applied yet")
T.check(journalAt(pathB) ~= nil, "and the journal stays")
local plan, why = Trade.plan({ from = open("red", "slot2", GEN1), fromIndex = 1,
to = open("red", "slot3", GEN1), toIndex = 1 })
T.eq(plan, nil, "a mon whose trade is unsettled can't be traded again")
T.eq(why, "that POKéMON's last trade isn't settled yet", "and says why")
held = false
relay:_barrier(0, remoteB.digest)
T.eq(relay.commits, 1, "the round commits late")
T.eq(settle(), true, "the next ask settles it")
T.eq(decodeAt(pathB).party[1].species, "PIKACHU", "applying the trade")
T.eq(journalAt(pathB), nil, "and dropping the journal")
end
do
files = {}
restart(nil)
local pathB = put("red", "slot2", gen1Save("BLUE", { gen1Mon("KADABRA", 30) }))
local before = files[pathB]
files[Trade.journalPath(pathB)] = SaveData.encode({ v = 1, entries = {
{ room = "r0123456789abcdef", digest = ("a"):rep(16), at = os.time() - 2 * 24 * 60 * 60,
version = "red", slotId = "slot2", sent = Protocol.packMon(gen1Mon("KADABRA", 30)),
record = gen1Mon("PIKACHU", 25) } } })
local relay = Relay.new()
local client = restart(relay)
T.eq(settle(), true, "a journal older than a day settles")
T.eq(#client.requests, 0, "without asking")
T.eq(journalAt(pathB), nil, "by dropping it")
T.eq(files[pathB], before, "and leaving the save alone")
files[Trade.journalPath(pathB)] = SaveData.encode({ v = 1, entries = {
{ room = relay.room, digest = ("b"):rep(16), at = os.time(),
version = "red", slotId = "slot2", sent = Protocol.packMon(gen1Mon("PIKACHU", 7)),
record = gen1Mon("PIKACHU", 25) } } })
relay.ledger[1] = { room = relay.room, n = 1, outcome = "commit", digests = { ("b"):rep(16) } }
restart(relay)
T.eq(settle(), true, "a commit whose sent mon is gone settles")
T.eq(journalAt(pathB), nil, "by dropping the journal")
T.eq(files[pathB], before, "without touching the save")
end
LT.outcomeClient = nil
Trade.withDataset = realWithDataset
Trade._resolver, Trade._scanned = nil, false
Trade._applied, Trade._journalPaths = {}, {}
SaveData.portableFs = realPortableFs
-- ------- backup pruning keeps the three most recent per slot
do
local kept = {}
local pruneFs = {
getInfo = function(name) return kept[name] and { type = "file" } or nil end,
read = function(name) return kept[name] end,
write = function(name, body) kept[name] = body return true end,
remove = function(name) kept[name] = nil return true end,
createDirectory = function() return true end,
getDirectoryItems = function(dir)
local prefix, out = dir .. "/", {}
for name in pairs(kept) do
if name:sub(1, #prefix) == prefix then
out[#out + 1] = name:sub(#prefix + 1)
end
end
table.sort(out)
return out
end,
}
SaveData.portableFs = function() return pruneFs end
local path = "saves/red/slot1.lua"
kept[path] = "live"
kept[path .. ".bak"] = "one back"
for _, stamp in ipairs({ 100, 200, 300, 400, 500 }) do
kept[("%s.trade-bak-%d"):format(path, stamp)] = "backup " .. stamp
end
kept["saves/red/slot2.lua.trade-bak-100"] = "another slot"
T.eq(Trade.pruneBackups(path, 3), 2, "the two oldest backups are removed")
T.eq(kept[path .. ".trade-bak-100"], nil, "the oldest is gone")
T.eq(kept[path .. ".trade-bak-200"], nil, "and the next oldest")
T.eq(kept[path .. ".trade-bak-300"], "backup 300", "three are kept")
T.eq(kept[path .. ".trade-bak-500"], "backup 500", "the newest among them")
T.eq(kept[path], "live", "the save itself is untouched")
T.eq(kept[path .. ".bak"], "one back", "so is the ordinary .bak")
T.eq(kept["saves/red/slot2.lua.trade-bak-100"], "another slot",
"another slot's backups are not this slot's")
T.eq(Trade.pruneBackups(path, 3), 0, "a second pass has nothing to remove")
T.eq(Trade.pruneBackups("saves/red/slot9.lua", 3), 0,
"a slot with no backups prunes nothing")
T.eq(Trade.pruneBackups(nil, 3), 0, "and no path prunes nothing")
SaveData.portableFs = realPortableFs
end
T.finish("online trade")