mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
801 lines
35 KiB
Lua
801 lines
35 KiB
Lua
-- M12 link compatibility: fingerprint determinism, the v2 handshake and its
|
|
-- verdicts, the negotiated trade subset, the extra bag, ppUps on the wire,
|
|
-- and desync attribution over a loopback lockstep battle. Self-contained
|
|
-- like the other mod suites: own bootstrap, assert-based checks, error() on
|
|
-- failure. Chained from tests/run_link_tests.lua.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
love = love or require("tests.love_stub")
|
|
|
|
local Data = require("src.core.Data")
|
|
if not Data.maps then Data:load() end
|
|
require("src.render.Font").load(Data)
|
|
|
|
local Events = require("src.mods.Events")
|
|
local Fingerprint = require("src.link.Fingerprint")
|
|
local Handshake = require("src.link.Handshake")
|
|
local Hooks = require("src.mods.Hooks")
|
|
local Input = require("src.core.Input")
|
|
local Json = require("src.link.Json")
|
|
local LinkBattle = require("src.link.LinkBattle")
|
|
local Net = require("src.link.Net")
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
local Protocol = require("src.link.Protocol")
|
|
local Runtime = require("src.mods.Runtime")
|
|
|
|
local S = require("tests.harness").suite("mod link")
|
|
local check, eq = S.check, S.eq
|
|
|
|
-- a message as the peer sees it: through the same encoder the wire uses
|
|
local function wire(msg)
|
|
return Json.decode(Json.encode(msg))
|
|
end
|
|
|
|
local function copy(record)
|
|
local out = {}
|
|
for k, v in pairs(record) do out[k] = v end
|
|
return out
|
|
end
|
|
|
|
-- a merged-data view with its own id maps, so a fixture can retune a record
|
|
-- without touching the shared catalog
|
|
local function cloneData(base)
|
|
local out = { pokemon = {}, moves = {}, type_chart = base.type_chart,
|
|
constants = base.constants }
|
|
for id, record in pairs(base.pokemon) do out.pokemon[id] = record end
|
|
for id, record in pairs(base.moves) do out.moves[id] = record end
|
|
return out
|
|
end
|
|
|
|
local function fakeGame(data, name)
|
|
return { data = data, save = { player = { name = name } } }
|
|
end
|
|
|
|
-- ------- fingerprint determinism
|
|
|
|
local vanilla = cloneData(Data)
|
|
local first = Fingerprint.compute(vanilla, {})
|
|
eq(Fingerprint.compute(vanilla, {}), first, "fingerprint is stable across calls")
|
|
eq(#first, 16, "fingerprint is a 64-bit hex digest")
|
|
|
|
-- the same records reached through a differently built map: the digest walks
|
|
-- a sorted id list, so table layout can never leak into it
|
|
local reordered = { pokemon = {}, moves = {}, type_chart = Data.type_chart,
|
|
constants = Data.constants }
|
|
local ids = {}
|
|
for id in pairs(Data.pokemon) do ids[#ids + 1] = id end
|
|
table.sort(ids)
|
|
for i = #ids, 1, -1 do reordered.pokemon[ids[i]] = Data.pokemon[ids[i]] end
|
|
local moveIds = {}
|
|
for id in pairs(Data.moves) do moveIds[#moveIds + 1] = id end
|
|
table.sort(moveIds)
|
|
for i = #moveIds, 1, -1 do reordered.moves[moveIds[i]] = Data.moves[moveIds[i]] end
|
|
eq(Fingerprint.surface(reordered, {}), Fingerprint.surface(vanilla, {}),
|
|
"insertion order does not reach the canonical stream")
|
|
eq(Fingerprint.compute(reordered, {}), first, "reordered data fingerprints the same")
|
|
|
|
-- a record rebuilt with its subtable keys in another order still hashes the
|
|
-- same, and a stat edit does not
|
|
local restated = cloneData(Data)
|
|
local pidgey = copy(Data.pokemon.PIDGEY)
|
|
pidgey.baseStats = { special = Data.pokemon.PIDGEY.baseStats.special,
|
|
speed = Data.pokemon.PIDGEY.baseStats.speed,
|
|
defense = Data.pokemon.PIDGEY.baseStats.defense,
|
|
attack = Data.pokemon.PIDGEY.baseStats.attack,
|
|
hp = Data.pokemon.PIDGEY.baseStats.hp }
|
|
restated.pokemon.PIDGEY = pidgey
|
|
eq(Fingerprint.compute(restated, {}), first, "subtable key order is irrelevant")
|
|
|
|
local buffed = cloneData(Data)
|
|
local strongPidgey = copy(Data.pokemon.PIDGEY)
|
|
strongPidgey.baseStats = copy(Data.pokemon.PIDGEY.baseStats)
|
|
strongPidgey.baseStats.attack = strongPidgey.baseStats.attack + 1
|
|
buffed.pokemon.PIDGEY = strongPidgey
|
|
check(Fingerprint.compute(buffed, {}) ~= first, "a baseStats edit moves the digest")
|
|
|
|
-- ------- path independence (excluded fields)
|
|
|
|
local repathed = cloneData(Data)
|
|
local movedSprites = copy(Data.pokemon.PIDGEY)
|
|
movedSprites.spriteFront = "assets/generated/other/machine/pidgey.png"
|
|
movedSprites.spriteBack = "assets/generated/other/machine/pidgeyb.png"
|
|
movedSprites.source = "ROM:BaseStats[999]"
|
|
movedSprites.dexEntry = { kind = "TINY BIRD", heightFt = 1, heightIn = 0,
|
|
weight = 4.0, text = "different flavour" }
|
|
movedSprites.learnset = {}
|
|
repathed.pokemon.PIDGEY = movedSprites
|
|
eq(Fingerprint.compute(repathed, {}), first,
|
|
"sprite paths, source, dex entry and learnset stay out of the digest")
|
|
|
|
local retuned = cloneData(Data)
|
|
local strongTackle = copy(Data.moves.TACKLE)
|
|
strongTackle.power = 60
|
|
retuned.moves.TACKLE = strongTackle
|
|
check(Fingerprint.compute(retuned, {}) ~= first, "a move power edit moves the digest")
|
|
|
|
-- the affects-link mod set is folded in, so a logic-only change that ships
|
|
-- as a new version moves the digest even with identical records
|
|
local withMod = { { id = "rijon", version = "1.2.0", affectsLink = true } }
|
|
check(Fingerprint.compute(vanilla, withMod) ~= first, "affects-link mods fold in")
|
|
Fingerprint.forget(vanilla)
|
|
eq(Fingerprint.compute(vanilla, { { id = "rijon", version = "1.2.0",
|
|
affectsLink = false } }), first,
|
|
"a mod that declares it stays link-compatible does not")
|
|
|
|
-- the hook lets a total conversion widen or narrow the surface
|
|
local hooks = Hooks.new()
|
|
local events = Events.new()
|
|
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
|
Runtime.install(events, hooks, {})
|
|
hooks:wrap("link.fingerprint", function(nxt, data, mods)
|
|
return "ff" .. nxt(data, mods):sub(3)
|
|
end, 0, "test")
|
|
Fingerprint.forget(vanilla)
|
|
eq(Fingerprint.compute(vanilla, {}):sub(1, 2), "ff", "link.fingerprint hook applies")
|
|
hooks:removeOwner("test")
|
|
Fingerprint.forget(vanilla)
|
|
eq(Fingerprint.compute(vanilla, {}), first, "unwrapping restores the vanilla digest")
|
|
|
|
-- ------- linkModified: the cheap answer to "can I link with an old peer?"
|
|
|
|
-- the loader surface the discovery walk needs, backed by a path->text table
|
|
local function memfs(files)
|
|
return {
|
|
read = function(path) return files[path] end,
|
|
getInfo = function(path)
|
|
if files[path] then return { type = "file" } end
|
|
local prefix = path .. "/"
|
|
for key in pairs(files) do
|
|
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
|
end
|
|
return nil
|
|
end,
|
|
load = function(path)
|
|
if not files[path] then return nil, "no file: " .. path end
|
|
return load(files[path], path)
|
|
end,
|
|
getDirectoryItems = function(path)
|
|
local seen, items = {}, {}
|
|
local prefix = path .. "/"
|
|
for key in pairs(files) do
|
|
if key:sub(1, #prefix) == prefix then
|
|
local child = key:sub(#prefix + 1):match("^[^/]+")
|
|
if child and not seen[child] then
|
|
seen[child] = true
|
|
items[#items + 1] = child
|
|
end
|
|
end
|
|
end
|
|
table.sort(items)
|
|
return items
|
|
end,
|
|
}
|
|
end
|
|
|
|
local pika = { name = "PIKA", baseStats = { hp = 1, attack = 1, defense = 1,
|
|
speed = 1, special = 1 }, types = { "NORMAL" }, catchRate = 1,
|
|
baseExp = 1, growthRate = "MEDIUM_FAST" }
|
|
|
|
local function loadMods(files)
|
|
local data = { pokemon = { PIKA = pika }, moves = {}, audio = {} }
|
|
local loader = require("src.mods.Loader").new({ fs = memfs(files) })
|
|
loader:load(data)
|
|
-- the loader claims the process-wide buses on load; this suite wants its own
|
|
Runtime.install(events, hooks, {})
|
|
return { mods = loader, data = data, save = { player = { name = "RED" } } }
|
|
end
|
|
|
|
local bareGame = loadMods({})
|
|
eq(Handshake.linkModified(bareGame), false, "no mods means an unmodified link")
|
|
eq(#Handshake.mods(bareGame), 0, "and an empty mod array on the wire")
|
|
|
|
local tweakGame = loadMods({
|
|
["mods/tweak/manifest.json"] =
|
|
'{"id":"tweak","name":"tweak","version":"1.0.0","entry":"main.lua"}',
|
|
["mods/tweak/main.lua"] = [[
|
|
return function(mod)
|
|
mod.content.pokemon:patch("PIKA", { catchRate = 40 })
|
|
end
|
|
]],
|
|
})
|
|
eq(Handshake.linkModified(tweakGame), true,
|
|
"a content mod writing a link-surface record modifies the link")
|
|
eq(Handshake.mods(tweakGame)[1].affectsLink, false,
|
|
"but a content pack does not claim the fingerprint by itself")
|
|
|
|
local overhaulGame = loadMods({
|
|
["mods/rijon/manifest.json"] =
|
|
'{"id":"rijon","name":"rijon","version":"1.2.0","entry":"main.lua","profile":"overhaul"}',
|
|
["mods/rijon/main.lua"] = "return function(mod) end",
|
|
})
|
|
eq(Handshake.linkModified(overhaulGame), true, "an overhaul modifies the link")
|
|
eq(Handshake.mods(overhaulGame)[1].affectsLink, true, "and rides the hello")
|
|
eq(Handshake.hello(tweakGame, "trade").linkModified, true,
|
|
"the hello carries the flag a v1 peer is judged against")
|
|
|
|
-- ------- builtin records are private per dataset
|
|
|
|
-- two independent loads must not share record tables: an edit through one
|
|
-- dataset (hot reload, a suite loading twice) must never reach the other,
|
|
-- nor the module statics the engine falls back on when it has no loader
|
|
local isoA, isoB = loadMods({}), loadMods({})
|
|
check(not rawequal(isoA.data.type_chart.types.NORMAL,
|
|
isoB.data.type_chart.types.NORMAL),
|
|
"builtin type records are private per dataset")
|
|
check(not rawequal(isoA.data.type_chart.types.NORMAL,
|
|
require("src.battle.TypeChart").TYPES.NORMAL),
|
|
"and are not the module's own table")
|
|
eq(isoA.data.type_chart.types.NORMAL.category, "physical",
|
|
"the copy still carries the vanilla value")
|
|
isoA.data.type_chart.types.NORMAL.category = "special"
|
|
eq(isoB.data.type_chart.types.NORMAL.category, "physical",
|
|
"an edit through one dataset stays in it")
|
|
check(not rawequal(isoA.data.statuses.BRN, isoB.data.statuses.BRN),
|
|
"status records are private per dataset")
|
|
check(rawequal(isoA.data.statuses.BRN.residual, isoB.data.statuses.BRN.residual),
|
|
"handler functions ride the copy by reference")
|
|
|
|
-- ------- link_fields: a declared extra field that forces agreement
|
|
|
|
local plainGame = loadMods({})
|
|
eq(plainGame.data.link_fields, nil,
|
|
"an unregistered link_fields namespace never reaches Data")
|
|
local plainPrint = Fingerprint.compute(plainGame.data, {})
|
|
|
|
-- the held-item mod from the design, registered verbatim
|
|
local heldGame = loadMods({
|
|
["mods/held_items/manifest.json"] =
|
|
'{"id":"held_items","name":"held items","version":"1.0.0","entry":"main.lua",'
|
|
.. '"api":2,"affects_link":true}',
|
|
["mods/held_items/main.lua"] = [[
|
|
return function(mod)
|
|
mod.content.link_fields:register("held_item", {
|
|
rev = 1,
|
|
pack = function(mon) return mon.heldItem end,
|
|
unpack = function(mon, v) mon.heldItem = v end,
|
|
})
|
|
end
|
|
]],
|
|
})
|
|
eq(#heldGame.mods.errors, 0, "a link_fields registration loads clean")
|
|
check(heldGame.mods.content.link_fields ~= nil,
|
|
"the loader builds content.link_fields from the catalog")
|
|
eq(heldGame.data.link_fields.held_item.rev, 1, "and the merge lands the record")
|
|
eq(type(heldGame.data.link_fields.held_item.pack), "function",
|
|
"the codec rides along for the wire")
|
|
eq(Handshake.linkModified(heldGame), true, "a declared field modifies the link")
|
|
local heldPrint = Fingerprint.compute(heldGame.data, {})
|
|
check(heldPrint ~= plainPrint, "and moves the fingerprint off vanilla")
|
|
|
|
-- rev is what an author bumps when the codec's meaning changes, so a peer
|
|
-- on the old revision lands in subset instead of desyncing mid-battle
|
|
heldGame.data.link_fields.held_item.rev = 2
|
|
Fingerprint.forget(heldGame.data)
|
|
check(Fingerprint.compute(heldGame.data, {}) ~= heldPrint, "bumping rev moves it again")
|
|
|
|
-- two mods can ship different bodies under one rev, so the digest must not
|
|
-- pretend it can see them
|
|
heldGame.data.link_fields.held_item.rev = 1
|
|
heldGame.data.link_fields.held_item.pack = function() return nil end
|
|
Fingerprint.forget(heldGame.data)
|
|
eq(Fingerprint.compute(heldGame.data, {}), heldPrint,
|
|
"swapping the codec body alone does not")
|
|
|
|
local revlessGame = loadMods({
|
|
["mods/revless/manifest.json"] =
|
|
'{"id":"revless","name":"revless","version":"1.0.0","entry":"main.lua","api":2}',
|
|
["mods/revless/main.lua"] = [[
|
|
return function(mod)
|
|
mod.content.link_fields:register("held_item", { pack = function() end })
|
|
end
|
|
]],
|
|
})
|
|
check(table.concat(revlessGame.mods.errors, "\n")
|
|
:find("link_fields.held_item", 1, true) ~= nil,
|
|
"a field with no rev is rejected by name")
|
|
eq(revlessGame.data.link_fields, nil, "and leaves no residue")
|
|
|
|
-- ------- handshake verdicts
|
|
|
|
local helloA = Handshake.hello(fakeGame(vanilla, "RED"), "trade")
|
|
local helloB = Handshake.hello(fakeGame(cloneData(Data), "BLUE"), nil)
|
|
eq(helloA.protocol, 2, "hello announces the protocol revision")
|
|
eq(helloA.apiVersion, require("src.core.Version").modApi, "hello carries the api version")
|
|
eq(helloA.linkModified, false, "no mods means an unmodified link surface")
|
|
eq(helloA.fingerprint, helloB.fingerprint, "identical data fingerprints alike")
|
|
eq(Handshake.checkCompat(helloA, wire(helloB)), "full", "matching peers get full")
|
|
check(Handshake.battleAllowed("full"), "full allows lockstep")
|
|
check(Handshake.strict("full"), "full negotiates strictly")
|
|
|
|
-- side B adds a species and retunes a move: same engine, different surface
|
|
local modded = cloneData(Data)
|
|
local zorua = copy(Data.pokemon.RATTATA)
|
|
zorua.id, zorua.name, zorua.dex = "ZORUA", "ZORUA", 152
|
|
modded.pokemon.ZORUA = zorua
|
|
local moddedTackle = copy(Data.moves.TACKLE)
|
|
moddedTackle.power = 60
|
|
modded.moves.TACKLE = moddedTackle
|
|
local helloMod = Handshake.hello(fakeGame(modded, "BLUE"), nil)
|
|
helloMod.mods = { { id = "rijon", version = "1.2.0", affectsLink = true } }
|
|
eq(Handshake.checkCompat(helloA, wire(helloMod)), "subset",
|
|
"differing fingerprints negotiate a subset")
|
|
check(not Handshake.battleAllowed("subset"), "subset refuses lockstep")
|
|
check(Handshake.tradeAllowed("subset"), "subset still trades")
|
|
|
|
-- v1 interop: no protocol field at all
|
|
eq(Handshake.checkCompat(helloA, { type = "hello", name = "OLD", mode = "trade" }),
|
|
"vanilla_peer", "a v1 peer is compatible with an unmodified game")
|
|
local modifiedLocal = { linkModified = true, engineVersion = helloA.engineVersion,
|
|
fingerprint = "deadbeefdeadbeef", mods = {} }
|
|
eq(Handshake.checkCompat(modifiedLocal, { type = "hello", name = "OLD" }),
|
|
"refused", "a v1 peer is refused by a link-modified game")
|
|
check(not Handshake.strict(nil), "no verdict keeps the v1 unpack path")
|
|
|
|
-- a different engine major is refused outright
|
|
local nextEngine = Handshake.hello(fakeGame(vanilla, "BLUE"), nil)
|
|
nextEngine.engineVersion = "2.0.0"
|
|
eq(Handshake.checkCompat(helloA, nextEngine), "refused", "engine major mismatch refuses")
|
|
|
|
local lines = Handshake.describe(helloA, wire(helloMod), "subset", "battle")
|
|
check(#lines > 0, "the incompatibility screen has something to say")
|
|
local joined = table.concat(lines, " ")
|
|
check(joined:find("RIJON", 1, true) ~= nil, "the report names the missing mod")
|
|
check(joined:find("battle", 1, true) ~= nil, "the report says battle is unavailable")
|
|
for _, line in ipairs(lines) do
|
|
check(#line <= 20, "report line fits the screen: " .. line)
|
|
end
|
|
local refusedLines = Handshake.describe(modifiedLocal,
|
|
{ type = "hello", name = "OLD" }, "refused", "trade")
|
|
check(#refusedLines > 0, "a refusal explains itself too")
|
|
for _, line in ipairs(refusedLines) do
|
|
check(#line <= 20, "refusal line fits the screen: " .. line)
|
|
end
|
|
|
|
-- ------- ppUps and the extra bag on the wire
|
|
|
|
local ppMon = Pokemon.new(Data, "PIDGEY", 20)
|
|
local base = Data.moves[ppMon.moves[1].id].pp
|
|
ppMon.moves[1].ppUps = 3
|
|
ppMon.moves[1].pp = base + 3 * math.floor(base / 5)
|
|
ppMon.extra = { held_items = { held_item = "LEFTOVERS", count = 2, on = true },
|
|
bogus = print }
|
|
local packedPP = wire(Protocol.packMon(ppMon))
|
|
eq(packedPP.moves[1].ppUps, 3, "ppUps reaches the wire")
|
|
local restored = Protocol.unpackMon(Data, packedPP, { strict = true })
|
|
eq(restored.moves[1].ppUps, 3, "ppUps survives the round trip")
|
|
eq(restored.moves[1].pp, base + 3 * math.floor(base / 5),
|
|
"PP clamps to the PP-Up-adjusted maximum, not base PP")
|
|
eq(restored.extra.held_items.held_item, "LEFTOVERS", "the extra bag round-trips")
|
|
eq(restored.extra.held_items.count, 2, "extra numbers round-trip")
|
|
eq(restored.extra.held_items.on, true, "extra booleans round-trip")
|
|
eq(restored.extra.bogus, nil, "a function in the extra bag is stripped")
|
|
|
|
local plainMon = Pokemon.new(Data, "PIDGEY", 20)
|
|
local packedPlain = Protocol.packMon(plainMon)
|
|
eq(packedPlain.moves[1].ppUps, nil, "a mon without PP Ups sends no ppUps key")
|
|
eq(packedPlain.extra, nil, "a mon without extra data sends no bag")
|
|
eq(Protocol.unpackMon(Data, wire(packedPlain)).moves[1].ppUps, nil,
|
|
"an absent ppUps stays absent")
|
|
|
|
-- ------- negotiated rejection replaces the silent fallbacks
|
|
|
|
local orphan = { species = "PIDGEY", level = 20,
|
|
moves = { { id = "NOT_A_MOVE", pp = 10 } } }
|
|
local rebuilt = Protocol.unpackMon(Data, orphan)
|
|
eq(rebuilt.moves[1].id, "TACKLE", "the v1 path keeps the TACKLE substitute")
|
|
local rejected, why = Protocol.unpackMon(Data, orphan, { strict = true })
|
|
eq(rejected, nil, "strict mode rejects a mon with no shared moves")
|
|
eq(why, "no shared moves", "and says why")
|
|
local unknown, unknownWhy = Protocol.unpackMon(Data,
|
|
{ species = "ZORUA", level = 20, moves = {} }, { strict = true })
|
|
eq(unknown, nil, "strict mode rejects an unknown species")
|
|
check(unknownWhy ~= nil, "an unknown species is reported")
|
|
|
|
-- ------- subset trade: only mons both games rebuild identically
|
|
|
|
local partyVanilla = { Pokemon.new(Data, "PIDGEY", 12),
|
|
Pokemon.new(Data, "RATTATA", 12) }
|
|
partyVanilla[1].moves = { { id = "GUST", pp = Data.moves.GUST.pp } }
|
|
partyVanilla[2].moves = { { id = "TACKLE", pp = Data.moves.TACKLE.pp } }
|
|
local partyModded = { Pokemon.new(modded, "ZORUA", 12),
|
|
Pokemon.new(modded, "RATTATA", 12),
|
|
Pokemon.new(modded, "PIDGEY", 12) }
|
|
partyModded[1].moves = { { id = "GUST", pp = modded.moves.GUST.pp } }
|
|
partyModded[2].moves = { { id = "TACKLE", pp = modded.moves.TACKLE.pp } }
|
|
partyModded[3].moves = { { id = "GUST", pp = modded.moves.GUST.pp } }
|
|
|
|
local sessionA = Protocol.TradeSession.new(Data, partyVanilla,
|
|
{ subset = true, strict = true, peerName = "BLUE" })
|
|
local sessionB = Protocol.TradeSession.new(modded, partyModded,
|
|
{ subset = true, strict = true, peerName = "RED" })
|
|
local recordsA, recordsB = wire(sessionA:opening()), wire(sessionB:opening())
|
|
eq(recordsA.type, "records", "a subset trade opens with the record hashes")
|
|
local partyMsgA = wire(sessionA:handle(recordsB))
|
|
local partyMsgB = wire(sessionB:handle(recordsA))
|
|
eq(#partyMsgA.mons, 1, "only the agreed mons leave the vanilla game")
|
|
eq(#partyMsgB.mons, 1, "only the agreed mons leave the modded game")
|
|
eq(partyMsgA.mons[1].species, "PIDGEY", "the clean PIDGEY is eligible")
|
|
eq(partyMsgB.mons[1].species, "PIDGEY", "the modded game sends its clean PIDGEY")
|
|
check(sessionA:canPick(1), "a mon with shared data can be picked")
|
|
check(not sessionA:canPick(2), "a mon knowing a retuned move cannot")
|
|
check(sessionA.reasons[2] ~= nil, "the ineligible mon carries a reason")
|
|
check(not sessionB:canPick(1), "a species the other game lacks cannot be picked")
|
|
eq(sessionB.reasons[1], "not on the other game", "and says so")
|
|
|
|
sessionA:handle(partyMsgB)
|
|
sessionB:handle(partyMsgA)
|
|
eq(sessionA.stage, "picking", "both parties arrived")
|
|
local pickA = wire(sessionA:pick(1)) -- real slot 1
|
|
local pickB = wire(sessionB:pick(3)) -- real slot 3, wire slot 1
|
|
eq(pickB.index, 1, "the wire index is a position in the filtered list")
|
|
sessionA:handle(pickB)
|
|
sessionB:handle(pickA)
|
|
eq(sessionA.stage, "confirming", "both picks landed")
|
|
sessionA:handle(wire(sessionB:confirm(true)))
|
|
sessionB:handle(wire(sessionA:confirm(true)))
|
|
eq(sessionA.stage, "done", "the subset trade completes")
|
|
local received = sessionA:apply(nil)
|
|
eq(received.species, "PIDGEY", "the vanilla game received the agreed mon")
|
|
eq(partyVanilla[1], received, "and it landed in the slot that was given")
|
|
|
|
-- a full-verdict session sends the whole party and indexes it directly
|
|
local fullSession = Protocol.TradeSession.new(Data, partyVanilla, { strict = true })
|
|
eq(fullSession.stage, "waitParty", "a full session skips the record exchange")
|
|
eq(#fullSession:opening().mons, 2, "a full session sends the whole party")
|
|
eq(fullSession:wireIndex(2), 2, "and its wire indices are party slots")
|
|
|
|
-- ------- the state machine: hello promoted to pairing, verdict branch
|
|
|
|
local LinkState = require("src.link.LinkState")
|
|
|
|
local function mkInput()
|
|
local stub = { pressed = {} }
|
|
function stub:wasPressed(key) return self.pressed[key] == true end
|
|
return stub
|
|
end
|
|
|
|
local function linkGame(name, species, data)
|
|
local save = require("src.core.SaveData").newGame()
|
|
save.player.name = name
|
|
table.insert(save.party, Pokemon.new(Data, species, 20))
|
|
local stack = { list = {} }
|
|
function stack:push(state, ...)
|
|
table.insert(self.list, state)
|
|
if state.enter then state:enter(...) end
|
|
end
|
|
function stack:pop() return table.remove(self.list) end
|
|
function stack:top() return self.list[#self.list] end
|
|
return { data = data or Data, save = save, stack = stack, input = mkInput() }
|
|
end
|
|
|
|
-- two paired states, host already listening and guest already dialling
|
|
local function pairStates(gameA, gameB)
|
|
local netA, netB = Net.loopbackPair()
|
|
local host, guest = LinkState.new(gameA), LinkState.new(gameB)
|
|
host.net, guest.net = netA, netB
|
|
host.stage, guest.stage = "hosting", "joining"
|
|
gameA.stack:push(host)
|
|
gameB.stack:push(guest)
|
|
return host, guest
|
|
end
|
|
|
|
local function pump(a, b, gameA, gameB, times)
|
|
for _ = 1, (times or 1) do
|
|
a:update(1 / 60)
|
|
b:update(1 / 60)
|
|
gameA.input.pressed = {}
|
|
gameB.input.pressed = {}
|
|
end
|
|
end
|
|
|
|
local gameHost, gameGuest = linkGame("RED", "PIDGEY"), linkGame("BLUE", "RATTATA")
|
|
local host, guest = pairStates(gameHost, gameGuest)
|
|
pump(host, guest, gameHost, gameGuest, 2)
|
|
eq(host.stage, "modeSelect", "the host reaches mode select")
|
|
eq(guest.stage, "waitMode", "the guest waits for the mode")
|
|
check(guest.myHello ~= nil and guest.myHello.protocol == 2,
|
|
"the guest announces itself the moment it pairs")
|
|
check(host.peerHello ~= nil, "the host has the peer hello before it picks")
|
|
|
|
gameHost.input.pressed = { a = true }
|
|
host:update(1 / 60)
|
|
gameHost.input.pressed = {}
|
|
eq(host.verdict, "full", "two identical games agree")
|
|
eq(host.stage, "trade", "and go straight into the mode")
|
|
pump(host, guest, gameHost, gameGuest, 3)
|
|
eq(guest.verdict, "full", "the guest reaches the same verdict")
|
|
eq(host.trade.stage, "picking", "the host's trade session is ready")
|
|
eq(guest.trade.stage, "picking", "the guest's trade session is ready")
|
|
check(host.trade.strict, "a v2 verdict unpacks strictly")
|
|
|
|
-- a v1 peer sends the raw {name, mode} hello and nothing else
|
|
local gameOld = linkGame("RED", "PIDGEY")
|
|
local oldNet, peerNet = Net.loopbackPair()
|
|
local v1guest = LinkState.new(gameOld)
|
|
v1guest.net = oldNet
|
|
v1guest.stage = "joining"
|
|
gameOld.stack:push(v1guest)
|
|
v1guest:update(1 / 60)
|
|
peerNet:send({ type = "hello", name = "OLD", mode = "trade" })
|
|
peerNet:send({ type = "party",
|
|
mons = Protocol.packParty({ Pokemon.new(Data, "MACHOKE", 20) }) })
|
|
v1guest:update(1 / 60)
|
|
eq(v1guest.verdict, "vanilla_peer", "an old peer is accepted by an unmodified game")
|
|
v1guest:update(1 / 60)
|
|
eq(v1guest.trade.stage, "picking", "and the v1 trade runs as it always did")
|
|
check(not v1guest.trade.strict, "the v1 path keeps the old unpack rules")
|
|
|
|
-- the host talking to a peer that never says hello falls back after the
|
|
-- grace period, and its own hello still carries the v1 fields
|
|
local gameLone = linkGame("RED", "PIDGEY")
|
|
local loneNet, silentNet = Net.loopbackPair()
|
|
local v1host = LinkState.new(gameLone)
|
|
v1host.net = loneNet
|
|
v1host.stage = "hosting"
|
|
gameLone.stack:push(v1host)
|
|
v1host:update(1 / 60)
|
|
gameLone.input.pressed = { a = true }
|
|
v1host:update(1 / 60)
|
|
gameLone.input.pressed = {}
|
|
eq(v1host.stage, "waitHello", "the host waits for the peer's hello")
|
|
for _ = 1, 200 do v1host:update(1 / 60) end
|
|
eq(v1host.verdict, "vanilla_peer", "silence means a pre-mod peer")
|
|
eq(v1host.stage, "trade", "and the v1 path runs")
|
|
local sawHello = false
|
|
for _, msg in ipairs(silentNet:poll()) do
|
|
if msg.type == "hello" then
|
|
sawHello = true
|
|
eq(msg.mode, "trade", "the hello still carries the mode a v1 guest reads")
|
|
eq(msg.name, "RED", "and the name")
|
|
end
|
|
end
|
|
check(sawHello, "the host's hello went out")
|
|
|
|
-- mismatched surfaces: the screen explains, trade continues in subset mode
|
|
local gameVan, gameMod = linkGame("RED", "PIDGEY"), linkGame("BLUE", "RATTATA", modded)
|
|
local vanHost, modGuest = pairStates(gameVan, gameMod)
|
|
pump(vanHost, modGuest, gameVan, gameMod, 2)
|
|
gameVan.input.pressed = { a = true }
|
|
vanHost:update(1 / 60)
|
|
gameVan.input.pressed = {}
|
|
eq(vanHost.verdict, "subset", "differing data lands in subset")
|
|
eq(vanHost.stage, "notice", "and shows the incompatibility screen")
|
|
check(#vanHost.noticeLines > 0, "the screen has lines to draw")
|
|
vanHost:draw() -- smoke: the report renders under the headless stub
|
|
check(not vanHost.noticeExits, "a subset trade may continue")
|
|
pump(vanHost, modGuest, gameVan, gameMod, 2)
|
|
eq(modGuest.stage, "notice", "the guest sees the same screen")
|
|
gameVan.input.pressed = { a = true }
|
|
gameMod.input.pressed = { a = true }
|
|
vanHost:update(1 / 60)
|
|
modGuest:update(1 / 60)
|
|
gameVan.input.pressed = {}
|
|
gameMod.input.pressed = {}
|
|
eq(vanHost.trade.stage, "waitRecords", "continuing opens a subset session")
|
|
pump(vanHost, modGuest, gameVan, gameMod, 3)
|
|
eq(vanHost.trade.stage, "picking", "the record exchange completes")
|
|
check(vanHost.trade.subset, "the session negotiated a subset")
|
|
vanHost:draw() -- smoke: the ineligible rows render too
|
|
|
|
-- the same mismatch on the battle side refuses instead
|
|
local gameVan2, gameMod2 = linkGame("RED", "PIDGEY"), linkGame("BLUE", "RATTATA", modded)
|
|
local vanHost2, modGuest2 = pairStates(gameVan2, gameMod2)
|
|
pump(vanHost2, modGuest2, gameVan2, gameMod2, 2)
|
|
vanHost2.index = 2 -- BATTLE
|
|
gameVan2.input.pressed = { a = true }
|
|
vanHost2:update(1 / 60)
|
|
gameVan2.input.pressed = {}
|
|
eq(vanHost2.stage, "notice", "a mismatched link battle stops at the screen")
|
|
check(vanHost2.noticeExits, "and the screen is the end of it")
|
|
vanHost2:draw() -- smoke: the battle-refusal wording renders too
|
|
gameVan2.input.pressed = { a = true }
|
|
vanHost2:update(1 / 60)
|
|
gameVan2.input.pressed = {}
|
|
check(gameVan2.stack:top() ~= vanHost2, "acknowledging leaves link play")
|
|
|
|
-- ------- lockstep battle: refusal, the turn-order hook, desync attribution
|
|
|
|
-- pinned DVs keep the lockstep run reproducible: the shared seed only fixes
|
|
-- the RNG stream, and rolled DVs would move the whole battle underneath it
|
|
local function fixedMon(species, level)
|
|
local mon = Pokemon.new(Data, species, level)
|
|
mon.dvs = { hp = 8, attack = 8, defense = 8, speed = 8, special = 8 }
|
|
mon.statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }
|
|
mon.stats = require("src.pokemon.Stats").calc(Data.pokemon[species], level,
|
|
mon.dvs, mon.statExp)
|
|
mon.hp = mon.stats.hp
|
|
return mon
|
|
end
|
|
|
|
local function makeFakeGame(leadSpecies)
|
|
local save = require("src.core.SaveData").newGame()
|
|
table.insert(save.party, fixedMon(leadSpecies, 50))
|
|
local stack = { list = {} }
|
|
function stack:push(state, ...)
|
|
table.insert(self.list, state)
|
|
if state.enter then state:enter(...) end
|
|
end
|
|
function stack:pop() table.remove(self.list) end
|
|
function stack:top() return self.list[#self.list] end
|
|
function stack:update(dt)
|
|
local top = self:top()
|
|
if top and top.update then top:update(dt) end
|
|
end
|
|
return { data = Data, input = Input, stack = stack, save = save }
|
|
end
|
|
|
|
Input:init()
|
|
|
|
local refused, refusedWhy = LinkBattle.newHost(makeFakeGame("CHARIZARD"),
|
|
select(1, Net.loopbackPair()),
|
|
{ myParty = Protocol.packParty(partyVanilla),
|
|
theirParty = Protocol.packParty(partyVanilla), verdict = "subset" })
|
|
eq(refused, nil, "a subset verdict refuses to build a link battle")
|
|
check(refusedWhy ~= nil, "and reports why")
|
|
local strictRefused = LinkBattle.newHost(makeFakeGame("CHARIZARD"),
|
|
select(1, Net.loopbackPair()),
|
|
{ myParty = { { species = "ZORUA", level = 20, moves = {} } },
|
|
theirParty = Protocol.packParty(partyVanilla),
|
|
verdict = "full", strict = true })
|
|
eq(strictRefused, nil, "a strict battle refuses a mon the peer cannot rebuild")
|
|
|
|
-- ------- pokemon.received on the link-battle unpack
|
|
|
|
-- the held-item validator from the design's mod-author example: it must get
|
|
-- the same shot at a link battle's mons that it gets at a traded one
|
|
local function heldMon(species, level, item)
|
|
local mon = fixedMon(species, level)
|
|
mon.extra = { held_items = { held_item = item } }
|
|
return mon
|
|
end
|
|
|
|
local mine = { heldMon("CHARIZARD", 50, "LEFTOVERS"), fixedMon("PIDGEY", 20) }
|
|
local theirs = { heldMon("BLASTOISE", 50, "BAD_ITEM") }
|
|
local packedMine, packedTheirs = Protocol.packParty(mine), Protocol.packParty(theirs)
|
|
|
|
local seen
|
|
events:on("pokemon.received", function(payload)
|
|
seen[#seen + 1] = payload
|
|
payload.mon.nickname = "CHECKED"
|
|
local bag = payload.mon.extra and payload.mon.extra.held_items
|
|
if bag and bag.held_item == "BAD_ITEM" then bag.held_item = nil end
|
|
end, 0, "test")
|
|
|
|
seen = {}
|
|
local hostSide = LinkBattle.newHost(makeFakeGame("CHARIZARD"),
|
|
select(1, Net.loopbackPair()),
|
|
{ myParty = packedMine, theirParty = packedTheirs, theirName = "BLUE",
|
|
seed = 11, verdict = "full", strict = true })
|
|
check(hostSide ~= nil, "the host side builds")
|
|
eq(#seen, 3, "pokemon.received fires once per mon on both parties")
|
|
eq(seen[1].from, "link", "the payload names the link as the source")
|
|
eq(seen[1].peerName, "BLUE", "and carries the peer name")
|
|
eq(seen[1].mon.species .. "," .. seen[2].mon.species .. "," .. seen[3].mon.species,
|
|
"CHARIZARD,PIDGEY,BLASTOISE", "the host announces its own party first")
|
|
eq(hostSide.player.name, "CHECKED",
|
|
"a listener's edit reaches the battler it is built from")
|
|
eq(hostSide.enemy.mon.extra.held_items.held_item, nil,
|
|
"an unrecognised held item is stripped before the simulation sees it")
|
|
|
|
-- the guest holds the same two parties the other way round and has to walk
|
|
-- them in the same order, or a mutating validator desyncs the lockstep
|
|
seen = {}
|
|
local guestSide = LinkBattle.newGuest(makeFakeGame("BLASTOISE"),
|
|
select(1, Net.loopbackPair()),
|
|
{ myParty = packedTheirs, theirParty = packedMine, theirName = "RED",
|
|
seed = 11, verdict = "full", strict = true })
|
|
check(guestSide ~= nil, "the guest side builds")
|
|
eq(#seen, 3, "and sees the same three mons")
|
|
eq(seen[1].mon.species .. "," .. seen[2].mon.species .. "," .. seen[3].mon.species,
|
|
"CHARIZARD,PIDGEY,BLASTOISE", "in the same host-first order as the host")
|
|
|
|
events:removeOwner("test")
|
|
seen = {}
|
|
local unhooked = LinkBattle.newHost(makeFakeGame("CHARIZARD"),
|
|
select(1, Net.loopbackPair()),
|
|
{ myParty = packedMine, theirParty = packedTheirs, theirName = "BLUE",
|
|
seed = 11, verdict = "full", strict = true })
|
|
eq(#seen, 0, "nothing is emitted once the listener is gone")
|
|
eq(unhooked.enemy.mon.extra.held_items.held_item, "BAD_ITEM",
|
|
"and the unpacked mon is untouched with nobody subscribed")
|
|
|
|
-- both sides run the same modded ordering rule and the same asymmetric
|
|
-- stage bump; the first proves the link path honours the battle hooks M6
|
|
-- landed, the second proves a divergence is caught and named
|
|
local orderCalls = 0
|
|
hooks:wrap("battle.turn_order", function(nxt, a, aMove, b, bMove, ctx)
|
|
orderCalls = orderCalls + 1
|
|
return nxt(a, aMove, b, bMove, ctx)
|
|
end, 0, "test")
|
|
|
|
local turnStarts = 0
|
|
local desync = nil
|
|
events:on("battle.turn_started", function(payload)
|
|
turnStarts = turnStarts + 1
|
|
check(payload.turn ~= nil, "battle.turn_started carries the turn number")
|
|
end, 0, "test")
|
|
events:on("link.desync", function(payload) desync = desync or payload end, 0, "test")
|
|
|
|
local gameA, gameB = makeFakeGame("CHARIZARD"), makeFakeGame("BLASTOISE")
|
|
gameB.save.player.name = "BLUE"
|
|
local netA, netB = Net.loopbackPair()
|
|
local packedA = Protocol.packParty(gameA.save.party)
|
|
local packedB = Protocol.packParty(gameB.save.party)
|
|
local battleA = LinkBattle.newHost(gameA, netA, {
|
|
myParty = packedA, theirParty = packedB, theirName = "BLUE", seed = 424242,
|
|
verdict = "full", strict = true })
|
|
local battleB = LinkBattle.newGuest(gameB, netB, {
|
|
myParty = packedB, theirParty = packedA, theirName = "RED", seed = 424242,
|
|
verdict = "full", strict = true })
|
|
check(battleA ~= nil and battleB ~= nil, "a full verdict builds both sides")
|
|
|
|
-- only the host's simulation gets the extra boost, so the two states must
|
|
-- disagree on the actives component
|
|
events:on("battle.turn_started", function(payload)
|
|
if payload.battle == battleA and payload.turn == 2 then
|
|
local stages = payload.battle.player.stages
|
|
stages.attack = (stages.attack or 0) + 2
|
|
end
|
|
end, 0, "test")
|
|
|
|
local resA, resB
|
|
battleA.onFinish = function(result) resA = result end
|
|
battleB.onFinish = function(result) resB = result end
|
|
gameA.stack:push(battleA)
|
|
gameB.stack:push(battleB)
|
|
local guard = 0
|
|
while (resA == nil or resB == nil) and guard < 60000 do
|
|
guard = guard + 1
|
|
Input.pressed = { a = true }
|
|
gameA.stack:update(1 / 60)
|
|
gameB.stack:update(1 / 60)
|
|
end
|
|
check(orderCalls > 0, "the link path routes turn order through battle.turn_order")
|
|
check(turnStarts > 0, "the link path emits battle.turn_started")
|
|
eq(resA, "draw", "the host ends the desynced match as a draw")
|
|
eq(resB, "draw", "the guest ends the desynced match as a draw")
|
|
check(desync ~= nil, "link.desync fired")
|
|
eq(desync.component, "actives", "the report names the diverging component")
|
|
eq(desync.turn, 2, "and the turn it happened on")
|
|
check(desync.localHash ~= desync.remoteHash, "the two component hashes differ")
|
|
check(battleA.localHashes[2]:find("^%u+:%d+:") ~= nil,
|
|
"the hash message keeps the v1 value shape a pre-mod peer compares")
|
|
|
|
-- a verified turn stays recorded, so a finished battle's whole hash trail
|
|
-- can be swept; consuming compared turns left 0-1 entries behind
|
|
for turn = 1, battleA.turnCount do
|
|
check(battleA.localHashes[turn] ~= nil and battleA.localParts[turn] ~= nil,
|
|
"the host retains the turn " .. turn .. " hash record")
|
|
end
|
|
eq(battleA.localHashes[1], battleB.localHashes[1],
|
|
"the retained pre-desync hashes agree across peers")
|
|
|
|
-- a pre-mod peer sends no parts at all, so the combined value has to stay
|
|
-- the comparison of record
|
|
desync = nil
|
|
local gameOldPeer = makeFakeGame("CHARIZARD")
|
|
local oldNetA = select(1, Net.loopbackPair())
|
|
local battleOld = LinkBattle.newHost(gameOldPeer, oldNetA, {
|
|
myParty = Protocol.packParty(gameOldPeer.save.party),
|
|
theirParty = Protocol.packParty(makeFakeGame("BLASTOISE").save.party),
|
|
theirName = "OLD", seed = 7 })
|
|
gameOldPeer.stack:push(battleOld)
|
|
battleOld.localHashes[1] = "CHARIZARD:100:nil|BLASTOISE:100:nil"
|
|
table.insert(oldNetA.inbox, { type = "hash", turn = 1,
|
|
value = "CHARIZARD:100:nil|BLASTOISE:100:nil" })
|
|
gameOldPeer.stack:update(1 / 60)
|
|
eq(battleOld.result, nil, "a matching v1 hash is not a desync")
|
|
eq(desync, nil, "and nothing is reported")
|
|
check(battleOld.localHashes[1] ~= nil, "a matching v1 hash stays recorded")
|
|
table.insert(oldNetA.inbox, { type = "hash", turn = 2, value = "elsewhere" })
|
|
battleOld.localHashes[2] = "CHARIZARD:90:nil|BLASTOISE:80:nil"
|
|
gameOldPeer.stack:update(1 / 60)
|
|
eq(battleOld.result, "draw", "a differing v1 hash still ends the match")
|
|
check(desync ~= nil and desync.component == "state",
|
|
"and is attributed to the whole state, the only thing a v1 peer sends")
|
|
|
|
events:removeOwner("test")
|
|
hooks:removeOwner("test")
|
|
Runtime.install(savedEvents, savedHooks, nil)
|
|
|
|
S.finish()
|