diff --git a/src/core/DiscordPresence.lua b/src/core/DiscordPresence.lua index 2be37aad..47b73836 100644 --- a/src/core/DiscordPresence.lua +++ b/src/core/DiscordPresence.lua @@ -38,8 +38,18 @@ local state = { unsubs = {}, pid = nil, 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() return os.getenv("POKEPORT_NO_DISCORD") == "1" or os.getenv("POKEPORT_AUTOPILOT") ~= nil @@ -378,6 +388,15 @@ local function connect() state.dirty = true state.lastSentKey = nil 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") return true end) @@ -427,7 +446,7 @@ local function buildActivity() activityState = "At the title screen" end - return { + local activity = { details = details, state = activityState, timestamps = { start = state.startedAt }, @@ -436,10 +455,25 @@ local function buildActivity() 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 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 local function flush(force) @@ -567,6 +601,96 @@ local function subscribe(game) 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) local ok, err = pcall(function() DiscordPresence.shutdown() @@ -579,6 +703,8 @@ function DiscordPresence.init(game) state.location = "Title screen" state.mapId = nil state.battleLabel = nil + state.joinCode = nil + state.game = game state.dirty = true state.nextReconnectAt = 0 state.loggedAbsent = false @@ -621,6 +747,7 @@ function DiscordPresence.update(_dt) end return end + pollJoinRequest() if state.dirty then flush(false) end end) end @@ -649,6 +776,8 @@ function DiscordPresence.shutdown() state.lastSentKey = nil state.connected = false state.socket = nil + state.subscribedJoin = false + state.joinCode = nil end -- test / debug helpers diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 2c4c7a53..c0e2c258 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -182,7 +182,7 @@ function LinkBattle.new(game, net, opts) -- both parties pass through the same pack->unpack clamp on both -- 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) return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species)) end) @@ -646,7 +646,7 @@ function LinkBattle.newSpectator(game, net, opts) return nil, "Link battle needs\nthe same mods on\nboth games." 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) return ("%s's %s can't\nbattle on this\ngame."):format(hostName, tostring(p.species)) end) diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 2295dd6d..28f98f7a 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -3,6 +3,7 @@ -- lua-enet (bundled with LÖVE), no relay server. local CodeEntry = require("src.link.CodeEntry") +local DiscordPresence = require("src.core.DiscordPresence") local Font = require("src.render.Font") local Handshake = require("src.link.Handshake") local Net = require("src.link.Net") @@ -16,6 +17,26 @@ LinkState.__index = LinkState LinkState.isOpaque = true 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" -- -- .net can still be a leftover failed attempt sitting on self, so error/ @@ -54,7 +75,23 @@ function LinkState.new(game) return self 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) + DiscordPresence.setJoinCode(nil) Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") }) if self.net then self.net:close() end self.game.stack:pop() @@ -108,7 +145,12 @@ function LinkState:decideCompat(mode, isHost) mods = peer and peer.mods, fingerprint = peer and peer.fingerprint }, }) 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 end -- naming the difference up front is the whole point: the old behaviour @@ -208,8 +250,13 @@ function LinkState:update(dt) end 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 self.net.paired then + DiscordPresence.setJoinCode(nil) -- someone's here now; stop advertising self.stage = "modeSelect" self.index = 1 end @@ -300,6 +347,23 @@ function LinkState:update(dt) self:exitWith(nil) 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 if input:wasPressed("b") then self:exitWith(nil) return end local got, other = self:pollHello() @@ -343,6 +407,9 @@ function LinkState:update(dt) local msgs = self.net:poll() for i, msg in ipairs(msgs) do 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 opts = { myParty = Protocol.packParty(self.game.save.party), @@ -351,6 +418,7 @@ function LinkState:update(dt) seed = self.isHost and self.linkSeed or msg.seed, verdict = self.verdict, strict = Handshake.strict(self.verdict), + forceLevel = self.forceLevel, } local battle, why if self.isHost then @@ -401,7 +469,8 @@ function LinkState:startMode(mode, isHost) end self.net:send({ type = "party", mons = Protocol.packParty(self.game.save.party), - seed = self.linkSeed }) + seed = self.linkSeed, + forceLevel = isHost and self.forceLevel or nil }) end end @@ -557,6 +626,12 @@ function LinkState:draw() Font.draw("BATTLE", 32, 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 drawTitle("CONNECTED!") if self.stage == "waitHello" then diff --git a/src/link/Protocol.lua b/src/link/Protocol.lua index b8dcc235..f3e12c8c 100644 --- a/src/link/Protocol.lua +++ b/src/link/Protocol.lua @@ -77,6 +77,13 @@ function Protocol.unpackMon(data, packed, opts) return nil end 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 = {} 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))) @@ -104,6 +111,14 @@ function Protocol.unpackMon(data, packed, opts) if strict then return nil, "no shared moves" end moves = { { id = "TACKLE", pp = 35 } } 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 { species = packed.species, level = level, @@ -111,8 +126,8 @@ function Protocol.unpackMon(data, packed, opts) dvs = dvs, statExp = statExp, stats = stats, - hp = math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp))), - status = packed.status, + hp = hp, + status = status, nickname = packed.nickname, moves = moves, -- a namespace whose mod this install lacks survives untouched, so the diff --git a/src/link/Tournament.lua b/src/link/Tournament.lua index 1bd9e840..4f7b0564 100644 --- a/src/link/Tournament.lua +++ b/src/link/Tournament.lua @@ -9,6 +9,7 @@ -- time, uninterrupted by individual matches. local CodeEntry = require("src.link.CodeEntry") +local DiscordPresence = require("src.core.DiscordPresence") local Font = require("src.render.Font") local Handshake = require("src.link.Handshake") 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 local LEVEL_STEPS = { ANY, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 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) for i, v in ipairs(list) do @@ -52,6 +60,10 @@ 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 + local function partyStats(party) local size, minLevel, maxLevel = 0, nil, nil for _, mon in ipairs(party or {}) do @@ -69,7 +81,7 @@ function Tournament.new(game) self.stage = "menu" self.index = 1 self.settings = { turnLimit = 6, requiredPartySize = 3, minLevel = ANY, maxLevel = ANY, - participating = true } + forceLevel = ANY, participating = true } self.settingsIndex = 1 self.roster = {} self.spectatorRoster = {} @@ -77,7 +89,24 @@ function Tournament.new(game) return self 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) + DiscordPresence.setJoinCode(nil) Sound.stopLoop(MUSIC) Runtime.emit("link.ended", { reason = message and "error" or "bye" }) if self.net then self.net:close() end @@ -106,6 +135,7 @@ function Tournament:startHosting() requiredPartySize = self.settings.requiredPartySize, minLevel = levelForWire(self.settings.minLevel), maxLevel = levelForWire(self.settings.maxLevel), + forceLevel = levelForWire(self.settings.forceLevel), participating = self.settings.participating, name = self.game.save.player.name, partySize = size, partyMinLevel = minL, partyMaxLevel = maxL, @@ -145,8 +175,22 @@ function Tournament:handleMessage(msg) if msg.type == "tournament_hosted" then self.code = msg.code self.settings.turnLimit = msg.turnLimit + self.settings.forceLevel = msg.forceLevel == nil and ANY or msg.forceLevel self.participating = msg.participating 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 if msg.reason == "party_ineligible" then 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.minLevel = msg.minLevel == nil and ANY or msg.minLevel 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" + if self.isCreator and self.code then + DiscordPresence.setJoinCode(self.code, "tournament", + self:discordPartySize(), TOURNAMENT_PARTY_MAX) + end elseif msg.type == "bracket_update" then self.bracket = msg.tournament self.code = self.bracket.code @@ -238,6 +287,7 @@ function Tournament:beginMatchBattle() verdict = verdict, strict = Handshake.strict(verdict), turnLimit = self.matchTurnLimit, + forceLevel = levelForWire(self.settings.forceLevel), keepNetOpen = true, -- this is the tournament's own connection, not a -- 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, hostName = self.spectate.hostName, guestName = self.spectate.guestName, seed = self.spectate.seed, + forceLevel = levelForWire(self.settings.forceLevel), }) if not battle then self:exitWith(why or "Can't watch this\nmatch.") @@ -426,6 +477,10 @@ function Tournament:update(dt) i = ((i - 1 + delta) % #TURN_LIMITS) + 1 self.settings.turnLimit = TURN_LIMITS[i] 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 end elseif input:wasPressed("a") or input:wasPressed("start") then @@ -454,6 +509,7 @@ function Tournament:update(dt) elseif self.stage == "bracket" then if input:wasPressed("b") then self:exitWith(nil) return end 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" }) end @@ -475,7 +531,7 @@ local function drawTitle(text) Font.draw(text, 8, 6) 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() if self.stage == "menu" then @@ -491,6 +547,7 @@ function Tournament:draw() levelLabel(self.settings.minLevel), levelLabel(self.settings.maxLevel), self.settings.turnLimit .. "s", + forceLevelLabel(self.settings.forceLevel), self.settings.participating and "YES" or "NO", } for i, label in ipairs(SETTINGS_LABELS) do diff --git a/tests/drivers/discord_join_test.lua b/tests/drivers/discord_join_test.lua new file mode 100644 index 00000000..1f34fe07 --- /dev/null +++ b/tests/drivers/discord_join_test.lua @@ -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 diff --git a/tests/drivers/save_fake_party.lua b/tests/drivers/save_fake_party.lua new file mode 100644 index 00000000..8ca081e6 --- /dev/null +++ b/tests/drivers/save_fake_party.lua @@ -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 diff --git a/tests/run_link_tests.lua b/tests/run_link_tests.lua index aa586b18..a5363132 100644 --- a/tests/run_link_tests.lua +++ b/tests/run_link_tests.lua @@ -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, "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 -- opts.turnLimit only applies to tournament matches; the guest mashes -- through its own menu every frame while the host never presses anything,