big ass modding update

This commit is contained in:
bryanthaboi
2026-07-19 16:18:18 -04:00
parent b5a673b252
commit 47923d95b3
258 changed files with 31048 additions and 2310 deletions
+284
View File
@@ -0,0 +1,284 @@
-- Deterministic digest of the link surface: the slice of merged data whose
-- value decides whether two lockstep simulations stay identical and whether a
-- traded mon is rebuilt the same way on both machines (D8). Peers whose
-- digests agree may battle; peers whose digests differ negotiate a trade
-- subset instead of desyncing three turns in.
--
-- Everything is serialized through an explicit sorted key order. pairs()
-- order differs between two runs of the same build, so a digest that
-- inherited it would reject identical peers at random -- that is the whole
-- reason this file exists instead of a hash over tostring(data).
--
-- Deliberately excluded: sprite paths and `source` (install-specific
-- generated paths that differ between two otherwise identical machines),
-- names, dex entries, learnsets and TM/HM lists (they change no battle math
-- and no trade rebuild).
local Runtime = require("src.mods.Runtime")
local Fingerprint = {}
-- ------- FNV-1a, two lanes
-- Two 32-bit lanes with different offset bases, concatenated into a 64-bit
-- hex digest. Pure arithmetic: the 32-bit product is split so every
-- intermediate stays inside a double's exact integer range, and the low-byte
-- xor runs off a nibble table -- LuaJIT has bit ops, plain 5.1 does not, and
-- tools load this file outside the game.
local PRIME = 16777619
local LANE_A, LANE_B = 2166136261, 2654435769
local XOR4 = {}
for a = 0, 15 do
XOR4[a] = {}
for b = 0, 15 do
local x, y, r = a, b, 0
for place = 0, 3 do
if x % 2 ~= y % 2 then r = r + 2 ^ place end
x, y = math.floor(x / 2), math.floor(y / 2)
end
XOR4[a][b] = r
end
end
local function xor8(a, b)
return XOR4[math.floor(a / 16)][math.floor(b / 16)] * 16 + XOR4[a % 16][b % 16]
end
local function step(h, byte)
local lo = h % 65536
local hi = (h - lo) / 65536
lo = lo - lo % 256 + xor8(lo % 256, byte)
return (lo * PRIME + (hi * PRIME % 65536) * 65536) % 4294967296
end
local function digest(text)
local a, b = LANE_A, LANE_B
for i = 1, #text do
local byte = text:byte(i)
a = step(a, byte)
b = step(b, byte)
end
return ("%08x%08x"):format(a, b)
end
Fingerprint.digest = digest
-- ------- canonical serialization
-- %.17g is exact for every integer stat involved and is the same format the
-- wire encoder uses, so a value that survives JSON hashes the same
local function number(v)
return ("%.17g"):format(v)
end
local writeValue
-- tables are written array part first (order is meaning there: type chart
-- rows, evolution lists), then named keys in sorted order
writeValue = function(out, v)
local t = type(v)
if t == "number" then
out[#out + 1] = "#" .. number(v)
elseif t == "string" then
out[#out + 1] = "$" .. v
elseif t == "boolean" then
out[#out + 1] = v and "T" or "F"
elseif t == "table" then
out[#out + 1] = "("
local n = #v
for i = 1, n do writeValue(out, v[i]) end
local keys = {}
for k in pairs(v) do
if not (type(k) == "number" and k >= 1 and k <= n and k % 1 == 0) then
keys[#keys + 1] = k
end
end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
for _, k in ipairs(keys) do
out[#out + 1] = "." .. tostring(k)
writeValue(out, v[k])
end
out[#out + 1] = ")"
else
-- a handler's bytes are not portably hashable; mods bump the record's
-- rev instead, and the mod version is the backstop when they forget
out[#out + 1] = "?"
end
end
-- an absent field is skipped identically on both sides, so a record that
-- never had the key and one whose mod removed it agree
local function writeFields(out, record, fields)
for _, field in ipairs(fields) do
local v = record[field]
if v ~= nil then
out[#out + 1] = "." .. field
writeValue(out, v)
end
end
end
local function sortedIds(map)
local ids = {}
for id in pairs(map or {}) do ids[#ids + 1] = id end
table.sort(ids)
return ids
end
-- ------- the link surface
local SPECIES_FIELDS = { "baseStats", "types", "catchRate", "baseExp",
"growthRate", "evolutions" }
local MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", "category",
"priority", "highCrit", "fixedDamage", "multiHit",
"counterable", "semiInvulnerable" }
-- catchBonus/shakeBonus are this engine's names for the plan's catchModifier
local STATUS_FIELDS = { "rev", "catchBonus", "shakeBonus", "statPenalty",
"cureOnSwitch", "beforeMovePriority" }
local EFFECT_FIELDS = { "rev", "kind", "accuracyChecked" }
local CONSTANT_FIELDS = { "partyMax", "moveMax", "levelCap", "dexSize",
"badgeBoosts" }
local RECORD_FIELDS = { pokemon = SPECIES_FIELDS, moves = MOVE_FIELDS,
statuses = STATUS_FIELDS, move_effects = EFFECT_FIELDS }
Fingerprint.FIELDS = RECORD_FIELDS
local function writeSection(out, data, kind)
local map = data[kind]
if map == nil then return end
local fields = RECORD_FIELDS[kind]
out[#out + 1] = "[" .. kind .. "]"
for _, id in ipairs(sortedIds(map)) do
local record = map[id]
if type(record) == "table" then
out[#out + 1] = "@" .. id
writeFields(out, record, fields)
end
end
end
-- the chart rows are an ordered array whose order the merge rebuilds from
-- registration history, so they hash in place; the type records ride along
-- because `category` decides the physical/special split
local function writeTypeChart(out, data)
local chart = data.type_chart
if not chart then return end
out[#out + 1] = "[type_chart]"
for _, row in ipairs(chart.matchups or {}) do
out[#out + 1] = ("@%s>%s"):format(tostring(row.attacker), tostring(row.defender))
writeValue(out, row.multiplier)
end
for _, id in ipairs(sortedIds(chart.types)) do
local record = chart.types[id]
if type(record) == "table" then
out[#out + 1] = "@" .. id
writeFields(out, record, { "category", "index" })
end
end
end
local function writeConstants(out, data)
if not data.constants then return end
out[#out + 1] = "[constants]"
writeFields(out, data.constants, CONSTANT_FIELDS)
end
-- a mod that wants an extra mon field to force agreement declares it here;
-- only the author revision is hashable, the pack/unpack pair is not
local function writeLinkFields(out, data)
local fields = data.link_fields
if not fields then return end
out[#out + 1] = "[link_fields]"
for _, id in ipairs(sortedIds(fields)) do
local record = fields[id]
if type(record) == "table" then
out[#out + 1] = "@" .. id
writeFields(out, record, { "rev" })
end
end
end
-- id@version of every enabled mod that touches the link surface: the
-- backstop for a logic-only change whose author forgot to bump a rev
local function modKey(mods)
local parts = {}
for _, mod in ipairs(mods or {}) do
if mod.affectsLink ~= false then
parts[#parts + 1] = ("%s@%s"):format(tostring(mod.id),
tostring(mod.version or "?"))
end
end
table.sort(parts)
return table.concat(parts, ",")
end
Fingerprint.modKey = modKey
-- ------- public API
-- memoized per merged-data identity: the digest is only ever asked for on
-- entry to link play, and vanilla single-player must not pay for it at all
local cache = setmetatable({}, { __mode = "k" })
local function surface(data, mods)
local out = {}
writeSection(out, data, "pokemon")
writeSection(out, data, "moves")
writeTypeChart(out, data)
writeSection(out, data, "statuses")
writeSection(out, data, "move_effects")
writeConstants(out, data)
writeLinkFields(out, data)
out[#out + 1] = "[mods]" .. modKey(mods)
return table.concat(out)
end
Fingerprint.surface = surface
-- mods: { { id, version, affectsLink } } -- the hello's mod array
function Fingerprint.compute(data, mods)
if not data then return digest("") end
local key = modKey(mods)
local hit = cache[data]
if hit and hit.key == key then return hit.value end
local value = Runtime.call("link.fingerprint", function(d, m)
return digest(surface(d, m))
end, data, mods)
cache[data] = { key = key, value = value }
return value
end
-- per-record digests over the same allowlist, so two peers can agree on
-- exactly which species and moves they rebuild identically
local recordCache = setmetatable({}, { __mode = "k" })
function Fingerprint.records(data, kind)
local fields = RECORD_FIELDS[kind]
assert(fields, "no record allowlist for " .. tostring(kind))
local perData = recordCache[data]
if not perData then
perData = {}
recordCache[data] = perData
end
if perData[kind] then return perData[kind] end
local map = data[kind] or {}
local out = {}
for id, record in pairs(map) do
if type(record) == "table" then
local buf = { "@" .. id }
writeFields(buf, record, fields)
out[id] = digest(table.concat(buf))
end
end
perData[kind] = out
return out
end
function Fingerprint.forget(data)
cache[data] = nil
recordCache[data] = nil
end
return Fingerprint
+225
View File
@@ -0,0 +1,225 @@
-- Handshake v2 (D8): the `hello` both peers exchange on pairing and the
-- compatibility verdict drawn from the two of them.
--
-- v1 builds sent `{type="hello", name, mode}` and nothing else. Every field
-- here is additive, and a peer that omits `protocol` is by construction a
-- pre-mod build running unmodified content -- so a missing `protocol` reads
-- as "peer is vanilla" and the v1 code path is taken verbatim. That keeps
-- old installs byte-compatible instead of locking them out.
local Fingerprint = require("src.link.Fingerprint")
local Schemas = require("src.mods.Schemas")
local Version = require("src.core.Version")
local Handshake = {}
Handshake.PROTOCOL = Version.linkProtocol or 2
-- writing into any of these changes what a lockstep turn or a rebuilt trade
-- mon looks like, which is what a v1 peer cannot know about us
local LINK_SURFACE = {
pokemon = true, moves = true, type_chart = true, statuses = true,
move_effects = true, balls = true, rulesets = true, constants = true,
link_fields = true,
}
Handshake.LINK_SURFACE = LINK_SURFACE
local function loader(game)
return game and game.mods or nil
end
-- every enabled mod, sorted so both peers see one order. The whole set
-- rides the wire because the incompatibility screen diffs these arrays to
-- name what is missing; only the affects-link ones fold into the digest.
function Handshake.mods(game)
local mods = {}
local mod = loader(game)
if not mod or not mod.status then return mods end
local ok, status = pcall(mod.status, mod)
if not ok or not status then return mods end
for _, manifest in ipairs(status.loaded or {}) do
mods[#mods + 1] = { id = manifest.id, version = manifest.version,
affectsLink = manifest.affects_link ~= false }
end
table.sort(mods, function(a, b) return tostring(a.id) < tostring(b.id) end)
return mods
end
-- cheap answer to "can I link with a peer that assumes vanilla?": true as
-- soon as one enabled mod either declares affects_link or has written a
-- record into a link-surface registry
function Handshake.linkModified(game)
local mod = loader(game)
if not mod then return false end
for _, entry in ipairs(Handshake.mods(game)) do
if entry.affectsLink then return true end
end
for name, registry in pairs(mod.content or {}) do
if LINK_SURFACE[name] then
for _, list in pairs(registry.ops or {}) do
for _, entry in ipairs(list) do
if entry.owner and entry.owner ~= Schemas.ENGINE then return true end
end
end
end
end
return false
end
-- mode is nil on the guest: it pairs and announces itself before the host
-- has picked, and compatibility is decided from the two hellos, not the mode
function Handshake.hello(game, mode)
local mods = Handshake.mods(game)
return {
type = "hello",
protocol = Handshake.PROTOCOL,
name = game and game.save and game.save.player and game.save.player.name,
mode = mode,
engineVersion = Version.engine,
apiVersion = Version.modApi,
fingerprint = Fingerprint.compute(game and game.data, mods),
linkModified = Handshake.linkModified(game),
mods = mods,
}
end
local function major(semver)
return tonumber(tostring(semver or ""):match("^(%d+)")) or 0
end
-- full identical link surfaces: nothing to negotiate, lockstep is safe
-- vanilla_peer an old build, and we are unmodified, so it is right about us
-- subset both v2 but the surfaces differ: negotiated trade, no battle
-- refused an old build we would silently corrupt, or a different engine
function Handshake.checkCompat(localHello, remoteHello)
localHello = localHello or {}
if not remoteHello or not remoteHello.protocol then
if localHello.linkModified then
return "refused", "peer_v1_modified"
end
return "vanilla_peer", nil
end
if major(remoteHello.engineVersion) ~= major(localHello.engineVersion) then
return "refused", "engine_mismatch"
end
if remoteHello.fingerprint == localHello.fingerprint then
return "full", nil
end
return "subset", "fingerprint_mismatch"
end
-- only two v2 peers that agreed on a verdict may reject a mon outright; a v1
-- peer keeps the old substitute-a-move behaviour it was built against
function Handshake.strict(verdict)
return verdict == "full" or verdict == "subset"
end
function Handshake.battleAllowed(verdict)
return verdict == "full" or verdict == "vanilla_peer" or verdict == nil
end
function Handshake.tradeAllowed(verdict)
return verdict ~= "refused"
end
-- ------- incompatibility report
local function index(mods)
local byId = {}
for _, mod in ipairs(mods or {}) do byId[tostring(mod.id)] = mod end
return byId
end
-- the two mod arrays diffed, so the screen can name the difference instead
-- of the old silent mid-battle draw
function Handshake.modDiff(localHello, remoteHello)
local mine = index(localHello and localHello.mods)
local theirs = index(remoteHello and remoteHello.mods)
local onlyMine, onlyTheirs, differing = {}, {}, {}
for id, mod in pairs(mine) do
local peer = theirs[id]
if not peer then
onlyMine[#onlyMine + 1] = mod
elseif tostring(peer.version) ~= tostring(mod.version) then
differing[#differing + 1] = { id = id, mine = mod.version,
theirs = peer.version }
end
end
for id, mod in pairs(theirs) do
if not mine[id] then onlyTheirs[#onlyTheirs + 1] = mod end
end
local byId = function(a, b) return tostring(a.id) < tostring(b.id) end
table.sort(onlyMine, byId)
table.sort(onlyTheirs, byId)
table.sort(differing, byId)
return { onlyMine = onlyMine, onlyTheirs = onlyTheirs, differing = differing }
end
local WIDTH = 19 -- characters that fit one 160px line at 8px per glyph
local function wrap(lines, text)
while #text > WIDTH do
local cut = text:sub(1, WIDTH + 1):match("^.*()%s")
if not cut or cut <= 1 then cut = WIDTH + 1 end
lines[#lines + 1] = text:sub(1, cut - 1)
text = text:sub(cut + 1)
end
if #text > 0 then lines[#lines + 1] = text end
end
local function listMods(lines, heading, mods)
if #mods == 0 then return end
wrap(lines, heading)
for i, mod in ipairs(mods) do
if i > 3 then
wrap(lines, ("and %d more."):format(#mods - 3))
return
end
wrap(lines, (" %s %s"):format(tostring(mod.id):upper():sub(1, 12),
tostring(mod.version or "?")))
end
end
-- lines for the incompatibility screen: what differs, then what still works
function Handshake.describe(localHello, remoteHello, verdict, mode)
local lines = {}
local peer = (remoteHello and remoteHello.name) or "THEY"
if verdict == "refused" then
if not (remoteHello and remoteHello.protocol) then
wrap(lines, "The other game is")
wrap(lines, "an older version")
wrap(lines, "with no mods.")
wrap(lines, "Your mods can't")
wrap(lines, "link with it.")
else
wrap(lines, "The two games are")
wrap(lines, "different engine")
wrap(lines, "versions.")
end
return lines
end
wrap(lines, "Your games differ.")
local diff = Handshake.modDiff(localHello, remoteHello)
listMods(lines, peer .. " has:", diff.onlyTheirs)
listMods(lines, "You have:", diff.onlyMine)
for i, row in ipairs(diff.differing) do
if i > 2 then break end
wrap(lines, ("%s %s vs %s"):format(tostring(row.id):upper():sub(1, 8),
tostring(row.mine), tostring(row.theirs)))
end
if #diff.onlyMine == 0 and #diff.onlyTheirs == 0 and #diff.differing == 0 then
wrap(lines, "The game data is")
wrap(lines, "not the same.")
end
if mode == "battle" then
wrap(lines, "Link battle needs")
wrap(lines, "the same mods.")
else
wrap(lines, "Trading is limited")
wrap(lines, "to shared POKéMON.")
end
return lines
end
return Handshake
+180 -22
View File
@@ -14,8 +14,11 @@
-- boosts don't apply on either side (divergence: Gen 1 famously kept
-- them in link battles).
local Fingerprint = require("src.link.Fingerprint")
local Handshake = require("src.link.Handshake")
local Logger = require("src.core.Logger")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local TurnOrder = require("src.battle.TurnOrder")
local LinkBattle = {}
@@ -47,7 +50,9 @@ local function mkBattler(data, mon, isPlayer)
}
end
-- canonical (host-side-first) state signature for desync detection
-- canonical (host-side-first) state hash, unchanged since v1: it stays on
-- the wire as `value` so a pre-mod peer still compares something it agrees
-- with, while the components below carry the real coverage
local function stateHash(self, role)
local function sig(b)
return ("%s:%d:%s"):format(b.mon.species, b.mon.hp, tostring(b.mon.status))
@@ -57,29 +62,141 @@ local function stateHash(self, role)
return sig(hostSide) .. "|" .. sig(guestSide)
end
-- The signature is split into components so a mismatch can name what
-- diverged: species:hp:status alone missed stat stages, PP, toxic counters
-- and bench damage until they happened to move an active's HP, and the
-- match then ended in a draw that explained nothing.
local STAGES = { "attack", "defense", "special", "speed", "accuracy", "evasion" }
local VOLATILE = {
"bideDamage", "bideTurns", "boundTurns", "chargeReady", "charging",
"confusedTurns", "disabledSlot", "disabledTurns", "flinched", "focusEnergy",
"invulnerable", "leechSeeded", "lightScreen", "mist", "mustRecharge",
"rageMove", "reflect", "skipMove", "sleepTurns", "substituteHP",
"thrashMove", "thrashTurns", "toxicCounter", "trapDamage", "trapMove",
"trappingTurns",
}
-- move instances ride some volatile slots; only their id is comparable
local function scalar(v)
if type(v) == "table" then return tostring(v.id or "?") end
if type(v) == "boolean" then return v and "T" or "F" end
return tostring(v)
end
local function stageStr(b)
local out = {}
for i, stat in ipairs(STAGES) do
out[i] = tostring((b.stages or {})[stat] or 0)
end
return table.concat(out, ",")
end
local function ppStr(mon)
local out = {}
for i, mv in ipairs(mon.moves or {}) do
out[i] = ("%s=%s"):format(tostring(mv.id), tostring(mv.pp or 0))
end
return table.concat(out, ",")
end
local function volStr(b)
local out = {}
for _, key in ipairs(VOLATILE) do
if b[key] ~= nil then
out[#out + 1] = key .. "=" .. scalar(b[key])
end
end
return table.concat(out, ",")
end
local function activeStr(b)
return ("%s:%d:%s:%s:%s"):format(b.mon.species, b.mon.hp,
tostring(b.mon.status), stageStr(b),
ppStr(b.mon))
end
local function benchStr(party)
local out = {}
for i, mon in ipairs(party or {}) do
out[i] = ("%s:%d:%s"):format(tostring(mon.species), mon.hp or 0,
tostring(mon.status))
end
return table.concat(out, "|")
end
-- canonical (host-side-first) per-component signature for desync detection
local function stateSig(self, role, myParty, theirParty)
local host = role == "host" and self.player or self.enemy
local guest = role == "host" and self.enemy or self.player
local hostParty = role == "host" and myParty or theirParty
local guestParty = role == "host" and theirParty or myParty
return {
actives = Fingerprint.digest(activeStr(host) .. "|" .. activeStr(guest)),
volatile = Fingerprint.digest(volStr(host) .. "|" .. volStr(guest)),
bench = Fingerprint.digest(benchStr(hostParty) .. "|" .. benchStr(guestParty)),
}
end
local PARTS = { "actives", "volatile", "bench" }
-- opts: { myParty = packed, theirParty = packed, theirName, role =
-- "host"/"guest", seed }
-- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when
-- the handshake says the two link surfaces don't match: a lockstep
-- simulation of two different rulebooks can only end in a bogus draw.
function LinkBattle.new(game, net, opts)
local BattleState = require("src.battle.BattleState")
local role = opts.role
local theirName = opts.theirName or "FOE"
if not Handshake.battleAllowed(opts.verdict) then
return nil, "Link battle needs\nthe same mods on\nboth games."
end
-- both parties pass through the same pack->unpack clamp on both
-- machines, so the copies are identical everywhere
local unpackOpts = { strict = opts.strict or false }
local myParty, theirParty = {}, {}
for _, p in ipairs(opts.myParty or {}) do
local mon = Protocol.unpackMon(game.data, p)
if mon then table.insert(myParty, mon) end
local mon = Protocol.unpackMon(game.data, p, unpackOpts)
if mon then
table.insert(myParty, mon)
elseif unpackOpts.strict then
return nil, ("Your %s can't\nbattle on the\nother game."):format(
tostring(p.species))
end
end
for _, p in ipairs(opts.theirParty or {}) do
local mon = Protocol.unpackMon(game.data, p)
if mon then table.insert(theirParty, mon) end
local mon, why = Protocol.unpackMon(game.data, p, unpackOpts)
if mon then
table.insert(theirParty, mon)
elseif unpackOpts.strict then
return nil, ("Their %s isn't\nin this game.\n(%s)"):format(
tostring(p.species), tostring(why))
end
end
if #myParty == 0 or #theirParty == 0 then
Logger.warn("link: empty party on one side")
end
-- build on a wild battle and reshape it into the lockstep link battle
-- a mod validates its own extra namespace here, the same site the trade
-- path gets in TradeSession:apply, before anything simulates with it.
-- Both parties go through it on both machines and in the canonical
-- host-first order: a validator that strips a field from one side only,
-- or in a different order, leaves the two simulations holding different
-- mons and desyncs on the first turn the difference matters.
local function announceReceived(party)
for _, mon in ipairs(party) do
Runtime.emit("pokemon.received",
{ mon = mon, from = "link", peerName = theirName })
end
end
announceReceived(role == "host" and myParty or theirParty)
announceReceived(role == "host" and theirParty or myParty)
-- build on a wild battle and reshape it into the lockstep link battle.
-- The RATTATA scaffold is unreachable on the negotiated path: an empty or
-- unrebuildable party is refused above, so it only ever covers a caller
-- that skipped the handshake.
local self = BattleState.newWild(game, theirParty[1] and theirParty[1].species
or "RATTATA", 5)
self.kind = "link"
@@ -99,6 +216,9 @@ function LinkBattle.new(game, net, opts)
self.introText = ("%s wants\nto battle!"):format(theirName)
self.remoteHashes = {}
self.localHashes = {}
self.remoteParts = {}
self.localParts = {}
self.checkedTurns = {}
local send = function(msg) net:send(msg) end
@@ -128,17 +248,40 @@ function LinkBattle.new(game, net, opts)
return nil
end
-- with the handshake guaranteeing both games share a link surface, a
-- mismatch here is RNG non-determinism -- almost always a mod rolling
-- love.math.random inside battle logic instead of the injected s.rng
local function reportDesync(s, turn, component, localH, remoteH)
Logger.warn("link: desync turn %s component=%s (%s vs %s)",
tostring(turn), component, tostring(localH), tostring(remoteH))
Runtime.emit("link.desync", { turn = turn, component = component,
localHash = localH, remoteHash = remoteH })
endAsDraw(s, ("Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?")
:format(component))
end
-- a verified turn stays recorded: consuming it here left a finished
-- battle holding 0-1 entries, so the whole-battle sweep the link suite
-- runs over localHashes had nothing left to compare
local function checkHashes(s)
for turn, localH in pairs(s.localHashes) do
local remoteH = s.remoteHashes[turn]
if remoteH and remoteH ~= localH then
Logger.warn("link: desync on turn %d (%s vs %s)", turn, localH, remoteH)
endAsDraw(s, "Link error!\nThe battle ends\nin a draw.")
return
end
if remoteH then
s.localHashes[turn] = nil
s.remoteHashes[turn] = nil
if remoteH and not s.checkedTurns[turn] then
s.checkedTurns[turn] = true
local mine, theirs = s.localParts[turn], s.remoteParts[turn]
if mine and theirs then
for _, component in ipairs(PARTS) do
if mine[component] ~= theirs[component] then
reportDesync(s, turn, component, mine[component], theirs[component])
return
end
end
end
-- a v1 peer sends the combined value only
if remoteH ~= localH then
reportDesync(s, turn, "state", localH, remoteH)
return
end
end
end
end
@@ -178,12 +321,25 @@ function LinkBattle.new(game, net, opts)
s:act(function()
local theirAction = decodeTheirAction(s, theirMsg)
Runtime.emit("battle.turn_started", {
battle = s, turn = s.turnCount,
playerAction = myAction, enemyAction = theirAction,
})
if myAction and theirAction then
-- the tie-break roll is shared: the guest inverts it so both
-- machines agree on who goes first
local first = TurnOrder.firstMover(s.player, orderMove(myAction),
s.enemy, orderMove(theirAction),
s.rng, role == "guest")
-- machines agree on who goes first. A modded ordering rule has
-- to run here too, or the two peers order the turn differently.
local first
local myMove, theirMove = orderMove(myAction), orderMove(theirAction)
if Runtime.wantsHook("battle.turn_order") then
first = Runtime.call("battle.turn_order", function(a, aMove, b, bMove, c)
return TurnOrder.firstMover(a, aMove, b, bMove, c.rng, c.invertTie)
end, s.player, myMove, s.enemy, theirMove,
{ rng = s.rng, invertTie = role == "guest" })
else
first = TurnOrder.firstMover(s.player, myMove, s.enemy, theirMove,
s.rng, role == "guest")
end
local order
if first then
order = { { s.player, s.enemy, myAction },
@@ -203,9 +359,11 @@ function LinkBattle.new(game, net, opts)
s:act(function() s:endOfTurn() end)
s:act(function()
if s.linkEnded then return end
local parts = stateSig(s, role, myParty, theirParty)
local h = stateHash(s, role)
s.localHashes[s.turnCount] = h
send({ type = "hash", turn = s.turnCount, value = h })
s.localParts[s.turnCount] = parts
send({ type = "hash", turn = s.turnCount, value = h, parts = parts })
checkHashes(s)
end)
end)
@@ -260,11 +418,10 @@ function LinkBattle.new(game, net, opts)
-- the party menu must offer the clamped link copies
self.openParty = function(s)
local PartyMenu = require("src.ui.PartyMenu")
s.phase = "messages"
s.afterQueue = "menu"
s:ui(function()
return PartyMenu.new(game, {
return s:buildScreen("PartyMenu", {
battle = s,
party = myParty,
onSwitch = function(mon)
@@ -333,6 +490,7 @@ function LinkBattle.new(game, net, opts)
tryResolve(s)
elseif msg.type == "hash" then
s.remoteHashes[msg.turn or 0] = msg.value
s.remoteParts[msg.turn or 0] = msg.parts
checkHashes(s)
elseif msg.type == "bye" then
-- only a draw if our own simulation hasn't already decided
+149 -21
View File
@@ -3,8 +3,11 @@
-- lua-enet (bundled with LÖVE), no relay server.
local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake")
local Net = require("src.link.Net")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local TextBox = require("src.render.TextBox")
local LinkState = {}
@@ -13,6 +16,10 @@ LinkState.isOpaque = true
local CURSOR = 0xED
-- how long the host waits for a v2 hello before deciding the peer predates
-- the handshake (a pre-mod guest sends nothing until it hears the mode)
local HELLO_GRACE = 2
-- the joiner edits an IPv4 address as 12 digits (three per octet),
-- prefilled with our own LAN IP so usually only the tail needs changing
local function ipDigits(ip)
@@ -40,7 +47,8 @@ function LinkState.new(game)
return self
end
function LinkState:exitWith(message)
function LinkState:exitWith(message, reason)
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
if self.net then self.net:close() end
self.game.stack:pop()
if message then
@@ -48,6 +56,61 @@ function LinkState:exitWith(message)
end
end
-- -------------------------------------------------------------------
-- handshake v2 (D8): both peers announce engine version, api version and
-- a fingerprint of their link surface, and the verdict comes from the two
-- hellos rather than from whoever picked the mode. The guest announces
-- itself the moment it pairs; the host's hello still carries the mode, so
-- a pre-mod build reads it exactly as it always did.
-- -------------------------------------------------------------------
-- take the peer's hello out of the inbox without eating anything that
-- shares the batch with it
function LinkState:pollHello()
local msgs = self.net:poll()
local keep, got = {}, false
for _, msg in ipairs(msgs) do
if msg.type == "hello" and not self.peerHello then
self.peerHello = msg
self.peerName = msg.name
got = true
else
keep[#keep + 1] = msg
end
end
for i = #keep, 1, -1 do
table.insert(self.net.inbox, 1, keep[i])
end
return got, #keep > 0
end
function LinkState:sendHello(mode)
self.myHello = Handshake.hello(self.game, mode)
self.net:send(self.myHello)
end
function LinkState:decideCompat(mode, isHost)
self.isHost = isHost
self.pendingMode = mode
self.myHello = self.myHello or Handshake.hello(self.game, isHost and mode or nil)
local peer = self.peerHello
self.verdict = Handshake.checkCompat(self.myHello, peer)
Runtime.emit("link.connected", {
role = isHost and "host" or "guest",
remote = { name = peer and peer.name or self.peerName, mode = mode,
mods = peer and peer.mods, fingerprint = peer and peer.fingerprint },
})
if self.verdict == "full" or self.verdict == "vanilla_peer" then
self:startMode(mode, isHost)
return
end
-- naming the difference up front is the whole point: the old behaviour
-- was a silent draw three turns into a battle that could never work
self.noticeLines = Handshake.describe(self.myHello, peer, self.verdict, mode)
self.noticeExits = self.verdict == "refused" or mode ~= "trade"
self.stage = "notice"
end
-- -------------------------------------------------------------------
-- update
-- -------------------------------------------------------------------
@@ -64,7 +127,7 @@ function LinkState:update(dt)
-- so a final message travelling with the disconnect still counts)
if self.net.closed and #self.net.inbox == 0
and self.stage ~= "menu" and self.stage ~= "addrEntry"
and self.stage ~= "battleRunning" then
and self.stage ~= "notice" and self.stage ~= "battleRunning" then
self:exitWith("The link was\nbroken.")
return
end
@@ -124,35 +187,62 @@ function LinkState:update(dt)
if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then
self.stage = "waitMode"
self:sendHello(nil) -- the host owns the mode; this is just who we are
end
elseif self.stage == "modeSelect" then -- host picks
self:pollHello()
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("a") then
local mode = self.index == 1 and "trade" or "battle"
self.net:send({ type = "hello", name = self.game.save.player.name, mode = mode })
self:startMode(mode, true)
self:sendHello(mode)
if self.peerHello then
self:decideCompat(mode, true)
else
self.pendingMode = mode
self.helloWait = 0
self.stage = "waitHello"
end
elseif input:wasPressed("b") then
self:exitWith(nil)
end
elseif self.stage == "waitHello" then -- host waits for the peer's hello
if input:wasPressed("b") then self:exitWith(nil) return end
local got, other = self:pollHello()
self.helloWait = self.helloWait + (dt or 0)
-- a pre-mod peer never sends one: it just gets on with the mode, so
-- its first message -- or the grace period -- is the answer
if got or other or self.helloWait > HELLO_GRACE then
self:decideCompat(self.pendingMode, true)
end
elseif self.stage == "waitMode" then -- guest waits for host's pick
if input:wasPressed("b") then self:exitWith(nil) return end
local msgs = self.net:poll()
for i, msg in ipairs(msgs) do
if msg.type == "hello" then
self.peerHello = msg
self.peerName = msg.name
self:startMode(msg.mode, false)
-- the host's next messages (party, ...) can share this batch;
-- put them back so the new stage's poll sees them
for j = #msgs, i + 1, -1 do
table.insert(self.net.inbox, 1, msgs[j])
end
self:decideCompat(msg.mode, false)
break
end
end
elseif self.stage == "notice" then
if input:wasPressed("b") or (self.noticeExits and input:wasPressed("a")) then
self.net:send({ type = "bye" })
self:exitWith(nil, "error")
elseif input:wasPressed("a") then
self:startMode(self.pendingMode, self.isHost)
end
elseif self.stage == "trade" then
self:updateTrade(input)
@@ -167,12 +257,21 @@ function LinkState:update(dt)
theirParty = msg.mons,
theirName = self.peerName or "FOE",
seed = self.isHost and self.linkSeed or msg.seed,
verdict = self.verdict,
strict = Handshake.strict(self.verdict),
}
local battle, why
if self.isHost then
self.game.stack:push(LinkBattle.newHost(self.game, self.net, opts))
battle, why = LinkBattle.newHost(self.game, self.net, opts)
else
self.game.stack:push(LinkBattle.newGuest(self.game, self.net, opts))
battle, why = LinkBattle.newGuest(self.game, self.net, opts)
end
if not battle then
self.net:send({ type = "bye" })
self:exitWith(why or "Link battle\ncan't start.", "error")
return
end
self.game.stack:push(battle)
self.stage = "battleRunning"
for j = #msgs, i + 1, -1 do
table.insert(self.net.inbox, 1, msgs[j])
@@ -192,8 +291,15 @@ function LinkState:startMode(mode, isHost)
self.isHost = isHost
if mode == "trade" then
self.stage = "trade"
self.trade = Protocol.TradeSession.new(self.game.data, self.game.save.party)
self.net:send({ type = "party", mons = Protocol.packParty(self.game.save.party) })
-- a subset session settles which mons both games rebuild identically
-- before either party goes out, so a pick can't land on a mon the
-- other side would reconstruct differently
self.trade = Protocol.TradeSession.new(self.game.data, self.game.save.party, {
subset = self.verdict == "subset",
strict = Handshake.strict(self.verdict),
peerName = self.peerName,
})
self.net:send(self.trade:opening())
self.index = 1
else
self.stage = "battleWait"
@@ -213,24 +319,26 @@ end
function LinkState:updateTrade(input)
for _, msg in ipairs(self.net:poll()) do
self.trade:handle(msg)
local reply = self.trade:handle(msg)
if reply then self.net:send(reply) end
end
local t = self.trade
if t.stage == "cancelled" then
self:exitWith("The trade was\ncancelled.")
self:exitWith(t.error and ("The trade stopped:\n%s."):format(t.error)
or "The trade was\ncancelled.")
return
end
if t.stage == "done" then
local sent = t.party[t.myPick]
local received, evoTo = t:apply(self.game)
local name = received.nickname or self.game.data.pokemon[received.species].name
Runtime.emit("link.ended", { reason = "done" })
self.net:close()
self.game.stack:pop()
local game = self.game
require("src.core.Sound").play(game.data, "Trade_Machine")
local TradeAnim = require("src.ui.TradeAnim")
game.stack:push(TradeAnim.new(game, {
Screens.push(game, "TradeAnim", {
sent = sent, received = received,
onDone = function()
game.stack:push(TextBox.new(game,
@@ -241,7 +349,7 @@ function LinkState:updateTrade(input)
end
end))
end,
}))
})
return
end
@@ -257,7 +365,9 @@ function LinkState:updateTrade(input)
self.net:send({ type = "bye" })
self:exitWith("The trade was\ncancelled.")
elseif t.stage == "picking" and input:wasPressed("a") then
self.net:send(t:pick(self.index))
if t:canPick(self.index) then
self.net:send(t:pick(self.index))
end
elseif t.stage == "confirming" and self.confirmed == nil then
if input:wasPressed("a") then
self.confirmed = true
@@ -318,10 +428,23 @@ function LinkState:draw()
Font.draw("BATTLE", 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "waitMode" then
elseif self.stage == "waitMode" or self.stage == "waitHello" then
drawTitle("CONNECTED!")
Font.draw("Waiting for the", 16, 56)
Font.draw("host to choose...", 16, 72)
if self.stage == "waitHello" then
Font.draw("Checking the", 16, 56)
Font.draw("other game...", 16, 72)
else
Font.draw("Waiting for the", 16, 56)
Font.draw("host to choose...", 16, 72)
end
elseif self.stage == "notice" then
drawTitle("CHECK YOUR MODS")
for i, line in ipairs(self.noticeLines or {}) do
if i > 8 then break end -- what fits above the prompt row
Font.draw(line, 8, 24 + (i - 1) * 12)
end
Font.draw(self.noticeExits and "A: back" or "A: trade anyway", 8, 128)
elseif self.stage == "trade" then
drawTitle("TRADE")
@@ -329,7 +452,9 @@ function LinkState:draw()
Font.draw("YOURS", 8, 20)
for i, mon in ipairs(self.game.save.party) do
local def = self.game.data.pokemon[mon.species]
Font.draw((mon.nickname or def.name):sub(1, 8), 16, 20 + i * 12)
local label = (mon.nickname or def.name):sub(1, 8)
if not t:canPick(i) then label = label .. "X" end
Font.draw(label, 16, 20 + i * 12)
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
end
Font.draw("THEIRS", 84, 20)
@@ -339,8 +464,11 @@ function LinkState:draw()
if t.theirPick == i then Font.drawCode(CURSOR, 84, 20 + i * 12) end
end
local hint
if t.stage == "waitParty" then hint = "Exchanging data..."
elseif t.stage == "picking" then hint = "Pick one to trade"
if t.stage == "waitRecords" then hint = "Comparing games..."
elseif t.stage == "waitParty" then hint = "Exchanging data..."
elseif t.stage == "picking" then
hint = t:canPick(self.index) and "Pick one to trade"
or "X: not on theirs"
elseif t.stage == "waitPick" then hint = "Waiting for them..."
elseif t.stage == "confirming" then
hint = self.confirmed and "Waiting..." or "A: trade B: cancel"
+211 -25
View File
@@ -2,21 +2,52 @@
-- state machine (pure logic, headless-testable).
--
-- Message types exchanged after pairing:
-- {type="hello", name, mode} host announces trade|battle
-- {type="party", mons=[...]} full party (both directions)
-- {type="pick", index} trade: chosen party slot
-- {type="hello", ...} handshake v2 (src/link/Handshake.lua)
-- {type="records", pokemon=, moves=} subset trade: per-record hashes
-- {type="party", mons=[...]} party (both directions)
-- {type="pick", index} trade: chosen slot in the sent list
-- {type="confirm", ok=bool} trade: final yes/no
-- {type="action", ...} battle: guest -> host choice
-- {type="event", ...} battle: host -> guest display event
-- {type="bye"}
local Fingerprint = require("src.link.Fingerprint")
local Handshake = require("src.link.Handshake")
local Runtime = require("src.mods.Runtime")
local Protocol = {}
-- serialize a mon instance for the wire (plain data only)
Protocol.hello = Handshake.hello
Protocol.checkCompat = Handshake.checkCompat
-- the extra bag is JSON-safe by contract, the same restriction the save
-- serializer enforces; anything else is dropped rather than trusted
local function plainCopy(value, depth)
if type(value) ~= "table" then return nil end
if (depth or 0) > 8 then return nil end
local out = {}
for k, v in pairs(value) do
local kt, vt = type(k), type(v)
if kt == "string" or kt == "number" then
if vt == "string" or vt == "number" or vt == "boolean" then
out[k] = v
elseif vt == "table" then
out[k] = plainCopy(v, (depth or 0) + 1)
end
end
end
return out
end
Protocol.plainCopy = plainCopy
-- serialize a mon instance for the wire (plain data only). ppUps rides
-- along because the real cable transmitted it and its absence silently
-- capped a PP-Upped move at base PP on the receiving side.
function Protocol.packMon(mon)
local moves = {}
for _, mv in ipairs(mon.moves) do
table.insert(moves, { id = mv.id, pp = mv.pp })
table.insert(moves, { id = mv.id, pp = mv.pp, ppUps = mv.ppUps })
end
return {
species = mon.species,
@@ -28,16 +59,23 @@ function Protocol.packMon(mon)
dvs = mon.dvs,
statExp = mon.statExp,
moves = moves,
extra = plainCopy(mon.extra),
}
end
-- rebuild a mon locally (recomputes stats from real species data so a
-- tampered packet can't invent stats)
function Protocol.unpackMon(data, packed)
-- tampered packet can't invent stats). opts.strict is set once two v2
-- peers have agreed on a verdict: a mon that cannot be rebuilt identically
-- is rejected by name instead of quietly mutated into something else.
function Protocol.unpackMon(data, packed, opts)
local Stats = require("src.pokemon.Stats")
local Growth = require("src.pokemon.Growth")
local strict = opts and opts.strict
local def = data.pokemon[packed.species]
if not def then return nil end
if not def then
if strict then return nil, "unknown POKéMON" end
return nil
end
local level = math.max(2, math.min(100, math.floor(packed.level or 5)))
local dvs = {}
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
@@ -50,14 +88,20 @@ function Protocol.unpackMon(data, packed)
local stats = Stats.calc(def, level, dvs, statExp)
local moves = {}
for _, mv in ipairs(packed.moves or {}) do
if data.moves[mv.id] and #moves < 4 then
table.insert(moves, {
id = mv.id,
pp = math.max(0, math.min(data.moves[mv.id].pp, math.floor(mv.pp or 0))),
})
local mdef = data.moves[mv.id]
if mdef and #moves < 4 then
local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0)))
local maxPP = mdef.pp + ppUps * math.floor(mdef.pp / 5)
local entry = { id = mv.id,
pp = math.max(0, math.min(maxPP, math.floor(mv.pp or 0))) }
if mv.ppUps ~= nil then entry.ppUps = ppUps end
table.insert(moves, entry)
end
end
if #moves == 0 then
-- the v1 path keeps the substitute verbatim for peers built against it;
-- a negotiated v2 link says so out loud instead
if strict then return nil, "no shared moves" end
moves = { { id = "TACKLE", pp = 35 } }
end
return {
@@ -71,46 +115,170 @@ function Protocol.unpackMon(data, packed)
status = packed.status,
nickname = packed.nickname,
moves = moves,
-- a namespace whose mod this install lacks survives untouched, so the
-- mon keeps it for the trip home
extra = plainCopy(packed.extra),
}
end
function Protocol.packParty(party)
function Protocol.packParty(party, indices)
local mons = {}
if indices then
for _, i in ipairs(indices) do
table.insert(mons, Protocol.packMon(party[i]))
end
return mons
end
for _, mon in ipairs(party) do
table.insert(mons, Protocol.packMon(mon))
end
return mons
end
-- ------- subset negotiation
-- the species and moves this party actually references, so the exchange
-- stays small (six mons) instead of shipping the whole catalog
function Protocol.recordsMessage(data, party)
local species = Fingerprint.records(data, "pokemon")
local moves = Fingerprint.records(data, "moves")
local outSpecies, outMoves = {}, {}
for _, mon in ipairs(party or {}) do
if species[mon.species] then outSpecies[mon.species] = species[mon.species] end
for _, mv in ipairs(mon.moves or {}) do
if moves[mv.id] then outMoves[mv.id] = moves[mv.id] end
end
end
return { type = "records", pokemon = outSpecies, moves = outMoves }
end
-- a mon may cross the wire only if both peers rebuild it identically: the
-- species and every move id has to exist on the other game with the same
-- record hash. Filtering is symmetric, so the two sides always agree on
-- which slots are in play and a pick can never land on a different mon.
function Protocol.eligibleParty(party, myRecords, theirRecords)
local eligible, reasons = {}, {}
theirRecords = theirRecords or {}
local theirSpecies = theirRecords.pokemon or {}
local theirMoves = theirRecords.moves or {}
local mySpecies = (myRecords or {}).pokemon or {}
local myMoves = (myRecords or {}).moves or {}
for i, mon in ipairs(party or {}) do
local reason
if not theirSpecies[mon.species] then
reason = "not on the other game"
elseif theirSpecies[mon.species] ~= mySpecies[mon.species] then
reason = "different data"
else
for _, mv in ipairs(mon.moves or {}) do
if not theirMoves[mv.id] then
reason = "unknown move"
break
elseif theirMoves[mv.id] ~= myMoves[mv.id] then
reason = "different move data"
break
end
end
end
eligible[i] = reason == nil
reasons[i] = reason
end
return eligible, reasons
end
-- -------------------------------------------------------------------
-- Trade session: symmetric state machine. Feed it messages; read
-- .stage ("waitParty" -> "picking" -> "waitPick" -> "confirming" ->
-- "done"/"cancelled"). When done, .result = {give=idx, getMon=mon}.
-- .stage ("waitRecords" -> "waitParty" -> "picking" -> "waitPick" ->
-- "confirming" -> "done"/"cancelled"). When done, .result =
-- {give=idx, getMon=mon}. A subset session negotiates the eligible
-- slots first, so both sides send and index the same filtered list.
-- -------------------------------------------------------------------
local TradeSession = {}
TradeSession.__index = TradeSession
Protocol.TradeSession = TradeSession
function TradeSession.new(data, party)
return setmetatable({
-- opts: { subset, strict, peerName } -- all absent on the v1 path
function TradeSession.new(data, party, opts)
opts = opts or {}
local self = setmetatable({
data = data,
party = party,
stage = "waitParty",
subset = opts.subset or false,
strict = opts.strict or false,
peerName = opts.peerName,
stage = opts.subset and "waitRecords" or "waitParty",
sendIndices = nil,
eligible = nil,
reasons = {},
theirParty = nil,
myPick = nil,
theirPick = nil,
myConfirm = nil,
theirConfirm = nil,
}, TradeSession)
if not self.subset then
local all = {}
for i = 1, #party do all[i] = i end
self.sendIndices = all
end
return self
end
-- the first message on the wire: a subset trade has to agree on which mons
-- both games rebuild identically before either party can be sent
function TradeSession:opening()
if self.subset then
return Protocol.recordsMessage(self.data, self.party)
end
return self:partyMessage()
end
function TradeSession:partyMessage()
return { type = "party",
mons = Protocol.packParty(self.party, self.sendIndices) }
end
function TradeSession:_negotiate(theirRecords)
local mine = { pokemon = Fingerprint.records(self.data, "pokemon"),
moves = Fingerprint.records(self.data, "moves") }
self.eligible, self.reasons =
Protocol.eligibleParty(self.party, mine, theirRecords)
local indices = {}
for i = 1, #self.party do
if self.eligible[i] then indices[#indices + 1] = i end
end
self.sendIndices = indices
end
-- the UI greys what the other game would rebuild differently
function TradeSession:canPick(index)
return self.eligible == nil or self.eligible[index] == true
end
-- returns a message to put on the wire, or nil
function TradeSession:handle(msg)
if msg.type == "party" then
if msg.type == "records" then
-- only once: re-filtering after our party went out would slide the
-- indices the peer is already holding
if self.stage ~= "waitRecords" then return nil end
self:_negotiate(msg)
self.stage = "waitParty"
return self:partyMessage()
elseif msg.type == "party" then
self.theirParty = {}
for _, packed in ipairs(msg.mons or {}) do
local mon = Protocol.unpackMon(self.data, packed)
if mon then table.insert(self.theirParty, mon) end
local mon, why = Protocol.unpackMon(self.data, packed,
{ strict = self.strict })
if mon then
table.insert(self.theirParty, mon)
elseif self.strict then
-- dropping a row would slide every later index by one and the
-- two sides would commit different mons; refuse the whole trade
self.stage = "cancelled"
self.error = why or "the other game sent an unknown POKéMON"
return nil
end
end
if self.stage == "waitParty" then self.stage = "picking" end
elseif msg.type == "pick" then
@@ -122,12 +290,22 @@ function TradeSession:handle(msg)
elseif msg.type == "bye" then
self.stage = "cancelled"
end
return nil
end
-- index is a real party slot; the wire carries its position in the list
-- this side actually sent, which is the only space both peers share
function TradeSession:wireIndex(index)
for pos, i in ipairs(self.sendIndices or {}) do
if i == index then return pos end
end
return index
end
function TradeSession:pick(index)
self.myPick = index
self:advance()
return { type = "pick", index = index }
return { type = "pick", index = self:wireIndex(index) }
end
function TradeSession:confirm(ok)
@@ -157,19 +335,27 @@ end
function TradeSession:apply(game)
assert(self.stage == "done", "trade not complete")
local received = self.theirParty[self.theirPick]
local sent = self.party[self.myPick]
received.traded = true -- boosted exp (different OT)
-- a mod validates its own extra namespace here, before the mon is filed
Runtime.emit("pokemon.received",
{ mon = received, from = "link", peerName = self.peerName })
self.party[self.myPick] = received
if game and game.save.pokedex then
game.save.pokedex.seen[received.species] = true
game.save.pokedex.owned[received.species] = true
end
local def = self.data.pokemon[received.species]
local evolveTo
for _, evo in ipairs(def.evolutions or {}) do
if evo.method == "TRADE" then
return received, evo.species
evolveTo = evo.species
break
end
end
return received, nil
Runtime.emit("trade.completed",
{ sent = sent, received = received, evolveTo = evolveTo })
return received, evolveTo
end
return Protocol