mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 04:06:10 +02:00
big ass modding update
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user