mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
bazinga
This commit is contained in:
+63
-4
@@ -112,6 +112,19 @@ local function scalar(v)
|
||||
return tostring(v)
|
||||
end
|
||||
|
||||
-- A volatile slot is "off" three interchangeable ways -- absent, false, or
|
||||
-- a counter sitting at 0 -- because every reader tests `if b.key then` or
|
||||
-- `key > 0`. They have to hash the same, or a match ends over a difference
|
||||
-- that does not exist. They routinely disagree: the menu-phase flinch
|
||||
-- clear (BattleState:update) and the FIGHT-branch boundTurns mirror
|
||||
-- (fightLockedAction) are written by whichever machine is sitting at ITS
|
||||
-- OWN menu, and in a link battle that is a different battler on each side,
|
||||
-- so one peer holds flinched=false / boundTurns=0 where the other still
|
||||
-- holds nil with two identical simulations underneath.
|
||||
local function off(v)
|
||||
return v == nil or v == false or v == 0
|
||||
end
|
||||
|
||||
local function stageStr(b)
|
||||
local out = {}
|
||||
for i, stat in ipairs(STAGES) do
|
||||
@@ -131,7 +144,7 @@ end
|
||||
local function volStr(b)
|
||||
local out = {}
|
||||
for _, key in ipairs(VOLATILE) do
|
||||
if b[key] ~= nil then
|
||||
if not off(b[key]) then
|
||||
out[#out + 1] = key .. "=" .. scalar(b[key])
|
||||
end
|
||||
end
|
||||
@@ -168,6 +181,17 @@ end
|
||||
|
||||
local PARTS = { "actives", "volatile", "bench" }
|
||||
|
||||
-- Which components are allowed to end a match. `actives` and `bench` carry
|
||||
-- what decides one -- species, HP, status, stat stages, PP, the rest of the
|
||||
-- party -- so a divergence there is a real split between the two
|
||||
-- simulations and stays a draw. `volatile` is per-turn bookkeeping that
|
||||
-- both sides recompute from the authoritative state every turn (see `off`
|
||||
-- above): it can disagree for a turn without either side being wrong, and
|
||||
-- when it does mean something real it lands in `actives` as damage or
|
||||
-- status within a turn or two, where it is caught. Ending a match on it
|
||||
-- alone cost players games they were winning, over nothing.
|
||||
local FATAL_PART = { actives = true, bench = true }
|
||||
|
||||
-- opts: { myParty = packed, theirParty = packed, theirName, role =
|
||||
-- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when
|
||||
-- the handshake says the two link surfaces don't match: a lockstep
|
||||
@@ -315,12 +339,23 @@ function LinkBattle.new(game, net, opts)
|
||||
Logger.warn("link: desync turn %s component=%s (%s vs %s)",
|
||||
tostring(turn), component, tostring(localH), tostring(remoteH))
|
||||
Runtime.emit("link.desync", { turn = turn, component = component,
|
||||
localHash = localH, remoteHash = remoteH })
|
||||
localHash = localH, remoteHash = remoteH,
|
||||
fatal = true })
|
||||
endAsDraw(s, Strings(
|
||||
"Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?",
|
||||
component))
|
||||
end
|
||||
|
||||
-- a non-fatal component split: both sides log it and carry on, so the
|
||||
-- match is decided by the battle rather than by bookkeeping
|
||||
local function noteDrift(s, turn, component, localH, remoteH)
|
||||
Logger.warn("link: %s drift on turn %s (%s vs %s) -- match continues",
|
||||
component, tostring(turn), tostring(localH), tostring(remoteH))
|
||||
Runtime.emit("link.desync", { turn = turn, component = component,
|
||||
localHash = localH, remoteHash = remoteH,
|
||||
fatal = false })
|
||||
end
|
||||
|
||||
-- a verified turn stays recorded: consuming it here left a finished
|
||||
-- battle holding 0-1 entries, so the whole-battle sweep the link suite
|
||||
-- runs over localHashes had nothing left to compare
|
||||
@@ -333,8 +368,11 @@ function LinkBattle.new(game, net, opts)
|
||||
if mine and theirs then
|
||||
for _, component in ipairs(PARTS) do
|
||||
if mine[component] ~= theirs[component] then
|
||||
reportDesync(s, turn, component, mine[component], theirs[component])
|
||||
return
|
||||
if FATAL_PART[component] then
|
||||
reportDesync(s, turn, component, mine[component], theirs[component])
|
||||
return
|
||||
end
|
||||
noteDrift(s, turn, component, mine[component], theirs[component])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -565,10 +603,19 @@ function LinkBattle.new(game, net, opts)
|
||||
if net.closed and not s.linkEnded and not s.result then
|
||||
endAsDraw(s)
|
||||
end
|
||||
-- Both early returns below skip baseUpdate, which is where the
|
||||
-- presentational clock normally advances -- so tick it here, in the same
|
||||
-- order baseUpdate would (fx, then the queue). Without this an
|
||||
-- animation caught mid-flight froze for the whole wait: a flash stopped
|
||||
-- on its inverted BGP step and repainted the UI in inverted shades, and
|
||||
-- a pic part-way through a slide or grow-in stayed off screen, which is
|
||||
-- the "the screen went inverted" / "a Pokemon just vanished" pair.
|
||||
if s.phase == "waitRemote" then
|
||||
s:tickFx()
|
||||
return -- the other side is still choosing
|
||||
end
|
||||
if s.phase == "messages" and s.afterQueue == "linkNext" then
|
||||
s:tickFx()
|
||||
if not s:updateQueue() then
|
||||
s.afterQueue = "menu"
|
||||
s.phase = "menu"
|
||||
@@ -742,6 +789,13 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
end
|
||||
|
||||
s:act(function()
|
||||
-- the two real players cleared their flinch flags when their move
|
||||
-- menu opened; a spectator has no menu, so it does it here instead,
|
||||
-- at the same point in the turn (see BattleState:clearTurnFlinches).
|
||||
-- Without this a flinch survived into the next turn and ate a move
|
||||
-- that landed in the real match, and the replay -- sharing the RNG
|
||||
-- stream -- was watching a different battle from that point on.
|
||||
s:clearTurnFlinches()
|
||||
local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run"
|
||||
and decodeWireAction(s, hostMsg, s.player) or nil
|
||||
local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run"
|
||||
@@ -837,7 +891,11 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
if net.closed and not s.linkEnded and not s.result then
|
||||
endSpectate(s)
|
||||
end
|
||||
-- same as the real-participant loop: every path that skips baseUpdate
|
||||
-- still has to advance the presentational clock, and a spectator sits in
|
||||
-- waitBoth between every single turn
|
||||
if s.phase == "messages" and s.afterQueue == "linkNext" then
|
||||
s:tickFx()
|
||||
if not s:updateQueue() then
|
||||
s.afterQueue = "waitBoth"
|
||||
s.phase = "waitBoth"
|
||||
@@ -845,6 +903,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
return
|
||||
end
|
||||
if s.phase == "menu" or s.phase == "waitBoth" then
|
||||
s:tickFx()
|
||||
return -- frozen between resolved turns; never a real decision here
|
||||
end
|
||||
baseUpdate(s, dt)
|
||||
|
||||
+46
-1
@@ -73,6 +73,9 @@ end
|
||||
function LinkState.new(game)
|
||||
local self = setmetatable({}, LinkState)
|
||||
self.game = game
|
||||
-- a link session runs at 1X on both machines whatever either player set
|
||||
-- GAME SPEED to (see Game:logicSpeed); cleared in exitWith
|
||||
game.linkSession = true
|
||||
self.stage = "menu"
|
||||
self.index = 1
|
||||
self.addr = ipDigits(Net.lanIP())
|
||||
@@ -98,6 +101,7 @@ end
|
||||
|
||||
function LinkState:exitWith(message, reason)
|
||||
DiscordPresence.setJoinCode(nil)
|
||||
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
||||
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
|
||||
if self.net then self.net:close() end
|
||||
self.game.stack:pop()
|
||||
@@ -106,6 +110,46 @@ function LinkState:exitWith(message, reason)
|
||||
end
|
||||
end
|
||||
|
||||
-- Online play meets strangers, so it requires vanilla on both ends
|
||||
-- (Handshake.onlineAllowed). Mods merge into the shared Data registries at
|
||||
-- boot and there is no unmerge, so switching them off has to go through a
|
||||
-- relaunch -- but the player should not have to go find the mod manager and
|
||||
-- work out which mods count. This turns every enabled mod off, records
|
||||
-- them so the mod manager can put them back, and relaunches. The restart
|
||||
-- is confirmed rather than silent: it drops unsaved progress.
|
||||
function LinkState:offerVanillaRestart()
|
||||
local game = self.game
|
||||
local loader = game.mods
|
||||
local mods = Handshake.mods(game)
|
||||
local names = {}
|
||||
for i, mod in ipairs(mods) do
|
||||
if i > 2 then break end
|
||||
names[#names + 1] = tostring(mod.id):upper():sub(1, 12)
|
||||
end
|
||||
local list = table.concat(names, ", ")
|
||||
if #mods > #names then list = list .. (" +%d"):format(#mods - #names) end
|
||||
local text = Strings(
|
||||
"Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?", list)
|
||||
self.game.linkSession = nil
|
||||
Runtime.emit("link.ended", { reason = "error" })
|
||||
if self.net then self.net:close() end
|
||||
game.stack:pop()
|
||||
game.stack:push(TextBox.new(game, text, nil, { choice = function(yes)
|
||||
if not yes then return end
|
||||
-- setEnabled persists the toggle itself (Loader:_saveState), so the
|
||||
-- relaunch comes up vanilla and the mod manager lists them as disabled
|
||||
-- for the player to switch back on afterwards
|
||||
for _, mod in ipairs(mods) do
|
||||
if loader and loader.setEnabled then loader:setEnabled(mod.id, false) end
|
||||
end
|
||||
if game.restartWithMods then
|
||||
game:restartWithMods()
|
||||
elseif love.event and love.event.quit then
|
||||
love.event.quit("restart")
|
||||
end
|
||||
end }))
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- handshake v2 (D8): both peers announce engine version, api version and
|
||||
-- a fingerprint of their link surface, and the verdict comes from the two
|
||||
@@ -202,7 +246,7 @@ function LinkState:update(dt)
|
||||
self.index = 1
|
||||
elseif self.index == 2 or self.index == 3 then
|
||||
if not Handshake.onlineAllowed(self.game) then
|
||||
self:exitWith(Strings("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS."))
|
||||
self:offerVanillaRestart()
|
||||
return
|
||||
end
|
||||
if self.index == 2 then
|
||||
@@ -509,6 +553,7 @@ function LinkState:updateTrade(input)
|
||||
if self.game.writeSave then self.game:writeSave() end
|
||||
local name = received.nickname or self.game.data.pokemon[received.species].name
|
||||
Runtime.emit("link.ended", { reason = "done" })
|
||||
self.game.linkSession = nil -- this path pops without exitWith
|
||||
self.net:close()
|
||||
self.game.stack:pop()
|
||||
local game = self.game
|
||||
|
||||
@@ -91,6 +91,11 @@ function Tournament.new(game)
|
||||
self.settingsIndex = 1
|
||||
self.roster = {}
|
||||
self.spectatorRoster = {}
|
||||
-- everything from here to exitWith runs at 1X regardless of the GAME
|
||||
-- SPEED option (see Game:logicSpeed): a tournament's shot clock counts
|
||||
-- down on the logic step, so fast-forward would hand one player less
|
||||
-- real time to choose than the opponent they are racing
|
||||
game.linkSession = true
|
||||
Sound.startLoop(game.data, MUSIC)
|
||||
return self
|
||||
end
|
||||
@@ -113,6 +118,7 @@ end
|
||||
|
||||
function Tournament:exitWith(message)
|
||||
DiscordPresence.setJoinCode(nil)
|
||||
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
||||
Sound.stopLoop(MUSIC)
|
||||
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
|
||||
if self.net then self.net:close() end
|
||||
|
||||
Reference in New Issue
Block a user