mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
initial commit
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
-- Minimal JSON encoder/decoder for the link protocol (objects, arrays,
|
||||
-- strings, numbers, booleans, null). No unicode escapes beyond \uXXXX
|
||||
-- pass-through; good enough for our own messages.
|
||||
|
||||
local Json = {}
|
||||
|
||||
local function encodeValue(v, out)
|
||||
local t = type(v)
|
||||
if v == nil then
|
||||
out[#out + 1] = "null"
|
||||
elseif t == "boolean" then
|
||||
out[#out + 1] = v and "true" or "false"
|
||||
elseif t == "number" then
|
||||
out[#out + 1] = string.format("%.17g", v)
|
||||
elseif t == "string" then
|
||||
out[#out + 1] = '"' .. v:gsub('[%c"\\]', function(c)
|
||||
if c == '"' then return '\\"' end
|
||||
if c == "\\" then return "\\\\" end
|
||||
if c == "\n" then return "\\n" end
|
||||
if c == "\r" then return "\\r" end
|
||||
if c == "\t" then return "\\t" end
|
||||
return string.format("\\u%04x", c:byte())
|
||||
end) .. '"'
|
||||
elseif t == "table" then
|
||||
-- array if [1..n] contiguous
|
||||
local n = #v
|
||||
local isArray = n > 0
|
||||
if not isArray then
|
||||
isArray = next(v) == nil -- empty table -> []
|
||||
end
|
||||
if isArray then
|
||||
out[#out + 1] = "["
|
||||
for i = 1, n do
|
||||
if i > 1 then out[#out + 1] = "," end
|
||||
encodeValue(v[i], out)
|
||||
end
|
||||
out[#out + 1] = "]"
|
||||
else
|
||||
out[#out + 1] = "{"
|
||||
local first = true
|
||||
for k, val in pairs(v) do
|
||||
if not first then out[#out + 1] = "," end
|
||||
first = false
|
||||
encodeValue(tostring(k), out)
|
||||
out[#out + 1] = ":"
|
||||
encodeValue(val, out)
|
||||
end
|
||||
out[#out + 1] = "}"
|
||||
end
|
||||
else
|
||||
error("cannot encode " .. t)
|
||||
end
|
||||
end
|
||||
|
||||
function Json.encode(v)
|
||||
local out = {}
|
||||
encodeValue(v, out)
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- decoder -------------------------------------------------------------
|
||||
|
||||
local function skipWs(s, i)
|
||||
return (s:find("[^ \t\r\n]", i)) or (#s + 1)
|
||||
end
|
||||
|
||||
local decodeValue
|
||||
|
||||
local function decodeString(s, i)
|
||||
-- i points at opening quote
|
||||
local out = {}
|
||||
i = i + 1
|
||||
while i <= #s do
|
||||
local c = s:sub(i, i)
|
||||
if c == '"' then
|
||||
return table.concat(out), i + 1
|
||||
elseif c == "\\" then
|
||||
local esc = s:sub(i + 1, i + 1)
|
||||
if esc == "n" then out[#out + 1] = "\n"
|
||||
elseif esc == "r" then out[#out + 1] = "\r"
|
||||
elseif esc == "t" then out[#out + 1] = "\t"
|
||||
elseif esc == "b" then out[#out + 1] = string.char(8)
|
||||
elseif esc == "f" then out[#out + 1] = string.char(12)
|
||||
elseif esc == "u" then
|
||||
local hex = s:sub(i + 2, i + 5)
|
||||
local code = tonumber(hex, 16) or 32
|
||||
if code < 128 then
|
||||
out[#out + 1] = string.char(code)
|
||||
else -- utf8 encode (2-3 bytes covers our charmap)
|
||||
if code < 0x800 then
|
||||
out[#out + 1] = string.char(0xC0 + math.floor(code / 0x40),
|
||||
0x80 + code % 0x40)
|
||||
else
|
||||
out[#out + 1] = string.char(0xE0 + math.floor(code / 0x1000),
|
||||
0x80 + math.floor(code / 0x40) % 0x40,
|
||||
0x80 + code % 0x40)
|
||||
end
|
||||
end
|
||||
i = i + 4
|
||||
else
|
||||
out[#out + 1] = esc
|
||||
end
|
||||
i = i + 2
|
||||
else
|
||||
out[#out + 1] = c
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
error("unterminated string")
|
||||
end
|
||||
|
||||
decodeValue = function(s, i)
|
||||
i = skipWs(s, i)
|
||||
local c = s:sub(i, i)
|
||||
if c == '"' then
|
||||
return decodeString(s, i)
|
||||
elseif c == "{" then
|
||||
local obj = {}
|
||||
i = skipWs(s, i + 1)
|
||||
if s:sub(i, i) == "}" then return obj, i + 1 end
|
||||
while true do
|
||||
local key
|
||||
key, i = decodeString(s, skipWs(s, i))
|
||||
i = skipWs(s, i)
|
||||
assert(s:sub(i, i) == ":", "expected :")
|
||||
local val
|
||||
val, i = decodeValue(s, i + 1)
|
||||
obj[key] = val
|
||||
i = skipWs(s, i)
|
||||
local d = s:sub(i, i)
|
||||
if d == "}" then return obj, i + 1 end
|
||||
assert(d == ",", "expected , or }")
|
||||
i = i + 1
|
||||
end
|
||||
elseif c == "[" then
|
||||
local arr = {}
|
||||
i = skipWs(s, i + 1)
|
||||
if s:sub(i, i) == "]" then return arr, i + 1 end
|
||||
while true do
|
||||
local val
|
||||
val, i = decodeValue(s, i)
|
||||
arr[#arr + 1] = val
|
||||
i = skipWs(s, i)
|
||||
local d = s:sub(i, i)
|
||||
if d == "]" then return arr, i + 1 end
|
||||
assert(d == ",", "expected , or ]")
|
||||
i = i + 1
|
||||
end
|
||||
elseif c == "t" then
|
||||
assert(s:sub(i, i + 3) == "true")
|
||||
return true, i + 4
|
||||
elseif c == "f" then
|
||||
assert(s:sub(i, i + 4) == "false")
|
||||
return false, i + 5
|
||||
elseif c == "n" then
|
||||
assert(s:sub(i, i + 3) == "null")
|
||||
return nil, i + 4
|
||||
else
|
||||
local numStr = s:match("^-?%d+%.?%d*[eE]?[-+]?%d*", i)
|
||||
assert(numStr and #numStr > 0, "unexpected character '" .. c .. "'")
|
||||
return tonumber(numStr), i + #numStr
|
||||
end
|
||||
end
|
||||
|
||||
function Json.decode(s)
|
||||
local ok, v = pcall(function()
|
||||
local val = select(1, decodeValue(s, 1))
|
||||
return val
|
||||
end)
|
||||
if ok then return v end
|
||||
return nil, v
|
||||
end
|
||||
|
||||
return Json
|
||||
@@ -0,0 +1,386 @@
|
||||
-- Link battles over the peer-to-peer link (src/link/Net.lua),
|
||||
-- lockstep-simulated like the real link cable: BOTH sides run the
|
||||
-- full battle engine (BattleState) locally
|
||||
-- from mirrored perspectives, on a shared RNG seed the host deals out.
|
||||
-- Each turn the two chosen actions are exchanged and both machines
|
||||
-- resolve the turn independently -- identical clamped party copies +
|
||||
-- identical RNG stream = identical outcomes. A per-turn state hash is
|
||||
-- exchanged; a mismatch (desync) ends the match as a draw, like a
|
||||
-- cable pull.
|
||||
--
|
||||
-- Cable rules: no experience, no money, no items; either side may RUN
|
||||
-- (a draw); a fainted mon is auto-replaced by the next healthy party
|
||||
-- member (the original prompts; documented divergence). Badge stat
|
||||
-- boosts don't apply on either side (divergence: Gen 1 famously kept
|
||||
-- them in link battles).
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
|
||||
local LinkBattle = {}
|
||||
|
||||
-- Deterministic Park-Miller PRNG: both sides must roll identical
|
||||
-- streams, so love.math.random can't be used.
|
||||
local function makeRng(seed)
|
||||
local s = seed % 2147483647
|
||||
if s <= 0 then s = s + 2147483646 end
|
||||
return function(a, b)
|
||||
s = (s * 16807) % 2147483647
|
||||
if a == nil then return s / 2147483647 end
|
||||
if b == nil then a, b = 1, a end
|
||||
return a + (s % (b - a + 1))
|
||||
end
|
||||
end
|
||||
|
||||
-- battler builder shared by both sides -- NO badge boosts, so both
|
||||
-- machines compute identical stats
|
||||
local function mkBattler(data, mon, isPlayer)
|
||||
local def = data.pokemon[mon.species]
|
||||
local ok, img = pcall(love.graphics.newImage,
|
||||
isPlayer and def.spriteBack or def.spriteFront)
|
||||
return {
|
||||
mon = mon, def = def, isPlayer = isPlayer, stages = {},
|
||||
name = mon.nickname or def.name,
|
||||
curStats = mon.stats, curTypes = def.types, curMoves = mon.moves,
|
||||
sprite = ok and img or nil,
|
||||
}
|
||||
end
|
||||
|
||||
-- canonical (host-side-first) state signature for desync detection
|
||||
local function stateHash(self, role)
|
||||
local function sig(b)
|
||||
return ("%s:%d:%s"):format(b.mon.species, b.mon.hp, tostring(b.mon.status))
|
||||
end
|
||||
local hostSide = role == "host" and self.player or self.enemy
|
||||
local guestSide = role == "host" and self.enemy or self.player
|
||||
return sig(hostSide) .. "|" .. sig(guestSide)
|
||||
end
|
||||
|
||||
-- opts: { myParty = packed, theirParty = packed, theirName, role =
|
||||
-- "host"/"guest", seed }
|
||||
function LinkBattle.new(game, net, opts)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local role = opts.role
|
||||
local theirName = opts.theirName or "FOE"
|
||||
|
||||
-- both parties pass through the same pack->unpack clamp on both
|
||||
-- machines, so the copies are identical everywhere
|
||||
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
|
||||
end
|
||||
for _, p in ipairs(opts.theirParty or {}) do
|
||||
local mon = Protocol.unpackMon(game.data, p)
|
||||
if mon then table.insert(theirParty, mon) 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
|
||||
local self = BattleState.newWild(game, theirParty[1] and theirParty[1].species
|
||||
or "RATTATA", 5)
|
||||
self.kind = "link"
|
||||
self.linkRole = role
|
||||
self.net = net
|
||||
-- BattleState:update only runs while it's the top of the state
|
||||
-- stack, but the player can push PartyMenu/ChoiceBox/NamingScreen on
|
||||
-- top of it (forced switch on faint, evolution naming...); the ENet
|
||||
-- transport must stay serviced regardless, or the peer's actions
|
||||
-- back up and the link can stall or time out. Game:step services
|
||||
-- game.linkNet unconditionally every frame.
|
||||
game.linkNet = net
|
||||
self.rng = makeRng(opts.seed or 1)
|
||||
self.player = mkBattler(game.data, myParty[1], true)
|
||||
self.enemy = mkBattler(game.data, theirParty[1], false)
|
||||
self.enemyParty = theirParty
|
||||
self.introText = ("%s wants\nto battle!"):format(theirName)
|
||||
self.remoteHashes = {}
|
||||
self.localHashes = {}
|
||||
|
||||
local send = function(msg) net:send(msg) end
|
||||
|
||||
local function endAsDraw(s, text)
|
||||
if s.linkEnded then return end
|
||||
s.result = "draw"
|
||||
s.afterQueue = "finish"
|
||||
s.phase = "messages"
|
||||
if text then s:say(text) end
|
||||
end
|
||||
|
||||
local function orderMove(action)
|
||||
if action and action.id then return game.data.moves[action.id] end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- decode a remote action message against the enemy battler
|
||||
local function decodeTheirAction(s, msg)
|
||||
if msg.kind == "move" then
|
||||
local slot = math.max(1, math.min(#s.enemy.curMoves, math.floor(msg.slot or 1)))
|
||||
return s.enemy.curMoves[slot]
|
||||
elseif msg.kind == "struggle" then
|
||||
return { id = "STRUGGLE", pp = 1, struggle = true }
|
||||
elseif msg.kind == "locked" then
|
||||
return s:lockedAction(s.enemy)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- both actions in hand: resolve the turn identically on both machines
|
||||
local function resolveLockstep(s, myMsg, theirMsg)
|
||||
if myMsg.kind == "run" or theirMsg.kind == "run" then
|
||||
local who = myMsg.kind == "run" and game.save.player.name or theirName
|
||||
endAsDraw(s, ("%s ran from\nthe battle!"):format(who))
|
||||
return
|
||||
end
|
||||
s.phase = "messages"
|
||||
s.afterQueue = "linkNext"
|
||||
s.turnCount = (s.turnCount or 0) + 1
|
||||
|
||||
local myAction = myMsg.action
|
||||
local theirSwitch = theirMsg.kind == "switch"
|
||||
and math.max(1, math.min(#theirParty,
|
||||
math.floor(theirMsg.index or 1)))
|
||||
or nil
|
||||
|
||||
-- switches happen before attacks (both may switch)
|
||||
if myMsg.kind == "switch" then
|
||||
local idx = myMsg.index
|
||||
s:act(function()
|
||||
s.player = mkBattler(game.data, myParty[idx], true)
|
||||
s:sayNext(("Go! %s!"):format(s.player.name))
|
||||
end)
|
||||
myAction = nil
|
||||
end
|
||||
if theirSwitch then
|
||||
s:act(function()
|
||||
s.enemy = mkBattler(game.data, theirParty[theirSwitch], false)
|
||||
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
||||
end)
|
||||
end
|
||||
|
||||
s:act(function()
|
||||
local theirAction = decodeTheirAction(s, theirMsg)
|
||||
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")
|
||||
local order
|
||||
if first then
|
||||
order = { { s.player, s.enemy, myAction },
|
||||
{ s.enemy, s.player, theirAction } }
|
||||
else
|
||||
order = { { s.enemy, s.player, theirAction },
|
||||
{ s.player, s.enemy, myAction } }
|
||||
end
|
||||
for _, entry in ipairs(order) do
|
||||
s:act(function() s:executeAction(entry[1], entry[2], entry[3]) end)
|
||||
end
|
||||
elseif myAction then
|
||||
s:act(function() s:executeAction(s.player, s.enemy, myAction) end)
|
||||
elseif theirAction then
|
||||
s:act(function() s:executeAction(s.enemy, s.player, theirAction) end)
|
||||
end
|
||||
s:act(function() s:endOfTurn() end)
|
||||
s:act(function()
|
||||
if s.linkEnded then return end
|
||||
local h = stateHash(s, role)
|
||||
s.localHashes[s.turnCount] = h
|
||||
send({ type = "hash", turn = s.turnCount, value = h })
|
||||
checkHashes(s)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
self.pendingMyAction = nil
|
||||
self.remoteAction = nil
|
||||
local function tryResolve(s)
|
||||
if not s.pendingMyAction or not s.remoteAction then return end
|
||||
local mine, theirs = s.pendingMyAction, s.remoteAction
|
||||
s.pendingMyAction, s.remoteAction = nil, nil
|
||||
resolveLockstep(s, mine, theirs)
|
||||
end
|
||||
|
||||
-- my chosen action: send it and wait for theirs
|
||||
local function submit(s, msg, localAction)
|
||||
msg.action = nil
|
||||
send(msg)
|
||||
msg.action = localAction
|
||||
s.pendingMyAction = msg
|
||||
s.phase = "waitRemote"
|
||||
tryResolve(s)
|
||||
end
|
||||
|
||||
self.resolveTurn = function(s, action)
|
||||
local kind
|
||||
if action.struggle then
|
||||
kind = "struggle"
|
||||
elseif action.special then
|
||||
kind = "locked"
|
||||
else
|
||||
kind = "move"
|
||||
end
|
||||
local slot
|
||||
if kind == "move" then
|
||||
for i, mv in ipairs(s.player.curMoves) do
|
||||
if mv == action then slot = i end
|
||||
end
|
||||
if not slot then kind = "locked" end -- thrash/rage move instances
|
||||
end
|
||||
submit(s, { type = "action", kind = kind, slot = slot }, action)
|
||||
end
|
||||
|
||||
self.resolveSwitch = function(s, newMon)
|
||||
for i, mon in ipairs(myParty) do
|
||||
if mon == newMon then
|
||||
submit(s, { type = "action", kind = "switch", index = i }, nil)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 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, {
|
||||
battle = s,
|
||||
party = myParty,
|
||||
onSwitch = function(mon)
|
||||
if mon == s.player.mon then
|
||||
s:say(("%s is\nalready out!"):format(s.player.name))
|
||||
elseif mon.hp <= 0 then
|
||||
s:say("There's no will\nto fight!")
|
||||
else
|
||||
s:resolveSwitch(mon)
|
||||
end
|
||||
end,
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
self.openItems = function(s)
|
||||
s:say("Items can't be\nused in a link\nbattle!")
|
||||
s.phase = "messages"
|
||||
s.afterQueue = "menu"
|
||||
end
|
||||
|
||||
self.tryRun = function(s)
|
||||
submit(s, { type = "action", kind = "run" }, nil)
|
||||
end
|
||||
|
||||
-- fainted mons auto-replace with the next healthy teammate, in party
|
||||
-- order, identically on both machines
|
||||
self.playerMonFainted = function(s)
|
||||
for _, mon in ipairs(myParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function()
|
||||
s.player = mkBattler(game.data, mon, true)
|
||||
s:sayNext(("Go! %s!"):format(s.player.name))
|
||||
end)
|
||||
return
|
||||
end
|
||||
end
|
||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(game.save.player.name,
|
||||
theirName))
|
||||
s.result = "lose"
|
||||
s.afterQueue = "finish"
|
||||
end
|
||||
|
||||
self.enemyMonFainted = function(s)
|
||||
for _, mon in ipairs(theirParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function()
|
||||
s.enemy = mkBattler(game.data, mon, false)
|
||||
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
||||
end)
|
||||
return
|
||||
end
|
||||
end
|
||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(theirName,
|
||||
game.save.player.name))
|
||||
s.result = "win"
|
||||
s.afterQueue = "finish"
|
||||
end
|
||||
|
||||
local baseUpdate = self.update
|
||||
self.update = function(s, dt)
|
||||
net:update()
|
||||
for _, msg in ipairs(net:poll()) do
|
||||
if msg.type == "action" then
|
||||
s.remoteAction = msg
|
||||
tryResolve(s)
|
||||
elseif msg.type == "hash" then
|
||||
s.remoteHashes[msg.turn or 0] = msg.value
|
||||
checkHashes(s)
|
||||
elseif msg.type == "bye" then
|
||||
-- only a draw if our own simulation hasn't already decided
|
||||
-- (the winner's bye can arrive while we're still animating)
|
||||
if not s.result then
|
||||
endAsDraw(s, ("%s left the\nbattle."):format(theirName))
|
||||
end
|
||||
end
|
||||
end
|
||||
if net.closed and not s.linkEnded and not s.result then
|
||||
endAsDraw(s)
|
||||
end
|
||||
if s.phase == "waitRemote" then
|
||||
return -- the other side is still choosing
|
||||
end
|
||||
if s.phase == "messages" and s.afterQueue == "linkNext" then
|
||||
if not s:updateQueue() then
|
||||
s.afterQueue = "menu"
|
||||
s.phase = "menu"
|
||||
end
|
||||
return
|
||||
end
|
||||
baseUpdate(s, dt)
|
||||
end
|
||||
|
||||
local baseFinish = self.finish
|
||||
self.finish = function(s)
|
||||
if not s.linkEnded then
|
||||
s.linkEnded = true
|
||||
send({ type = "bye" })
|
||||
end
|
||||
net:close()
|
||||
if game.linkNet == net then game.linkNet = nil end
|
||||
baseFinish(s)
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
-- backwards-compatible entry points (LinkState passes role explicitly)
|
||||
function LinkBattle.newHost(game, net, opts)
|
||||
opts.role = "host"
|
||||
return LinkBattle.new(game, net, opts)
|
||||
end
|
||||
|
||||
function LinkBattle.newGuest(game, net, opts)
|
||||
opts.role = "guest"
|
||||
return LinkBattle.new(game, net, opts)
|
||||
end
|
||||
|
||||
return LinkBattle
|
||||
@@ -0,0 +1,357 @@
|
||||
-- Link play UI: one player hosts (the screen shows their LAN address),
|
||||
-- the other joins by typing that address in. Direct peer-to-peer over
|
||||
-- lua-enet (bundled with LÖVE), no relay server.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Net = require("src.link.Net")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local LinkState = {}
|
||||
LinkState.__index = LinkState
|
||||
LinkState.isOpaque = true
|
||||
|
||||
local CURSOR = 0xED
|
||||
|
||||
-- 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)
|
||||
local digits = {}
|
||||
local a, b, c, d = (ip or ""):match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
|
||||
local octets = { tonumber(a) or 192, tonumber(b) or 168,
|
||||
tonumber(c) or 0, tonumber(d) or 1 }
|
||||
for _, o in ipairs(octets) do
|
||||
o = math.min(255, o)
|
||||
table.insert(digits, math.floor(o / 100))
|
||||
table.insert(digits, math.floor(o / 10) % 10)
|
||||
table.insert(digits, o % 10)
|
||||
end
|
||||
return digits
|
||||
end
|
||||
|
||||
function LinkState.new(game)
|
||||
local self = setmetatable({}, LinkState)
|
||||
self.game = game
|
||||
self.stage = "menu"
|
||||
self.index = 1
|
||||
self.addr = ipDigits(Net.lanIP())
|
||||
self.addrPos = 12 -- the last octet is what usually differs
|
||||
self.status = ""
|
||||
return self
|
||||
end
|
||||
|
||||
function LinkState:exitWith(message)
|
||||
if self.net then self.net:close() end
|
||||
self.game.stack:pop()
|
||||
if message then
|
||||
self.game.stack:push(TextBox.new(self.game, message))
|
||||
end
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- update
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
function LinkState:update(dt)
|
||||
local input = self.game.input
|
||||
if self.net then
|
||||
self.net:update()
|
||||
if self.net.error and self.stage ~= "menu" then
|
||||
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
|
||||
return
|
||||
end
|
||||
-- the peer vanished without a bye (only once the inbox is drained,
|
||||
-- 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
|
||||
self:exitWith("The link was\nbroken.")
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if self.stage == "menu" then
|
||||
if input:wasPressed("up") or input:wasPressed("down") then
|
||||
self.index = self.index == 1 and 2 or 1
|
||||
elseif input:wasPressed("b") then
|
||||
self:exitWith(nil)
|
||||
elseif input:wasPressed("a") then
|
||||
self.net = Net.new()
|
||||
if self.index == 1 then
|
||||
if self.net:host() then
|
||||
self.stage = "hosting"
|
||||
else
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
end
|
||||
else
|
||||
self.stage = "addrEntry"
|
||||
end
|
||||
end
|
||||
|
||||
elseif self.stage == "hosting" then
|
||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||
if self.net.paired then
|
||||
self.stage = "modeSelect"
|
||||
self.index = 1
|
||||
end
|
||||
|
||||
elseif self.stage == "addrEntry" then
|
||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||
if input:wasPressed("up") then
|
||||
self.addr[self.addrPos] = (self.addr[self.addrPos] + 1) % 10
|
||||
elseif input:wasPressed("down") then
|
||||
self.addr[self.addrPos] = (self.addr[self.addrPos] - 1) % 10
|
||||
elseif input:wasPressed("left") then
|
||||
self.addrPos = math.max(1, self.addrPos - 1)
|
||||
elseif input:wasPressed("right") then
|
||||
self.addrPos = math.min(12, self.addrPos + 1)
|
||||
elseif input:wasPressed("a") then
|
||||
local octets = {}
|
||||
for i = 1, 4 do
|
||||
local base = (i - 1) * 3
|
||||
octets[i] = math.min(255, self.addr[base + 1] * 100
|
||||
+ self.addr[base + 2] * 10
|
||||
+ self.addr[base + 3])
|
||||
end
|
||||
if self.net:join(table.concat(octets, ".")) then
|
||||
self.stage = "joining"
|
||||
else
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
end
|
||||
end
|
||||
|
||||
elseif self.stage == "joining" then
|
||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||
if self.net.paired then
|
||||
self.stage = "waitMode"
|
||||
end
|
||||
|
||||
elseif self.stage == "modeSelect" then -- host picks
|
||||
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)
|
||||
elseif input:wasPressed("b") then
|
||||
self:exitWith(nil)
|
||||
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.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
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
elseif self.stage == "trade" then
|
||||
self:updateTrade(input)
|
||||
|
||||
elseif self.stage == "battleWait" then
|
||||
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 == "party" then
|
||||
local LinkBattle = require("src.link.LinkBattle")
|
||||
local opts = {
|
||||
myParty = Protocol.packParty(self.game.save.party),
|
||||
theirParty = msg.mons,
|
||||
theirName = self.peerName or "FOE",
|
||||
seed = self.isHost and self.linkSeed or msg.seed,
|
||||
}
|
||||
if self.isHost then
|
||||
self.game.stack:push(LinkBattle.newHost(self.game, self.net, opts))
|
||||
else
|
||||
self.game.stack:push(LinkBattle.newGuest(self.game, self.net, opts))
|
||||
end
|
||||
self.stage = "battleRunning"
|
||||
for j = #msgs, i + 1, -1 do
|
||||
table.insert(self.net.inbox, 1, msgs[j])
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
elseif self.stage == "battleRunning" then
|
||||
if self.game.stack:top() == self then
|
||||
self:exitWith(nil) -- battle finished
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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) })
|
||||
self.index = 1
|
||||
else
|
||||
self.stage = "battleWait"
|
||||
-- the host deals the shared RNG seed for the lockstep simulation
|
||||
if isHost then
|
||||
self.linkSeed = love.math.random(1, 2 ^ 30)
|
||||
end
|
||||
self.net:send({ type = "party",
|
||||
mons = Protocol.packParty(self.game.save.party),
|
||||
seed = self.linkSeed })
|
||||
end
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- trade flow
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
function LinkState:updateTrade(input)
|
||||
for _, msg in ipairs(self.net:poll()) do
|
||||
self.trade:handle(msg)
|
||||
end
|
||||
local t = self.trade
|
||||
|
||||
if t.stage == "cancelled" then
|
||||
self:exitWith("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
|
||||
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, {
|
||||
sent = sent, received = received,
|
||||
onDone = function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
|
||||
function()
|
||||
if evoTo then
|
||||
require("src.pokemon.Evolution").evolve(game, received, evoTo)
|
||||
end
|
||||
end))
|
||||
end,
|
||||
}))
|
||||
return
|
||||
end
|
||||
|
||||
if t.stage == "picking" and input:wasPressed("up") then
|
||||
self.index = math.max(1, self.index - 1)
|
||||
elseif t.stage == "picking" and input:wasPressed("down") then
|
||||
self.index = math.min(#self.game.save.party, self.index + 1)
|
||||
elseif self.confirmed == nil and input:wasPressed("b") then
|
||||
-- once confirm=true has been sent to the peer, backing out here
|
||||
-- would desync the two sides (the peer may already be committing
|
||||
-- the trade) -- B is dead after that, matching the A branch's own
|
||||
-- self.confirmed == nil guard
|
||||
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))
|
||||
elseif t.stage == "confirming" and self.confirmed == nil then
|
||||
if input:wasPressed("a") then
|
||||
self.confirmed = true
|
||||
self.net:send(t:confirm(true))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- draw
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local function drawTitle(text)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(text, 8, 6)
|
||||
end
|
||||
|
||||
function LinkState:draw()
|
||||
if self.stage == "menu" then
|
||||
drawTitle("LINK CABLE CLUB")
|
||||
Font.draw("HOST A GAME", 32, 48)
|
||||
Font.draw("JOIN A GAME", 32, 68)
|
||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||
Font.draw("UDP port " .. Net.defaultPort(), 8, 128)
|
||||
|
||||
elseif self.stage == "hosting" then
|
||||
drawTitle("HOSTING")
|
||||
Font.draw("Friend joins at:", 16, 48)
|
||||
Font.draw(self.net.address or "?", 16, 64)
|
||||
Font.draw("Waiting for join...", 8, 96)
|
||||
|
||||
elseif self.stage == "addrEntry" then
|
||||
drawTitle("ENTER HOST ADDRESS")
|
||||
for i = 1, 12 do
|
||||
local octet = math.floor((i - 1) / 3) -- 0..3
|
||||
local x = 16 + (i - 1) * 8 + octet * 8 -- gap for the dots
|
||||
Font.draw(tostring(self.addr[i]), x, 64)
|
||||
if i == self.addrPos then
|
||||
Font.drawCode(0xEE, x, 76) -- ▼ under the active digit
|
||||
end
|
||||
end
|
||||
for octet = 1, 3 do
|
||||
Font.draw(".", 16 + octet * 32 - 8, 64)
|
||||
end
|
||||
Font.draw("Port: " .. Net.defaultPort(), 16, 96)
|
||||
Font.draw("A: connect B: back", 8, 128)
|
||||
|
||||
elseif self.stage == "joining" then
|
||||
drawTitle("JOINING...")
|
||||
Font.draw("Calling...", 8, 56)
|
||||
Font.draw(self.net.target or "", 8, 72)
|
||||
|
||||
elseif self.stage == "modeSelect" then
|
||||
drawTitle("CONNECTED!")
|
||||
Font.draw("TRADE", 32, 48)
|
||||
Font.draw("BATTLE", 32, 68)
|
||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||
|
||||
elseif self.stage == "waitMode" then
|
||||
drawTitle("CONNECTED!")
|
||||
Font.draw("Waiting for the", 16, 56)
|
||||
Font.draw("host to choose...", 16, 72)
|
||||
|
||||
elseif self.stage == "trade" then
|
||||
drawTitle("TRADE")
|
||||
local t = self.trade
|
||||
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)
|
||||
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
|
||||
end
|
||||
Font.draw("THEIRS", 84, 20)
|
||||
for i, mon in ipairs(t.theirParty or {}) do
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
|
||||
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"
|
||||
elseif t.stage == "waitPick" then hint = "Waiting for them..."
|
||||
elseif t.stage == "confirming" then
|
||||
hint = self.confirmed and "Waiting..." or "A: trade B: cancel"
|
||||
end
|
||||
Font.draw(hint or "", 8, 132)
|
||||
|
||||
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
|
||||
drawTitle("LINK BATTLE")
|
||||
Font.draw("Exchanging data...", 16, 64)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return LinkState
|
||||
@@ -0,0 +1,245 @@
|
||||
-- Peer-to-peer link transport over lua-enet (bundled with LÖVE).
|
||||
-- One player hosts (binds a UDP port); the other joins by address.
|
||||
-- No external server: messages are JSON objects on ENet's
|
||||
-- reliable-ordered channel 0.
|
||||
--
|
||||
-- Usage:
|
||||
-- local net = Net.new()
|
||||
-- net:host() -- or net:join("192.168.1.20:7777")
|
||||
-- every frame: net:update(); msgs = net:poll()
|
||||
-- net.address -- host: "ip:port" to tell the friend
|
||||
-- net.paired -- true once both ends are connected
|
||||
-- net:send({ type = "hello" })
|
||||
--
|
||||
-- Plain luajit (headless tests) has no enet; Net.available() reports
|
||||
-- that, and Net.loopbackPair() returns two in-memory ends with the
|
||||
-- same API so the protocol/battle logic stays testable offline.
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
local Logger = require("src.core.Logger")
|
||||
|
||||
local hasEnet, enet = pcall(require, "enet")
|
||||
if not hasEnet then enet = nil end
|
||||
|
||||
local Net = {}
|
||||
Net.__index = Net
|
||||
|
||||
Net.DEFAULT_PORT = 7777
|
||||
|
||||
function Net.available()
|
||||
return enet ~= nil
|
||||
end
|
||||
|
||||
function Net.defaultPort()
|
||||
return tonumber(os.getenv("POKEPORT_LINK_PORT") or "") or Net.DEFAULT_PORT
|
||||
end
|
||||
|
||||
-- monotonic-ish clock for the join timeout
|
||||
local function now()
|
||||
if love and love.timer and love.timer.getTime then
|
||||
return love.timer.getTime()
|
||||
end
|
||||
local ok, socket = pcall(require, "socket")
|
||||
if ok and socket and socket.gettime then return socket.gettime() end
|
||||
return os.time()
|
||||
end
|
||||
|
||||
-- best-effort LAN IP to show the host (no packet is sent: connecting a
|
||||
-- UDP socket just picks the outbound interface)
|
||||
function Net.lanIP()
|
||||
local ok, ip = pcall(function()
|
||||
local socket = require("socket")
|
||||
local udp = socket.udp()
|
||||
udp:setpeername("192.0.2.1", 9) -- TEST-NET-1, never routed
|
||||
local addr = udp:getsockname()
|
||||
udp:close()
|
||||
return addr
|
||||
end)
|
||||
if ok and ip and ip ~= "0.0.0.0" then return ip end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Net.new()
|
||||
return setmetatable({
|
||||
enetHost = nil, -- our enet host object (both ends have one)
|
||||
peer = nil, -- the connected remote peer
|
||||
inbox = {},
|
||||
outbox = {}, -- messages queued before pairing completes
|
||||
paired = false,
|
||||
address = nil, -- host: "ip:port" the other player types in
|
||||
error = nil,
|
||||
closed = false,
|
||||
mode = nil,
|
||||
joinTimeout = 10,
|
||||
}, Net)
|
||||
end
|
||||
|
||||
-- two in-memory ends with the Net API, for tests / offline logic
|
||||
function Net.loopbackPair()
|
||||
local function make()
|
||||
local n = Net.new()
|
||||
n.paired = true
|
||||
n.mode = "loopback"
|
||||
return n
|
||||
end
|
||||
local a, b = make(), make()
|
||||
a.peerEnd, b.peerEnd = b, a
|
||||
return a, b
|
||||
end
|
||||
|
||||
function Net:host(port)
|
||||
if not enet then
|
||||
self.error = "link needs lua-enet (run the game with LOVE)"
|
||||
return false
|
||||
end
|
||||
port = tonumber(port) or Net.defaultPort()
|
||||
local ok, h, err = pcall(enet.host_create, ("*:%d"):format(port), 2, 1)
|
||||
if not ok or not h then
|
||||
self.error = ("can't open UDP port %d (%s)"):format(
|
||||
port, tostring(ok and err or h))
|
||||
return false
|
||||
end
|
||||
self.enetHost = h
|
||||
self.mode = "hosting"
|
||||
self.address = ("%s:%d"):format(Net.lanIP() or "?", port)
|
||||
return true
|
||||
end
|
||||
|
||||
function Net:join(address)
|
||||
if not enet then
|
||||
self.error = "link needs lua-enet (run the game with LOVE)"
|
||||
return false
|
||||
end
|
||||
local host, port = address:match("^(.-):(%d+)$")
|
||||
host = host or address
|
||||
port = tonumber(port) or Net.defaultPort()
|
||||
local target = ("%s:%d"):format(host, port)
|
||||
local ok, h = pcall(enet.host_create) -- client: no bind address
|
||||
if not ok or not h then
|
||||
self.error = "can't create network socket"
|
||||
return false
|
||||
end
|
||||
local okc, peer = pcall(h.connect, h, target, 1)
|
||||
if not okc or not peer then
|
||||
pcall(function() h:destroy() end)
|
||||
self.error = ("bad address %s"):format(target)
|
||||
return false
|
||||
end
|
||||
self.enetHost = h
|
||||
self.peer = peer
|
||||
self.mode = "joining"
|
||||
self.target = target
|
||||
self.joinDeadline = now() + self.joinTimeout
|
||||
return true
|
||||
end
|
||||
|
||||
function Net:send(msg)
|
||||
if self.closed then return end
|
||||
if self.peerEnd then -- loopback: re-encode through json like the wire
|
||||
local decoded = Json.decode(Json.encode(msg))
|
||||
if decoded and not self.peerEnd.closed then
|
||||
table.insert(self.peerEnd.inbox, decoded)
|
||||
end
|
||||
return
|
||||
end
|
||||
if not self.paired or not self.peer then
|
||||
table.insert(self.outbox, msg) -- flushed when the connection opens
|
||||
return
|
||||
end
|
||||
local ok, err = pcall(function()
|
||||
return self.peer:send(Json.encode(msg), 0, "reliable")
|
||||
end)
|
||||
if not ok then
|
||||
self.error = "send failed: " .. tostring(err)
|
||||
self.closed = true
|
||||
end
|
||||
end
|
||||
|
||||
-- pump enet events; decoded JSON messages are queued for poll()
|
||||
function Net:update()
|
||||
if self.peerEnd then return end -- loopback needs no pumping
|
||||
if not self.enetHost or self.closed then return end
|
||||
while true do
|
||||
local ok, event = pcall(self.enetHost.service, self.enetHost, 0)
|
||||
if not ok then
|
||||
-- an unreachable join target surfaces as a service error
|
||||
-- (ICMP unreachable on the connected UDP socket)
|
||||
if self.mode == "joining" and not self.paired then
|
||||
self.error = ("no answer from\n%s"):format(self.target or "the host")
|
||||
else
|
||||
self.error = tostring(event)
|
||||
end
|
||||
self.closed = true
|
||||
return
|
||||
end
|
||||
if not event then break end
|
||||
if event.type == "connect" then
|
||||
if self.mode == "hosting" and self.peer and self.peer ~= event.peer then
|
||||
pcall(function() event.peer:disconnect_now() end) -- room is taken
|
||||
else
|
||||
self.peer = event.peer
|
||||
self.paired = true
|
||||
local queued = self.outbox
|
||||
self.outbox = {}
|
||||
for _, msg in ipairs(queued) do self:send(msg) end
|
||||
end
|
||||
elseif event.type == "receive" then
|
||||
local msg = Json.decode(event.data)
|
||||
if msg then
|
||||
table.insert(self.inbox, msg)
|
||||
else
|
||||
Logger.warn("link: bad message %q", tostring(event.data):sub(1, 60))
|
||||
end
|
||||
elseif event.type == "disconnect" then
|
||||
if event.peer == self.peer then
|
||||
self.closed = true
|
||||
if not self.paired then
|
||||
self.error = self.error or
|
||||
("no answer from\n%s"):format(self.target or "the host")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if self.mode == "joining" and not self.paired
|
||||
and self.joinDeadline and now() > self.joinDeadline then
|
||||
self.error = ("no answer from\n%s"):format(self.target or "the host")
|
||||
self.closed = true
|
||||
pcall(function() self.peer:disconnect_now() end)
|
||||
end
|
||||
end
|
||||
|
||||
function Net:poll()
|
||||
local msgs = self.inbox
|
||||
self.inbox = {}
|
||||
return msgs
|
||||
end
|
||||
|
||||
function Net:close()
|
||||
if self.peerEnd then
|
||||
self.closed = true
|
||||
return
|
||||
end
|
||||
if self.enetHost then
|
||||
if self.peer and self.paired and not self.closed then
|
||||
-- graceful goodbye: disconnect_later delivers the queued
|
||||
-- reliables (e.g. the final confirm/bye) before disconnecting;
|
||||
-- disconnect_now would drop them on both ends. Pump briefly
|
||||
-- until the handshake completes.
|
||||
pcall(function() self.peer:disconnect_later() end)
|
||||
local deadline = now() + 0.5
|
||||
while now() < deadline do
|
||||
local ok, event = pcall(self.enetHost.service, self.enetHost, 10)
|
||||
if not ok or (event and event.type == "disconnect") then break end
|
||||
end
|
||||
elseif self.peer then
|
||||
pcall(function() self.peer:disconnect_now() end)
|
||||
end
|
||||
pcall(function() self.enetHost:flush() end)
|
||||
pcall(function() self.enetHost:destroy() end)
|
||||
self.enetHost = nil
|
||||
self.peer = nil
|
||||
end
|
||||
self.closed = true
|
||||
end
|
||||
|
||||
return Net
|
||||
@@ -0,0 +1,175 @@
|
||||
-- Link protocol helpers: Pokémon serialization and the trade session
|
||||
-- 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="confirm", ok=bool} trade: final yes/no
|
||||
-- {type="action", ...} battle: guest -> host choice
|
||||
-- {type="event", ...} battle: host -> guest display event
|
||||
-- {type="bye"}
|
||||
|
||||
local Protocol = {}
|
||||
|
||||
-- serialize a mon instance for the wire (plain data only)
|
||||
function Protocol.packMon(mon)
|
||||
local moves = {}
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
table.insert(moves, { id = mv.id, pp = mv.pp })
|
||||
end
|
||||
return {
|
||||
species = mon.species,
|
||||
level = mon.level,
|
||||
exp = mon.exp,
|
||||
hp = mon.hp,
|
||||
status = mon.status,
|
||||
nickname = mon.nickname,
|
||||
dvs = mon.dvs,
|
||||
statExp = mon.statExp,
|
||||
moves = moves,
|
||||
}
|
||||
end
|
||||
|
||||
-- rebuild a mon locally (recomputes stats from real species data so a
|
||||
-- tampered packet can't invent stats)
|
||||
function Protocol.unpackMon(data, packed)
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local def = data.pokemon[packed.species]
|
||||
if not def then 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
|
||||
dvs[k] = math.max(0, math.min(15, math.floor((packed.dvs or {})[k] or 0)))
|
||||
end
|
||||
local statExp = {}
|
||||
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
||||
statExp[k] = math.max(0, math.min(65535, math.floor((packed.statExp or {})[k] or 0)))
|
||||
end
|
||||
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))),
|
||||
})
|
||||
end
|
||||
end
|
||||
if #moves == 0 then
|
||||
moves = { { id = "TACKLE", pp = 35 } }
|
||||
end
|
||||
return {
|
||||
species = packed.species,
|
||||
level = level,
|
||||
exp = math.max(0, math.floor(packed.exp or Growth.expForLevel(def.growthRate, level))),
|
||||
dvs = dvs,
|
||||
statExp = statExp,
|
||||
stats = stats,
|
||||
hp = math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp))),
|
||||
status = packed.status,
|
||||
nickname = packed.nickname,
|
||||
moves = moves,
|
||||
}
|
||||
end
|
||||
|
||||
function Protocol.packParty(party)
|
||||
local mons = {}
|
||||
for _, mon in ipairs(party) do
|
||||
table.insert(mons, Protocol.packMon(mon))
|
||||
end
|
||||
return mons
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Trade session: symmetric state machine. Feed it messages; read
|
||||
-- .stage ("waitParty" -> "picking" -> "waitPick" -> "confirming" ->
|
||||
-- "done"/"cancelled"). When done, .result = {give=idx, getMon=mon}.
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local TradeSession = {}
|
||||
TradeSession.__index = TradeSession
|
||||
Protocol.TradeSession = TradeSession
|
||||
|
||||
function TradeSession.new(data, party)
|
||||
return setmetatable({
|
||||
data = data,
|
||||
party = party,
|
||||
stage = "waitParty",
|
||||
theirParty = nil,
|
||||
myPick = nil,
|
||||
theirPick = nil,
|
||||
myConfirm = nil,
|
||||
theirConfirm = nil,
|
||||
}, TradeSession)
|
||||
end
|
||||
|
||||
function TradeSession:handle(msg)
|
||||
if 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
|
||||
end
|
||||
if self.stage == "waitParty" then self.stage = "picking" end
|
||||
elseif msg.type == "pick" then
|
||||
self.theirPick = msg.index
|
||||
self:advance()
|
||||
elseif msg.type == "confirm" then
|
||||
self.theirConfirm = msg.ok
|
||||
self:advance()
|
||||
elseif msg.type == "bye" then
|
||||
self.stage = "cancelled"
|
||||
end
|
||||
end
|
||||
|
||||
function TradeSession:pick(index)
|
||||
self.myPick = index
|
||||
self:advance()
|
||||
return { type = "pick", index = index }
|
||||
end
|
||||
|
||||
function TradeSession:confirm(ok)
|
||||
self.myConfirm = ok
|
||||
self:advance()
|
||||
return { type = "confirm", ok = ok }
|
||||
end
|
||||
|
||||
function TradeSession:advance()
|
||||
if self.stage == "picking" and self.myPick then
|
||||
self.stage = self.theirPick and "confirming" or "waitPick"
|
||||
elseif self.stage == "waitPick" and self.theirPick then
|
||||
self.stage = "confirming"
|
||||
end
|
||||
if self.stage == "confirming" and self.myConfirm ~= nil and self.theirConfirm ~= nil then
|
||||
if self.myConfirm and self.theirConfirm then
|
||||
self.stage = "done"
|
||||
else
|
||||
self.stage = "cancelled"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- apply the completed trade to the local party; returns the new mon
|
||||
-- (trade evolutions like Kadabra -> Alakazam trigger on the receiving
|
||||
-- side, as on a real link cable)
|
||||
function TradeSession:apply(game)
|
||||
assert(self.stage == "done", "trade not complete")
|
||||
local received = self.theirParty[self.theirPick]
|
||||
received.traded = true -- boosted exp (different OT)
|
||||
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]
|
||||
for _, evo in ipairs(def.evolutions or {}) do
|
||||
if evo.method == "TRADE" then
|
||||
return received, evo.species
|
||||
end
|
||||
end
|
||||
return received, nil
|
||||
end
|
||||
|
||||
return Protocol
|
||||
Reference in New Issue
Block a user