mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 04:31:09 +02:00
online play updates and fixes and tweaks and cheeks
This commit is contained in:
@@ -38,8 +38,18 @@ local state = {
|
|||||||
unsubs = {},
|
unsubs = {},
|
||||||
pid = nil,
|
pid = nil,
|
||||||
loggedAbsent = false, -- one quiet note when Discord isn't running
|
loggedAbsent = false, -- one quiet note when Discord isn't running
|
||||||
|
game = nil, -- kept for the "friend clicked join" -> push a screen path
|
||||||
|
joinCode = nil, -- the online-match/tournament code to advertise as a join secret, if any
|
||||||
|
joinKind = "match", -- "match" (LinkState online) or "tournament"
|
||||||
|
partySize = 1, -- current headcount for the party.size Discord shows
|
||||||
|
partyMax = 2,
|
||||||
|
subscribedJoin = false, -- SUBSCRIBE ACTIVITY_JOIN sent on this connection?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- MSG_DONTWAIT differs by OS (Darwin 0x80, Linux 0x40); only used for the
|
||||||
|
-- ongoing per-frame inbound poll, never for the one-time connect/handshake
|
||||||
|
local MSG_DONTWAIT_BY_OS = { ["OS X"] = 0x80, Linux = 0x40 }
|
||||||
|
|
||||||
local function disabledByEnv()
|
local function disabledByEnv()
|
||||||
return os.getenv("POKEPORT_NO_DISCORD") == "1"
|
return os.getenv("POKEPORT_NO_DISCORD") == "1"
|
||||||
or os.getenv("POKEPORT_AUTOPILOT") ~= nil
|
or os.getenv("POKEPORT_AUTOPILOT") ~= nil
|
||||||
@@ -378,6 +388,15 @@ local function connect()
|
|||||||
state.dirty = true
|
state.dirty = true
|
||||||
state.lastSentKey = nil
|
state.lastSentKey = nil
|
||||||
state.loggedAbsent = false
|
state.loggedAbsent = false
|
||||||
|
state.subscribedJoin = false
|
||||||
|
-- ask for "friend clicked join" dispatches; a plain outbound write, so
|
||||||
|
-- it's harmless to send even on Windows, where the inbound poll below
|
||||||
|
-- isn't implemented yet (see pollJoinRequest)
|
||||||
|
if sendFrame(OP_FRAME, Json.encode({
|
||||||
|
cmd = "SUBSCRIBE", evt = "ACTIVITY_JOIN", nonce = nonce(),
|
||||||
|
})) then
|
||||||
|
state.subscribedJoin = true
|
||||||
|
end
|
||||||
Logger.info("discord: rich presence connected")
|
Logger.info("discord: rich presence connected")
|
||||||
return true
|
return true
|
||||||
end)
|
end)
|
||||||
@@ -427,7 +446,7 @@ local function buildActivity()
|
|||||||
activityState = "At the title screen"
|
activityState = "At the title screen"
|
||||||
end
|
end
|
||||||
|
|
||||||
return {
|
local activity = {
|
||||||
details = details,
|
details = details,
|
||||||
state = activityState,
|
state = activityState,
|
||||||
timestamps = { start = state.startedAt },
|
timestamps = { start = state.startedAt },
|
||||||
@@ -436,10 +455,25 @@ local function buildActivity()
|
|||||||
large_text = "Pokemon Gen1Recomp",
|
large_text = "Pokemon Gen1Recomp",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
-- a party/join-secret pair is what makes Discord show "Ask to Join" on
|
||||||
|
-- the profile card; the secret carries our own room/tournament code plus
|
||||||
|
-- a one-letter kind tag ("m"/"t") so the click handler on the other end
|
||||||
|
-- knows which screen to jump into -- Discord only echoes the string
|
||||||
|
-- back, so the kind has to ride inside it
|
||||||
|
if state.joinCode then
|
||||||
|
local kindTag = state.joinKind == "tournament" and "t" or "m"
|
||||||
|
activity.party = { id = "pokeport-" .. state.joinCode,
|
||||||
|
size = { state.partySize, state.partyMax } }
|
||||||
|
activity.secrets = { join = "join:" .. kindTag .. ":" .. state.joinCode }
|
||||||
|
end
|
||||||
|
return activity
|
||||||
end
|
end
|
||||||
|
|
||||||
local function activityKey(activity)
|
local function activityKey(activity)
|
||||||
return (activity.details or "") .. "|" .. (activity.state or "")
|
local partyKey = activity.party
|
||||||
|
and (activity.party.size[1] .. "/" .. activity.party.size[2]) or ""
|
||||||
|
return (activity.details or "") .. "|" .. (activity.state or "") .. "|"
|
||||||
|
.. (activity.secrets and activity.secrets.join or "") .. "|" .. partyKey
|
||||||
end
|
end
|
||||||
|
|
||||||
local function flush(force)
|
local function flush(force)
|
||||||
@@ -567,6 +601,96 @@ local function subscribe(game)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Advertise (or clear, with code=nil) an online-match/tournament code as a
|
||||||
|
-- Discord join secret. LinkState/Tournament call this once they have a
|
||||||
|
-- real code from the relay, and clear it again once paired/started or the
|
||||||
|
-- hosting screen exits (an invite that's already full or gone is worse
|
||||||
|
-- than no invite). kind is "match" (default) or "tournament"; size/max are
|
||||||
|
-- the party.size Discord shows (default 1/2, a plain 1v1 room) -- a
|
||||||
|
-- tournament passes its live roster count and a generous cap instead, and
|
||||||
|
-- should call this again whenever the roster changes, not just once.
|
||||||
|
function DiscordPresence.setJoinCode(code, kind, size, max)
|
||||||
|
pcall(function()
|
||||||
|
state.joinCode = code
|
||||||
|
state.joinKind = kind or "match"
|
||||||
|
state.partySize = size or 1
|
||||||
|
state.partyMax = max or 2
|
||||||
|
state.dirty = true
|
||||||
|
flush(false)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A friend clicked "Ask to Join" on our profile and Discord delivered the
|
||||||
|
-- secret back over this same connection. Only act from a safe spot (never
|
||||||
|
-- yank the player out of a battle or an already-running link session) --
|
||||||
|
-- "exploring"/"menu" are the coarse buckets subscribe() above already
|
||||||
|
-- tracks; anything more specific (a shop, a dialogue box) is a rarer miss
|
||||||
|
-- worth accepting rather than adding a second, finer-grained state tracker
|
||||||
|
-- just for this.
|
||||||
|
local function handleJoinRequest(secret)
|
||||||
|
if not secret or secret == "" then return end
|
||||||
|
if state.activity == "battle" then return end
|
||||||
|
local game = state.game
|
||||||
|
if not game or not game.stack then return end
|
||||||
|
local top = game.stack:top()
|
||||||
|
if top and top.stage and top.net then return end -- already in a link session
|
||||||
|
local kindTag, code = secret:match("^(%a):(.+)$")
|
||||||
|
if not kindTag then kindTag, code = "m", secret end -- older/plain secret: assume match
|
||||||
|
Runtime.emit("discord.join_requested", { code = code, kind = kindTag })
|
||||||
|
if kindTag == "t" then
|
||||||
|
local ok, Tournament = pcall(require, "src.link.Tournament")
|
||||||
|
if ok and Tournament.newJoinOnline then
|
||||||
|
game.stack:push(Tournament.newJoinOnline(game, code))
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local ok, LinkState = pcall(require, "src.link.LinkState")
|
||||||
|
if ok and LinkState.newJoinOnline then
|
||||||
|
game.stack:push(LinkState.newJoinOnline(game, code))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- non-blocking peek for an incoming ACTIVITY_JOIN dispatch. Unix (FFI)
|
||||||
|
-- only for now: reading a Windows named pipe without risking a stall needs
|
||||||
|
-- a HANDLE-level PeekNamedPipe that the plain io.open() connection below
|
||||||
|
-- doesn't give us, so a Windows player's presence/party/join-secret still
|
||||||
|
-- all work (they can still be seen and clicked), the click just isn't
|
||||||
|
-- delivered back to their own game session yet.
|
||||||
|
local function pollJoinRequest()
|
||||||
|
if state.isWindows or not state.ffi or not state.socket or not state.subscribedJoin then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
pcall(function()
|
||||||
|
local ffi = state.ffi
|
||||||
|
local dontwait = MSG_DONTWAIT_BY_OS[love.system.getOS()] or 0
|
||||||
|
local hbuf = ffi.new("char[8]")
|
||||||
|
local n = ffi.C.recv(state.socket, hbuf, 8, dontwait)
|
||||||
|
if not n or tonumber(n) <= 0 then return end -- nothing waiting right now
|
||||||
|
local header = ffi.string(hbuf, 8)
|
||||||
|
local opcode = unpackU32(header:sub(1, 4))
|
||||||
|
local length = unpackU32(header:sub(5, 8))
|
||||||
|
if not opcode or not length or length < 0 or length > 65536 then return end
|
||||||
|
-- the body may lag the header by a packet; the connection's normal
|
||||||
|
-- (short-timeout) blocking read is fine here since it has already
|
||||||
|
-- announced its exact length
|
||||||
|
local body = length > 0 and readExact(length) or ""
|
||||||
|
if body == nil then return end
|
||||||
|
if opcode == 3 then -- PING -> PONG, keeps Discord from closing on us
|
||||||
|
sendFrame(4, body)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if opcode ~= 1 then return end
|
||||||
|
local decoded = Json.decode(body)
|
||||||
|
if decoded and decoded.evt == "ACTIVITY_JOIN" then
|
||||||
|
local secret = decoded.data and decoded.data.secret
|
||||||
|
if type(secret) == "string" and secret ~= "" then
|
||||||
|
if secret:sub(1, 5) == "join:" then secret = secret:sub(6) end
|
||||||
|
handleJoinRequest(secret)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
function DiscordPresence.init(game)
|
function DiscordPresence.init(game)
|
||||||
local ok, err = pcall(function()
|
local ok, err = pcall(function()
|
||||||
DiscordPresence.shutdown()
|
DiscordPresence.shutdown()
|
||||||
@@ -579,6 +703,8 @@ function DiscordPresence.init(game)
|
|||||||
state.location = "Title screen"
|
state.location = "Title screen"
|
||||||
state.mapId = nil
|
state.mapId = nil
|
||||||
state.battleLabel = nil
|
state.battleLabel = nil
|
||||||
|
state.joinCode = nil
|
||||||
|
state.game = game
|
||||||
state.dirty = true
|
state.dirty = true
|
||||||
state.nextReconnectAt = 0
|
state.nextReconnectAt = 0
|
||||||
state.loggedAbsent = false
|
state.loggedAbsent = false
|
||||||
@@ -621,6 +747,7 @@ function DiscordPresence.update(_dt)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
pollJoinRequest()
|
||||||
if state.dirty then flush(false) end
|
if state.dirty then flush(false) end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -649,6 +776,8 @@ function DiscordPresence.shutdown()
|
|||||||
state.lastSentKey = nil
|
state.lastSentKey = nil
|
||||||
state.connected = false
|
state.connected = false
|
||||||
state.socket = nil
|
state.socket = nil
|
||||||
|
state.subscribedJoin = false
|
||||||
|
state.joinCode = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- test / debug helpers
|
-- test / debug helpers
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
|
|
||||||
-- both parties pass through the same pack->unpack clamp on both
|
-- both parties pass through the same pack->unpack clamp on both
|
||||||
-- machines, so the copies are identical everywhere
|
-- machines, so the copies are identical everywhere
|
||||||
local unpackOpts = { strict = opts.strict or false }
|
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
|
||||||
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
|
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
|
||||||
return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species))
|
return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species))
|
||||||
end)
|
end)
|
||||||
@@ -646,7 +646,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
return nil, "Link battle needs\nthe same mods on\nboth games."
|
return nil, "Link battle needs\nthe same mods on\nboth games."
|
||||||
end
|
end
|
||||||
|
|
||||||
local unpackOpts = { strict = opts.strict or false }
|
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
|
||||||
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p)
|
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p)
|
||||||
return ("%s's %s can't\nbattle on this\ngame."):format(hostName, tostring(p.species))
|
return ("%s's %s can't\nbattle on this\ngame."):format(hostName, tostring(p.species))
|
||||||
end)
|
end)
|
||||||
|
|||||||
+77
-2
@@ -3,6 +3,7 @@
|
|||||||
-- lua-enet (bundled with LÖVE), no relay server.
|
-- lua-enet (bundled with LÖVE), no relay server.
|
||||||
|
|
||||||
local CodeEntry = require("src.link.CodeEntry")
|
local CodeEntry = require("src.link.CodeEntry")
|
||||||
|
local DiscordPresence = require("src.core.DiscordPresence")
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Handshake = require("src.link.Handshake")
|
local Handshake = require("src.link.Handshake")
|
||||||
local Net = require("src.link.Net")
|
local Net = require("src.link.Net")
|
||||||
@@ -16,6 +17,26 @@ LinkState.__index = LinkState
|
|||||||
LinkState.isOpaque = true
|
LinkState.isOpaque = true
|
||||||
|
|
||||||
local CURSOR = 0xED
|
local CURSOR = 0xED
|
||||||
|
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,
|
||||||
|
-- converted to nil only on the wire (see levelForWire)
|
||||||
|
local FORCE_LEVEL_STEPS = { ANY, 50, 100 }
|
||||||
|
|
||||||
|
local function indexOf(list, value)
|
||||||
|
for i, v in ipairs(list) do
|
||||||
|
if v == value then return i end
|
||||||
|
end
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local function levelForWire(v)
|
||||||
|
return v == ANY and nil or v
|
||||||
|
end
|
||||||
|
|
||||||
|
local function forceLevelLabel(v)
|
||||||
|
return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v))
|
||||||
|
end
|
||||||
|
|
||||||
-- stages before any Net object is meaningfully "this session's link" --
|
-- stages before any Net object is meaningfully "this session's link" --
|
||||||
-- .net can still be a leftover failed attempt sitting on self, so error/
|
-- .net can still be a leftover failed attempt sitting on self, so error/
|
||||||
@@ -54,7 +75,23 @@ function LinkState.new(game)
|
|||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- entry point for a Discord "Ask to Join" click (see DiscordPresence.lua):
|
||||||
|
-- skips the whole LAN/ONLINE/TOURNAMENT menu and jumps straight to
|
||||||
|
-- "connecting with this code", same as if the player had typed it in
|
||||||
|
function LinkState.newJoinOnline(game, code)
|
||||||
|
local self = LinkState.new(game)
|
||||||
|
self.net = Net.new()
|
||||||
|
if self.net:joinOnline(nil, code) then
|
||||||
|
self.stage = "onlineJoining"
|
||||||
|
else
|
||||||
|
self.stage = "menu" -- exitWith below needs a real stage to unwind from
|
||||||
|
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||||
|
end
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
function LinkState:exitWith(message, reason)
|
function LinkState:exitWith(message, reason)
|
||||||
|
DiscordPresence.setJoinCode(nil)
|
||||||
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
|
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
|
||||||
if self.net then self.net:close() end
|
if self.net then self.net:close() end
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
@@ -108,7 +145,12 @@ function LinkState:decideCompat(mode, isHost)
|
|||||||
mods = peer and peer.mods, fingerprint = peer and peer.fingerprint },
|
mods = peer and peer.mods, fingerprint = peer and peer.fingerprint },
|
||||||
})
|
})
|
||||||
if self.verdict == "full" or self.verdict == "vanilla_peer" then
|
if self.verdict == "full" or self.verdict == "vanilla_peer" then
|
||||||
self:startMode(mode, isHost)
|
if isHost and mode == "battle" then
|
||||||
|
-- host picks the level rule now, before the parties are exchanged
|
||||||
|
self.stage = "battleOptions"
|
||||||
|
else
|
||||||
|
self:startMode(mode, isHost)
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- naming the difference up front is the whole point: the old behaviour
|
-- naming the difference up front is the whole point: the old behaviour
|
||||||
@@ -208,8 +250,13 @@ function LinkState:update(dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
elseif self.stage == "onlineHosting" then
|
elseif self.stage == "onlineHosting" then
|
||||||
|
if not self.discordCodeSet and self.net.code then
|
||||||
|
DiscordPresence.setJoinCode(self.net.code)
|
||||||
|
self.discordCodeSet = true
|
||||||
|
end
|
||||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||||
if self.net.paired then
|
if self.net.paired then
|
||||||
|
DiscordPresence.setJoinCode(nil) -- someone's here now; stop advertising
|
||||||
self.stage = "modeSelect"
|
self.stage = "modeSelect"
|
||||||
self.index = 1
|
self.index = 1
|
||||||
end
|
end
|
||||||
@@ -300,6 +347,23 @@ function LinkState:update(dt)
|
|||||||
self:exitWith(nil)
|
self:exitWith(nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
elseif self.stage == "battleOptions" then -- host picks the level rule,
|
||||||
|
-- once compat is confirmed and
|
||||||
|
-- before parties are exchanged
|
||||||
|
self.levelChoice = self.levelChoice or ANY
|
||||||
|
if input:wasPressed("b") then
|
||||||
|
self:exitWith(nil)
|
||||||
|
elseif input:wasPressed("up") or input:wasPressed("down")
|
||||||
|
or input:wasPressed("left") or input:wasPressed("right") then
|
||||||
|
local delta = (input:wasPressed("down") or input:wasPressed("left")) and -1 or 1
|
||||||
|
local i = indexOf(FORCE_LEVEL_STEPS, self.levelChoice)
|
||||||
|
i = ((i - 1 + delta) % #FORCE_LEVEL_STEPS) + 1
|
||||||
|
self.levelChoice = FORCE_LEVEL_STEPS[i]
|
||||||
|
elseif input:wasPressed("a") then
|
||||||
|
self.forceLevel = levelForWire(self.levelChoice)
|
||||||
|
self:startMode(self.pendingMode, true)
|
||||||
|
end
|
||||||
|
|
||||||
elseif self.stage == "waitHello" then -- host waits for the peer's hello
|
elseif self.stage == "waitHello" then -- host waits for the peer's hello
|
||||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||||
local got, other = self:pollHello()
|
local got, other = self:pollHello()
|
||||||
@@ -343,6 +407,9 @@ function LinkState:update(dt)
|
|||||||
local msgs = self.net:poll()
|
local msgs = self.net:poll()
|
||||||
for i, msg in ipairs(msgs) do
|
for i, msg in ipairs(msgs) do
|
||||||
if msg.type == "party" then
|
if msg.type == "party" then
|
||||||
|
-- the host owns this rule (same as mode); the guest only learns
|
||||||
|
-- it here, off the host's own party message
|
||||||
|
if not self.isHost then self.forceLevel = msg.forceLevel end
|
||||||
local LinkBattle = require("src.link.LinkBattle")
|
local LinkBattle = require("src.link.LinkBattle")
|
||||||
local opts = {
|
local opts = {
|
||||||
myParty = Protocol.packParty(self.game.save.party),
|
myParty = Protocol.packParty(self.game.save.party),
|
||||||
@@ -351,6 +418,7 @@ function LinkState:update(dt)
|
|||||||
seed = self.isHost and self.linkSeed or msg.seed,
|
seed = self.isHost and self.linkSeed or msg.seed,
|
||||||
verdict = self.verdict,
|
verdict = self.verdict,
|
||||||
strict = Handshake.strict(self.verdict),
|
strict = Handshake.strict(self.verdict),
|
||||||
|
forceLevel = self.forceLevel,
|
||||||
}
|
}
|
||||||
local battle, why
|
local battle, why
|
||||||
if self.isHost then
|
if self.isHost then
|
||||||
@@ -401,7 +469,8 @@ function LinkState:startMode(mode, isHost)
|
|||||||
end
|
end
|
||||||
self.net:send({ type = "party",
|
self.net:send({ type = "party",
|
||||||
mons = Protocol.packParty(self.game.save.party),
|
mons = Protocol.packParty(self.game.save.party),
|
||||||
seed = self.linkSeed })
|
seed = self.linkSeed,
|
||||||
|
forceLevel = isHost and self.forceLevel or nil })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -557,6 +626,12 @@ function LinkState:draw()
|
|||||||
Font.draw("BATTLE", 32, 68)
|
Font.draw("BATTLE", 32, 68)
|
||||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||||
|
|
||||||
|
elseif self.stage == "battleOptions" then
|
||||||
|
drawTitle("BATTLE OPTIONS")
|
||||||
|
Font.draw("LEVELS:", 16, 56)
|
||||||
|
Font.draw(forceLevelLabel(self.levelChoice or ANY), 88, 56)
|
||||||
|
Font.draw("A: continue B: back", 8, 128)
|
||||||
|
|
||||||
elseif self.stage == "waitMode" or self.stage == "waitHello" then
|
elseif self.stage == "waitMode" or self.stage == "waitHello" then
|
||||||
drawTitle("CONNECTED!")
|
drawTitle("CONNECTED!")
|
||||||
if self.stage == "waitHello" then
|
if self.stage == "waitHello" then
|
||||||
|
|||||||
+17
-2
@@ -77,6 +77,13 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local level = math.max(2, math.min(100, math.floor(packed.level or 5)))
|
local level = math.max(2, math.min(100, math.floor(packed.level or 5)))
|
||||||
|
-- "auto-level" tournaments/matches: every participant's real level is
|
||||||
|
-- ignored and everyone rebuilds at the same fixed level instead, so a
|
||||||
|
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
|
||||||
|
-- the identical forceLevel for a given match, so this stays symmetric.
|
||||||
|
if opts and opts.forceLevel then
|
||||||
|
level = math.max(2, math.min(100, math.floor(opts.forceLevel)))
|
||||||
|
end
|
||||||
local dvs = {}
|
local dvs = {}
|
||||||
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
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)))
|
dvs[k] = math.max(0, math.min(15, math.floor((packed.dvs or {})[k] or 0)))
|
||||||
@@ -104,6 +111,14 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
if strict then return nil, "no shared moves" end
|
if strict then return nil, "no shared moves" end
|
||||||
moves = { { id = "TACKLE", pp = 35 } }
|
moves = { { id = "TACKLE", pp = 35 } }
|
||||||
end
|
end
|
||||||
|
-- a forced level scales every stat including max HP, so the real party's
|
||||||
|
-- current HP/status (a different level's numbers, possibly mid-fight)
|
||||||
|
-- isn't meaningful anymore -- auto-level starts everyone full and fresh,
|
||||||
|
-- same as a standardized tournament format would
|
||||||
|
local forced = opts and opts.forceLevel
|
||||||
|
local hp = forced and stats.hp
|
||||||
|
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
|
||||||
|
local status = forced and nil or packed.status
|
||||||
return {
|
return {
|
||||||
species = packed.species,
|
species = packed.species,
|
||||||
level = level,
|
level = level,
|
||||||
@@ -111,8 +126,8 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
dvs = dvs,
|
dvs = dvs,
|
||||||
statExp = statExp,
|
statExp = statExp,
|
||||||
stats = stats,
|
stats = stats,
|
||||||
hp = math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp))),
|
hp = hp,
|
||||||
status = packed.status,
|
status = status,
|
||||||
nickname = packed.nickname,
|
nickname = packed.nickname,
|
||||||
moves = moves,
|
moves = moves,
|
||||||
-- a namespace whose mod this install lacks survives untouched, so the
|
-- a namespace whose mod this install lacks survives untouched, so the
|
||||||
|
|||||||
+60
-3
@@ -9,6 +9,7 @@
|
|||||||
-- time, uninterrupted by individual matches.
|
-- time, uninterrupted by individual matches.
|
||||||
|
|
||||||
local CodeEntry = require("src.link.CodeEntry")
|
local CodeEntry = require("src.link.CodeEntry")
|
||||||
|
local DiscordPresence = require("src.core.DiscordPresence")
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Handshake = require("src.link.Handshake")
|
local Handshake = require("src.link.Handshake")
|
||||||
local LinkBattle = require("src.link.LinkBattle")
|
local LinkBattle = require("src.link.LinkBattle")
|
||||||
@@ -33,7 +34,14 @@ local ANY = "ANY" -- sentinel: a leading *nil* array entry breaks ipairs
|
|||||||
-- to nil only on the wire
|
-- to nil only on the wire
|
||||||
local LEVEL_STEPS = { ANY, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65,
|
local LEVEL_STEPS = { ANY, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65,
|
||||||
70, 75, 80, 85, 90, 95, 100 }
|
70, 75, 80, 85, 90, 95, 100 }
|
||||||
local SETTINGS_ROWS = 5 -- POKEMON, MIN LV, MAX LV, TIMER, PLAYING
|
local SETTINGS_ROWS = 6 -- POKEMON, MIN LV, MAX LV, TIMER, LEVELS, PLAYING
|
||||||
|
-- LEVELS cycles through this: ANY (real levels) or a fixed level every
|
||||||
|
-- match normalizes to, regardless of each side's real party
|
||||||
|
local FORCE_LEVEL_STEPS = { ANY, 50, 100 }
|
||||||
|
-- tournaments have no real participant cap (any number can join before
|
||||||
|
-- start); this is just generous headroom so Discord's party.size never
|
||||||
|
-- reads as "full" and blocks a real invite click
|
||||||
|
local TOURNAMENT_PARTY_MAX = 16
|
||||||
|
|
||||||
local function indexOf(list, value)
|
local function indexOf(list, value)
|
||||||
for i, v in ipairs(list) do
|
for i, v in ipairs(list) do
|
||||||
@@ -52,6 +60,10 @@ local function levelForWire(v)
|
|||||||
return v == ANY and nil or v
|
return v == ANY and nil or v
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function forceLevelLabel(v)
|
||||||
|
return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v))
|
||||||
|
end
|
||||||
|
|
||||||
local function partyStats(party)
|
local function partyStats(party)
|
||||||
local size, minLevel, maxLevel = 0, nil, nil
|
local size, minLevel, maxLevel = 0, nil, nil
|
||||||
for _, mon in ipairs(party or {}) do
|
for _, mon in ipairs(party or {}) do
|
||||||
@@ -69,7 +81,7 @@ function Tournament.new(game)
|
|||||||
self.stage = "menu"
|
self.stage = "menu"
|
||||||
self.index = 1
|
self.index = 1
|
||||||
self.settings = { turnLimit = 6, requiredPartySize = 3, minLevel = ANY, maxLevel = ANY,
|
self.settings = { turnLimit = 6, requiredPartySize = 3, minLevel = ANY, maxLevel = ANY,
|
||||||
participating = true }
|
forceLevel = ANY, participating = true }
|
||||||
self.settingsIndex = 1
|
self.settingsIndex = 1
|
||||||
self.roster = {}
|
self.roster = {}
|
||||||
self.spectatorRoster = {}
|
self.spectatorRoster = {}
|
||||||
@@ -77,7 +89,24 @@ function Tournament.new(game)
|
|||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- entry point for a Discord "Ask to Join" click on a tournament invite
|
||||||
|
-- (see DiscordPresence.lua): skips the HOST/JOIN menu and code entry,
|
||||||
|
-- straight to "connecting with this code"
|
||||||
|
function Tournament.newJoinOnline(game, code)
|
||||||
|
local self = Tournament.new(game)
|
||||||
|
self:startJoining(code)
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
-- headcount for Discord's party.size: the roster only ever lists
|
||||||
|
-- competing players, so a non-participating (organizer-only) host isn't
|
||||||
|
-- in it even though they're right here running the thing
|
||||||
|
function Tournament:discordPartySize()
|
||||||
|
return #self.roster + (self.participating == false and 1 or 0)
|
||||||
|
end
|
||||||
|
|
||||||
function Tournament:exitWith(message)
|
function Tournament:exitWith(message)
|
||||||
|
DiscordPresence.setJoinCode(nil)
|
||||||
Sound.stopLoop(MUSIC)
|
Sound.stopLoop(MUSIC)
|
||||||
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
|
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
|
||||||
if self.net then self.net:close() end
|
if self.net then self.net:close() end
|
||||||
@@ -106,6 +135,7 @@ function Tournament:startHosting()
|
|||||||
requiredPartySize = self.settings.requiredPartySize,
|
requiredPartySize = self.settings.requiredPartySize,
|
||||||
minLevel = levelForWire(self.settings.minLevel),
|
minLevel = levelForWire(self.settings.minLevel),
|
||||||
maxLevel = levelForWire(self.settings.maxLevel),
|
maxLevel = levelForWire(self.settings.maxLevel),
|
||||||
|
forceLevel = levelForWire(self.settings.forceLevel),
|
||||||
participating = self.settings.participating,
|
participating = self.settings.participating,
|
||||||
name = self.game.save.player.name,
|
name = self.game.save.player.name,
|
||||||
partySize = size, partyMinLevel = minL, partyMaxLevel = maxL,
|
partySize = size, partyMinLevel = minL, partyMaxLevel = maxL,
|
||||||
@@ -145,8 +175,22 @@ function Tournament:handleMessage(msg)
|
|||||||
if msg.type == "tournament_hosted" then
|
if msg.type == "tournament_hosted" then
|
||||||
self.code = msg.code
|
self.code = msg.code
|
||||||
self.settings.turnLimit = msg.turnLimit
|
self.settings.turnLimit = msg.turnLimit
|
||||||
|
self.settings.forceLevel = msg.forceLevel == nil and ANY or msg.forceLevel
|
||||||
self.participating = msg.participating
|
self.participating = msg.participating
|
||||||
self.stage = "bracket"
|
self.stage = "bracket"
|
||||||
|
-- the server already seeded t.players/spectators with the creator at
|
||||||
|
-- creation time; mirror that here so the Discord party size (and the
|
||||||
|
-- "waiting for players" list) show the host from the very first frame,
|
||||||
|
-- not just once someone else joins and a real roster broadcast arrives
|
||||||
|
if self.participating then
|
||||||
|
self.roster = { self.game.save.player.name }
|
||||||
|
else
|
||||||
|
self.spectatorRoster = { self.game.save.player.name }
|
||||||
|
end
|
||||||
|
if self.isCreator then
|
||||||
|
DiscordPresence.setJoinCode(self.code, "tournament",
|
||||||
|
self:discordPartySize(), TOURNAMENT_PARTY_MAX)
|
||||||
|
end
|
||||||
elseif msg.type == "tournament_host_error" then
|
elseif msg.type == "tournament_host_error" then
|
||||||
if msg.reason == "party_ineligible" then
|
if msg.reason == "party_ineligible" then
|
||||||
self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format(
|
self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format(
|
||||||
@@ -168,7 +212,12 @@ function Tournament:handleMessage(msg)
|
|||||||
self.settings.requiredPartySize = msg.requiredPartySize
|
self.settings.requiredPartySize = msg.requiredPartySize
|
||||||
self.settings.minLevel = msg.minLevel == nil and ANY or msg.minLevel
|
self.settings.minLevel = msg.minLevel == nil and ANY or msg.minLevel
|
||||||
self.settings.maxLevel = msg.maxLevel == nil and ANY or msg.maxLevel
|
self.settings.maxLevel = msg.maxLevel == nil and ANY or msg.maxLevel
|
||||||
|
self.settings.forceLevel = msg.forceLevel == nil and ANY or msg.forceLevel
|
||||||
self.stage = "bracket"
|
self.stage = "bracket"
|
||||||
|
if self.isCreator and self.code then
|
||||||
|
DiscordPresence.setJoinCode(self.code, "tournament",
|
||||||
|
self:discordPartySize(), TOURNAMENT_PARTY_MAX)
|
||||||
|
end
|
||||||
elseif msg.type == "bracket_update" then
|
elseif msg.type == "bracket_update" then
|
||||||
self.bracket = msg.tournament
|
self.bracket = msg.tournament
|
||||||
self.code = self.bracket.code
|
self.code = self.bracket.code
|
||||||
@@ -238,6 +287,7 @@ function Tournament:beginMatchBattle()
|
|||||||
verdict = verdict,
|
verdict = verdict,
|
||||||
strict = Handshake.strict(verdict),
|
strict = Handshake.strict(verdict),
|
||||||
turnLimit = self.matchTurnLimit,
|
turnLimit = self.matchTurnLimit,
|
||||||
|
forceLevel = levelForWire(self.settings.forceLevel),
|
||||||
keepNetOpen = true, -- this is the tournament's own connection, not a
|
keepNetOpen = true, -- this is the tournament's own connection, not a
|
||||||
-- dedicated match socket -- don't let finish() close it
|
-- dedicated match socket -- don't let finish() close it
|
||||||
}
|
}
|
||||||
@@ -360,6 +410,7 @@ function Tournament:update(dt)
|
|||||||
hostParty = self.spectate.hostParty, guestParty = self.spectate.guestParty,
|
hostParty = self.spectate.hostParty, guestParty = self.spectate.guestParty,
|
||||||
hostName = self.spectate.hostName, guestName = self.spectate.guestName,
|
hostName = self.spectate.hostName, guestName = self.spectate.guestName,
|
||||||
seed = self.spectate.seed,
|
seed = self.spectate.seed,
|
||||||
|
forceLevel = levelForWire(self.settings.forceLevel),
|
||||||
})
|
})
|
||||||
if not battle then
|
if not battle then
|
||||||
self:exitWith(why or "Can't watch this\nmatch.")
|
self:exitWith(why or "Can't watch this\nmatch.")
|
||||||
@@ -426,6 +477,10 @@ function Tournament:update(dt)
|
|||||||
i = ((i - 1 + delta) % #TURN_LIMITS) + 1
|
i = ((i - 1 + delta) % #TURN_LIMITS) + 1
|
||||||
self.settings.turnLimit = TURN_LIMITS[i]
|
self.settings.turnLimit = TURN_LIMITS[i]
|
||||||
elseif self.settingsIndex == 5 then
|
elseif self.settingsIndex == 5 then
|
||||||
|
local i = indexOf(FORCE_LEVEL_STEPS, self.settings.forceLevel)
|
||||||
|
i = ((i - 1 + delta) % #FORCE_LEVEL_STEPS) + 1
|
||||||
|
self.settings.forceLevel = FORCE_LEVEL_STEPS[i]
|
||||||
|
elseif self.settingsIndex == 6 then
|
||||||
self.settings.participating = not self.settings.participating
|
self.settings.participating = not self.settings.participating
|
||||||
end
|
end
|
||||||
elseif input:wasPressed("a") or input:wasPressed("start") then
|
elseif input:wasPressed("a") or input:wasPressed("start") then
|
||||||
@@ -454,6 +509,7 @@ function Tournament:update(dt)
|
|||||||
elseif self.stage == "bracket" then
|
elseif self.stage == "bracket" then
|
||||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||||
if input:wasPressed("a") and self.isCreator and #self.roster >= 2 then
|
if input:wasPressed("a") and self.isCreator and #self.roster >= 2 then
|
||||||
|
DiscordPresence.setJoinCode(nil) -- roster's locking in; stop advertising
|
||||||
self.net:send({ type = "start_tournament" })
|
self.net:send({ type = "start_tournament" })
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -475,7 +531,7 @@ local function drawTitle(text)
|
|||||||
Font.draw(text, 8, 6)
|
Font.draw(text, 8, 6)
|
||||||
end
|
end
|
||||||
|
|
||||||
local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "PLAYING" }
|
local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "LEVELS", "PLAYING" }
|
||||||
|
|
||||||
function Tournament:draw()
|
function Tournament:draw()
|
||||||
if self.stage == "menu" then
|
if self.stage == "menu" then
|
||||||
@@ -491,6 +547,7 @@ function Tournament:draw()
|
|||||||
levelLabel(self.settings.minLevel),
|
levelLabel(self.settings.minLevel),
|
||||||
levelLabel(self.settings.maxLevel),
|
levelLabel(self.settings.maxLevel),
|
||||||
self.settings.turnLimit .. "s",
|
self.settings.turnLimit .. "s",
|
||||||
|
forceLevelLabel(self.settings.forceLevel),
|
||||||
self.settings.participating and "YES" or "NO",
|
self.settings.participating and "YES" or "NO",
|
||||||
}
|
}
|
||||||
for i, label in ipairs(SETTINGS_LABELS) do
|
for i, label in ipairs(SETTINGS_LABELS) do
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Driver: hosts a real online match (against the deployed relay) and
|
||||||
|
-- reports Discord IPC connection/subscribe/join-code state, so the whole
|
||||||
|
-- "Ask to Join" wiring can be checked against the actual local Discord
|
||||||
|
-- client instead of guessing from code review alone.
|
||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local DiscordPresence = require("src.core.DiscordPresence")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
|
||||||
|
local function report(line)
|
||||||
|
U.log(line)
|
||||||
|
local f = io.open("/tmp/discord_test_status.txt", "a")
|
||||||
|
if f then f:write(line .. "\n"); f:close() end
|
||||||
|
end
|
||||||
|
|
||||||
|
game.save.party = { Pokemon.new(game.data, "PIKACHU", 25) }
|
||||||
|
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||||
|
|
||||||
|
U.wait(30) -- give the Discord IPC connection a moment to establish
|
||||||
|
local s = DiscordPresence._state
|
||||||
|
report(("discord: enabled=%s connected=%s subscribedJoin=%s"):format(
|
||||||
|
tostring(s.enabled), tostring(s.connected), tostring(s.subscribedJoin)))
|
||||||
|
|
||||||
|
local LinkState = require("src.link.LinkState")
|
||||||
|
local link = LinkState.new(game)
|
||||||
|
game.stack:push(link)
|
||||||
|
U.wait(3)
|
||||||
|
U.tap(game, "down"); U.wait(2) -- ONLINE MATCH row
|
||||||
|
U.tap(game, "a"); U.wait(3) -- into onlineMenu
|
||||||
|
U.tap(game, "a"); U.wait(30) -- HOST ONLINE -> connect to the real relay
|
||||||
|
|
||||||
|
local waited = 0
|
||||||
|
while not link.net.code and waited < 300 do
|
||||||
|
U.wait(1)
|
||||||
|
waited = waited + 1
|
||||||
|
end
|
||||||
|
report(("host code: %s net error: %s"):format(
|
||||||
|
tostring(link.net.code), tostring(link.net.error)))
|
||||||
|
|
||||||
|
U.wait(60) -- let DiscordPresence pick up the code and push an activity
|
||||||
|
report(("discord joinCode now: %s connected: %s"):format(
|
||||||
|
tostring(s.joinCode), tostring(s.connected)))
|
||||||
|
report("DISCORD_JOIN_TEST: check your Discord profile/status now -- it")
|
||||||
|
report("should show an 'Ask to Join' button, code " .. tostring(link.net.code))
|
||||||
|
|
||||||
|
while true do
|
||||||
|
coroutine.yield()
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- One-shot: injects a fake 3-mon party and writes it to disk for the
|
||||||
|
-- current identity, then exits. Meant to be followed by a normal
|
||||||
|
-- (driver-free) `love .` launch, since POKEPORT_DRIVER disables Discord
|
||||||
|
-- presence for the run -- this is how to get a ready-made party AND a
|
||||||
|
-- fully interactive session with real Discord presence.
|
||||||
|
return function(game)
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
|
||||||
|
game.save.party = {
|
||||||
|
Pokemon.new(game.data, "CHARIZARD", 50),
|
||||||
|
Pokemon.new(game.data, "PIKACHU", 30),
|
||||||
|
Pokemon.new(game.data, "SNORLAX", 40),
|
||||||
|
}
|
||||||
|
game.save.player.name = "RED"
|
||||||
|
|
||||||
|
local ok = SaveData.save(game.save)
|
||||||
|
print("save_fake_party: saved =", ok)
|
||||||
|
end
|
||||||
@@ -364,6 +364,40 @@ eq(gameA.save.money, 3000, "no prize money in link battles")
|
|||||||
eq(gameA.save.party[1].hp, gameA.save.party[1].stats.hp,
|
eq(gameA.save.party[1].hp, gameA.save.party[1].stats.hp,
|
||||||
"the real party is untouched (battle used clamped copies)")
|
"the real party is untouched (battle used clamped copies)")
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- forced-level ("auto 50") matches
|
||||||
|
-- online matches/tournaments may force every participant's real level to a
|
||||||
|
-- fixed value for the match (opts.forceLevel), regardless of their save's
|
||||||
|
-- actual level -- this checks both sides normalize identically and stats
|
||||||
|
-- recompute for the forced level rather than clamping the original level's
|
||||||
|
local gameG = makeFakeGame("PIKACHU")
|
||||||
|
gameG.save.party[1] = Pokemon.new(Data, "PIKACHU", 12)
|
||||||
|
local gameH = makeFakeGame("GEODUDE")
|
||||||
|
gameH.save.party[1] = Pokemon.new(Data, "GEODUDE", 100)
|
||||||
|
gameH.save.player.name = "BLUE"
|
||||||
|
local netG, netH = Net.loopbackPair()
|
||||||
|
local packedG = Protocol.packParty(gameG.save.party)
|
||||||
|
local packedH = Protocol.packParty(gameH.save.party)
|
||||||
|
local seedGH = 13579
|
||||||
|
|
||||||
|
local battleG = LinkBattle.newHost(gameG, netG, {
|
||||||
|
myParty = packedG, theirParty = packedH, theirName = "BLUE", seed = seedGH,
|
||||||
|
forceLevel = 50,
|
||||||
|
})
|
||||||
|
local battleH = LinkBattle.newGuest(gameH, netH, {
|
||||||
|
myParty = packedH, theirParty = packedG, theirName = "RED", seed = seedGH,
|
||||||
|
forceLevel = 50,
|
||||||
|
})
|
||||||
|
eq(battleG.player.mon.level, 50, "forced level overrides a low real level (12 -> 50)")
|
||||||
|
eq(battleG.enemy.mon.level, 50, "...and a high real level (100 -> 50) the same way")
|
||||||
|
eq(battleH.player.mon.level, 50, "the guest's own mon is forced too")
|
||||||
|
eq(battleH.enemy.mon.level, 50, "the guest sees the host's mon forced too")
|
||||||
|
local expectedStats = require("src.pokemon.Stats").calc(
|
||||||
|
Data.pokemon["PIKACHU"], 50, battleG.player.mon.dvs, battleG.player.mon.statExp)
|
||||||
|
eq(battleG.player.mon.stats.hp, expectedStats.hp,
|
||||||
|
"forced-level stats are recomputed for level 50, not clamped from level 12")
|
||||||
|
eq(gameG.save.party[1].level, 12, "the real save data keeps its actual level")
|
||||||
|
eq(gameH.save.party[1].level, 100, "...on both sides")
|
||||||
|
|
||||||
-- ---------------------------------------------------------------- tournament shot clock
|
-- ---------------------------------------------------------------- tournament shot clock
|
||||||
-- opts.turnLimit only applies to tournament matches; the guest mashes
|
-- opts.turnLimit only applies to tournament matches; the guest mashes
|
||||||
-- through its own menu every frame while the host never presses anything,
|
-- through its own menu every frame while the host never presses anything,
|
||||||
|
|||||||
Reference in New Issue
Block a user