Closes #919, closes #982, closes #1003, closes #1012, closes #1022, closes #1028, closes #1033

This commit is contained in:
bryanthaboi
2026-08-14 10:05:51 -04:00
parent 2010ba71a6
commit a94fecfec8
48 changed files with 1263 additions and 541 deletions
+120 -11
View File
@@ -9,15 +9,16 @@
-- 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).
-- (a draw); a fainted mon is replaced from the party menu and the chosen
-- slot rides the wire, the way ChooseNextMon hands it to
-- LinkBattleExchangeData. Badge stat boosts don't apply on either side
-- (divergence: Gen 1 famously kept them in link battles).
local Fingerprint = require("src.link.Fingerprint")
local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake")
local Logger = require("src.core.Logger")
local Party = require("src.pokemon.Party")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local TurnOrder = require("src.battle.TurnOrder")
@@ -257,7 +258,9 @@ function LinkBattle.new(game, net, opts)
self.enemyParty = theirParty
self.playerParty = myParty -- intro ball row uses the clamped copies
self.opponentName = theirName
self.introText = Strings("%s wants\nto battle!", theirName)
-- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
-- takes PrintBeginningBattleText's .trainerBattle arm, link included
self.introText = Strings("%s wants\nto fight!", theirName)
self.remoteHashes = {}
self.localHashes = {}
self.remoteParts = {}
@@ -327,6 +330,68 @@ function LinkBattle.new(game, net, opts)
end)
end
-- ChooseNextMon (engine/battle/core.asm:1086-1103): the replacement after
-- a faint is a free party-menu pick in a link battle too -- DisplayPartyMenu
-- runs first, a fainted pick or a cancel goes back to it
-- (.goBackToPartyMenu), and only the chosen slot rides
-- LinkBattleExchangeData.
local function chooseReplacement(s)
s.linkReplacement = nil
s:uiNext(function()
return s:buildScreen("PartyMenu", {
battle = s,
party = myParty,
forceSwitch = true,
onSwitch = function(mon)
if mon.hp > 0 then s.linkReplacement = mon end
end,
})
end)
s:actNext(function()
local mon = s.linkReplacement
s.linkReplacement = nil
if s.result then return end
if not mon then
chooseReplacement(s)
return
end
for i, m in ipairs(myParty) do
if m == mon then send({ type = "replace", index = i }) break end
end
sendOutPlayer(s, mon)
end)
end
-- ReplaceFaintedEnemyMon (core.asm:892-905) reads the peer's slot back out
-- of the exchange, and EnemySendOutFirstMon (core.asm:1315-1320) decodes it
-- as wSerialExchangeNybbleReceiveData - 4. HandlePlayerMonFainted
-- (core.asm:989-996) always runs ChooseNextMon BEFORE that read, so on a
-- double faint both machines commit their own slot before they block on the
-- peer's; our queue can reach the enemy's handler first, so the wait
-- pre-empts itself with the local pick rather than deadlocking on a message
-- neither side is going to send.
local function awaitReplacement(s)
s:actNext(function()
if s.result then return end
if s.player.mon.hp <= 0 and not s.linkChoiceQueued
and Party.firstHealthy(myParty) then
s.linkChoiceQueued = true
chooseReplacement(s)
awaitReplacement(s)
return
end
local idx = s.remoteReplace
if not idx then
awaitReplacement(s)
return
end
s.remoteReplace = nil
local mon = theirParty[idx]
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(theirParty) end
sendOutEnemy(s, mon)
end)
end
-- decode a remote action message against the enemy battler
local function decodeTheirAction(s, msg)
return decodeWireAction(s, msg, s.enemy)
@@ -540,12 +605,17 @@ function LinkBattle.new(game, net, opts)
submit(s, { type = "action", kind = "run" }, nil)
end
-- fainted mons auto-replace with the next healthy teammate, in party
-- order, identically on both machines
-- linkChoiceQueued: awaitReplacement already pre-empted itself with this
-- side's ChooseNextMon and put the slot on the wire, so the handler the
-- faint queued has nothing left to do but drop the flag
self.playerMonFainted = function(s)
if s.linkChoiceQueued then
s.linkChoiceQueued = nil
return
end
for _, mon in ipairs(myParty) do
if mon.hp > 0 then
s:act(function() sendOutPlayer(s, mon) end)
if not s.result then chooseReplacement(s) end
return
end
end
@@ -558,7 +628,7 @@ function LinkBattle.new(game, net, opts)
self.enemyMonFainted = function(s)
for _, mon in ipairs(theirParty) do
if mon.hp > 0 then
s:act(function() sendOutEnemy(s, mon) end)
if not s.result then awaitReplacement(s) end
return
end
end
@@ -579,6 +649,9 @@ function LinkBattle.new(game, net, opts)
s.remoteHashes[msg.turn or 0] = msg.value
s.remoteParts[msg.turn or 0] = msg.parts
checkHashes(s)
elseif msg.type == "replace" then
local idx = math.floor(tonumber(msg.index) or 1)
s.remoteReplace = math.max(1, math.min(#theirParty, idx))
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)
@@ -770,6 +843,34 @@ function LinkBattle.newSpectator(game, net, opts)
end)
end
local function awaitHostReplacement(s)
s:actNext(function()
if s.result then return end
local idx = table.remove(s.hostReplace, 1)
if not idx then
awaitHostReplacement(s)
return
end
local mon = hostParty[idx]
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(hostParty) end
sendOutHost(s, mon)
end)
end
local function awaitGuestReplacement(s)
s:actNext(function()
if s.result then return end
local idx = table.remove(s.guestReplace, 1)
if not idx then
awaitGuestReplacement(s)
return
end
local mon = guestParty[idx]
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(guestParty) end
sendOutGuest(s, mon)
end)
end
local function resolveSpecTurn(s, hostMsg, guestMsg)
if hostMsg.kind == "run" or guestMsg.kind == "run" then
endSpectate(s, "The match ended.")
@@ -840,7 +941,7 @@ function LinkBattle.newSpectator(game, net, opts)
self.playerMonFainted = function(s)
for _, mon in ipairs(hostParty) do
if mon.hp > 0 then
s:act(function() sendOutHost(s, mon) end)
if not s.result then awaitHostReplacement(s) end
return
end
end
@@ -852,7 +953,7 @@ function LinkBattle.newSpectator(game, net, opts)
self.enemyMonFainted = function(s)
for _, mon in ipairs(guestParty) do
if mon.hp > 0 then
s:act(function() sendOutGuest(s, mon) end)
if not s.result then awaitGuestReplacement(s) end
return
end
end
@@ -862,6 +963,7 @@ function LinkBattle.newSpectator(game, net, opts)
end
self.hostMsg, self.guestMsg = nil, nil
self.hostReplace, self.guestReplace = {}, {}
local baseUpdate = self.update
self.update = function(s, dt)
net:update()
@@ -875,6 +977,13 @@ function LinkBattle.newSpectator(game, net, opts)
s.hostMsg, s.guestMsg = nil, nil
resolveSpecTurn(s, h, g)
end
elseif inner.type == "replace" then
local idx = math.floor(tonumber(inner.index) or 1)
if msg.side == "host" then
table.insert(s.hostReplace, math.max(1, math.min(#hostParty, idx)))
else
table.insert(s.guestReplace, math.max(1, math.min(#guestParty, idx)))
end
elseif inner.type == "bye" or inner.type == "forfeit" then
if not s.result then endSpectate(s, "The match ended.") end
end
+87 -17
View File
@@ -19,6 +19,7 @@ LinkState.__index = LinkState
LinkState.isOpaque = true
local CURSOR = 0xED
local CURSOR_HOLLOW = 0xEC
local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under
-- LuaJIT even though # still reports the full size, so
-- the level picker cycles this string instead of nil,
@@ -114,6 +115,7 @@ end
function LinkState:exitWith(message, reason)
DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED
if self.game.linkNet == self.net then self.game.linkNet = nil end
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
if self.net then self.net:close() end
self.game.stack:pop()
@@ -519,6 +521,9 @@ function LinkState:startMode(mode, isHost)
})
self.net:send(self.trade:opening())
self.index = 1
self.theirIndex = 1
self.side = "mine"
self.pickChoice = nil
else
self.stage = "battleWait"
-- the host deals the shared RNG seed for the lockstep simulation
@@ -536,7 +541,14 @@ end
-- trade flow
-- -------------------------------------------------------------------
function LinkState:openStats(mon)
if not mon then return end
self.game.linkNet = self.net
Screens.push(self.game, "SummaryMenu", mon)
end
function LinkState:updateTrade(input)
if self.game.linkNet == self.net then self.game.linkNet = nil end
for _, msg in ipairs(self.net:poll()) do
local reply = self.trade:handle(msg)
if reply then self.net:send(reply) end
@@ -591,10 +603,51 @@ function LinkState:updateTrade(input)
return
end
if t.stage == "picking" and input:wasPressed("up") then
self.index = math.max(1, self.index - 1)
-- pokered engine/link/cable_club.asm TradeCenter_SelectMon: A on one of
-- your own mons opens the "STATS TRADE" row (.displayStatsTradeMenu)
-- and only TRADE commits the pick, while the enemy list carries its own
-- cursor whose A shows that mon's status pages (.displayEnemyMonStats).
-- The cart's enemy path sets hl but never wMonDataLocation, the way the
-- battle menu's STATS (engine/battle/core.asm) does, so LoadMonData_ reads
-- the player's party and it draws YOUR mon at that slot -- an omission,
-- not behaviour, so we show the peer's mon.
if t.stage == "picking" and self.pickChoice then
if input:wasPressed("left") then
self.pickChoice = 1
elseif input:wasPressed("right") then
self.pickChoice = 2
elseif input:wasPressed("b") then
self.pickChoice = nil -- .cancelPlayerMonChoice: back to the list, not
-- out of the trade
elseif input:wasPressed("a") then
if self.pickChoice == 1 then
self.pickChoice = nil
self:openStats(self.game.save.party[self.index])
elseif t:canPick(self.index) then
self.pickChoice = nil
self.side = "mine"
self.net:send(t:pick(self.index))
end
end
elseif t.stage == "picking" and input:wasPressed("up") then
if self.side == "theirs" then
self.theirIndex = math.max(1, self.theirIndex - 1)
else
self.index = math.max(1, self.index - 1)
end
elseif t.stage == "picking" and input:wasPressed("down") then
self.index = math.min(#self.game.save.party, self.index + 1)
if self.side == "theirs" then
self.theirIndex = math.min(#(t.theirParty or {}), self.theirIndex + 1)
else
self.index = math.min(#self.game.save.party, self.index + 1)
end
elseif t.stage == "picking" and input:wasPressed("right") then
if t.theirParty and #t.theirParty > 0 then
self.side = "theirs"
self.theirIndex = math.min(self.theirIndex, #t.theirParty)
end
elseif t.stage == "picking" and input:wasPressed("left") then
self.side = "mine"
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
@@ -603,8 +656,10 @@ function LinkState:updateTrade(input)
self.net:send({ type = "bye" })
self:exitWith(Strings("The trade was\ncancelled."))
elseif t.stage == "picking" and input:wasPressed("a") then
if t:canPick(self.index) then
self.net:send(t:pick(self.index))
if self.side == "theirs" then
self:openStats((t.theirParty or {})[self.theirIndex])
else
self.pickChoice = 1
end
elseif t.stage == "confirming" and self.confirmed == nil then
if input:wasPressed("a") then
@@ -738,25 +793,40 @@ function LinkState:draw()
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
if i == self.index and self.side ~= "theirs" then
Font.drawCode(CURSOR, 8, 20 + i * 12)
end
end
Font.draw(Strings("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
if self.side == "theirs" and i == self.theirIndex then
Font.drawCode(CURSOR, 84, 20 + i * 12)
elseif t.theirPick == i then
Font.drawCode(CURSOR_HOLLOW, 84, 20 + i * 12)
end
end
local hint
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 Strings("X: not on theirs")
elseif t.stage == "waitPick" then hint = "Waiting for them..."
elseif t.stage == "confirming" then
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
if self.pickChoice then
Font.draw(Strings("STATS"), 16, 128)
Font.draw(Strings("TRADE"), 96, 128)
Font.drawCode(CURSOR, self.pickChoice == 1 and 8 or 88, 128)
else
local hint
if t.stage == "waitRecords" then hint = "Comparing games..."
elseif t.stage == "waitParty" then hint = "Exchanging data..."
elseif t.stage == "picking" then
if self.side == "theirs" then hint = Strings("A: stats")
else
hint = t:canPick(self.index) and "Pick one to trade"
or Strings("X: not on theirs")
end
elseif t.stage == "waitPick" then hint = "Waiting for them..."
elseif t.stage == "confirming" then
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
end
Font.draw(hint or "", 8, 132)
end
Font.draw(hint or "", 8, 132)
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
drawTitle("LINK BATTLE")