initial commit

This commit is contained in:
bryanthaboi
2026-07-17 20:30:02 -04:00
commit a5d2e77e7d
298 changed files with 100561 additions and 0 deletions
+174
View File
@@ -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
+386
View File
@@ -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
+357
View File
@@ -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
+245
View File
@@ -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
+175
View File
@@ -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