From 450b0b1021c5404d8d5aa8cba404f8ce018e1a67 Mon Sep 17 00:00:00 2001 From: Endaris Date: Thu, 15 Jan 2026 17:42:16 +0100 Subject: [PATCH 01/18] extend challengePlayer and requestLeaderboard with an indicator for the game type --- common/network/ClientProtocol.lua | 8 ++++++-- server/ClientMessages.lua | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/common/network/ClientProtocol.lua b/common/network/ClientProtocol.lua index 5f8d72a9..2432381e 100644 --- a/common/network/ClientProtocol.lua +++ b/common/network/ClientProtocol.lua @@ -61,7 +61,8 @@ function ClientMessages.challengePlayer(senderName, receiverName) game_request = { sender = senderName, - receiver = receiverName + receiver = receiverName, + gameType = "2P_VS", } } @@ -89,7 +90,10 @@ function ClientMessages.requestSpectate(spectatorName, roomNumber) end function ClientMessages.requestLeaderboard() - local leaderboardRequestMessage = {leaderboard_request = true} + local leaderboardRequestMessage = { + leaderboard_request = true, + leaderboardType = "2P_VS", + } return { messageType = msgTypes.jsonMessage, diff --git a/server/ClientMessages.lua b/server/ClientMessages.lua index 9ae0e96e..39dfedac 100644 --- a/server/ClientMessages.lua +++ b/server/ClientMessages.lua @@ -115,6 +115,7 @@ function ClientMessages.sanitizeGameRequest(gameRequest) { sender = gameRequest.game_request.sender, receiver = gameRequest.game_request.receiver, + gameType = gameRequest.game_request.gameType or "2P_VS", } } @@ -137,7 +138,8 @@ end function ClientMessages.sanitizeLeaderboardRequest(leaderboardRequest) local sanitized = { - leaderboard_request = leaderboardRequest.leaderboard_request + leaderboard_request = leaderboardRequest.leaderboard_request, + gameType = leaderboardRequest.leaderboardType or "2P_VS", } return sanitized From 7da40fa05a737b86b89e35e7ec006e0ef959fa85 Mon Sep 17 00:00:00 2001 From: Endaris Date: Thu, 15 Jan 2026 18:17:17 +0100 Subject: [PATCH 02/18] change protocol to use names already used for creating GameModes --- common/data/GameModes.lua | 14 +++++--------- common/network/ClientProtocol.lua | 4 ++-- server/ClientMessages.lua | 4 ++-- server/server.lua | 3 ++- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/common/data/GameModes.lua b/common/data/GameModes.lua index 2e4efbc7..0abcfba4 100644 --- a/common/data/GameModes.lua +++ b/common/data/GameModes.lua @@ -4,6 +4,8 @@ local TIME_ATTACK_TIME = 120 local GameModes = {} +---@alias GameModeID ("ONE_PLAYER_VS_SELF"|"ONE_PLAYER_TIME_ATTACK"|"ONE_PLAYER_ENDLESS"|"ONE_PLAYER_TRAINING"|"ONE_PLAYER_PUZZLE"|"ONE_PLAYER_CHALLENGE"|"TWO_PLAYER_VS"|"TWO_PLAYER_TIME_ATTACK") Used as identifiers for the type of game that is being played + ---@class GameMode ---@field stackInteraction StackInteractions ---@field matchRules MatchRules @@ -15,6 +17,7 @@ local GameModes = {} ---@field style Styles ---@field richPresenceLabel string? ---@field updateLocalPlayersDerivedSettings function +---@field gameModeId GameModeID local GameMode = class(function(self, properties) for key, value in pairs(properties) do self[key] = value @@ -208,7 +211,7 @@ local TwoPlayerTimeAttack = GameMode({ GameModes.Styles = Styles GameModes.StackInteractions = StackInteractions ----@type table +---@type table local privateGameModes = {} privateGameModes.ONE_PLAYER_VS_SELF = OnePlayerVsSelf privateGameModes.ONE_PLAYER_TIME_ATTACK = OnePlayerTimeAttack @@ -219,15 +222,8 @@ privateGameModes.ONE_PLAYER_CHALLENGE = OnePlayerChallenge privateGameModes.TWO_PLAYER_VS = TwoPlayerVersus privateGameModes.TWO_PLAYER_TIME_ATTACK = TwoPlayerTimeAttack +---@param mode GameModeID ---@return GameMode ----@overload fun(mode: "ONE_PLAYER_VS_SELF"): GameMode ----@overload fun(mode: "ONE_PLAYER_TIME_ATTACK"): GameMode ----@overload fun(mode: "ONE_PLAYER_ENDLESS"): GameMode ----@overload fun(mode: "ONE_PLAYER_TRAINING"): GameMode ----@overload fun(mode: "ONE_PLAYER_PUZZLE"): GameMode ----@overload fun(mode: "ONE_PLAYER_CHALLENGE"): GameMode ----@overload fun(mode: "TWO_PLAYER_VS"): GameMode ----@overload fun(mode: "TWO_PLAYER_TIME_ATTACK"): GameMode function GameModes.getPreset(mode) assert(privateGameModes[mode], "Trying to access non existing mode " .. mode) return deepcpy(privateGameModes[mode]) diff --git a/common/network/ClientProtocol.lua b/common/network/ClientProtocol.lua index 2432381e..26cbc979 100644 --- a/common/network/ClientProtocol.lua +++ b/common/network/ClientProtocol.lua @@ -62,7 +62,7 @@ function ClientMessages.challengePlayer(senderName, receiverName) { sender = senderName, receiver = receiverName, - gameType = "2P_VS", + gameType = "TWO_PLAYER_VS", } } @@ -92,7 +92,7 @@ end function ClientMessages.requestLeaderboard() local leaderboardRequestMessage = { leaderboard_request = true, - leaderboardType = "2P_VS", + leaderboardType = "TWO_PLAYER_VS", } return { diff --git a/server/ClientMessages.lua b/server/ClientMessages.lua index 39dfedac..4caaaa1d 100644 --- a/server/ClientMessages.lua +++ b/server/ClientMessages.lua @@ -115,7 +115,7 @@ function ClientMessages.sanitizeGameRequest(gameRequest) { sender = gameRequest.game_request.sender, receiver = gameRequest.game_request.receiver, - gameType = gameRequest.game_request.gameType or "2P_VS", + gameType = gameRequest.game_request.gameType or "TWO_PLAYER_VS", } } @@ -139,7 +139,7 @@ function ClientMessages.sanitizeLeaderboardRequest(leaderboardRequest) local sanitized = { leaderboard_request = leaderboardRequest.leaderboard_request, - gameType = leaderboardRequest.leaderboardType or "2P_VS", + gameType = leaderboardRequest.leaderboardType or "TWO_PLAYER_VS", } return sanitized diff --git a/server/server.lua b/server/server.lua index 8ed7b0f6..c1978e31 100644 --- a/server/server.lua +++ b/server/server.lua @@ -258,7 +258,8 @@ end ---@param sender ServerPlayer ---@param receiver ServerPlayer -function Server:proposeGame(sender, receiver) +---@param gameModeId GameModeID +function Server:proposeGame(sender, receiver, gameModeId) logger.debug("propose game: " .. sender.name .. " " .. receiver.name) local proposals = self.proposals From 8f3bec9f8178ecfd58b215a3840f9816a57154dc Mon Sep 17 00:00:00 2001 From: Endaris Date: Thu, 15 Jan 2026 23:42:20 +0100 Subject: [PATCH 03/18] change game requests to use the established keywords for fetch GameModes and allow specification of gameModeID for game requests, including "any" to leave the choice to the opponent --- common/data/GameModes.lua | 24 ++++++++++ common/network/ClientProtocol.lua | 7 ++- common/network/ServerProtocol.lua | 26 +++++++++- server/ClientMessages.lua | 6 ++- server/Player.lua | 3 +- server/Room.lua | 1 + server/server.lua | 80 ++++++++++++++++++++++--------- server/tests/ServerTests.lua | 44 +++++++++++++++-- testLauncher.lua | 2 +- 9 files changed, 159 insertions(+), 34 deletions(-) diff --git a/common/data/GameModes.lua b/common/data/GameModes.lua index 0abcfba4..74fadc42 100644 --- a/common/data/GameModes.lua +++ b/common/data/GameModes.lua @@ -252,4 +252,28 @@ function GameModes.createFromServerData(gameModeData) return result end +---@type table +GameModes.gameModeIdToName = { + TWO_PLAYER_VS = "VS", + ONE_PLAYER_TIME_ATTACK = "timeattack", + ONE_PLAYER_ENDLESS = "endless", + ONE_PLAYER_TRAINING = "training", + ONE_PLAYER_CHALLENGE = "challenge", + ONE_PLAYER_VS_SELF = "vsSelf", + ONE_PLAYER_PUZZLE = "puzzle", + TWO_PLAYER_TIME_ATTACK = "2p_timeattack", +} + +---@type table +GameModes.nameToGameModeId = { + VS = "TWO_PLAYER_VS", + timeattack = "ONE_PLAYER_TIME_ATTACK", + endless = "ONE_PLAYER_ENDLESS", + training = "ONE_PLAYER_TRAINING", + challenge = "ONE_PLAYER_CHALLENGE", + vsSelf = "ONE_PLAYER_VS_SELF", + puzzle = "ONE_PLAYER_PUZZLE", + ["2p_timeattack"] = "TWO_PLAYER_TIME_ATTACK" +} + return GameModes diff --git a/common/network/ClientProtocol.lua b/common/network/ClientProtocol.lua index 26cbc979..1f2dbf0b 100644 --- a/common/network/ClientProtocol.lua +++ b/common/network/ClientProtocol.lua @@ -55,14 +55,17 @@ end ------------------------- -- players are challenged by their current name on the server -function ClientMessages.challengePlayer(senderName, receiverName) +---@param senderName string +---@param receiverName string +---@param gameModeId GameModeID? nil if the challenged picks the game mode +function ClientMessages.challengePlayer(senderName, receiverName, gameModeId) local playerChallengeMessage = { game_request = { sender = senderName, receiver = receiverName, - gameType = "TWO_PLAYER_VS", + gameModeId = gameModeId, } } diff --git a/common/network/ServerProtocol.lua b/common/network/ServerProtocol.lua index 13e35583..c1e07917 100644 --- a/common/network/ServerProtocol.lua +++ b/common/network/ServerProtocol.lua @@ -469,12 +469,14 @@ local challengeTemplate = { ---@param sender ServerPlayer ---@param receiver ServerPlayer +---@param gameModeId GameModeID? nil if the challenged picks the game mode ---@return {messageType: table, messageText: ServerMessage} -function ServerProtocol.sendChallenge(sender, receiver) +function ServerProtocol.sendChallenge(sender, receiver, gameModeId) local challengeMessage = challengeTemplate challengeMessage.senderId = sender.publicPlayerID challengeMessage.content.sender = sender.name challengeMessage.content.receiver = receiver.name + challengeMessage.content.gameModeId = gameModeId return { messageType = msgTypes.jsonMessage, @@ -482,6 +484,28 @@ function ServerProtocol.sendChallenge(sender, receiver) } end +local cancelChallengeTemplate = { + sender = "player", + senderId = nil, + type = "challengeCancelled", + content = {} +} + +---@param sender ServerPlayer +---@param receiver ServerPlayer +---@return {messageType: table, messageText: ServerMessage} +function ServerProtocol.cancelChallenge(sender, receiver) + local cancelChallengeMessage = cancelChallengeTemplate + cancelChallengeMessage.sender = sender.publicPlayerID + cancelChallengeMessage.content.sender = sender.name + cancelChallengeMessage.content.receiver = receiver.name + + return { + messageType = msgTypes.jsonMessage, + messageText = cancelChallengeMessage + } +end + local abortGameTemplate = { sender = "room", senderId = nil, diff --git a/server/ClientMessages.lua b/server/ClientMessages.lua index 4caaaa1d..9165f520 100644 --- a/server/ClientMessages.lua +++ b/server/ClientMessages.lua @@ -115,7 +115,8 @@ function ClientMessages.sanitizeGameRequest(gameRequest) { sender = gameRequest.game_request.sender, receiver = gameRequest.game_request.receiver, - gameType = gameRequest.game_request.gameType or "TWO_PLAYER_VS", + -- default value only for slow adaption, remove and sanity check once clients send this properly + gameModeId = gameRequest.game_request.gameModeId or "TWO_PLAYER_VS", } } @@ -139,7 +140,8 @@ function ClientMessages.sanitizeLeaderboardRequest(leaderboardRequest) local sanitized = { leaderboard_request = leaderboardRequest.leaderboard_request, - gameType = leaderboardRequest.leaderboardType or "TWO_PLAYER_VS", + -- default value only for slow adaption, remove and sanity check later + gameModeId = leaderboardRequest.leaderboardType or "TWO_PLAYER_VS", } return sanitized diff --git a/server/Player.lua b/server/Player.lua index 2d73a954..e0fc7981 100644 --- a/server/Player.lua +++ b/server/Player.lua @@ -7,11 +7,12 @@ local Signal = require("common.lib.signal") local logger = require("common.lib.logger") ---@alias PlayerState ("lobby" | "character select" | "playing" | "spectating") +---@alias PublicPlayerID integer ---@class ServerPlayer : Signal ---@field package connection Connection ONLY FOR SENDING; accessing this in tests is fine, otherwise not, all message processing has to go through server ---@field userId privateUserId ----@field publicPlayerID integer +---@field publicPlayerID PublicPlayerID ---@field character string id of the specific character that was picked ---@field character_is_random string? id of the character (bundle) that was selected; will match character if not a bundle ---@field stage string id of the specific stage that was picked diff --git a/server/Room.lua b/server/Room.lua index f9fa247e..c4e83d64 100644 --- a/server/Room.lua +++ b/server/Room.lua @@ -22,6 +22,7 @@ local ServerGame = require("server.Game") ---@field matchCount integer ---@field game ServerGame? ---@field gameMode table -- only the data portion of the game mode +---@field gameModeId GameModeID ---@field ranked boolean if the next match is anticipated to be ranked ---@field rankedReasons string[] ---@overload fun(roomNumber: integer, players: ServerPlayer[], gameMode: GameMode, leaderboard: Leaderboard?): Room diff --git a/server/server.lua b/server/server.lua index c1978e31..8fef97a9 100644 --- a/server/server.lua +++ b/server/server.lua @@ -38,7 +38,7 @@ local time = os.time ---@field connectionNumberIndex integer GLOBAL counter of the next available connection index ---@field roomNumberIndex integer the next available room number ---@field rooms Room[] mapping of room number to room ----@field proposals table> mapping of player name to a mapping of the players they have challenged +---@field proposals table> mapping of player name to a mapping of the players they have challenged ---@field connections Connection[] mapping of connection number to connection ---@field nameToConnectionIndex table mapping of player names to their unique connectionNumberIndex ---@field socketToConnectionIndex table mapping of sockets to their unique connectionNumberIndex @@ -258,38 +258,72 @@ end ---@param sender ServerPlayer ---@param receiver ServerPlayer ----@param gameModeId GameModeID -function Server:proposeGame(sender, receiver, gameModeId) - logger.debug("propose game: " .. sender.name .. " " .. receiver.name) +---@param gameModeId GameModeID? +function Server:processGameRequest(sender, receiver, gameModeId) + logger.debug(sender.name .. " challenges " .. receiver.name .. " to a game of " .. (gameModeId or "their choice (any)")) - local proposals = self.proposals if sender and sender.state == "lobby" and receiver and receiver.state == "lobby" then - proposals[sender] = proposals[sender] or {} - proposals[receiver] = proposals[receiver] or {} - if proposals[sender][receiver] then - if proposals[sender][receiver][receiver] then - self:create_room(GameModes.getPreset("TWO_PLAYER_VS"), sender, receiver) + local previouslyProposedGameMode = self.proposals[receiver.publicPlayerID] and self.proposals[receiver.publicPlayerID][sender.publicPlayerID] + if previouslyProposedGameMode then + -- the challenged player issued a challenge prior to this + if previouslyProposedGameMode == gameModeId then + -- gameModeId ~= nil is implied because previouslyProposedGameMode is evidently not nil (but gameModeId also won't be "any") + ---@cast gameModeId GameModeID + self:create_room(GameModes.getPreset(gameModeId), sender, receiver) + elseif previouslyProposedGameMode == "any" then + if gameModeId then + self:create_room(GameModes.getPreset(gameModeId), sender, receiver) + else + -- both sent unspecific challenges at the same time, cannot create room + -- forward the challenge so that the client can see the problem + self:registerChallenge(sender, receiver, gameModeId) + end + else + if not gameModeId then + ---@cast previouslyProposedGameMode GameModeID + self:create_room(GameModes.getPreset(previouslyProposedGameMode), sender, receiver) + else + -- there is a conflict in which game mode they want to play, so no room + -- forward the challenge so that the client can see the problem + self:registerChallenge(sender, receiver, gameModeId) + end end else - receiver:sendJson(ServerProtocol.sendChallenge(sender, receiver)) - local prop = {[sender] = true} - proposals[sender][receiver] = prop - proposals[receiver][sender] = prop + -- no existing challenge + self:registerChallenge(sender, receiver, gameModeId) end end end +---@param sender ServerPlayer +---@param receiver ServerPlayer +function Server:registerChallenge(sender, receiver, gameModeId) + local senderChallenges = self.proposals[sender.publicPlayerID] or {} + -- save as "any" if no specific game mode was proposed to differentiate with no challenge + senderChallenges[receiver.publicPlayerID] = gameModeId or "any" + + self.proposals[sender.publicPlayerID] = senderChallenges + receiver:sendJson(ServerProtocol.sendChallenge(sender, receiver, gameModeId)) +end + +---@param sender ServerPlayer +---@param receiver ServerPlayer +function Server:cancelChallenge(sender, receiver) + local senderChallenges = self.proposals[sender.publicPlayerID] or {} + senderChallenges[receiver.publicPlayerID] = nil + + self.proposals[sender.publicPlayerID] = senderChallenges + + receiver:sendJson(ServerProtocol.cancelChallenge(sender, receiver)) +end + ---@param player ServerPlayer function Server:clearProposals(player) - local proposals = self.proposals - if proposals[player] then - for otherPlayer, _ in pairs(proposals[player]) do - proposals[player][otherPlayer] = nil - if proposals[otherPlayer] then - proposals[otherPlayer][player] = nil - end + self.proposals[player.publicPlayerID] = {} + for _, challenges in pairs(self.proposals) do + if challenges[player.publicPlayerID] then + challenges[player.publicPlayerID] = nil end - proposals[player] = nil end end @@ -589,7 +623,7 @@ function Server:processMessage(message, connection) return false elseif player.state == "lobby" and message.game_request then if message.game_request.sender == player.name then - self:proposeGame(player, self.nameToPlayer[message.game_request.receiver]) + self:processGameRequest(player, self.nameToPlayer[message.game_request.receiver], message.game_request.gameModeId) return true end elseif player.state == "lobby" and message.roomRequest then diff --git a/server/tests/ServerTests.lua b/server/tests/ServerTests.lua index 17ea93d2..cab77a79 100644 --- a/server/tests/ServerTests.lua +++ b/server/tests/ServerTests.lua @@ -39,20 +39,55 @@ local function testRoomSetup() alice.connection:receiveMessage(json.encode(ClientProtocol.challengePlayer("Alice", "Ben").messageText)) server:update() - assert(server.proposals[alice][ben][alice]) - assert(server.proposals[ben][alice][alice]) + assert(server.proposals[alice.publicPlayerID][ben.publicPlayerID] == "TWO_PLAYER_VS") local message = ben.connection.outgoingMessageQueue:pop().messageText assert(message.type == "challenge" and message.content.sender == "Alice" and message.content.receiver == "Ben") assert(ben.connection.outgoingMessageQueue:len() == 0) ben.connection:receiveMessage(json.encode(ClientProtocol.challengePlayer("Ben", "Alice").messageText)) server:update() - assert(server.proposals[alice] == nil) - assert(server.proposals[ben] == nil) + assert(server.proposals[alice.publicPlayerID] == nil or next(server.proposals[alice.publicPlayerID]) == nil) + assert(server.proposals[ben.publicPlayerID] == nil or next(server.proposals[ben.publicPlayerID]) == nil) assert(server.roomNumberIndex == 2) local room = server.playerToRoom[alice] assert(room and room.roomNumber == 1) assert(room == server.playerToRoom[ben]) + assert(room.gameMode.name == GameModes.gameModeIdToName["TWO_PLAYER_VS"]) + message = alice.connection.outgoingMessageQueue:pop().messageText + assert(message.type == "createRoom" and tableUtils.length(message.content.players) == 2) + message = ben.connection.outgoingMessageQueue:pop().messageText + assert(message.type == "createRoom" and tableUtils.length(message.content.players) == 2) + + message = bob.connection.outgoingMessageQueue:pop().messageText.content + assert(message.unpaired and #message.unpaired == 1) + assert(message.spectatable and #message.spectatable == 1) +end + +-- same as the other one, except we're specifying TWO_PLAYER_TIME_ATTACK as the game mode for the challenges +local function testRoomSetup2() + local server = ServerTesting.getTestServer() + local alice = ServerTesting.login(server, ServerTesting.players[2]) + local ben = ServerTesting.login(server, ServerTesting.players[3]) + local bob = ServerTesting.login(server, ServerTesting.players[1]) + -- there are other tests to verify lobby data + ServerTesting.clearOutgoingMessages({alice, ben, bob}) + + alice.connection:receiveMessage(json.encode(ClientProtocol.challengePlayer("Alice", "Ben", "TWO_PLAYER_TIME_ATTACK").messageText)) + server:update() + assert(server.proposals[alice.publicPlayerID][ben.publicPlayerID] == "TWO_PLAYER_TIME_ATTACK") + local message = ben.connection.outgoingMessageQueue:pop().messageText + assert(message.type == "challenge" and message.content.sender == "Alice" and message.content.receiver == "Ben") + assert(ben.connection.outgoingMessageQueue:len() == 0) + + ben.connection:receiveMessage(json.encode(ClientProtocol.challengePlayer("Ben", "Alice", "TWO_PLAYER_TIME_ATTACK").messageText)) + server:update() + assert(server.proposals[alice.publicPlayerID] == nil or next(server.proposals[alice.publicPlayerID]) == nil) + assert(server.proposals[ben.publicPlayerID] == nil or next(server.proposals[ben.publicPlayerID]) == nil) + assert(server.roomNumberIndex == 2) + local room = server.playerToRoom[alice] + assert(room and room.roomNumber == 1) + assert(room == server.playerToRoom[ben]) + assert(room.gameMode.name == GameModes.gameModeIdToName["TWO_PLAYER_TIME_ATTACK"]) message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "createRoom" and tableUtils.length(message.content.players) == 2) message = ben.connection.outgoingMessageQueue:pop().messageText @@ -310,6 +345,7 @@ end testLogin() testRoomSetup() +testRoomSetup2() testGameplay() testDisconnect() testLobbyDataComposition() diff --git a/testLauncher.lua b/testLauncher.lua index f314f582..b9b0c23e 100644 --- a/testLauncher.lua +++ b/testLauncher.lua @@ -77,10 +77,10 @@ local allTests = { "common.tests.network.NetworkProtocolTests", "common.tests.network.TouchDataEncodingTests", "common.tests.data.InputCompressionTests", - "server.tests.ServerTests", "server.tests.LeaderboardTests", "server.tests.RoomTests", "server.tests.LoginTests", + "server.tests.ServerTests", "server.tests.RealSocketPartialSendTest", "client.tests.FileUtilsTests", "client.tests.ModControllerTests", From 0aa93e78b038b31a7ba2b5d1211ed69a63601c3e Mon Sep 17 00:00:00 2001 From: Endaris Date: Fri, 16 Jan 2026 14:01:34 +0100 Subject: [PATCH 04/18] create a new lobbystate, fix publicIDs being pseudorandom in server tests --- common/lib/timezones.lua | 2 + common/network/ServerProtocol.lua | 19 +++++++++ server/Game.lua | 2 + server/server.lua | 67 +++++++++++++++++++++++++++++-- server/tests/MockPersistence.lua | 10 ++++- server/tests/ServerTesting.lua | 1 + server/tests/ServerTests.lua | 47 ++++++++++++++++++++++ 7 files changed, 143 insertions(+), 5 deletions(-) diff --git a/common/lib/timezones.lua b/common/lib/timezones.lua index 695520ba..5e5ae7e8 100644 --- a/common/lib/timezones.lua +++ b/common/lib/timezones.lua @@ -33,6 +33,8 @@ local function get_timezone_offset(ts) end local currentTimeZoneOffset = get_timezone_offset(os.time()) +---@param time_to_convert integer +---@return integer function to_UTC(time_to_convert) return time_to_convert + -1 * currentTimeZoneOffset end diff --git a/common/network/ServerProtocol.lua b/common/network/ServerProtocol.lua index c1e07917..edec335f 100644 --- a/common/network/ServerProtocol.lua +++ b/common/network/ServerProtocol.lua @@ -316,6 +316,25 @@ function ServerProtocol.lobbyState(unpaired, rooms, allPlayers) } end +local lobbyState2Template = { + sender = "server", + type = "lobbyStateV2", + content = { } +} + +---@return {messageType: table, messageText: ServerMessage} +function ServerProtocol.lobbyStateV2(players, rooms) + local lobbyStateV2Message = lobbyState2Template + + lobbyStateV2Message.content.players = players + lobbyStateV2Message.content.rooms = rooms + + return { + messageType = msgTypes.jsonMessage, + messageText = lobbyStateV2Message, + } +end + local loginResponseTemplate = { sender = "server", type = "loginResponse", diff --git a/server/Game.lua b/server/Game.lua index 28f67a58..cd9ebcd4 100644 --- a/server/Game.lua +++ b/server/Game.lua @@ -17,6 +17,7 @@ local LevelPresets = require("common.data.LevelPresets") ---@field package inputs string[][] ---@field package outcomeReports integer[] ---@field complete boolean +---@field creationTime integer local Game = class( ---@param players ServerPlayer[] ---@param id integer? @@ -30,6 +31,7 @@ function(self, players, id) self.id = id self.outcomeReports = {} self.complete = false + self.creationTime = os.time() end) ---@param room Room diff --git a/server/server.lua b/server/server.lua index 8fef97a9..90eb0e70 100644 --- a/server/server.lua +++ b/server/server.lua @@ -256,11 +256,67 @@ function Server:lobby_state() return {unpaired = names, spectatable = spectatableRooms, players = players} end +---@alias LobbyPlayerV2 {publicId: PublicPlayerID, name: string, state: string, ratings: table} +---@alias LobbyRoomV2 {roomNumber: integer, state: string, gameModeId: GameModeID, players: PublicPlayerID[], spectators: PublicPlayerID[]} +---@alias LobbyStateV2 { players: table, rooms: table } + +---@return LobbyStateV2 +function Server:lobbyStateV2() + local players = {} + local rooms = {} + + for _, connection in pairs(self.connections) do + local player = self.connectionToPlayer[connection] + if player then + logger.debug("Player " .. player.name .. " state is " .. player.state) + end + + players[player.publicPlayerID] = { + publicId = player.publicPlayerID, + name = player.name, + state = player.state, + ratings = { }, + } + + if self.leaderboard and self.leaderboard.players[player.userId] and self.leaderboard.players[player.userId].placement_done then + players[player.publicPlayerID].ratings.TWO_PLAYER_VS = math.round(self.leaderboard.players[player.userId].rating) + end + end + + for _, room in pairs(self.rooms) do + local lobbyRoom = { + roomNumber = room.roomNumber, + state = room:state(), + gameModeId = GameModes.nameToGameModeId[room.gameMode.name], + players = {}, + spectators = {} + } + + if room.game then + lobbyRoom.gameStartTime = os.date("*t", to_UTC(room.game.creationTime)) + end + + for i, player in ipairs(room.players) do + players[player.publicPlayerID].roomNumber = room.roomNumber + lobbyRoom.players[i] = player.publicPlayerID + end + + for i, spectator in ipairs(room.spectators) do + players[spectator.publicPlayerID].roomNumber = room.roomNumber + lobbyRoom.spectators[i] = spectator.publicPlayerID + end + + rooms[lobbyRoom.roomNumber] = lobbyRoom + end + + return { players = players, rooms = rooms } +end + ---@param sender ServerPlayer ---@param receiver ServerPlayer ---@param gameModeId GameModeID? function Server:processGameRequest(sender, receiver, gameModeId) - logger.debug(sender.name .. " challenges " .. receiver.name .. " to a game of " .. (gameModeId or "their choice (any)")) + logger.debug(string.format("%s challenges %s to a game of %s", sender.name, receiver.name, (gameModeId or "their choice (any)"))) if sender and sender.state == "lobby" and receiver and receiver.state == "lobby" then local previouslyProposedGameMode = self.proposals[receiver.publicPlayerID] and self.proposals[receiver.publicPlayerID][sender.publicPlayerID] @@ -515,11 +571,11 @@ local function handleError(msg) local trace = debug.traceback() ---@type any - local sanitizedmsg = {} + local sanitizedMsgTable = {} for char in msg:gmatch(utf8.charpattern) do - table.insert(sanitizedmsg, char) + table.insert(sanitizedMsgTable, char) end - sanitizedmsg = table.concat(sanitizedmsg) + local sanitizedmsg = table.concat(sanitizedMsgTable) local err = {} @@ -687,11 +743,14 @@ end function Server:broadCastLobbyIfChanged() if self.lobbyChanged then local lobbyState = self:lobby_state() + local lobbyStateV2 = self:lobbyStateV2() local message = ServerProtocol.lobbyState(lobbyState.unpaired, lobbyState.spectatable, lobbyState.players) + local messageV2 = ServerProtocol.lobbyStateV2(lobbyStateV2.players, lobbyStateV2.rooms) for _, connection in pairs(self.connections) do local player = self.connectionToPlayer[connection] if player and player.state == "lobby" then connection:sendJson(message) + connection:sendJson(messageV2) end end self.lobbyChanged = false diff --git a/server/tests/MockPersistence.lua b/server/tests/MockPersistence.lua index 547f1fde..3fd0a547 100644 --- a/server/tests/MockPersistence.lua +++ b/server/tests/MockPersistence.lua @@ -1,7 +1,8 @@ ----@diagnostic disable: missing-fields, duplicate-set-field +---@diagnostic disable: missing-fields, duplicate-set-field, inject-field ---@type Persistence local MockPersistence = {} +local testData -- this should be a reference to the same player data the Playerbase holds onto local PlayerData @@ -63,7 +64,14 @@ function MockPersistence.getPlayerData() end ---@param privateUserId privateUserId +---@return DB_Player? function MockPersistence.getPlayerInfo(privateUserId) + --publicPlayerID: integer, privatePlayerID: integer, username: string, lastLoginTime: integer + return {publicPlayerID = testData[tonumber(privateUserId)].publicPlayerID, privatePlayerID = privateUserId, username = testData[tonumber(privateUserId)].name, lastLoginTime = 0} +end + +function MockPersistence.setTestData(playerData) + testData = playerData end return MockPersistence \ No newline at end of file diff --git a/server/tests/ServerTesting.lua b/server/tests/ServerTesting.lua index e659470e..5a9ca3bd 100644 --- a/server/tests/ServerTesting.lua +++ b/server/tests/ServerTesting.lua @@ -58,6 +58,7 @@ function ServerTesting.addToLeaderboard(lb, player) end function ServerTesting.getTestServer() + MockPersistence.setTestData(ServerTesting.players) local testServer = Server(false, MockPersistence) testServer:initializePlayerData("", playerData) diff --git a/server/tests/ServerTests.lua b/server/tests/ServerTests.lua index cab77a79..c69f61c6 100644 --- a/server/tests/ServerTests.lua +++ b/server/tests/ServerTests.lua @@ -27,6 +27,9 @@ local function testLogin() assert(message and message.messageText.type == "loginResponse" and message.messageText.content.approved) message = bob.connection.outgoingMessageQueue:pop().messageText assert(message and message.type == "lobbyState" and message.content.unpaired and message.content.unpaired[1] == "Bob") + -- during migration we'll have both; remove old lobby state once clients have moved to V2 + message = bob.connection.outgoingMessageQueue:pop().messageText + assert(message and message.type == "lobbyStateV2" and message.content.players and message.content.players[4].name == "Bob") end local function testRoomSetup() @@ -122,6 +125,15 @@ local function testGameplay() assert(message.type == "lobbyState") assert(message.content.unpaired and #message.content.unpaired == 1) assert(#message.content.spectatable == 1 and message.content.spectatable[1].state == "playing" and message.content.spectatable[1].roomNumber == 1) + + -- during migration we'll have both; remove old lobby state once clients have moved to V2 + message = bob.connection.outgoingMessageQueue:pop().messageText + assert(message.type == "lobbyStateV2") + assert(message.content.players and tableUtils.length(message.content.players) == 3) + assert(tableUtils.length(message.content.rooms) == 1) + local roomNumber, lobbyRoomV2 = next(message.content.rooms) + assert(lobbyRoomV2.state == "playing" and lobbyRoomV2.roomNumber == 1) + local matchStart = alice.connection.outgoingMessageQueue:pop().messageText assert(matchStart.type == "matchStart") matchStart = ben.connection.outgoingMessageQueue:pop().messageText @@ -233,6 +245,12 @@ local function testDisconnect() assert(message.type == "lobbyState" and message.content.unpaired and #message.content.unpaired == 2) message = bob.connection.outgoingMessageQueue:pop().messageText assert(message.type == "lobbyState" and message.content.unpaired and #message.content.unpaired == 2) + + -- during migration we'll have both; remove old lobby state once clients have moved to V2 + message = alice.connection.outgoingMessageQueue:pop().messageText + assert(message and message.type == "lobbyStateV2" and message.content.players and message.content.players[5].name == "Alice" and message.content.players[5].state == "lobby") + message = bob.connection.outgoingMessageQueue:pop().messageText + assert(message and message.type == "lobbyStateV2" and message.content.players and message.content.players[4].name == "Bob" and message.content.players[4].state == "lobby") end @@ -277,6 +295,27 @@ local function testLobbyDataComposition() -- bob as a spectator is invisible rip assert(not message.players["Bob"]) + + -- and now for V2 + message = alice.connection.outgoingMessageQueue:pop().messageText + assert(message.type == "lobbyStateV2") + message = message.content + ---@cast message LobbyStateV2 + assert(message.players and tableUtils.length(message.players) == 5) + assert(message.rooms and tableUtils.length(message.rooms) == 1) + for id, player in pairs(message.players) do + assert(id == player.publicId) + assert((player.state == "lobby" and (player.name == "Alice" or player.name == "Berta") and tonumber(player.ratings.TWO_PLAYER_VS)) + or (player.state == "character select" and (player.name == "Ben" or player.name == "Jerry")) + or (player.state == "spectating" and player.name == "Bob")) + end + + local roomNumber, lobbyRoom = next(message.rooms) + assert(lobbyRoom.roomNumber == roomNumber) + assert(#lobbyRoom.players == 2 and #lobbyRoom.spectators == 1) + -- in the new lobby data spectators are not invisible anymore! + assert(message.players[lobbyRoom.spectators[1]].name == "Bob") + assert(lobbyRoom.gameModeId == "TWO_PLAYER_VS") end local function testSinglePlayer() @@ -296,6 +335,11 @@ local function testSinglePlayer() assert(message.type == "lobbyState") assert(#message.content.spectatable == 1) + -- during migration we'll have both; remove old lobby state once clients have moved to V2 + message = alice.connection.outgoingMessageQueue:pop().messageText + assert(message.type == "lobbyStateV2") + assert(tableUtils.length(message.content.rooms) == 1) + alice.connection:receiveMessage(json.encode(ClientProtocol.requestSpectate("Alice", server.playerToRoom[bob].roomNumber).messageText)) server:update() @@ -325,6 +369,9 @@ local function testSinglePlayer() assert(message.type == "leaveRoom") message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "lobbyState") + -- during migration we'll have both; remove old lobby state once clients have moved to V2 + message = alice.connection.outgoingMessageQueue:pop().messageText + assert(message.type == "lobbyStateV2") alice.connection:receiveMessage(json.encode(ClientProtocol.requestSpectate("Alice", server.playerToRoom[bob].roomNumber).messageText)) server:update() From 45d28b69dfe6f8e0147c209c88b22468e4c1c8df Mon Sep 17 00:00:00 2001 From: Endaris Date: Sun, 1 Feb 2026 18:40:08 +0100 Subject: [PATCH 05/18] dumping progress because I forgot to merge in beta changes (oh no) --- client/assets/localization.csv | 2 + client/src/network/NetClient.lua | 66 ++++++++++ client/src/network/ServerMessages.lua | 2 + client/src/scenes/Lobby.lua | 175 +++++++++++++++++++++---- client/src/ui/LobbyChallengeButton.lua | 0 common/network/ClientProtocol.lua | 20 +++ common/network/ServerProtocol.lua | 9 +- server/Room.lua | 4 +- server/server.lua | 68 ++++------ 9 files changed, 278 insertions(+), 68 deletions(-) create mode 100644 client/src/ui/LobbyChallengeButton.lua diff --git a/client/assets/localization.csv b/client/assets/localization.csv index 7a7a8441..26e22b00 100644 --- a/client/assets/localization.csv +++ b/client/assets/localization.csv @@ -638,3 +638,5 @@ mod_manage_submods,Label for submods in mod management,Sub Mods,Sous-mods,Submod mod_manage_enabled,Label for enabled in mod management,Enabled,Activé,Ativado,有効,Activado,Aktiviert,Abilitato,เปิดใช้งาน mm_2_time,,2P time attack,2J contre la montre,2J contra o tempo,2P スコアアタック,2J Contrareloj,2P Time Attack,2P a tempo,2P time attack op_about_puzzles,,About custom puzzles,À propos des puzzles personnalisés,Sobre quebra-cabeças personalizados,カスタムパズルについて,Acerca de rompecabezas personalizados,How to: Eigene Puzzles erstellen,A proposito dei puzzle personalizzabili,เกี่ยวกับ custom puzzles +gm_time_attack,Name of the score attack game mode with time limit,Time Attack,Contre la montre,Contra o tempo,スコアアタック,Contrareloj,Time Attack,A Tempo,Time Attack +lb_mode_choice,Label indicating that the choice of game mode is left to the opponent,Their choice,,,,,Gegner wählt Spielmodus,,, \ No newline at end of file diff --git a/client/src/network/NetClient.lua b/client/src/network/NetClient.lua index 4954bae6..07881450 100644 --- a/client/src/network/NetClient.lua +++ b/client/src/network/NetClient.lua @@ -29,6 +29,20 @@ local function resetLobbyData(self) spectatableRooms = {}, sentRequests = {} } + + ---@class PersonalizedLobbyDataV2 + self.lobbyDataV2 = { + ---@type table + players = {}, + ---@type LobbyPlayerV2[] + availablePlayers = {}, + ---@type table> + outgoingChallenges = {}, + ---@type table> + incomingChallenges = {}, + ---@type table + rooms = {} + } end local function updateLobbyState(self, lobbyState) @@ -57,6 +71,43 @@ local function updateLobbyState(self, lobbyState) self:emitSignal("lobbyStateUpdate", self.lobbyData) end +---@param lobbyStateV2Message { content: LobbyStateV2 } +local function updateLobbyStateV2(self, lobbyStateV2Message) + local lobbyStateV2 = lobbyStateV2Message.content + if lobbyStateV2.players then + self.lobbyDataV2.players = lobbyStateV2.players + end + + local availablePlayers = {} + for publicId, player in pairs(lobbyStateV2.players) do + if not player.roomNumber then + availablePlayers[#availablePlayers+1] = player + end + end + + -- if a player we challenged is not in lobby data or is in a room, they cannot accept our challenge anymore + for publicId, player in pairs(self.lobbyDataV2.outgoingChallenges) do + if not self.lobbyDataV2.players[publicId] then + self.lobbyDataV2.outgoingChallenges[publicId] = nil + elseif self.lobbyDataV2.players[publicId].roomNumber then + self.lobbyDataV2.outgoingChallenges[publicId] = nil + end + end + + -- if a player that challenged us is not in lobby data or is in a room, we cannot accept their challenge anymore + for publicId, player in pairs(self.lobbyData.incomingChallenges) do + if not self.lobbyDataV2.players[publicId] then + self.lobbyDataV2.incomingChallenges[publicId] = nil + elseif self.lobbyDataV2.players[publicId].roomNumber then + self.lobbyDataV2.incomingChallenges[publicId] = nil + end + end + + self.lobbyData.rooms = lobbyStateV2.rooms + + self:emitSignal("lobbyStateV2Update", self.lobbyDataV2) +end + ---@param room BattleRoom local function getSceneFromRoom(room) -- this is so hacky oh my god @@ -328,6 +379,7 @@ local function createListeners(self) local messageListeners = {} messageListeners.create_room = createListener(self, "create_room", start2pVsOnlineMatch) messageListeners.players = createListener(self, "unpaired", updateLobbyState) + messageListeners.lobbyStateV2 = createListener(self, "lobbyStateV2", updateLobbyStateV2) messageListeners.game_request = createListener(self, "game_request", processGameRequest) messageListeners.menu_state = createListener(self, "menu_state", processMenuStateMessage) messageListeners.ranked_match_approved = createListener(self, "ranked_match_approved", processRankedStatusMessage) @@ -366,6 +418,7 @@ local NetClient = class(function(self) -- all listeners running while online but not in a room/match self.lobbyListeners = { players = messageListeners.players, + lobbyStateV2 = messageListeners.lobbyStateV2, create_room = messageListeners.create_room, game_request = messageListeners.game_request, } @@ -396,6 +449,7 @@ local NetClient = class(function(self) Signal.turnIntoEmitter(self) self:createSignal("lobbyStateUpdate") + self:createSignal("lobbyDataV2Update") self:createSignal("leaderboardUpdate") -- only fires for unintended disconnects self:createSignal("clientDisconnected") @@ -457,6 +511,18 @@ function NetClient:challengePlayer(name) end end +---@param opponentId PublicPlayerID +---@param gameModeId GameModeID +function NetClient:challengePlayerById(opponentId, gameModeId) + if not self.lobbyDataV2.outgoingChallenges[opponentId] then + self.tcpClient:sendRequest(ClientMessages.challengePlayerV2(GAME.localPlayer.publicId, opponentId, gameModeId)) + self.lobbyDataV2.outgoingChallenges[opponentId] = self.lobbyDataV2.outgoingChallenges[opponentId] or {} + self.lobbyDataV2.outgoingChallenges[opponentId][gameModeId] = true + self:emitSignal("lobbyDataV2Update", self.lobbyDataV2) + end + +end + function NetClient:requestSpectate(roomNumber) if not self.pendingResponses.spectateResponse then self.pendingResponses.spectateResponse = self.tcpClient:sendRequest(ClientMessages.requestSpectate(config.name, roomNumber)) diff --git a/client/src/network/ServerMessages.lua b/client/src/network/ServerMessages.lua index 4a1a5dcc..5f08ec45 100644 --- a/client/src/network/ServerMessages.lua +++ b/client/src/network/ServerMessages.lua @@ -133,6 +133,8 @@ function ServerMessages.sanitizeServerMessage(message) end elseif message.type == "lobbyState" then return message.content + elseif message.type == "lobbyStateV2" then + return { lobbyStateV2 = true, content = message.content } elseif message.type == "leaderboardReport" then return { leaderboard_report = message.content } elseif message.type == "spectateRequestGranted" then diff --git a/client/src/scenes/Lobby.lua b/client/src/scenes/Lobby.lua index a0e303d6..ce1ad12c 100644 --- a/client/src/scenes/Lobby.lua +++ b/client/src/scenes/Lobby.lua @@ -121,14 +121,16 @@ function Lobby:playerRatingString(playerName) return rating end --- challenges the opponent with that name -function Lobby:requestGameFunction(opponentName) +-- sends a challenge for the opponent with that id +---@param publicId PublicPlayerID +---@param gameModeId GameModeID? +function Lobby:requestGameFunction(publicId, gameModeId) return function() if GAME.localPlayer.settings.style ~= GameModes.Styles.MODERN then GAME.localPlayer:setStyle(GameModes.Styles.MODERN) GAME.netClient:sendPlayerSettings(GAME.localPlayer) end - GAME.netClient:challengePlayer(opponentName) + GAME.netClient:challengePlayerById(publicId, gameModeId) GAME.theme:playValidationSfx() end end @@ -141,6 +143,143 @@ function Lobby:requestSpectateFunction(room) end end +---@param publicId PublicPlayerID +---@param gameModeId GameModeID? +---@return string +function Lobby.getPlayerNameWithRating(publicId, gameModeId) + local player = GAME.netClient.lobbyDataV2.players[publicId] + + if not player then + logger.warn("Tried to get rating for unknown player id " .. publicId) + return tostring(publicId) + else + gameModeId = gameModeId or "TWO_PLAYER_VS" + if player.rating[gameModeId] then + return player.name .. " (" .. player.rating[gameModeId] .. ")" + else + return player.name + end + end +end + +---@param personalizedLobbyData PersonalizedLobbyDataV2 +function Lobby:createPlayerButtons(personalizedLobbyData) + local playerButtons = {} + + for publicId, player in pairs(personalizedLobbyData.players) do + local playerName + if personalizedLobbyData.incomingChallenges[publicId] and next(personalizedLobbyData.incomingChallenges[publicId]) then + playerName = Lobby.getPlayerNameWithRating(publicId) .. " " .. loc("lb_received") + elseif personalizedLobbyData.outgoingChallenges[publicId] and next(personalizedLobbyData.outgoingChallenges[publicId]) then + playerName = Lobby.getPlayerNameWithRating(publicId) .. " " .. loc("lb_request") + else + playerName = Lobby.getPlayerNameWithRating(publicId) + end + + local button = ui.MenuItem.createButtonMenuItem(playerName, nil, false, self:requestGameFunction(publicId)) + button.player = player + playerButtons[#playerButtons+1] = button + end + + table.sort(playerButtons, function(a, b) + -- a more sensible order could be login time or idle status but the server does not track those at the moment + -- but we need a consistent order + return a.player.publicId < b.player.publicId + end) + + return playerButtons +end + +---@param personalizedLobbyData PersonalizedLobbyDataV2 +function Lobby:createRoomButtons(personalizedLobbyData) + local roomButtons = {} + + for _, room in pairs(personalizedLobbyData.rooms) do + ---@type table + local playerStrings = {} + for i, playerId in ipairs(room.playerIds) do + playerStrings[i] = Lobby.getPlayerNameWithRating(playerId, room.gameModeId) + end + + local roomName + + if #room.players == 1 then + roomName = loc("lb_spectate") .. " " .. playerStrings[1] .. " (" .. room.state .. ")" + else + roomName = loc("lb_spectate") .. " " .. playerStrings[1] .. " vs " .. playerStrings[2] .. " (" .. room.state .. ")" + end + + local button = ui.MenuItem.createButtonMenuItem(roomName, nil, false, self:requestSpectateFunction(room)) + button.room = room + roomButtons[#roomButtons+1] = button + end + + table.sort(roomButtons, function(a, b) + return a.room.roomNumber < b.room.roomNumber + end) + + return roomButtons +end + +---@param playerId PublicPlayerID +function Lobby:openPlayerSubMenu(playerId) + local lobbyData = GAME.netClient.lobbyData + + local menu = ui.Menu({ + x = 0, + y = 0, + hAlign = "center", + vAlign = "center", + height = themes[config.theme].main_menu_max_height + }) + + local backButton = ui.MenuItem.createButtonMenuItem("back", nil, true, function() + menu:detach() + end) + + if lobbyData.incomingChallenges[playerId] then + local gameMode = lobbyData.incomingChallenges[playerId] + if gameMode == "TWO_PLAYER_VS" or gameMode == "any" then + local button = ui.MenuItem.createButtonMenuItem("vs", nil, true, function() + self:requestGameFunction(playerId, "TWO_PLAYER_VS") + menu:detach() + end) + menu:addMenuItem(button) + end + if gameMode == "TWO_PLAYER_TIME_ATTACK" or gameMode == "any" then + local button = ui.MenuItem.createButtonMenuItem("gm_time_attack", nil, true, function() + self:requestGameFunction(playerId, "TWO_PLAYER_TIME_ATTACK") + menu:detach() + end) + menu:addMenuItem(button) + end + elseif lobbyData.outgoingChallenges[playerId] then + + else + local vsButton = ui.MenuItem.createButtonMenuItem("vs", nil, true, function() + self:requestGameFunction(playerId, "TWO_PLAYER_VS") + menu:detach() + end) + menu:addMenuItem(vsButton) + + local timeAttack = ui.MenuItem.createButtonMenuItem("gm_time_attack", nil, true, function() + self:requestGameFunction(playerId, "TWO_PLAYER_TIME_ATTACK") + menu:detach() + end) + menu:addMenuItem(timeAttack) + + local anyButton = ui.MenuItem.createButtonMenuItem("lb_mode_choice", nil, true, function() + self:requestGameFunction(playerId) + menu:detach() + end) + menu:addMenuItem(anyButton) + end + + menu:addMenuItem(backButton) + + self.uiRoot:addChild(menu) +end + -- rebuilds the UI based on the new lobby information function Lobby:onLobbyStateUpdate(lobbyState) local previousText @@ -155,28 +294,16 @@ function Lobby:onLobbyStateUpdate(lobbyState) end self.lobbyMenu:setSelectedIndex(1) - for _, v in ipairs(lobbyState.unpairedPlayers) do - if v ~= config.name then - local unmatchedPlayer = v .. self:playerRatingString(v) - if lobbyState.sentRequests[v] then - unmatchedPlayer = unmatchedPlayer .. " " .. loc("lb_request") - end - if lobbyState.willingPlayers[v] then - unmatchedPlayer = unmatchedPlayer .. " " .. loc("lb_received") - end - self.lobbyMenu:addMenuItem(2, ui.MenuItem.createButtonMenuItem(unmatchedPlayer, nil, false, self:requestGameFunction(v))) - end + local playerButtons = Lobby:createPlayerButtons(GAME.netClient.lobbyData) + + for _, button in ipairs(playerButtons) do + self.lobbyMenu:addMenuItem(2, button) end - for _, room in ipairs(lobbyState.spectatableRooms) do - if room.b then - local playerA = room.a .. self:playerRatingString(room.a) - local playerB = room.b .. self:playerRatingString(room.b) - local roomName = loc("lb_spectate") .. " " .. playerA .. " vs " .. playerB .. " (" .. room.state .. ")" - self.lobbyMenu:addMenuItem(2, ui.MenuItem.createButtonMenuItem(roomName, nil, false, self:requestSpectateFunction(room))) - else - local roomName = loc("lb_spectate") .. " " .. room.name .. " (" .. room.state .. ")" - self.lobbyMenu:addMenuItem(2, ui.MenuItem.createButtonMenuItem(roomName, nil, false, self:requestSpectateFunction(room))) - end + + local roomButtons = Lobby:createRoomButtons(GAME.netClient.lobbyData) + + for _, button in ipairs(roomButtons) do + self.lobbyMenu:addMenuItem(2, button) end if self.lobbyMenuStartingUp then diff --git a/client/src/ui/LobbyChallengeButton.lua b/client/src/ui/LobbyChallengeButton.lua new file mode 100644 index 00000000..e69de29b diff --git a/common/network/ClientProtocol.lua b/common/network/ClientProtocol.lua index 1f2dbf0b..ebd327be 100644 --- a/common/network/ClientProtocol.lua +++ b/common/network/ClientProtocol.lua @@ -75,6 +75,26 @@ function ClientMessages.challengePlayer(senderName, receiverName, gameModeId) } end +---@param senderId PublicPlayerID +---@param receiverId PublicPlayerID +---@param gameModeId GameModeID +function ClientMessages.challengePlayerV2(senderId, receiverId, gameModeId) + local playerChallengeV2Message = + { + gameRequestV2 = + { + senderId = senderId, + receiverId = receiverId, + gameModeId = gameModeId, + } + } + + return { + messageType = msgTypes.jsonMessage, + messageText = playerChallengeV2Message, + } +end + function ClientMessages.requestSpectate(spectatorName, roomNumber) local spectateRequestMessage = { diff --git a/common/network/ServerProtocol.lua b/common/network/ServerProtocol.lua index edec335f..d80ca03a 100644 --- a/common/network/ServerProtocol.lua +++ b/common/network/ServerProtocol.lua @@ -316,13 +316,16 @@ function ServerProtocol.lobbyState(unpaired, rooms, allPlayers) } end +---@class LobbyStateV2Message : ServerMessage +---@field content LobbyStateV2 + local lobbyState2Template = { sender = "server", type = "lobbyStateV2", content = { } } ----@return {messageType: table, messageText: ServerMessage} +---@return {messageType: table, messageText: LobbyStateV2Message} function ServerProtocol.lobbyStateV2(players, rooms) local lobbyStateV2Message = lobbyState2Template @@ -512,12 +515,14 @@ local cancelChallengeTemplate = { ---@param sender ServerPlayer ---@param receiver ServerPlayer +---@param gameModeId GameModeID ---@return {messageType: table, messageText: ServerMessage} -function ServerProtocol.cancelChallenge(sender, receiver) +function ServerProtocol.cancelChallenge(sender, receiver, gameModeId) local cancelChallengeMessage = cancelChallengeTemplate cancelChallengeMessage.sender = sender.publicPlayerID cancelChallengeMessage.content.sender = sender.name cancelChallengeMessage.content.receiver = receiver.name + cancelChallengeMessage.content.gameModeId = gameModeId return { messageType = msgTypes.jsonMessage, diff --git a/server/Room.lua b/server/Room.lua index c4e83d64..a06686bb 100644 --- a/server/Room.lua +++ b/server/Room.lua @@ -8,13 +8,15 @@ local ServerPlayer = require("server.Player") local Signal = require("common.lib.signal") local ServerGame = require("server.Game") +---@alias roomNumber integer + -- Object that represents a current session of play between two connections -- Players alternate between the character select state and playing, and spectators can join and leave ---@class Room : Signal ---@field players ServerPlayer[] ---@field leaderboard Leaderboard? ---@field name string ----@field roomNumber integer +---@field roomNumber roomNumber ---@field stage string? stage for the game, randomly picked from both players ---@field spectators ServerPlayer[] array of spectator connection objects ---@field win_counts integer[] win counts by player number diff --git a/server/server.lua b/server/server.lua index 90eb0e70..e5b83058 100644 --- a/server/server.lua +++ b/server/server.lua @@ -38,7 +38,7 @@ local time = os.time ---@field connectionNumberIndex integer GLOBAL counter of the next available connection index ---@field roomNumberIndex integer the next available room number ---@field rooms Room[] mapping of room number to room ----@field proposals table> mapping of player name to a mapping of the players they have challenged +---@field proposals table>> mapping of player name to a mapping of the players they have challenged for each game mode ---@field connections Connection[] mapping of connection number to connection ---@field nameToConnectionIndex table mapping of player names to their unique connectionNumberIndex ---@field socketToConnectionIndex table mapping of sockets to their unique connectionNumberIndex @@ -256,9 +256,9 @@ function Server:lobby_state() return {unpaired = names, spectatable = spectatableRooms, players = players} end ----@alias LobbyPlayerV2 {publicId: PublicPlayerID, name: string, state: string, ratings: table} ----@alias LobbyRoomV2 {roomNumber: integer, state: string, gameModeId: GameModeID, players: PublicPlayerID[], spectators: PublicPlayerID[]} ----@alias LobbyStateV2 { players: table, rooms: table } +---@alias LobbyPlayerV2 { publicId: PublicPlayerID, name: string, state: string, ratings: table, roomNumber: roomNumber? } +---@alias LobbyRoomV2 { roomNumber: roomNumber, state: string, gameModeId: GameModeID, players: PublicPlayerID[], spectators: PublicPlayerID[], wins: integer[] } +---@alias LobbyStateV2 { players: table, rooms: table } ---@return LobbyStateV2 function Server:lobbyStateV2() @@ -289,7 +289,8 @@ function Server:lobbyStateV2() state = room:state(), gameModeId = GameModes.nameToGameModeId[room.gameMode.name], players = {}, - spectators = {} + spectators = {}, + wins = {}, } if room.game then @@ -299,6 +300,7 @@ function Server:lobbyStateV2() for i, player in ipairs(room.players) do players[player.publicPlayerID].roomNumber = room.roomNumber lobbyRoom.players[i] = player.publicPlayerID + lobbyRoom.wins[i] = room.win_counts[i] end for i, spectator in ipairs(room.spectators) do @@ -314,38 +316,16 @@ end ---@param sender ServerPlayer ---@param receiver ServerPlayer ----@param gameModeId GameModeID? +---@param gameModeId GameModeID function Server:processGameRequest(sender, receiver, gameModeId) - logger.debug(string.format("%s challenges %s to a game of %s", sender.name, receiver.name, (gameModeId or "their choice (any)"))) + logger.debug(string.format("%s challenges %s to a game of %s", sender.name, receiver.name, gameModeId)) if sender and sender.state == "lobby" and receiver and receiver.state == "lobby" then - local previouslyProposedGameMode = self.proposals[receiver.publicPlayerID] and self.proposals[receiver.publicPlayerID][sender.publicPlayerID] - if previouslyProposedGameMode then - -- the challenged player issued a challenge prior to this - if previouslyProposedGameMode == gameModeId then - -- gameModeId ~= nil is implied because previouslyProposedGameMode is evidently not nil (but gameModeId also won't be "any") - ---@cast gameModeId GameModeID - self:create_room(GameModes.getPreset(gameModeId), sender, receiver) - elseif previouslyProposedGameMode == "any" then - if gameModeId then - self:create_room(GameModes.getPreset(gameModeId), sender, receiver) - else - -- both sent unspecific challenges at the same time, cannot create room - -- forward the challenge so that the client can see the problem - self:registerChallenge(sender, receiver, gameModeId) - end - else - if not gameModeId then - ---@cast previouslyProposedGameMode GameModeID - self:create_room(GameModes.getPreset(previouslyProposedGameMode), sender, receiver) - else - -- there is a conflict in which game mode they want to play, so no room - -- forward the challenge so that the client can see the problem - self:registerChallenge(sender, receiver, gameModeId) - end - end + local previouslyProposedGameModes = self.proposals[receiver.publicPlayerID] and self.proposals[receiver.publicPlayerID][sender.publicPlayerID] + if previouslyProposedGameModes[gameModeId] then + self:create_room(GameModes.getPreset(gameModeId), sender, receiver) else - -- no existing challenge + -- no existing challenge for this game mode self:registerChallenge(sender, receiver, gameModeId) end end @@ -353,10 +333,11 @@ end ---@param sender ServerPlayer ---@param receiver ServerPlayer +---@param gameModeId GameModeID function Server:registerChallenge(sender, receiver, gameModeId) local senderChallenges = self.proposals[sender.publicPlayerID] or {} - -- save as "any" if no specific game mode was proposed to differentiate with no challenge - senderChallenges[receiver.publicPlayerID] = gameModeId or "any" + senderChallenges[receiver.publicPlayerID] = senderChallenges[receiver.publicPlayerID] or {} + senderChallenges[receiver.publicPlayerID][gameModeId] = true self.proposals[sender.publicPlayerID] = senderChallenges receiver:sendJson(ServerProtocol.sendChallenge(sender, receiver, gameModeId)) @@ -364,13 +345,18 @@ end ---@param sender ServerPlayer ---@param receiver ServerPlayer -function Server:cancelChallenge(sender, receiver) +---@param gameModeId GameModeID +function Server:cancelChallenge(sender, receiver, gameModeId) local senderChallenges = self.proposals[sender.publicPlayerID] or {} - senderChallenges[receiver.publicPlayerID] = nil - - self.proposals[sender.publicPlayerID] = senderChallenges - - receiver:sendJson(ServerProtocol.cancelChallenge(sender, receiver)) + if not senderChallenges[receiver.publicPlayerID] then + -- can end up here if the server cleared out the challenges after the recipient accepted a different challenge or logged off + -- no handling needed in this case, the sender will already receive refreshed lobby data to reflect that + else + senderChallenges[receiver.publicPlayerID][gameModeId] = false + self.proposals[sender.publicPlayerID] = senderChallenges + + receiver:sendJson(ServerProtocol.cancelChallenge(sender, receiver, gameModeId)) + end end ---@param player ServerPlayer From eef0b196409f9d467381a81db36733b93dd70eb7 Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 11 Feb 2026 13:02:53 +0100 Subject: [PATCH 06/18] offer a submenu for picking the game mode you challenge a player to, alongside server changes to accomodate independent challenge state per game mode --- client/src/network/NetClient.lua | 29 +++- client/src/network/ServerMessages.lua | 10 +- client/src/scenes/Lobby.lua | 196 ++++++++++++++++--------- client/src/ui/LobbyChallengeButton.lua | 90 ++++++++++++ client/src/ui/Menu.lua | 7 +- client/src/ui/StackPanel.lua | 13 +- client/src/ui/init.lua | 2 + common/network/ClientProtocol.lua | 5 +- common/network/ServerProtocol.lua | 36 +---- server/ClientMessages.lua | 17 +++ server/server.lua | 51 +++---- server/simplecsv.lua | 5 +- serverLauncher.lua | 8 +- 13 files changed, 321 insertions(+), 148 deletions(-) diff --git a/client/src/network/NetClient.lua b/client/src/network/NetClient.lua index 75cc1fd9..ad225d5c 100644 --- a/client/src/network/NetClient.lua +++ b/client/src/network/NetClient.lua @@ -95,7 +95,7 @@ local function updateLobbyStateV2(self, lobbyStateV2Message) end -- if a player that challenged us is not in lobby data or is in a room, we cannot accept their challenge anymore - for publicId, player in pairs(self.lobbyData.incomingChallenges) do + for publicId, player in pairs(self.lobbyDataV2.incomingChallenges) do if not self.lobbyDataV2.players[publicId] then self.lobbyDataV2.incomingChallenges[publicId] = nil elseif self.lobbyDataV2.players[publicId].roomNumber then @@ -103,7 +103,7 @@ local function updateLobbyStateV2(self, lobbyStateV2Message) end end - self.lobbyData.rooms = lobbyStateV2.rooms + self.lobbyDataV2.rooms = lobbyStateV2.rooms self:emitSignal("lobbyStateV2Update", self.lobbyDataV2) end @@ -111,7 +111,7 @@ end ---@param room BattleRoom local function getSceneFromRoom(room) -- this is so hacky oh my god - if room.mode.name == "VS" then + if room.mode.name == "VS" or room.mode.name == "2p_timeattack" then return CharacterSelect2p({battleRoom = room}) elseif room.mode.name == "endless" then return require("client.src.scenes.EndlessMenu")({battleRoom = room}) @@ -335,6 +335,17 @@ local function processGameRequest(self, gameRequestMessage) end end +---@param self NetClient +local function processChallengeUpdate(self, challengeUpdateMessage) + if challengeUpdateMessage.challengeUpdate then + local challengeUpdate = challengeUpdateMessage.challengeUpdate + local challenges = self.lobbyDataV2.incomingChallenges[challengeUpdate.senderId] or {} + challenges[challengeUpdate.gameModeId] = challengeUpdate.challengeActive + self.lobbyDataV2.incomingChallenges[challengeUpdate.senderId] = challenges + self:emitSignal("lobbyStateV2Update", self.lobbyDataV2) + end +end + -- starts to spectate a 2p vs online match local function spectate2pVsOnlineMatch(self, spectateRequestGrantedMessage) resetLobbyData(self) @@ -383,6 +394,7 @@ local function createListeners(self) messageListeners.players = createListener(self, "unpaired", updateLobbyState) messageListeners.lobbyStateV2 = createListener(self, "lobbyStateV2", updateLobbyStateV2) messageListeners.game_request = createListener(self, "game_request", processGameRequest) + messageListeners.challengeUpdate = createListener(self, "challengeUpdate", processChallengeUpdate) messageListeners.menu_state = createListener(self, "menu_state", processMenuStateMessage) messageListeners.ranked_match_approved = createListener(self, "ranked_match_approved", processRankedStatusMessage) messageListeners.leave_room = createListener(self, "leave_room", processLeaveRoomMessage) @@ -406,6 +418,7 @@ end ---@field messageListeners table ---@field room BattleRoom? ---@field lobbyData table +---@field lobbyDataV2 PersonalizedLobbyDataV2 ---@overload fun(): NetClient local NetClient = class(function(self) self.tcpClient = TcpClient() @@ -422,7 +435,8 @@ local NetClient = class(function(self) players = messageListeners.players, lobbyStateV2 = messageListeners.lobbyStateV2, create_room = messageListeners.create_room, - game_request = messageListeners.game_request, + --game_request = messageListeners.game_request, + challengeUpdate = messageListeners.challengeUpdate, } -- all listeners running while in a room but not in a match @@ -451,7 +465,7 @@ local NetClient = class(function(self) Signal.turnIntoEmitter(self) self:createSignal("lobbyStateUpdate") - self:createSignal("lobbyDataV2Update") + self:createSignal("lobbyStateV2Update") self:createSignal("leaderboardUpdate") -- only fires for unintended disconnects self:createSignal("clientDisconnected") @@ -517,12 +531,11 @@ end ---@param gameModeId GameModeID function NetClient:challengePlayerById(opponentId, gameModeId) if not self.lobbyDataV2.outgoingChallenges[opponentId] then - self.tcpClient:sendRequest(ClientMessages.challengePlayerV2(GAME.localPlayer.publicId, opponentId, gameModeId)) + self.tcpClient:sendRequest(ClientMessages.updateChallengeStatus(GAME.localPlayer.publicId, opponentId, gameModeId, true)) self.lobbyDataV2.outgoingChallenges[opponentId] = self.lobbyDataV2.outgoingChallenges[opponentId] or {} self.lobbyDataV2.outgoingChallenges[opponentId][gameModeId] = true - self:emitSignal("lobbyDataV2Update", self.lobbyDataV2) + self:emitSignal("lobbyStateV2Update", self.lobbyDataV2) end - end function NetClient:requestSpectate(roomNumber) diff --git a/client/src/network/ServerMessages.lua b/client/src/network/ServerMessages.lua index 5f08ec45..d9e86b86 100644 --- a/client/src/network/ServerMessages.lua +++ b/client/src/network/ServerMessages.lua @@ -204,13 +204,15 @@ function ServerMessages.sanitizePlayerMessage(message) index = content.index, player_number = content.playerNumber, } - elseif message.type == "challenge" then + elseif message.type == "challengeUpdate" then return { - game_request = + challengeUpdate = { - sender = content.sender, - receiver = content.receiver, + senderId = content.senderId, + receiverId = content.receiverId, + gameModeId = content.gameModeId, + challengeActive = content.challengeActive, } } end diff --git a/client/src/scenes/Lobby.lua b/client/src/scenes/Lobby.lua index ce1ad12c..708524b3 100644 --- a/client/src/scenes/Lobby.lua +++ b/client/src/scenes/Lobby.lua @@ -48,7 +48,7 @@ function Lobby:load(sceneParams) GAME.netClient:login(sceneParams.serverIp, sceneParams.serverPort) end - GAME.netClient:connectSignal("lobbyStateUpdate", self, self.onLobbyStateUpdate) + GAME.netClient:connectSignal("lobbyStateV2Update", self, self.onLobbyStateUpdate) GAME.netClient:connectSignal("clientDisconnected", self, self.onDisconnect) GAME.netClient:connectSignal("leaderboardUpdate", self.leaderboard, self.leaderboard.updateData) GAME.netClient:connectSignal("loginFinished", self, self.onLoginFinish) @@ -154,8 +154,8 @@ function Lobby.getPlayerNameWithRating(publicId, gameModeId) return tostring(publicId) else gameModeId = gameModeId or "TWO_PLAYER_VS" - if player.rating[gameModeId] then - return player.name .. " (" .. player.rating[gameModeId] .. ")" + if player.ratings[gameModeId] then + return player.name .. " (" .. player.ratings[gameModeId] .. ")" else return player.name end @@ -167,18 +167,26 @@ function Lobby:createPlayerButtons(personalizedLobbyData) local playerButtons = {} for publicId, player in pairs(personalizedLobbyData.players) do - local playerName - if personalizedLobbyData.incomingChallenges[publicId] and next(personalizedLobbyData.incomingChallenges[publicId]) then - playerName = Lobby.getPlayerNameWithRating(publicId) .. " " .. loc("lb_received") - elseif personalizedLobbyData.outgoingChallenges[publicId] and next(personalizedLobbyData.outgoingChallenges[publicId]) then - playerName = Lobby.getPlayerNameWithRating(publicId) .. " " .. loc("lb_request") - else - playerName = Lobby.getPlayerNameWithRating(publicId) - end + --if publicId ~= GAME.localPlayer.publicId then + local playerName + if personalizedLobbyData.incomingChallenges[publicId] and next(personalizedLobbyData.incomingChallenges[publicId]) then + playerName = Lobby.getPlayerNameWithRating(publicId) .. " " .. loc("lb_received") + elseif personalizedLobbyData.outgoingChallenges[publicId] and next(personalizedLobbyData.outgoingChallenges[publicId]) then + playerName = Lobby.getPlayerNameWithRating(publicId) .. " " .. loc("lb_request") + else + playerName = Lobby.getPlayerNameWithRating(publicId) + end - local button = ui.MenuItem.createButtonMenuItem(playerName, nil, false, self:requestGameFunction(publicId)) - button.player = player - playerButtons[#playerButtons+1] = button + local menuItem = ui.MenuItem.createButtonMenuItem(playerName, nil, false, + function(button) + self:openPlayerSubMenu(publicId, button) + end + ) + ui.Focusable(menuItem.textButton) + ui.FocusDirector(menuItem.textButton) + menuItem.player = player + playerButtons[#playerButtons+1] = menuItem + --end end table.sort(playerButtons, function(a, b) @@ -209,9 +217,9 @@ function Lobby:createRoomButtons(personalizedLobbyData) roomName = loc("lb_spectate") .. " " .. playerStrings[1] .. " vs " .. playerStrings[2] .. " (" .. room.state .. ")" end - local button = ui.MenuItem.createButtonMenuItem(roomName, nil, false, self:requestSpectateFunction(room)) - button.room = room - roomButtons[#roomButtons+1] = button + local menuItem = ui.MenuItem.createButtonMenuItem(roomName, nil, false, self:requestSpectateFunction(room)) + menuItem.room = room + roomButtons[#roomButtons+1] = menuItem end table.sort(roomButtons, function(a, b) @@ -222,66 +230,97 @@ function Lobby:createRoomButtons(personalizedLobbyData) end ---@param playerId PublicPlayerID -function Lobby:openPlayerSubMenu(playerId) - local lobbyData = GAME.netClient.lobbyData - - local menu = ui.Menu({ - x = 0, - y = 0, - hAlign = "center", - vAlign = "center", - height = themes[config.theme].main_menu_max_height +---@param button Button the button the click that opens this submenu originated from +function Lobby:openPlayerSubMenu(playerId, button) + if self.playerSubMenu then + self.playerSubMenu:yieldFocus() + --self.playerSubMenu:detach() + self.playerSubMenu = nil + end + + local lobbyDataV2 = GAME.netClient.lobbyDataV2 + + local x, y = button:getScreenPos() + + local subMenu = ui.Menu({ + x = x + button.width + 8, + y = y, + hAlign = "left", + vAlign = "top", + height = 0, + width = 120, + menuItems = {}, }) - local backButton = ui.MenuItem.createButtonMenuItem("back", nil, true, function() - menu:detach() - end) + subMenu.playerId = playerId + + local backButton = ui.TextButton({ + label = ui.Label({text = "back"}), + width = 120, + onClick = function() + subMenu:yieldFocus() + self.playerSubMenu = nil + end}) + + local vsButton = ui.LobbyChallengeButton({ + gameModeId = "TWO_PLAYER_VS", + iconSize = 16, + playerId = playerId, + text = "vs", + acceptImage = GAME.theme:comboImage(4), + proposeImage = GAME.theme:comboImage(5), + withdrawImage = GAME.theme:comboImage(6), + height = 24, + width = 120 + }) + + subMenu:addMenuItem(1, ui.MenuItem.createMenuItem(vsButton)) + + local timeAttackButton = ui.LobbyChallengeButton({ + gameModeId = "TWO_PLAYER_TIME_ATTACK", + iconSize = 16, + playerId = playerId, + text = "gm_time_attack", + acceptImage = GAME.theme:comboImage(4), + proposeImage = GAME.theme:comboImage(5), + withdrawImage = GAME.theme:comboImage(6), + height = 24, + width = 120 + }) + subMenu:addMenuItem(2, ui.MenuItem.createMenuItem(timeAttackButton)) - if lobbyData.incomingChallenges[playerId] then - local gameMode = lobbyData.incomingChallenges[playerId] - if gameMode == "TWO_PLAYER_VS" or gameMode == "any" then - local button = ui.MenuItem.createButtonMenuItem("vs", nil, true, function() - self:requestGameFunction(playerId, "TWO_PLAYER_VS") - menu:detach() - end) - menu:addMenuItem(button) + if lobbyDataV2.outgoingChallenges[playerId] then + if lobbyDataV2.outgoingChallenges[playerId]["TWO_PLAYER_VS"] == true then + vsButton:setState(vsButton.challengeStates.PROPOSING) end - if gameMode == "TWO_PLAYER_TIME_ATTACK" or gameMode == "any" then - local button = ui.MenuItem.createButtonMenuItem("gm_time_attack", nil, true, function() - self:requestGameFunction(playerId, "TWO_PLAYER_TIME_ATTACK") - menu:detach() - end) - menu:addMenuItem(button) + if lobbyDataV2.outgoingChallenges[playerId]["TWO_PLAYER_TIME_ATTACK"] == true then + timeAttackButton:setState(timeAttackButton.challengeStates.PROPOSING) end - elseif lobbyData.outgoingChallenges[playerId] then + end - else - local vsButton = ui.MenuItem.createButtonMenuItem("vs", nil, true, function() - self:requestGameFunction(playerId, "TWO_PLAYER_VS") - menu:detach() - end) - menu:addMenuItem(vsButton) - - local timeAttack = ui.MenuItem.createButtonMenuItem("gm_time_attack", nil, true, function() - self:requestGameFunction(playerId, "TWO_PLAYER_TIME_ATTACK") - menu:detach() - end) - menu:addMenuItem(timeAttack) - - local anyButton = ui.MenuItem.createButtonMenuItem("lb_mode_choice", nil, true, function() - self:requestGameFunction(playerId) - menu:detach() - end) - menu:addMenuItem(anyButton) + if lobbyDataV2.incomingChallenges[playerId] then + if lobbyDataV2.incomingChallenges[playerId]["TWO_PLAYER_VS"] then + vsButton:setState(vsButton.challengeStates.CHALLENGED) + end + if lobbyDataV2.incomingChallenges[playerId]["TWO_PLAYER_TIME_ATTACK"] then + timeAttackButton:setState(timeAttackButton.challengeStates.CHALLENGED) + end end - menu:addMenuItem(backButton) + subMenu:addMenuItem(3, ui.MenuItem.createMenuItem(backButton)) + self.playerSubMenu = subMenu + + button:setFocus(subMenu, function() + subMenu:detach() + button:yieldFocus() + end) - self.uiRoot:addChild(menu) + self.uiRoot:addChild(subMenu) end -- rebuilds the UI based on the new lobby information -function Lobby:onLobbyStateUpdate(lobbyState) +---@param lobbyDataV2 PersonalizedLobbyDataV2 +function Lobby:onLobbyStateUpdate(lobbyDataV2) local previousText if self.lobbyMenu.menuItems[self.lobbyMenu.selectedIndex].textButton then previousText = self.lobbyMenu.menuItems[self.lobbyMenu.selectedIndex].textButton.children[1].text @@ -294,13 +333,13 @@ function Lobby:onLobbyStateUpdate(lobbyState) end self.lobbyMenu:setSelectedIndex(1) - local playerButtons = Lobby:createPlayerButtons(GAME.netClient.lobbyData) + local playerButtons = self:createPlayerButtons(lobbyDataV2) for _, button in ipairs(playerButtons) do self.lobbyMenu:addMenuItem(2, button) end - local roomButtons = Lobby:createRoomButtons(GAME.netClient.lobbyData) + local roomButtons = self:createRoomButtons(lobbyDataV2) for _, button in ipairs(roomButtons) do self.lobbyMenu:addMenuItem(2, button) @@ -318,6 +357,29 @@ function Lobby:onLobbyStateUpdate(lobbyState) end self.lobbyMenu:setSelectedIndex(util.bound(2, desiredIndex, #self.lobbyMenu.menuItems)) end + + if self.playerSubMenu then + if not lobbyDataV2.players[self.playerSubMenu.playerId] then + self.playerSubMenu:yieldFocus() + self.playerSubMenu = nil + else + for _, menuItem in ipairs(self.playerSubMenu.children) do + for _, item in ipairs(menuItem.children) do + if item.gameModeId then + ---@cast item LobbyChallengeButton + if lobbyDataV2.incomingChallenges[self.playerSubMenu.playerId] and lobbyDataV2.incomingChallenges[self.playerSubMenu.playerId][item.gameModeId] == true then + item:setState(item.challengeStates.CHALLENGED) + elseif lobbyDataV2.outgoingChallenges[self.playerSubMenu.playerId] and lobbyDataV2.outgoingChallenges[self.playerSubMenu.playerId][item.gameModeId] == true then + item:setState(item.challengeStates.PROPOSING) + else + item:setState(item.challengeStates.NEUTRAL) + end + end + + end + end + end + end end ------------------------------ diff --git a/client/src/ui/LobbyChallengeButton.lua b/client/src/ui/LobbyChallengeButton.lua index e69de29b..337ea53a 100644 --- a/client/src/ui/LobbyChallengeButton.lua +++ b/client/src/ui/LobbyChallengeButton.lua @@ -0,0 +1,90 @@ +local import = require("common.lib.import") +local Button = import("./Button") +local Label = import("./Label") +local class = require("common.lib.class") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local GameModes = require("common.data.GameModes") + +---@class LobbyChallengeButtonOptions : ButtonOptions +---@field text string +---@field proposeImage love.Texture +---@field acceptImage love.Texture +---@field withdrawImage love.Texture +---@field iconSize integer +---@field gameModeId GameModeID +---@field playerId PublicPlayerID + + +---@class LobbyChallengeButton : Button +---@operator call(LobbyChallengeButtonOptions): LobbyChallengeButton +---@field proposeImage love.Texture +---@field acceptImage love.Texture +---@field withdrawImage love.Texture +---@field challengeState ChallengeState +---@field iconSize integer +---@field text string +---@field gameModeId GameModeID +---@field playerId PublicPlayerID +---@overload fun(options: LobbyChallengeButtonOptions): LobbyChallengeButton +local LobbyChallengeButton = class( +---@param self LobbyChallengeButton +---@param options LobbyChallengeButtonOptions +function(self, options) + self.text = options.text + self.challengeState = self.challengeStates.NEUTRAL + self.proposeImage = options.proposeImage + self.acceptImage = options.acceptImage + self.withdrawImage = options.withdrawImage + self.iconSize = options.iconSize + self.playerId = options.playerId + self.gameModeId = options.gameModeId +end, +Button, "LobbyChallengeButton") + +LobbyChallengeButton.TYPE = "LobbyChallengeButton" + +---@enum ChallengeState +LobbyChallengeButton.challengeStates = { CHALLENGED = "CHALLENGED", PROPOSING = "PROPOSING", NEUTRAL = "NEUTRAL" } + +---@param challengeState ChallengeState +function LobbyChallengeButton:setState(challengeState) + self.challengeState = challengeState +end + +function LobbyChallengeButton:onClick() + if self.challengeState == LobbyChallengeButton.challengeStates.PROPOSING then + --GAME.netClient:withdrawChallenge(self.playerId, self.gameModeId) + GAME.theme:playValidationSfx() + else + if GAME.localPlayer.settings.style ~= GameModes.Styles.MODERN then + GAME.localPlayer:setStyle(GameModes.Styles.MODERN) + GAME.netClient:sendPlayerSettings(GAME.localPlayer) + end + GAME.netClient:challengePlayerById(self.playerId, self.gameModeId) + GAME.theme:playValidationSfx() + end +end + +local padding = 4 + +function LobbyChallengeButton:drawSelf() + self:drawBackground() + self:drawOutline() + + local icon + if self.challengeState == LobbyChallengeButton.challengeStates.NEUTRAL then + icon = self.proposeImage + elseif self.challengeState == LobbyChallengeButton.challengeStates.CHALLENGED then + icon = self.acceptImage + elseif self.challengeState == LobbyChallengeButton.challengeStates.PROPOSING then + icon = self.withdrawImage + end + + local imageWidth, imageHeight = icon:getDimensions() + local scale = math.min(self.iconSize / imageWidth, self.iconSize / imageHeight) + GraphicsUtil.draw(icon, self.x + padding, self.y + padding, 0, scale, scale) + + GraphicsUtil.printf(loc(self.text), self.x + padding * 2 + self.iconSize, self.y + padding, self.width - (padding * 2 + self.iconSize), "left") +end + +return LobbyChallengeButton \ No newline at end of file diff --git a/client/src/ui/Menu.lua b/client/src/ui/Menu.lua index 1b46b178..92257758 100644 --- a/client/src/ui/Menu.lua +++ b/client/src/ui/Menu.lua @@ -155,7 +155,7 @@ end function Menu:addMenuItem(index, menuItem) local needsIncreasedIndex = false - if index <= self.selectedIndex then + if index <= self.selectedIndex and #self.menuItems > 0 then needsIncreasedIndex = true end table.insert(self.menuItems, index, menuItem) @@ -218,7 +218,10 @@ function Menu:setSelectedIndex(index) if #self.menuItems >= self.selectedIndex then self.menuItems[self.selectedIndex]:setSelected(false) end - if self.firstActiveIndex > index then + if self.firstActiveIndex == nil then + -- first element that was added on an empty menu + self.yOffset = self.menuItemYOffsets[index] + elseif self.firstActiveIndex > index then self.yOffset = self.menuItemYOffsets[index] elseif self.lastActiveIndex < index then local currentIndex = 1 diff --git a/client/src/ui/StackPanel.lua b/client/src/ui/StackPanel.lua index da7a729b..1c463456 100644 --- a/client/src/ui/StackPanel.lua +++ b/client/src/ui/StackPanel.lua @@ -5,21 +5,26 @@ local tableUtils = require("common.lib.tableUtils") local GraphicsUtil = require("client.src.graphics.graphics_util") local DebugSettings = require("client.src.debug.DebugSettings") +---@class StackPanelOptions : UiElementOptions +---@field alignment "left"|"right"|"top"|"bottom" + -- StackPanel is a layouting element that stacks up all its children in one direction based on an alignment setting -- Useful for auto-aligning multiple ui elements that only know one of their dimensions ---@class StackPanel : UiElement +---@operator call(StackPanelOptions): StackPanel ---@field alignment "left"|"right"|"top"|"bottom" Direction in which children are stacked ---@field pixelsTaken number Tracks how many pixels are already taken in the stacking direction ---@field TYPE string Class type identifier -local StackPanel = class(function(stackPanel, options) +local StackPanel = class( +---@param stackPanel StackPanel +---@param options StackPanelOptions +function(stackPanel, options) ---@type "left"|"right"|"top"|"bottom" stackPanel.alignment = options.alignment ---@type number stackPanel.pixelsTaken = 0 end, -UiElement) - -StackPanel.TYPE = "StackPanel" +UiElement, "StackPanel") ---Applies positioning and sizing settings to a UI element based on the StackPanel's alignment ---@param uiElement UiElement The element to apply settings to diff --git a/client/src/ui/init.lua b/client/src/ui/init.lua index 2e47094d..1e101ad6 100644 --- a/client/src/ui/init.lua +++ b/client/src/ui/init.lua @@ -56,6 +56,8 @@ local ui = { Leaderboard = import("./Leaderboard"), ---@source LevelSlider.lua LevelSlider = import("./LevelSlider"), + ---@source LobbyChallengeButton.lua + LobbyChallengeButton = import("./LobbyChallengeButton"), ---@source Menu.lua Menu = import("./Menu"), ---@source MenuItem.lua diff --git a/common/network/ClientProtocol.lua b/common/network/ClientProtocol.lua index ebd327be..5aaa96b8 100644 --- a/common/network/ClientProtocol.lua +++ b/common/network/ClientProtocol.lua @@ -78,14 +78,15 @@ end ---@param senderId PublicPlayerID ---@param receiverId PublicPlayerID ---@param gameModeId GameModeID -function ClientMessages.challengePlayerV2(senderId, receiverId, gameModeId) +function ClientMessages.updateChallengeStatus(senderId, receiverId, gameModeId, challengeActive) local playerChallengeV2Message = { - gameRequestV2 = + challengeUpdate = { senderId = senderId, receiverId = receiverId, gameModeId = gameModeId, + challengeActive = challengeActive, } } diff --git a/common/network/ServerProtocol.lua b/common/network/ServerProtocol.lua index d80ca03a..f530128c 100644 --- a/common/network/ServerProtocol.lua +++ b/common/network/ServerProtocol.lua @@ -482,23 +482,27 @@ function ServerProtocol.taunt(player, type, index) } end -local challengeTemplate = { +local challengeUpdateTemplate = { sender = "player", senderId = nil, - type = "challenge", + type = "challengeUpdate", content = {} } ---@param sender ServerPlayer ---@param receiver ServerPlayer ---@param gameModeId GameModeID? nil if the challenged picks the game mode +---@param challengeActive boolean ---@return {messageType: table, messageText: ServerMessage} -function ServerProtocol.sendChallenge(sender, receiver, gameModeId) - local challengeMessage = challengeTemplate +function ServerProtocol.sendChallengeUpdate(sender, receiver, gameModeId, challengeActive) + local challengeMessage = challengeUpdateTemplate challengeMessage.senderId = sender.publicPlayerID challengeMessage.content.sender = sender.name + challengeMessage.content.senderId = sender.publicPlayerID challengeMessage.content.receiver = receiver.name + challengeMessage.content.receiverId = receiver.publicPlayerID challengeMessage.content.gameModeId = gameModeId + challengeMessage.content.challengeActive = challengeActive return { messageType = msgTypes.jsonMessage, @@ -506,30 +510,6 @@ function ServerProtocol.sendChallenge(sender, receiver, gameModeId) } end -local cancelChallengeTemplate = { - sender = "player", - senderId = nil, - type = "challengeCancelled", - content = {} -} - ----@param sender ServerPlayer ----@param receiver ServerPlayer ----@param gameModeId GameModeID ----@return {messageType: table, messageText: ServerMessage} -function ServerProtocol.cancelChallenge(sender, receiver, gameModeId) - local cancelChallengeMessage = cancelChallengeTemplate - cancelChallengeMessage.sender = sender.publicPlayerID - cancelChallengeMessage.content.sender = sender.name - cancelChallengeMessage.content.receiver = receiver.name - cancelChallengeMessage.content.gameModeId = gameModeId - - return { - messageType = msgTypes.jsonMessage, - messageText = cancelChallengeMessage - } -end - local abortGameTemplate = { sender = "room", senderId = nil, diff --git a/server/ClientMessages.lua b/server/ClientMessages.lua index 9165f520..7b7bbd80 100644 --- a/server/ClientMessages.lua +++ b/server/ClientMessages.lua @@ -13,6 +13,8 @@ function ClientMessages.sanitizeMessage(clientMessage) return ClientMessages.sanitizeLoginRequest(clientMessage) elseif clientMessage.game_request then return ClientMessages.sanitizeGameRequest(clientMessage) + elseif clientMessage.challengeUpdate then + return ClientMessages.sanitizeChallengeUpdate(clientMessage) elseif clientMessage.menu_state then return ClientMessages.sanitizeMenuState(clientMessage.menu_state) elseif clientMessage.spectate_request then @@ -123,6 +125,21 @@ function ClientMessages.sanitizeGameRequest(gameRequest) return sanitized end +function ClientMessages.sanitizeChallengeUpdate(message) + local sanitized = + { + challengeUpdate = + { + senderId = message.challengeUpdate.senderId, + receiverId = message.challengeUpdate.receiverId, + gameModeId = message.challengeUpdate.gameModeId, + challengeActive = message.challengeUpdate.challengeActive, + } + } + + return sanitized +end + function ClientMessages.sanitizeSpectateRequest(spectateRequest) local sanitized = { diff --git a/server/server.lua b/server/server.lua index e5b83058..ae4f862e 100644 --- a/server/server.lua +++ b/server/server.lua @@ -43,6 +43,7 @@ local time = os.time ---@field nameToConnectionIndex table mapping of player names to their unique connectionNumberIndex ---@field socketToConnectionIndex table mapping of sockets to their unique connectionNumberIndex ---@field connectionToPlayer table Mapping of connections to the player they send for +---@field publicIdToPlayer table Mapping of publicId to the logged in ServerPlayer ---@field playerToRoom table ---@field spectatorToRoom table ---@field nameToPlayer table @@ -65,6 +66,7 @@ local Server = class( self.nameToConnectionIndex = {} self.socketToConnectionIndex = {} self.connectionToPlayer = {} + self.publicIdToPlayer = {} self.playerToRoom = {} self.spectatorToRoom = {} self.nameToPlayer = {} @@ -317,51 +319,41 @@ end ---@param sender ServerPlayer ---@param receiver ServerPlayer ---@param gameModeId GameModeID -function Server:processGameRequest(sender, receiver, gameModeId) - logger.debug(string.format("%s challenges %s to a game of %s", sender.name, receiver.name, gameModeId)) - +---@param challengeActive boolean +function Server:processChallengeUpdate(sender, receiver, gameModeId, challengeActive) if sender and sender.state == "lobby" and receiver and receiver.state == "lobby" then + logger.debug(string.format("%s challenges %s to a game of %s", sender.name, receiver.name, gameModeId)) local previouslyProposedGameModes = self.proposals[receiver.publicPlayerID] and self.proposals[receiver.publicPlayerID][sender.publicPlayerID] - if previouslyProposedGameModes[gameModeId] then + if previouslyProposedGameModes and previouslyProposedGameModes[gameModeId] then self:create_room(GameModes.getPreset(gameModeId), sender, receiver) else -- no existing challenge for this game mode - self:registerChallenge(sender, receiver, gameModeId) + self:updateChallenge(sender, receiver, gameModeId, challengeActive) + receiver:sendJson(ServerProtocol.sendChallengeUpdate(sender, receiver, gameModeId, challengeActive)) end + else + -- this message won't be handled because one of the parties is no longer in lobby + -- related things would be handled in the state change / logout end end ---@param sender ServerPlayer ---@param receiver ServerPlayer ---@param gameModeId GameModeID -function Server:registerChallenge(sender, receiver, gameModeId) +---@param challengeActive boolean +function Server:updateChallenge(sender, receiver, gameModeId, challengeActive) local senderChallenges = self.proposals[sender.publicPlayerID] or {} senderChallenges[receiver.publicPlayerID] = senderChallenges[receiver.publicPlayerID] or {} - senderChallenges[receiver.publicPlayerID][gameModeId] = true + senderChallenges[receiver.publicPlayerID][gameModeId] = challengeActive self.proposals[sender.publicPlayerID] = senderChallenges - receiver:sendJson(ServerProtocol.sendChallenge(sender, receiver, gameModeId)) -end - ----@param sender ServerPlayer ----@param receiver ServerPlayer ----@param gameModeId GameModeID -function Server:cancelChallenge(sender, receiver, gameModeId) - local senderChallenges = self.proposals[sender.publicPlayerID] or {} - if not senderChallenges[receiver.publicPlayerID] then - -- can end up here if the server cleared out the challenges after the recipient accepted a different challenge or logged off - -- no handling needed in this case, the sender will already receive refreshed lobby data to reflect that - else - senderChallenges[receiver.publicPlayerID][gameModeId] = false - self.proposals[sender.publicPlayerID] = senderChallenges - - receiver:sendJson(ServerProtocol.cancelChallenge(sender, receiver, gameModeId)) - end end ---@param player ServerPlayer function Server:clearProposals(player) + -- blanket reset for the player self.proposals[player.publicPlayerID] = {} + -- reset all challenges to the player for _, challenges in pairs(self.proposals) do if challenges[player.publicPlayerID] then challenges[player.publicPlayerID] = nil @@ -663,9 +655,10 @@ function Server:processMessage(message, connection) if message.logout then self:closeConnection(connection, player.name .. " logged out") return false - elseif player.state == "lobby" and message.game_request then - if message.game_request.sender == player.name then - self:processGameRequest(player, self.nameToPlayer[message.game_request.receiver], message.game_request.gameModeId) + elseif player.state == "lobby" and message.challengeUpdate then + local receiver = self.publicIdToPlayer[message.challengeUpdate.receiverId] + if message.challengeUpdate.senderId == player.publicPlayerID and receiver then + self:processChallengeUpdate(player, receiver, message.challengeUpdate.gameModeId, message.challengeUpdate.challengeActive) return true end elseif player.state == "lobby" and message.roomRequest then @@ -735,7 +728,7 @@ function Server:broadCastLobbyIfChanged() for _, connection in pairs(self.connections) do local player = self.connectionToPlayer[connection] if player and player.state == "lobby" then - connection:sendJson(message) + --connection:sendJson(message) connection:sendJson(messageV2) end end @@ -809,6 +802,7 @@ function Server:login(connection, userId, name, ipAddress, port, engineVersion, player:updateSettings(loginMessage.playerSettings) self.nameToConnectionIndex[name] = connection.index self.connectionToPlayer[connection] = player + self.publicIdToPlayer[player.publicPlayerID] = player self.nameToPlayer[name] = player if self.leaderboard then self.leaderboard:update_timestamp(userId) @@ -928,6 +922,7 @@ function Server:closeConnection(connection, reason) if player then self:clearProposals(player) self:handleLeaveRoom(player, reason) + self.publicIdToPlayer[player.publicPlayerID] = nil self.playerToRoom[player] = nil self.spectatorToRoom[player] = nil self.nameToPlayer[player.name] = nil diff --git a/server/simplecsv.lua b/server/simplecsv.lua index e0658322..6d3ddbcd 100644 --- a/server/simplecsv.lua +++ b/server/simplecsv.lua @@ -76,7 +76,10 @@ function read(path, sep, tonum, null) sep = sep or "," null = null or "" local csvFile = {} - local file = assert(io.open(path, "r")) + local file = io.open(path, "r") + if not file then + return nil + end for line in file:lines() do fields = line:split(sep) if tonum then -- convert numeric fields to numbers diff --git a/serverLauncher.lua b/serverLauncher.lua index cbe779e2..9fc94c3e 100644 --- a/serverLauncher.lua +++ b/serverLauncher.lua @@ -21,10 +21,10 @@ end -- We must launch the server from the root directory so all the requires are the right path relatively. require("server.server_globals") -require("server.tests.ServerTests") -require("server.tests.LeaderboardTests") -require("server.tests.RoomTests") -require("server.tests.LoginTests") +-- require("server.tests.ServerTests") +-- require("server.tests.LeaderboardTests") +-- require("server.tests.RoomTests") +-- require("server.tests.LoginTests") local database = require("server.PADatabase") local Server = require("server.server") From 06c962807ee885e69ab3c4bc558fd2bd9cc74388 Mon Sep 17 00:00:00 2001 From: Endaris Date: Fri, 13 Feb 2026 17:59:06 +0100 Subject: [PATCH 07/18] fix submenu navigation for lobby and add better visual indication --- client/src/network/NetClient.lua | 12 -- client/src/scenes/Lobby.lua | 210 ++++++++++++++++--------- client/src/ui/Line.lua | 31 ++++ client/src/ui/LobbyChallengeButton.lua | 34 ++-- client/src/ui/ScrollContainer.lua | 1 + client/src/ui/ScrollMenu.lua | 151 ++++++++++++++++++ client/src/ui/TextButton.lua | 11 +- client/src/ui/UIElement.lua | 14 +- client/src/ui/init.lua | 4 + common/network/ClientProtocol.lua | 15 +- 10 files changed, 366 insertions(+), 117 deletions(-) create mode 100644 client/src/ui/Line.lua create mode 100644 client/src/ui/ScrollMenu.lua diff --git a/client/src/network/NetClient.lua b/client/src/network/NetClient.lua index ad225d5c..1befc05d 100644 --- a/client/src/network/NetClient.lua +++ b/client/src/network/NetClient.lua @@ -325,16 +325,6 @@ local function processInputMessages(self) end end -local function processGameRequest(self, gameRequestMessage) - if gameRequestMessage.game_request then - self.lobbyData.willingPlayers[gameRequestMessage.game_request.sender] = true - love.window.requestAttention() - SoundController:playSfx(themes[config.theme].sounds.notification) - -- this might be moot if the server sends a lobby update to everyone after receiving the challenge - self:emitSignal("lobbyStateUpdate", self.lobbyData) - end -end - ---@param self NetClient local function processChallengeUpdate(self, challengeUpdateMessage) if challengeUpdateMessage.challengeUpdate then @@ -393,7 +383,6 @@ local function createListeners(self) messageListeners.create_room = createListener(self, "create_room", start2pVsOnlineMatch) messageListeners.players = createListener(self, "unpaired", updateLobbyState) messageListeners.lobbyStateV2 = createListener(self, "lobbyStateV2", updateLobbyStateV2) - messageListeners.game_request = createListener(self, "game_request", processGameRequest) messageListeners.challengeUpdate = createListener(self, "challengeUpdate", processChallengeUpdate) messageListeners.menu_state = createListener(self, "menu_state", processMenuStateMessage) messageListeners.ranked_match_approved = createListener(self, "ranked_match_approved", processRankedStatusMessage) @@ -435,7 +424,6 @@ local NetClient = class(function(self) players = messageListeners.players, lobbyStateV2 = messageListeners.lobbyStateV2, create_room = messageListeners.create_room, - --game_request = messageListeners.game_request, challengeUpdate = messageListeners.challengeUpdate, } diff --git a/client/src/scenes/Lobby.lua b/client/src/scenes/Lobby.lua index 708524b3..7f397dfa 100644 --- a/client/src/scenes/Lobby.lua +++ b/client/src/scenes/Lobby.lua @@ -6,6 +6,7 @@ local util = require("common.lib.util") local NetClient = require("client.src.network.NetClient") local MessageTransition = require("client.src.scenes.Transitions.MessageTransition") local GameModes = require("common.data.GameModes") +local tableUtils = require("common.lib.tableUtils") -- expects a serverIp and serverPort as a param (unless already set in GAME.connected_server_ip & GAME.connected_server_port respectively) local Lobby = class(function(self, sceneParams) @@ -58,34 +59,53 @@ function Lobby:load(sceneParams) end function Lobby:initLobbyMenu() - local menuItems = { - ui.MenuItem.createMenuItem(self.lobbyMessage), - ui.MenuItem.createButtonMenuItem("mm_1_endless", nil, nil, function() + self.lobbyMenuWidth = 140 + self.onePlayerEndlessButton = ui.TextButton({ + label = ui.Label({text = "mm_1_endless"}), + width = self.lobbyMenuWidth, + onClick = function() GAME.netClient:requestRoom(GameModes.getPreset("ONE_PLAYER_ENDLESS")) - end), - ui.MenuItem.createButtonMenuItem("mm_1_time", nil, nil, function() + end + }) + self.onePlayerTimeAttackButton = ui.TextButton({ + label = ui.Label({text = "mm_1_time"}), + width = self.lobbyMenuWidth, + onClick = function() GAME.netClient:requestRoom(GameModes.getPreset("ONE_PLAYER_TIME_ATTACK")) - end), - ui.MenuItem.createButtonMenuItem("mm_1_vs", nil, nil, function() + end + }) + self.onePlayerVsButton = ui.TextButton({ + label = ui.Label({text = "mm_1_vs"}), + width = self.lobbyMenuWidth, + onClick = function() if GAME.localPlayer.settings.style ~= GameModes.Styles.MODERN then GAME.localPlayer:setStyle(GameModes.Styles.MODERN) GAME.netClient:sendPlayerSettings(GAME.localPlayer) end GAME.netClient:requestRoom(GameModes.getPreset("ONE_PLAYER_VS_SELF")) - end), - ui.MenuItem.createButtonMenuItem("lb_show_board", nil, nil, function() + end + }) + self.leaderboardToggleLabel = ui.Label({text = "lb_show_board"}) + self.showLeaderboardButton = ui.TextButton({ + label = self.leaderboardToggleLabel, + width = self.lobbyMenuWidth, + onClick = function() if self.leaderboard.hasFocus then self.leaderboard:yieldFocus() else self:toggleLeaderboard() end - end), - ui.MenuItem.createButtonMenuItem("lb_back", nil, nil, exitMenu) - } - self.leaderboardToggleLabel = menuItems[5].textButton.children[1] + end + }) + self.backButton = ui.TextButton({ + label = ui.Label({text = "lb_back"}), + width = self.lobbyMenuWidth, + onClick = exitMenu + }) + self.lobbyMenuStartingUp = true - self.lobbyMenu = ui.Menu.createCenteredMenu(menuItems) + self.lobbyMenu = ui.ScrollMenu({height = 540, width = 300, hAlign = "center", vAlign = "center"}) self.lobbyMenu.x = self.lobbyMenuXoffsetMap[false] self.uiRoot:addChild(self.lobbyMenu) @@ -177,15 +197,18 @@ function Lobby:createPlayerButtons(personalizedLobbyData) playerName = Lobby.getPlayerNameWithRating(publicId) end - local menuItem = ui.MenuItem.createButtonMenuItem(playerName, nil, false, - function(button) - self:openPlayerSubMenu(publicId, button) - end - ) - ui.Focusable(menuItem.textButton) - ui.FocusDirector(menuItem.textButton) - menuItem.player = player - playerButtons[#playerButtons+1] = menuItem + local button = ui.TextButton({ + label = ui.Label({text = playerName, translate = false}), + width = self.lobbyMenuWidth, + onClick = + function(button) + self:openPlayerSubMenu(publicId, button) + GAME.theme:playValidationSfx() + end + }) + button.lobbyType = "player" + button.player = player + playerButtons[#playerButtons+1] = button --end end @@ -217,9 +240,14 @@ function Lobby:createRoomButtons(personalizedLobbyData) roomName = loc("lb_spectate") .. " " .. playerStrings[1] .. " vs " .. playerStrings[2] .. " (" .. room.state .. ")" end - local menuItem = ui.MenuItem.createButtonMenuItem(roomName, nil, false, self:requestSpectateFunction(room)) - menuItem.room = room - roomButtons[#roomButtons+1] = menuItem + local button = ui.TextButton({ + label = ui.Label({text = roomName, translate = false}), + width = self.lobbyMenuWidth, + onClick = function() self:requestSpectateFunction(room) end + }) + button.lobbyType = "room" + button.room = room + roomButtons[#roomButtons+1] = button end table.sort(roomButtons, function(a, b) @@ -234,60 +262,49 @@ end function Lobby:openPlayerSubMenu(playerId, button) if self.playerSubMenu then self.playerSubMenu:yieldFocus() - --self.playerSubMenu:detach() - self.playerSubMenu = nil end local lobbyDataV2 = GAME.netClient.lobbyDataV2 local x, y = button:getScreenPos() - local subMenu = ui.Menu({ - x = x + button.width + 8, + local subMenu = ui.ScrollMenu({ + x = x + self.lobbyMenu.width + 8, y = y, hAlign = "left", vAlign = "top", - height = 0, + height = 88, width = 120, - menuItems = {}, + padding = 0, + childGap = 8, }) subMenu.playerId = playerId - local backButton = ui.TextButton({ - label = ui.Label({text = "back"}), - width = 120, - onClick = function() - subMenu:yieldFocus() - self.playerSubMenu = nil - end}) - local vsButton = ui.LobbyChallengeButton({ gameModeId = "TWO_PLAYER_VS", iconSize = 16, playerId = playerId, - text = "vs", + label = ui.Label({text = "vs"}), acceptImage = GAME.theme:comboImage(4), proposeImage = GAME.theme:comboImage(5), withdrawImage = GAME.theme:comboImage(6), - height = 24, width = 120 }) - subMenu:addMenuItem(1, ui.MenuItem.createMenuItem(vsButton)) + subMenu:addChild(vsButton) local timeAttackButton = ui.LobbyChallengeButton({ gameModeId = "TWO_PLAYER_TIME_ATTACK", iconSize = 16, playerId = playerId, - text = "gm_time_attack", + label = ui.Label({text = "gm_time_attack"}), acceptImage = GAME.theme:comboImage(4), proposeImage = GAME.theme:comboImage(5), withdrawImage = GAME.theme:comboImage(6), - height = 24, width = 120 }) - subMenu:addMenuItem(2, ui.MenuItem.createMenuItem(timeAttackButton)) + subMenu:addChild(timeAttackButton) if lobbyDataV2.outgoingChallenges[playerId] then if lobbyDataV2.outgoingChallenges[playerId]["TWO_PLAYER_VS"] == true then @@ -307,55 +324,109 @@ function Lobby:openPlayerSubMenu(playerId, button) end end - subMenu:addMenuItem(3, ui.MenuItem.createMenuItem(backButton)) + local backButton = ui.TextButton({ + label = ui.Label({text = "back"}), + width = 120, + onClick = function() + GAME.theme:playCancelSfx() + subMenu:yieldFocus() + end}) + + subMenu:addChild(backButton) + subMenu:select(vsButton) self.playerSubMenu = subMenu - button:setFocus(subMenu, function() - subMenu:detach() - button:yieldFocus() + local subMenuLine = ui.Line({ + x = x + button.width + 8, + y = y + button.height / 2, + height = button.height, + points = {x + button.width + 8, y + button.height / 2, subMenu.x - 8, y + button.height / 2} + }) + self.subMenuLine = subMenuLine + + self.lobbyMenu:setFocus(subMenu, function() + self.playerSubMenu:detach() + self.playerSubMenu = nil + self.subMenuLine:detach() + self.subMenuLine = nil end) self.uiRoot:addChild(subMenu) + self.uiRoot:addChild(subMenuLine) end -- rebuilds the UI based on the new lobby information ---@param lobbyDataV2 PersonalizedLobbyDataV2 function Lobby:onLobbyStateUpdate(lobbyDataV2) - local previousText - if self.lobbyMenu.menuItems[self.lobbyMenu.selectedIndex].textButton then - previousText = self.lobbyMenu.menuItems[self.lobbyMenu.selectedIndex].textButton.children[1].text - end - local desiredIndex = self.lobbyMenu.selectedIndex + local copy = shallowcpy(self.lobbyMenu.children) + local previousIndex = self.lobbyMenu.selectedIndex + + self.lobbyMenu.selectedIndex = nil - -- cleanup previous lobby menu - while #self.lobbyMenu.menuItems > 6 do - self.lobbyMenu:removeMenuItemAtIndex(2) + for i, child in ipairs(self.lobbyMenu.children) do + child:detach() end - self.lobbyMenu:setSelectedIndex(1) + + self.lobbyMenu:addChild(self.lobbyMessage) local playerButtons = self:createPlayerButtons(lobbyDataV2) for _, button in ipairs(playerButtons) do - self.lobbyMenu:addMenuItem(2, button) + self.lobbyMenu:addChild(button) end local roomButtons = self:createRoomButtons(lobbyDataV2) for _, button in ipairs(roomButtons) do - self.lobbyMenu:addMenuItem(2, button) + self.lobbyMenu:addChild(button) end + self.lobbyMenu:addChild(self.onePlayerEndlessButton) + self.lobbyMenu:addChild(self.onePlayerTimeAttackButton) + self.lobbyMenu:addChild(self.onePlayerVsButton) + self.lobbyMenu:addChild(self.showLeaderboardButton) + self.lobbyMenu:addChild(self.backButton) + if self.lobbyMenuStartingUp then - self.lobbyMenu:setSelectedIndex(2) + self.lobbyMenu:select(self.lobbyMenu.children[2]) self.lobbyMenuStartingUp = false - else - for i = 1, #self.lobbyMenu.menuItems do - if self.lobbyMenu.menuItems[i].textButton and self.lobbyMenu.menuItems[i].textButton.children[1].text == previousText then - desiredIndex = i - break + elseif previousIndex then + if copy[previousIndex].lobbyType then + local prev = copy[previousIndex] + if prev.lobbyType == "player" then + for i, playerButton in ipairs(playerButtons) do + if prev.player.publicId == playerButton.player.publicId then + self.lobbyMenu:select(playerButton) + break + end + end + elseif prev.lobbyType == "room" then + for i, roomButton in ipairs(roomButtons) do + if prev.room.roomNumber == roomButton.room.roomNumber then + self.lobbyMenu:select(roomButton) + break + end + end + end + elseif previousIndex == 1 then + self.lobbyMenu:select(self.lobbyMessage) + else + local reverseOffset = #copy - previousIndex + if reverseOffset == 0 then + self.lobbyMenu:select(self.backButton) + elseif reverseOffset == 1 then + self.lobbyMenu:select(self.showLeaderboardButton) + elseif reverseOffset == 2 then + self.lobbyMenu:select(self.onePlayerVsButton) + elseif reverseOffset == 3 then + self.lobbyMenu:select(self.onePlayerTimeAttackButton) + elseif reverseOffset == 4 then + self.lobbyMenu:select(self.onePlayerEndlessButton) + else + logger.warn("Unexpectedly couldn't find previous non-player/room selection, resetting to 1") + self.lobbyMenu:select(self.lobbyMessage) end end - self.lobbyMenu:setSelectedIndex(util.bound(2, desiredIndex, #self.lobbyMenu.menuItems)) end if self.playerSubMenu then @@ -375,7 +446,6 @@ function Lobby:onLobbyStateUpdate(lobbyDataV2) item:setState(item.challengeStates.NEUTRAL) end end - end end end @@ -399,7 +469,7 @@ function Lobby:updateSelf(dt) self.lobbyMessage:setText("lb_select_player", nil, true) end end - self.lobbyMenu:receiveInputs() + self.lobbyMenu:receiveInputs(GAME.input) end end diff --git a/client/src/ui/Line.lua b/client/src/ui/Line.lua new file mode 100644 index 00000000..6c594148 --- /dev/null +++ b/client/src/ui/Line.lua @@ -0,0 +1,31 @@ +local import = require("common.lib.import") +local UiElement = import("./UIElement") +local class = require("common.lib.class") +local GraphicsUtil = require("client.src.graphics.graphics_util") + +---@class LineOptions : UiElementOptions +---@field points number[] +---@field lineWidth integer? +---@field color number[]? + +---@class Line : UiElement +---@operator call(LineOptions): Line +---@field points number[] +---@field lineWidth integer +---@field color number[] +local Line = class( +function(self, options) + self.points = options.points + self.lineWidth = options.lineWidth or 2 + self.color = options.color or {1, 1, 1, 0.6} +end, +UiElement) + +function Line:drawSelf() + love.graphics.setColor(self.color) + love.graphics.setLineWidth(self.lineWidth) + love.graphics.line(self.points) + GraphicsUtil.setColor(1, 1, 1, 1) +end + +return Line \ No newline at end of file diff --git a/client/src/ui/LobbyChallengeButton.lua b/client/src/ui/LobbyChallengeButton.lua index 337ea53a..5d32a8fa 100644 --- a/client/src/ui/LobbyChallengeButton.lua +++ b/client/src/ui/LobbyChallengeButton.lua @@ -1,12 +1,12 @@ local import = require("common.lib.import") -local Button = import("./Button") +local TextButton = import("./TextButton") local Label = import("./Label") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") local GameModes = require("common.data.GameModes") ---@class LobbyChallengeButtonOptions : ButtonOptions ----@field text string +---@field label Label ---@field proposeImage love.Texture ---@field acceptImage love.Texture ---@field withdrawImage love.Texture @@ -15,14 +15,14 @@ local GameModes = require("common.data.GameModes") ---@field playerId PublicPlayerID ----@class LobbyChallengeButton : Button +---@class LobbyChallengeButton : TextButton ---@operator call(LobbyChallengeButtonOptions): LobbyChallengeButton ---@field proposeImage love.Texture ---@field acceptImage love.Texture ---@field withdrawImage love.Texture ---@field challengeState ChallengeState ---@field iconSize integer ----@field text string +---@field label Label ---@field gameModeId GameModeID ---@field playerId PublicPlayerID ---@overload fun(options: LobbyChallengeButtonOptions): LobbyChallengeButton @@ -30,7 +30,7 @@ local LobbyChallengeButton = class( ---@param self LobbyChallengeButton ---@param options LobbyChallengeButtonOptions function(self, options) - self.text = options.text + self.label = options.label self.challengeState = self.challengeStates.NEUTRAL self.proposeImage = options.proposeImage self.acceptImage = options.acceptImage @@ -38,8 +38,13 @@ function(self, options) self.iconSize = options.iconSize self.playerId = options.playerId self.gameModeId = options.gameModeId + + local width, _ = self.label:getEffectiveDimensions() + self.width = math.max(width + self.WIDTH_PADDING * 4 + self.iconSize, self.width) + self.label.x = self.WIDTH_PADDING * 3 + self.iconSize + self.label.hAlign = "left" end, -Button, "LobbyChallengeButton") +TextButton, "LobbyChallengeButton") LobbyChallengeButton.TYPE = "LobbyChallengeButton" @@ -65,12 +70,21 @@ function LobbyChallengeButton:onClick() end end -local padding = 4 +function LobbyChallengeButton:receiveInputs(input) + if input.isDown["MenuSelect"] then + self:onClick() + -- this is a really stupid way to make sure you can activate back buttons with escape + elseif input.isDown["MenuEsc"] then + self:onClick() + end +end + +local padding = 4 function LobbyChallengeButton:drawSelf() self:drawBackground() self:drawOutline() - + local icon if self.challengeState == LobbyChallengeButton.challengeStates.NEUTRAL then icon = self.proposeImage @@ -79,12 +93,12 @@ function LobbyChallengeButton:drawSelf() elseif self.challengeState == LobbyChallengeButton.challengeStates.PROPOSING then icon = self.withdrawImage end - + local imageWidth, imageHeight = icon:getDimensions() local scale = math.min(self.iconSize / imageWidth, self.iconSize / imageHeight) GraphicsUtil.draw(icon, self.x + padding, self.y + padding, 0, scale, scale) - GraphicsUtil.printf(loc(self.text), self.x + padding * 2 + self.iconSize, self.y + padding, self.width - (padding * 2 + self.iconSize), "left") + --GraphicsUtil.printf(loc(self.text), self.x + padding * 2 + self.iconSize, self.y + padding, self.width - (padding * 2 + self.iconSize), "left") end return LobbyChallengeButton \ No newline at end of file diff --git a/client/src/ui/ScrollContainer.lua b/client/src/ui/ScrollContainer.lua index fe674170..568f8610 100644 --- a/client/src/ui/ScrollContainer.lua +++ b/client/src/ui/ScrollContainer.lua @@ -105,6 +105,7 @@ local loveMajor = love.getVersion() function ScrollContainer:draw() if self.isVisible then + self:drawDebugOutline() -- make a stencil according to width/height if loveMajor >= 12 then love.graphics.setStencilMode("draw", 1) diff --git a/client/src/ui/ScrollMenu.lua b/client/src/ui/ScrollMenu.lua new file mode 100644 index 00000000..37102370 --- /dev/null +++ b/client/src/ui/ScrollMenu.lua @@ -0,0 +1,151 @@ +local import = require("common.lib.import") +local ScrollContainer = import("./ScrollContainer") +local class = require("common.lib.class") +local util = require("common.lib.util") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local tableUtils = require("common.lib.tableUtils") +local FocusDirector = import("./FocusDirector") + +---@class ScrollMenu : ScrollContainer +local ScrollMenu = class( + ---@param self ScrollMenu + ---@param options ScrollContainerOptions +function(self, options) + self.selectedIndex = nil + self.scrollOrientation = "vertical" + self.childGap = options.childGap or 8 + self.padding = options.padding or 32 +end, +ScrollContainer) + +ScrollMenu.TYPE = "ScrollMenu" + +FocusDirector(ScrollMenu) + +function ScrollMenu:onRelease(x, y, duration) + if self.touchedChild and self.focused and self.focused ~= self.touchedChild then + self.focused:yieldFocus() + end + ScrollContainer.onRelease(self, x, y, duration) +end + +function ScrollMenu:selectPrevious() + if not self.selectedIndex then + return + end + + local child + for i = self.selectedIndex - 1, self.selectedIndex - #self.children, -1 do + local index = wrap(1, i, #self.children) + child = self.children[index] + if child.receiveInputs and child.isEnabled and child.isVisible then + self.selectedIndex = index + break + end + end + self:keepVisible(-child.y, child.height) + GAME.theme:playMoveSfx() +end + +function ScrollMenu:selectNext() + if not self.selectedIndex then + return + end + + local child + for i = self.selectedIndex + 1, self.selectedIndex + #self.children do + local index = wrap(1, i, #self.children) + child = self.children[index] + if child.receiveInputs and child.isEnabled and child.isVisible then + self.selectedIndex = index + break + end + end + self:keepVisible(-child.y, child.height) + GAME.theme:playMoveSfx() +end + +function ScrollMenu:selectLast() + self.selectedIndex = self:getLastIndex() + local child = self.children[self.selectedIndex] + self:keepVisible(-child.y, child.height) +end + +function ScrollMenu:getLastIndex() + for i = #self.children, 1, -1 do + local child = self.children[i] + if child.receiveInputs and child.isEnabled and child.isVisible then + return i + end + end +end + +function ScrollMenu:select(uiElement) + for i, child in ipairs(self.children) do + if child == uiElement then + self.selectedIndex = i + end + end +end + +---@param inputs InputConfiguration +---@param dt number? +function ScrollMenu:receiveInputs(inputs, dt) + if not self.isEnabled or not self.selectedIndex then + return + end + + if self.focused then + self.focused:receiveInputs(inputs, dt) + else + local selectedElement = self.children[self.selectedIndex] + + if inputs.isDown["MenuEsc"] then + if self:getLastIndex() ~= self.selectedIndex then + self:selectLast() + GAME.theme:playCancelSfx() + else + selectedElement:receiveInputs(inputs, dt) + end + elseif inputs:isPressedWithRepeat("MenuUp") then + self:selectPrevious() + elseif inputs:isPressedWithRepeat("MenuDown") then + self:selectNext() + else + if inputs.isDown["MenuSelect"] and selectedElement.isFocusable then + self:setFocus(selectedElement) + else + selectedElement:receiveInputs(inputs, dt) + end + end + end +end + +---@param uiElement UiElement +function ScrollMenu:addChild(uiElement) + local lastChild = self.children[#self.children] + local newIndex = #self.children + 1 + local y + if lastChild then + y = lastChild.y + lastChild.height + self.childGap + else + y = self.padding + end + uiElement.y = y + ScrollContainer.addChild(self, uiElement) +end + +function ScrollMenu:drawChildren() + for i, uiElement in ipairs(self.children) do + if uiElement.isVisible then + if self.selectedIndex and i == self.selectedIndex then + GraphicsUtil.setColor(0.6, 0.6, 1, 0.5) + love.graphics.rectangle("fill", uiElement.x, uiElement.y, uiElement.width, uiElement.height) + love.graphics.rectangle("line", uiElement.x, uiElement.y, uiElement.width, uiElement.height) + end + uiElement:draw() + end + end +end + +return ScrollMenu \ No newline at end of file diff --git a/client/src/ui/TextButton.lua b/client/src/ui/TextButton.lua index f4c7bb4c..0992ac74 100644 --- a/client/src/ui/TextButton.lua +++ b/client/src/ui/TextButton.lua @@ -2,9 +2,6 @@ local PATH = (...):gsub('%.[^%.]+$', '') local Button = require(PATH .. ".Button") local class = require("common.lib.class") -local TEXT_WIDTH_PADDING = 6 -local TEXT_HEIGHT_PADDING = 6 - ---@class TextButtonOptions : ButtonOptions ---@field label Label @@ -20,10 +17,12 @@ local TextButton = class(function(self, options) -- stretch to fit text local width, height = self.label:getEffectiveDimensions() - self.width = math.max(width + TEXT_WIDTH_PADDING, self.width) - self.height = math.max(height + TEXT_HEIGHT_PADDING, self.height) - + self.width = math.max(width + self.WIDTH_PADDING * 2, self.width) + self.height = math.max(height + self.HEIGHT_PADDING * 2, self.height) end, Button) TextButton.TYPE = "TextButton" +TextButton.WIDTH_PADDING = 3 +TextButton.HEIGHT_PADDING = 3 + return TextButton diff --git a/client/src/ui/UIElement.lua b/client/src/ui/UIElement.lua index 61f3049f..29ef02c3 100644 --- a/client/src/ui/UIElement.lua +++ b/client/src/ui/UIElement.lua @@ -163,13 +163,17 @@ function UIElement:updateChildren(dt) end end +function UIElement:drawDebugOutline() + if DebugSettings.showUIElementBorders() then + GraphicsUtil.setColor(0, 0, 1, 1) + GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) + GraphicsUtil.setColor(1, 1, 1, 1) + end +end + function UIElement:draw() if self.isVisible then - if DebugSettings.showUIElementBorders() then - GraphicsUtil.setColor(0, 0, 1, 1) - GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) - GraphicsUtil.setColor(1, 1, 1, 1) - end + self:drawDebugOutline() self:drawSelf() -- if DEBUG_ENABLED then -- GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height, 1, 1, 1, 0.5) diff --git a/client/src/ui/init.lua b/client/src/ui/init.lua index 1e101ad6..6d45be17 100644 --- a/client/src/ui/init.lua +++ b/client/src/ui/init.lua @@ -56,6 +56,8 @@ local ui = { Leaderboard = import("./Leaderboard"), ---@source LevelSlider.lua LevelSlider = import("./LevelSlider"), + ---@source Line.lua + Line = import("./Line"), ---@source LobbyChallengeButton.lua LobbyChallengeButton = import("./LobbyChallengeButton"), ---@source Menu.lua @@ -72,6 +74,8 @@ local ui = { PixelFontLabel = import("./PixelFontLabel"), ---@source ScrollContainer.lua ScrollContainer = import("./ScrollContainer"), + ---@source ScrollMenu.lua + ScrollMenu = import("./ScrollMenu"), ---@source ScrollText.lua ScrollText = import("./ScrollText"), ---@source Slider.lua diff --git a/common/network/ClientProtocol.lua b/common/network/ClientProtocol.lua index 5aaa96b8..c6f118b1 100644 --- a/common/network/ClientProtocol.lua +++ b/common/network/ClientProtocol.lua @@ -59,20 +59,7 @@ end ---@param receiverName string ---@param gameModeId GameModeID? nil if the challenged picks the game mode function ClientMessages.challengePlayer(senderName, receiverName, gameModeId) - local playerChallengeMessage = - { - game_request = - { - sender = senderName, - receiver = receiverName, - gameModeId = gameModeId, - } - } - - return { - messageType = msgTypes.jsonMessage, - messageText = playerChallengeMessage, - } + error("game_request has been retired") end ---@param senderId PublicPlayerID From d94aa5da30461d1dde0afd409c06a0475e325479 Mon Sep 17 00:00:00 2001 From: Endaris Date: Sat, 14 Feb 2026 00:02:29 +0100 Subject: [PATCH 08/18] smooth out presentation and add icons for lobbychallengebuttons --- .../checkbox_checked@2x.png | Bin 0 -> 2245 bytes .../checkbox_unchecked@2x.png | Bin 0 -> 953 bytes .../themes/Panel Attack Modern/fight@2x.png | Bin 0 -> 2730 bytes .../Panel Attack Modern/stopwatch@2x.png | Bin 0 -> 2700 bytes client/src/mods/Theme.lua | 16 +++++ client/src/network/NetClient.lua | 13 +++- client/src/scenes/Lobby.lua | 59 ++++++++++-------- client/src/ui/Line.lua | 4 ++ client/src/ui/LobbyChallengeButton.lua | 6 +- client/src/ui/ScrollMenu.lua | 4 ++ 10 files changed, 68 insertions(+), 34 deletions(-) create mode 100644 client/assets/themes/Panel Attack Modern/checkbox_checked@2x.png create mode 100644 client/assets/themes/Panel Attack Modern/checkbox_unchecked@2x.png create mode 100644 client/assets/themes/Panel Attack Modern/fight@2x.png create mode 100644 client/assets/themes/Panel Attack Modern/stopwatch@2x.png diff --git a/client/assets/themes/Panel Attack Modern/checkbox_checked@2x.png b/client/assets/themes/Panel Attack Modern/checkbox_checked@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9d43f84faeff65ad6e853a3c35e3a18a84d5dbd0 GIT binary patch literal 2245 zcmV;$2s-zPP)z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy32;bRa{vG?BLDy{BLR4&KXw2B2rfxPK~z}7tyf!9Q&|@N_C7nw zNdhFq$fdDEA}Tr^BOqdrC@8#mnexFZEk~c`ZGMM8!N+-MU#2X_vV8JZV!;SH2&f=f zcqyY2E=mL>Gm?{V&e>}o+$ViHrR~&ouc}=;=Twq!t+m(s_O}>58*?oUfKWSkz5joV z)RF*f0G8UF_as~Z9DoOa4}h+gM>+Ca9IyeH019dq)w6_()B*Pb2m%NJh^RfNzT;$q z{zqWuirfOQ1|SFEBY;))Y%1rV0q_IZ2;dI@4v&tG{M@N79J%DZ7Cd;zOG)-DxUnkQv$+9f6ZJS0$MlJyOBY*|~A*b4P0HG~gwse;t zW+sz)HaQ|*M@K~}m8xA>SXc>%!^;f~4I2O;5C{m>>KXuE&I2VIA0NN3B41SPW4NLR z0OFi80Gx<400AQMBoc|p{{8#69z1w(Z!(!o)z;S5+P3YnZ5xF`0UnPB4!;l)6J=>E z*~N<&pQyBdQsC?A>V8gzB`bedh0ueC)f`|eDs*fBwlGwk0|DN5u zcegh-Hr7{EROpN`W*CNL7={D>i(tjCu$irvbgEbg0U}}!I1vdV@&gE^QmOB{y1I6@ zwYBYz#bWirV9*Ny`F!4*nVDJToY(2P4$e8;ndDz2aQeB%h=>!BMnufAET(oYrSvEc zsgB3v-yb`6tYz=sy`70fB3@Zp>0^wsVzFq?&dx56jEuY$LO|Db2qB!{Bx4NxOW-P1 z!gVouiO5St!nSQqO6gTa7fB=%vE#>&w{&%N9cpfF{vjL=2RY}=vMia+W(+}&a0|NsOhlhuYm6et7`Fzkc&3#RmYJdSK zffot|96NSwQ!<&XuB@yy=H}++w{6=td*#ZNWz`LVcs$(DgKsR8<&Z2Cn8xRes6X(FG`qo=25 zS2CHb(RE#9v)P$&I6U&=#fzz_sj0PCELPRo*}1K^x3@EyOt#k5)ot*4y_%F#=5o1w zI-P$0;K74`-nen&$-=^d0YDQ%fOC$|y!EpQ#u!uW1R{cM+b~TN&CSi56NyBl*X#9E zS64SO#x}fv|2}LO#=BH1747csZfR?4YiVd`sPXxH8WGW_PoLIbzkdC8WMpJ$U|?Y4 z>C>lk>b5PGfzwyp(CGl5&*$@gHW3bo!%a<1sh*ym3L!)$8jXhA+uOIrTO1RrKvJ4=+!B z$pQKtGC2+iA>i}*Fg7+etLr-3zI}UBBoe6-Lhxub>JJ10KE@dQem`f7u|lC>&Ckz& zc=YJe^0KL_xEF=h~v2Y~nF$&;b4v7MU-BgBs_Yt*@`!@7}#zo|>9^a_7#S`}gkMn||}=&3ndJo`|?2s?gc9 zXH#EM%MdQ{C>dkWGz~tV4+jn$*naNZxo4(nJ~2&G?%cU^M_pZA*z5Ibj4_y|DHj(P zKTb_eP4@Tq4-X9uz071X%ZxEoMXoFD_g!miYiF4Q(k;tN(pgGran6ONX=rY4-g4>E zr30r=pB^#{W7;qbYH4ZNT2oUK)-;VREiL6HCnulXym|BSwQJYr78VxP0r-e0tOBb7 z@ZI?M_}N%2wxfhBrRmlcLV05XSj**dnMfqE**S`R`}Y0V*Vh*v92|V!*Vng@&1S8} z#>Q&SIh&uKUw-i5!P{%st`)M`Y?Tri765kgYSz@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy32;bRa{vG?BLDy{BLR4&KXw2B10qR8K~z}7?U%hy8$l4qe?Ffb zz9gGOB!NgSf+-RTd`%RI0*Q(za3qUoqe7PAhoB?HH3t|2!Q|?$!yjmTM4j$9Pk2|laUie zW>FvjhJY{Qk9>!SrABxjSOf~dOPLpCkMTq_rQnRRd<0wrec%%4$@fr{BMZy}E5Ju! z&vo5{ZnxVEf?yEMNI?(`y4`Nmb=?DCPh_kB3*uq&KoKYdd(CF^D0(7lHk(JH#D;iz z9?|42@X2-EgXoD#Jng`b@kN0s zAs|gQ*Nmf&pQ^&FNE1|Wj!e#KzU&(-wU4S9mlTXw!m2l3;?Lt z>!;%OD-k{r1xCVr6dQVNAZM z?iBaOD`2F`u;G)MHfa>52#qGx{`edvq-`{f0@Dhe>8!;w(|-jfDUeC35%<6Z6VF-` z{>vmV%G^U}VwKfA?y3W}!iE|%MpGye$Cvgr9vEXsozO!c{#HL6opq3TEIbH(Z0Bgc4 zvC7)IW`|)oG?<`NDt+)gZ(lIM!YqJ9KgD4f4z(e+Xfp(^`~Cj;^78U~v$;~K?6z91 z)yITny4EKh7qI-Sm=H71=-M>i&$x-rqYYg)4+@Jifcx)&=} zC!z_J5S3X&x${2IiMM9)s+E%no|icvtAr2fi~|WcUvQ!y&jMpj5bJ1t)x6Hyo@=nG bVIF@0F8yDOKJq=H00000NkvXXu0mjfkx!Ti literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/fight@2x.png b/client/assets/themes/Panel Attack Modern/fight@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..7b8504590d94b1a6e3a9aa1c50c55030a2646019 GIT binary patch literal 2730 zcmV;b3RU%qP)z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy32;bRa{vG?BLDy{BLR4&KXw2B3KL00K~z}7byr<%8&wwm?$7*q z#vXeTJHd7HXW}GElm3O$E&EbYX;)NTc-a+-paKbj#1k((lo#F+4@f)$BBkO1g%uJl zbR%_HsoSQ3lF+22EhcH=q-~ruapKx>6WcR$_n~*P)2?=;D@U=J;s>eoC{S|!DQNl!8rg` z2%!Pc0H^>sA%sbX1OWUr%R^;g4nUFis&*v*+ zV`E!Fh??hl0)Xc@4tU`17y#wSkt3nkUw?g9cXxMZBof)Rv9a-RVq)Unr=NbhIx{m< z5kfo!utf-|0dOP$F5fr=z{q4W=E;*M69WSSDciP2u0h~)FlMjRtbwY?MuR(CxYDA`yrPFC65{c}x zEbEz2DAc8@Y8zwhsbj~E#kRM%e?D{O%);X0;s$^+fT{$b0f@A=w%V`0`fA&;W5*74 zbaXsxSyn4!Oap*OBr=gsr*8q+lrKvF5JoAbvWPeU)wb=>fddDUwr!_1O?!%SuB)nQ z8HQoUVlh?M^;#~M6Qxp#$-pLn*zVoCn_hY4m8V~L;f3eAy1M=li^Wwg@4P-%UXP3;@@4T}>nsN<%|K#4wDws;U;HRM9joY#2r~8jY%& zrq$=?=jrC=W(a_lOeW)lgM;bg$B#eT)6+9x+jfdErg)xLFO^CQ3kwTZMn^}lUAlB> z?f(7yTZE80A>$_mfrawHrM*Q3#>ZWu;P)3h*SjO)5?nx+}GEKB2@ zV}5>KH%-%i`Q?{ePnAG%85Jos0wjz;8h%u)0_V&h4oH)_D zckkZbcs$jqAD_@L?V$yA`$8B?M?Re^|i<2@dnO0b6wXh6bh@i zZrz$ZfByW>Uw--J?Zw5#RR9kNArIv&ad$?urfEKT1c6WhtW+xTTrLOBxf_i}HPbXB zx~}V-a~=wXw8q9pE168%wr!i7bM82fySBDgn4X@#`Q3Nl{qogUU(MgWdv_JU27rp6 zeoCoFDHW7bFkRP)=Xpd927iV#f25YnWi6M>aY`vH%L+vzk#Hy!QWZsEx~_9w*EwU1 zI*#M6uCA7*rlxM6J9ln+czAd*pU+0%COioVzJ)6xIv)OD#a)twd2qB2)d4fM7K72}5 zJ32bdo}Qi~O-)VDgu~%VTU*;^EEa1pP19hEk$S!EmdoXu9J4CtoZ7Z+_Vx9(?%usS z+}PL{HVnh;>FK%G(a|xH&1Or1EKngI#uy`>=lR8__~UhUcGl|a>!U3#Ev?~jxY@F- zXeboYD5a!cuevxxymNqm^qlRHv=!;CRrC=}*KMn*cIq7+9i5t{>4XsCd7dZ~3hNUS6Ek0b z{q@ZB^z?n(w%x|YM#C_S5S34@R@=UK@#4Qf{P4r<^78Vg1W=Vc;1?(O6UE1+iqQe+ zhYugNT3T9~6-Ckfs&yQvcIVEW#qYoW{>J(9=hp#1Rn_vYUAr{fwj+!&9RL(X(OOzs znhzg7Y)wv1>e8nwW7Pe@$AiJ8sL%j}-g@gTdvI`YPbQPu!x&?ZgwtW0HjhWmP(~e&N%66GG}z zO$kN_*(p4ke(SyW-b3#Dyuhc-1zzG)vH%++eUM9b2=7_&5w_d z-%KWx58r+F-QILM{oKmR%FQ2s_~Bx;T3xp+%QQ_ho=7B`+S}W!4Gj(X%a<>g1K03~ zj0hoIR;LQUc;}sW5^ucm#_^t>p1tO?YIAW|NZxGm&@h5&CSi5iA18wG|gl@9&c}JYb$fk zXMg(Xr;5)906b7iRYHhK2#EtopFVy1c&Stx^E}US9A|TOcJ`Z7r%t^I;3WWm25|J~ z(W8F^a1g*=c@E0|9hJX%>C~xHZ_dune&aaKrssK1sZ<&p8X7td;AsF&gb<4m5@J64 z`GTaUr-yWQcJfdtWEBd9@hexZ{Nv1-Gq;zQmp1?u0hDsNTnRu$o-F{?TrRgI|K0+y zU8z*cZEbDqi9`a7vFg&&(#VAi7bZtXN6V6u9YTo5sK4IGs=R#pvTK^=-D0sge(~bP zAHVqGi-qy=@gjhd)N{Tn-X?_9B*D}G_@@e>y1cwxy?5_kVS9UfdU<(ybYx`Y+6Nze zuqJJH01!n{nCrU1g+!N~ph!@TY@3>2JpQH=AQ$okllmQ{$hH&G&vrl&IfM|8GsdJ$ z_go=FO-?9B4kpNT%#p`mvBVQBwJkW*eq8w?jvt&-Dmdp{NJ8>_mXbmI ztJ&G>{3SE+8Qise5S|3$$zVZ0Za@!sj4^OUQQ&!=pM~J=Mgp6y7YyvL>One|+(H7` k5CQ!UY3~KZ2sr2BKdoy$t{r~#sQ>@~07*qoM6N<$f*9-_nE(I) literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/stopwatch@2x.png b/client/assets/themes/Panel Attack Modern/stopwatch@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..239cee4d6f229e1115d8cdf8642882a0eef25437 GIT binary patch literal 2700 zcmWkw2|U!>7r!%OOSXCwQI^IwNs^JJ45K$>9^)Z~5t+ZWBn@S<#XLop6e^EoiKIp$ zVus0*LYUN;XEb)QB-ug+;s5J?KIfj#J@?%6xyw1j* z0SMCv;2Q;i<5>VGP#@Mgnn4pgs5a!w0O+az7Fc+(1r4&T5b&1HZg20qUbJ%#kdNug zjkZQN0=uz}wpo2Zpv3d-e;ymy zohhGCLt?ww9hN4M4!uJ8!oj3J|`VIS_Pvwb4b9JU+(4Q=H%pLVqlOVttO5OCK8DaylKwEtSkpWPgLtPaCV6w z3@)MQhY#;f*ci02wJoBW*e!++G;t1spz?-wM!@_~{olq$M&@VS-2hRt9wL~vTW$QlGEq}5S3UHOjp?+d){ z!EU~_Oi4*eZ1LWW=>$$$g3y(3F6?&SznFp`BNvy8+1c51_qKI;D>J>-LqkJbK5e%> zO(TAc@28`8wZQ1vwl`N*&(=qVkx3*{=3p?h`0-;jhcikwQIZgW zKX~HVcxAe~c($Lp3RnkzO6?L&MC0*CtsJkUv^2*w=Fb~4AF~zpb3P!G@EV@Id;EVs(jQL#dsCHqvr)O?aWu-wQi^VcA zHfEY=YPP_EkB`rAWc?7o*DH1tMJJ%@hbj*U{S6w*6zVP4$vk z+99Q5e**!0#I(G3g9d;1_3OFvh0{CKY!_}gdi(F7@1jf7*-+B(zbrmau`YUq3FwOM z`Sgj}P+S}z6&V={L}0pbw_lSvmnM-`Np8BCa`bU!v#WC+{6pohL)niWjsD8U1T-XD z7af3Js0@lk+W()H&?-};w-j^(R!2t%15hZG0%k+QmM;BZY0q(2H#gzPOP@A_mMCSI z9H*VhSOKaum07d*g9j8@R z@66^t%w;t4!7xp_LWN50XqW3xNs zPcCsdXUkXR=z%~KKQ6MXIH`^=Eh9t2U}UAGS77014Lu%ZMGCwFk_*Uip@Ai78t#@^ z;U!k!c4d1W%E9%lY;ke%ZP>^`AS;({yW2wzrqA*y^8kAl6>Fs^Dmdo>?esBLMYV)n zQu=oh%I??lduj}T)xDsQ@Nl6dfPDSDMQni07WdaxIL^y!SdLynrR?cC*&HHCU;gu^ z%!{t}_JeAuJSY_hsxS140%m-wq}sLFzG@?8|3$uG>c;x|7WvGXC}V4Hqy~aw<;Vfl zv3E&|U}0l3*2B#$>r>cf*N=(L{`g!`9Le8*^1&&U2;ZDS^p9y?$uL46g_{B0b!>Ki zA|CZDnAyMCbz=UZMa(bZ_pQy1N9bfLUr{;jjoKFAjm2Wyw6wLS)YB97&MNQ0eVfXU z-J(WBtXTu3WPBXQj!=w8)hC7QPr?gh`qUMHRS~y4~WXLASY!e&_L>x-$z zLuijG|I`u|E4-(vsmbQTg&A&KT$~*_Qysz)nEt>2VN&jxub*=zle0Ck*g-=xv!IyK zc!f*^&QQtICa-zbml6{*G*2~oJ~1&N3-%(^!f3S2+QB(Lpnvpeh`NQvHkK=!MA_xI zae{zt2J+G7Uii<&Bxh%&hPOt5`|j4`bk>vey|99(PtRG!y#NJ8MMWjAUM=H^#1E!` z2ew2;nhh9r)0!jlac8Y|mgN!X?(C8&rYXkt5~dy=9)Z;Au}! zkHzbseHVC_8rGgfT)DCw64Ff<=Gt~7$l#J&ygNRBc6)o~Qhl`@c-bf%OEImjtvv*A z@7$0d-&x|dP@#x$xh8uE1`RN)tE>5<3*w-k&1P>$e`%F2uUM2A3(-nj=~U>{4({%KmY_@h)a~b9wV@N&$Cd-Eb|COb zxZK?&n}Eqgn`!5;u$6W$my3d`I=D0Q@SW4!b%T4sgBSrKPl!2L*n!pD%!65}~5h#&3R`JpEJZI2Pz-&Z2f zz;5kKufyRT2^nKzciTlgd*5($>o7w-J=G3mvguT4ZKZ*M!H<;WWRlqu2V)O*D(HFW z!}X7N);TMys@|FFXO>&YNoe^0NjiP{Gre8xony66t>Q`Iga$ofYS8Ho)J+If{iyez!+5)l!4z8|KjHgf0IyyS;A*zmj zN8*OplhB)rBbN2#B%s#??2 z(?}^XLiC=4!0u!o+sd=b-Q8WMqq8%1g2t|b_Bd-@#FYZ+HX0DKU5yG146JNyjHR~S zR=ze???iv)UKogvtGyMjGjFw)UVG30Mpi~LaNJ&RC6=MjBF5S2R@38SU#hzzq zCj4#pH8IC~!)l>GgW!1mjhw5?{rQ2~%U4Y{_nrRMd7=}ocog~-0)mx2zU(9=?*D}s B0+0Xz literal 0 HcmV?d00001 diff --git a/client/src/mods/Theme.lua b/client/src/mods/Theme.lua index 7eccafb7..9248c146 100644 --- a/client/src/mods/Theme.lua +++ b/client/src/mods/Theme.lua @@ -448,6 +448,12 @@ function Theme:loadSelectionGraphics() loadPlayerNumberIcons(self) loadInputPromptIcons(self) loadGridCursors(self) + + self.images.IMG_checkBox = {} + self.images.IMG_checkBox[true] = self:load_theme_img("checkbox_checked") + self.images.IMG_checkBox[false] = self:load_theme_img("checkbox_unchecked") + self.images.fight = self:load_theme_img("fight") + self.images.stopWatch = self:load_theme_img("stopwatch") end function Theme:loadIngameGraphics() @@ -1173,6 +1179,16 @@ function Theme:getTimePixelFont() return self.fontMaps.time end +---@param checked boolean +---@return love.Texture +function Theme:getCheckboxImage(checked) + return self.images.IMG_checkBox[checked] +end + +function Theme:getFightImage() + return self.images.fight +end + ---@param index integer ---@return IngameAssetPack function Theme:getIngameAssetPack(index) diff --git a/client/src/network/NetClient.lua b/client/src/network/NetClient.lua index 1befc05d..48c61ed7 100644 --- a/client/src/network/NetClient.lua +++ b/client/src/network/NetClient.lua @@ -518,10 +518,17 @@ end ---@param opponentId PublicPlayerID ---@param gameModeId GameModeID function NetClient:challengePlayerById(opponentId, gameModeId) - if not self.lobbyDataV2.outgoingChallenges[opponentId] then - self.tcpClient:sendRequest(ClientMessages.updateChallengeStatus(GAME.localPlayer.publicId, opponentId, gameModeId, true)) + self.lobbyDataV2.outgoingChallenges[opponentId] = self.lobbyDataV2.outgoingChallenges[opponentId] or {} + self.tcpClient:sendRequest(ClientMessages.updateChallengeStatus(GAME.localPlayer.publicId, opponentId, gameModeId, true)) + self.lobbyDataV2.outgoingChallenges[opponentId][gameModeId] = true + self:emitSignal("lobbyStateV2Update", self.lobbyDataV2) +end + +function NetClient:withdrawChallengeForId(opponentId, gameModeId) + if self.lobbyDataV2.outgoingChallenges[opponentId] then + self.tcpClient:sendRequest(ClientMessages.updateChallengeStatus(GAME.localPlayer.publicId, opponentId, gameModeId, false)) self.lobbyDataV2.outgoingChallenges[opponentId] = self.lobbyDataV2.outgoingChallenges[opponentId] or {} - self.lobbyDataV2.outgoingChallenges[opponentId][gameModeId] = true + self.lobbyDataV2.outgoingChallenges[opponentId][gameModeId] = false self:emitSignal("lobbyStateV2Update", self.lobbyDataV2) end end diff --git a/client/src/scenes/Lobby.lua b/client/src/scenes/Lobby.lua index 7f397dfa..67f27109 100644 --- a/client/src/scenes/Lobby.lua +++ b/client/src/scenes/Lobby.lua @@ -228,7 +228,7 @@ function Lobby:createRoomButtons(personalizedLobbyData) for _, room in pairs(personalizedLobbyData.rooms) do ---@type table local playerStrings = {} - for i, playerId in ipairs(room.playerIds) do + for i, playerId in ipairs(room.players) do playerStrings[i] = Lobby.getPlayerNameWithRating(playerId, room.gameModeId) end @@ -286,9 +286,9 @@ function Lobby:openPlayerSubMenu(playerId, button) iconSize = 16, playerId = playerId, label = ui.Label({text = "vs"}), - acceptImage = GAME.theme:comboImage(4), - proposeImage = GAME.theme:comboImage(5), - withdrawImage = GAME.theme:comboImage(6), + acceptImage = GAME.theme:getFightImage(), + proposeImage = GAME.theme:getCheckboxImage(false), + withdrawImage = GAME.theme:getCheckboxImage(true), width = 120 }) @@ -299,9 +299,9 @@ function Lobby:openPlayerSubMenu(playerId, button) iconSize = 16, playerId = playerId, label = ui.Label({text = "gm_time_attack"}), - acceptImage = GAME.theme:comboImage(4), - proposeImage = GAME.theme:comboImage(5), - withdrawImage = GAME.theme:comboImage(6), + acceptImage = GAME.theme:getFightImage(), + proposeImage = GAME.theme:getCheckboxImage(false), + withdrawImage = GAME.theme:getCheckboxImage(true), width = 120 }) subMenu:addChild(timeAttackButton) @@ -363,8 +363,8 @@ function Lobby:onLobbyStateUpdate(lobbyDataV2) self.lobbyMenu.selectedIndex = nil - for i, child in ipairs(self.lobbyMenu.children) do - child:detach() + for i = #self.lobbyMenu.children, 1, -1 do + self.lobbyMenu.children[i]:detach() end self.lobbyMenu:addChild(self.lobbyMessage) @@ -387,22 +387,25 @@ function Lobby:onLobbyStateUpdate(lobbyDataV2) self.lobbyMenu:addChild(self.showLeaderboardButton) self.lobbyMenu:addChild(self.backButton) + local previousButton + if self.lobbyMenuStartingUp then self.lobbyMenu:select(self.lobbyMenu.children[2]) self.lobbyMenuStartingUp = false elseif previousIndex then if copy[previousIndex].lobbyType then - local prev = copy[previousIndex] - if prev.lobbyType == "player" then + previousButton = copy[previousIndex] + if previousButton.lobbyType == "player" then for i, playerButton in ipairs(playerButtons) do - if prev.player.publicId == playerButton.player.publicId then + if previousButton.player.publicId == playerButton.player.publicId then self.lobbyMenu:select(playerButton) + previousButton = playerButton break end end - elseif prev.lobbyType == "room" then + elseif previousButton.lobbyType == "room" then for i, roomButton in ipairs(roomButtons) do - if prev.room.roomNumber == roomButton.room.roomNumber then + if previousButton.room.roomNumber == roomButton.room.roomNumber then self.lobbyMenu:select(roomButton) break end @@ -432,22 +435,26 @@ function Lobby:onLobbyStateUpdate(lobbyDataV2) if self.playerSubMenu then if not lobbyDataV2.players[self.playerSubMenu.playerId] then self.playerSubMenu:yieldFocus() - self.playerSubMenu = nil else - for _, menuItem in ipairs(self.playerSubMenu.children) do - for _, item in ipairs(menuItem.children) do - if item.gameModeId then - ---@cast item LobbyChallengeButton - if lobbyDataV2.incomingChallenges[self.playerSubMenu.playerId] and lobbyDataV2.incomingChallenges[self.playerSubMenu.playerId][item.gameModeId] == true then - item:setState(item.challengeStates.CHALLENGED) - elseif lobbyDataV2.outgoingChallenges[self.playerSubMenu.playerId] and lobbyDataV2.outgoingChallenges[self.playerSubMenu.playerId][item.gameModeId] == true then - item:setState(item.challengeStates.PROPOSING) - else - item:setState(item.challengeStates.NEUTRAL) - end + for _, button in ipairs(self.playerSubMenu.children) do + if button.gameModeId then + ---@cast button LobbyChallengeButton + if lobbyDataV2.incomingChallenges[self.playerSubMenu.playerId] and lobbyDataV2.incomingChallenges[self.playerSubMenu.playerId][button.gameModeId] == true then + button:setState(button.challengeStates.CHALLENGED) + elseif lobbyDataV2.outgoingChallenges[self.playerSubMenu.playerId] and lobbyDataV2.outgoingChallenges[self.playerSubMenu.playerId][button.gameModeId] == true then + button:setState(button.challengeStates.PROPOSING) + else + button:setState(button.challengeStates.NEUTRAL) end end end + + local x, y = previousButton:getScreenPos() + + self.subMenuLine.x = x + previousButton.width + 8 + self.subMenuLine.y = y + previousButton.height / 2 + self.subMenuLine:setPoints({self.subMenuLine.x, self.subMenuLine.y, self.playerSubMenu.x - 8, self.subMenuLine.y}) + self.playerSubMenu.y = y end end end diff --git a/client/src/ui/Line.lua b/client/src/ui/Line.lua index 6c594148..bae42c3c 100644 --- a/client/src/ui/Line.lua +++ b/client/src/ui/Line.lua @@ -28,4 +28,8 @@ function Line:drawSelf() GraphicsUtil.setColor(1, 1, 1, 1) end +function Line:setPoints(points) + self.points = points +end + return Line \ No newline at end of file diff --git a/client/src/ui/LobbyChallengeButton.lua b/client/src/ui/LobbyChallengeButton.lua index 5d32a8fa..eb516560 100644 --- a/client/src/ui/LobbyChallengeButton.lua +++ b/client/src/ui/LobbyChallengeButton.lua @@ -58,7 +58,7 @@ end function LobbyChallengeButton:onClick() if self.challengeState == LobbyChallengeButton.challengeStates.PROPOSING then - --GAME.netClient:withdrawChallenge(self.playerId, self.gameModeId) + GAME.netClient:withdrawChallengeForId(self.playerId, self.gameModeId) GAME.theme:playValidationSfx() else if GAME.localPlayer.settings.style ~= GameModes.Styles.MODERN then @@ -73,13 +73,9 @@ end function LobbyChallengeButton:receiveInputs(input) if input.isDown["MenuSelect"] then self:onClick() - -- this is a really stupid way to make sure you can activate back buttons with escape - elseif input.isDown["MenuEsc"] then - self:onClick() end end - local padding = 4 function LobbyChallengeButton:drawSelf() self:drawBackground() diff --git a/client/src/ui/ScrollMenu.lua b/client/src/ui/ScrollMenu.lua index 37102370..3fab1ae6 100644 --- a/client/src/ui/ScrollMenu.lua +++ b/client/src/ui/ScrollMenu.lua @@ -26,6 +26,9 @@ function ScrollMenu:onRelease(x, y, duration) if self.touchedChild and self.focused and self.focused ~= self.touchedChild then self.focused:yieldFocus() end + if not self.scrolling then + self:select(self.touchedChild) + end ScrollContainer.onRelease(self, x, y, duration) end @@ -84,6 +87,7 @@ function ScrollMenu:select(uiElement) for i, child in ipairs(self.children) do if child == uiElement then self.selectedIndex = i + self:keepVisible(-child.y, child.height) end end end From c39aec942b3196102b1d0bc17c8174e0b5cf7002 Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 18 Feb 2026 18:29:13 +0100 Subject: [PATCH 09/18] adjust lobby layout, add icons to rooms --- .../themes/Panel Attack Modern/endless@2x.png | Bin 0 -> 602 bytes client/src/mods/Theme.lua | 9 +++ client/src/scenes/Lobby.lua | 23 +++++-- client/src/ui/Button.lua | 2 + client/src/ui/IconTextButton.lua | 63 ++++++++++++++++++ client/src/ui/LobbyChallengeButton.lua | 54 ++++----------- client/src/ui/ScrollContainer.lua | 20 ++++++ client/src/ui/TextButton.lua | 3 - client/src/ui/init.lua | 2 + 9 files changed, 128 insertions(+), 48 deletions(-) create mode 100644 client/assets/themes/Panel Attack Modern/endless@2x.png create mode 100644 client/src/ui/IconTextButton.lua diff --git a/client/assets/themes/Panel Attack Modern/endless@2x.png b/client/assets/themes/Panel Attack Modern/endless@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..65cebf1c2ffe26571f8ead171c43ac31c56a1354 GIT binary patch literal 602 zcmV-g0;Tz@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy32;bRa{vG?BLDy{BLR4&KXw2B0mDf|K~z}7?UqkVRZ$eh*KVRh z2}Vz?hfm+2kPwNYi35X3bl|6PAov-Y`T;5^(bR$Bv_AtKBu#vQAe2KRpJG8-8vU9) z;jlaB-m8-)cg@b-Ywf+yIeYJOr4l7dl=#oElmWOZX-LuyNk1iRnc1fzPJup2wb0&qFdbpTHa=$gQTRPg?S z!OyS-RDdqv-8poJKs}bgyd$mw_ks4-wM+qD9cL4G>%M&lW`KH2dk0u{oFyN;2RID~ zD}Y6ktmo{u0SqQicp>2Z2Cj$3N6yjRj5_HA-baFO1J^Qiw;g|6k}kFEJ&wPV>|3*M zlBOc2X)|kP)c8lY?hO<8?2LmMJL!@%8!(_w5Ln1_ppOU>JDm=SVEW8nAzker^EW7nlPFz(DGL zM}RK{oGiLY*bY1`I0;Ad#&H#R1{_BmtOFAn+K0fKh%WgSSWdMMNvcZvA*o?zy9FP# onxtV#SAy5DS^j*KC{ZGYziRR4zCD2;!2kdN07*qoM6N<$f-`gNl>h($ literal 0 HcmV?d00001 diff --git a/client/src/mods/Theme.lua b/client/src/mods/Theme.lua index 9248c146..d6631e69 100644 --- a/client/src/mods/Theme.lua +++ b/client/src/mods/Theme.lua @@ -454,6 +454,7 @@ function Theme:loadSelectionGraphics() self.images.IMG_checkBox[false] = self:load_theme_img("checkbox_unchecked") self.images.fight = self:load_theme_img("fight") self.images.stopWatch = self:load_theme_img("stopwatch") + self.images.endless = self:load_theme_img("endless") end function Theme:loadIngameGraphics() @@ -1189,6 +1190,14 @@ function Theme:getFightImage() return self.images.fight end +function Theme:getStopwatchImage() + return self.images.stopWatch +end + +function Theme:getEndlessImage() + return self.images.endless +end + ---@param index integer ---@return IngameAssetPack function Theme:getIngameAssetPack(index) diff --git a/client/src/scenes/Lobby.lua b/client/src/scenes/Lobby.lua index 67f27109..853b3f90 100644 --- a/client/src/scenes/Lobby.lua +++ b/client/src/scenes/Lobby.lua @@ -187,7 +187,7 @@ function Lobby:createPlayerButtons(personalizedLobbyData) local playerButtons = {} for publicId, player in pairs(personalizedLobbyData.players) do - --if publicId ~= GAME.localPlayer.publicId then + if publicId ~= GAME.localPlayer.publicId and not personalizedLobbyData.players.roomNumber then local playerName if personalizedLobbyData.incomingChallenges[publicId] and next(personalizedLobbyData.incomingChallenges[publicId]) then playerName = Lobby.getPlayerNameWithRating(publicId) .. " " .. loc("lb_received") @@ -209,7 +209,7 @@ function Lobby:createPlayerButtons(personalizedLobbyData) button.lobbyType = "player" button.player = player playerButtons[#playerButtons+1] = button - --end + end end table.sort(playerButtons, function(a, b) @@ -237,11 +237,24 @@ function Lobby:createRoomButtons(personalizedLobbyData) if #room.players == 1 then roomName = loc("lb_spectate") .. " " .. playerStrings[1] .. " (" .. room.state .. ")" else - roomName = loc("lb_spectate") .. " " .. playerStrings[1] .. " vs " .. playerStrings[2] .. " (" .. room.state .. ")" + roomName = loc("lb_spectate") .. "\n" .. playerStrings[1] .. "\nvs\n" .. playerStrings[2] .. "\n(" .. room.state .. ")" + end + + local icon + if room.gameModeId == "TWO_PLAYER_VS" or room.gameModeId == "ONE_PLAYER_VS_SELF" then + icon = GAME.theme:getFightImage() + elseif room.gameModeId == "TWO_PLAYER_TIME_ATTACK" or room.gameModeId == "ONE_PLAYER_TIME_ATTACK" then + icon = GAME.theme:getStopwatchImage() + elseif room.gameModeId == "ONE_PLAYER_ENDLESS" then + icon = GAME.theme:getEndlessImage() + else + icon = GAME.theme:chainImage(0) end - local button = ui.TextButton({ - label = ui.Label({text = roomName, translate = false}), + local button = ui.IconTextButton({ + label = ui.Label({text = roomName, translate = false, wrapWidth = self.lobbyMenu.width - 19}), + iconSize = 16, + icon = icon, width = self.lobbyMenuWidth, onClick = function() self:requestSpectateFunction(room) end }) diff --git a/client/src/ui/Button.lua b/client/src/ui/Button.lua index 0d1026e7..8791c47c 100644 --- a/client/src/ui/Button.lua +++ b/client/src/ui/Button.lua @@ -27,6 +27,8 @@ local Button = class( ) Button.TYPE = "Button" +Button.WIDTH_PADDING = 3 +Button.HEIGHT_PADDING = 3 function Button:onClick() GAME.theme:playValidationSfx() diff --git a/client/src/ui/IconTextButton.lua b/client/src/ui/IconTextButton.lua new file mode 100644 index 00000000..ebf6bb7d --- /dev/null +++ b/client/src/ui/IconTextButton.lua @@ -0,0 +1,63 @@ +local import = require("common.lib.import") +local Button = import("./Button") +local class = require("common.lib.class") +local GraphicsUtil = require("client.src.graphics.graphics_util") + +---@class IconTextButtonOptions : ButtonOptions +---@field icon love.Texture +---@field iconSize integer +---@field label Label + +---@class IconTextButton : Button +---@operator call(IconTextButtonOptions): IconTextButton +local IconTextButton = class( +---@param self IconTextButton +---@param options IconTextButtonOptions +function(self, options) + self.icon = options.icon + self.iconSize = options.iconSize + + self.label = options.label + self.label.hAlign = "center" + self.label.vAlign = "top" + --self.label + self:addChild(self.label) + + -- stretch to fit text+icon + local width, height = self.label:getEffectiveDimensions() + self.height = math.max(math.max(self.iconSize, height) + self.HEIGHT_PADDING * 2, self.height) + + -- reposition label based on new stretch + if self.width > width + self.WIDTH_PADDING * 3 + self.iconSize then + self.label.x = (self.width - (width + self.WIDTH_PADDING)) / 2 + self.WIDTH_PADDING + else + self.width = width + self.WIDTH_PADDING * 3 + self.iconSize + self.label.x = self.width - width -self.WIDTH_PADDING + end + self.label.y = self.height - height - self.HEIGHT_PADDING +end, +Button) + +function IconTextButton:draw() + if self.isVisible then + self:drawDebugOutline() + self:drawSelf() + -- if DEBUG_ENABLED then + -- GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height, 1, 1, 1, 0.5) + -- end + love.graphics.push("transform") + love.graphics.translate(self.x, self.y) + self:drawChildren() + love.graphics.pop() + end +end + +function IconTextButton:drawChildren() + local imageWidth, imageHeight = self.icon:getDimensions() + local scale = math.min(self.iconSize / imageWidth, self.iconSize / imageHeight) + GraphicsUtil.draw(self.icon, self.label.x - self.WIDTH_PADDING - self.iconSize, self.label.y + (self.label.height - self.iconSize) / 2, 0, scale, scale) + + self.label:draw() +end + +return IconTextButton \ No newline at end of file diff --git a/client/src/ui/LobbyChallengeButton.lua b/client/src/ui/LobbyChallengeButton.lua index eb516560..d33e16e9 100644 --- a/client/src/ui/LobbyChallengeButton.lua +++ b/client/src/ui/LobbyChallengeButton.lua @@ -1,28 +1,23 @@ local import = require("common.lib.import") -local TextButton = import("./TextButton") -local Label = import("./Label") +local IconTextButton = import("./IconTextButton") local class = require("common.lib.class") -local GraphicsUtil = require("client.src.graphics.graphics_util") local GameModes = require("common.data.GameModes") ----@class LobbyChallengeButtonOptions : ButtonOptions ----@field label Label +---@class LobbyChallengeButtonOptions : IconTextButtonOptions ---@field proposeImage love.Texture ---@field acceptImage love.Texture ---@field withdrawImage love.Texture ----@field iconSize integer ---@field gameModeId GameModeID ---@field playerId PublicPlayerID +---@field challengeState ChallengeState? +---@field icon nil - ----@class LobbyChallengeButton : TextButton +---@class LobbyChallengeButton : IconTextButton ---@operator call(LobbyChallengeButtonOptions): LobbyChallengeButton ---@field proposeImage love.Texture ---@field acceptImage love.Texture ---@field withdrawImage love.Texture ---@field challengeState ChallengeState ----@field iconSize integer ----@field label Label ---@field gameModeId GameModeID ---@field playerId PublicPlayerID ---@overload fun(options: LobbyChallengeButtonOptions): LobbyChallengeButton @@ -30,21 +25,14 @@ local LobbyChallengeButton = class( ---@param self LobbyChallengeButton ---@param options LobbyChallengeButtonOptions function(self, options) - self.label = options.label - self.challengeState = self.challengeStates.NEUTRAL self.proposeImage = options.proposeImage self.acceptImage = options.acceptImage self.withdrawImage = options.withdrawImage - self.iconSize = options.iconSize self.playerId = options.playerId self.gameModeId = options.gameModeId - - local width, _ = self.label:getEffectiveDimensions() - self.width = math.max(width + self.WIDTH_PADDING * 4 + self.iconSize, self.width) - self.label.x = self.WIDTH_PADDING * 3 + self.iconSize - self.label.hAlign = "left" + self:setState(options.challengeState or self.challengeStates.NEUTRAL) end, -TextButton, "LobbyChallengeButton") +IconTextButton, "LobbyChallengeButton") LobbyChallengeButton.TYPE = "LobbyChallengeButton" @@ -54,6 +42,13 @@ LobbyChallengeButton.challengeStates = { CHALLENGED = "CHALLENGED", PROPOSING = ---@param challengeState ChallengeState function LobbyChallengeButton:setState(challengeState) self.challengeState = challengeState + if self.challengeState == LobbyChallengeButton.challengeStates.NEUTRAL then + self.icon = self.proposeImage + elseif self.challengeState == LobbyChallengeButton.challengeStates.CHALLENGED then + self.icon = self.acceptImage + elseif self.challengeState == LobbyChallengeButton.challengeStates.PROPOSING then + self.icon = self.withdrawImage + end end function LobbyChallengeButton:onClick() @@ -76,25 +71,4 @@ function LobbyChallengeButton:receiveInputs(input) end end -local padding = 4 -function LobbyChallengeButton:drawSelf() - self:drawBackground() - self:drawOutline() - - local icon - if self.challengeState == LobbyChallengeButton.challengeStates.NEUTRAL then - icon = self.proposeImage - elseif self.challengeState == LobbyChallengeButton.challengeStates.CHALLENGED then - icon = self.acceptImage - elseif self.challengeState == LobbyChallengeButton.challengeStates.PROPOSING then - icon = self.withdrawImage - end - - local imageWidth, imageHeight = icon:getDimensions() - local scale = math.min(self.iconSize / imageWidth, self.iconSize / imageHeight) - GraphicsUtil.draw(icon, self.x + padding, self.y + padding, 0, scale, scale) - - --GraphicsUtil.printf(loc(self.text), self.x + padding * 2 + self.iconSize, self.y + padding, self.width - (padding * 2 + self.iconSize), "left") -end - return LobbyChallengeButton \ No newline at end of file diff --git a/client/src/ui/ScrollContainer.lua b/client/src/ui/ScrollContainer.lua index 568f8610..53550d99 100644 --- a/client/src/ui/ScrollContainer.lua +++ b/client/src/ui/ScrollContainer.lua @@ -2,6 +2,7 @@ local PATH = (...):gsub('%.[^%.]+$', '') local UiElement = require(PATH .. ".UIElement") local class = require("common.lib.class") local util = require("common.lib.util") +local GraphicsUtil = require("client.src.graphics.graphics_util") ---@class ScrollContainerOptions : UiElementOptions ---@field scrollOrientation ("vertical" | "horizontal" | nil) @@ -137,6 +138,25 @@ function ScrollContainer:draw() else love.graphics.setStencilTest() end + + if self.maxScrollOffset > 0 then + local fontSize = GraphicsUtil.fontSize + if self.scrollOrientation == "vertical" then + if self.scrollOffset < 0 then + GraphicsUtil.print("^", self.x + self.width / 2 - fontSize / 2, self.y - 20) + end + if math.abs(self.scrollOffset) < self.maxScrollOffset then + GraphicsUtil.print("v", self.x + self.width / 2 - fontSize / 2, self.y + self.height + 8) + end + else + if self.scrollOffset < 0 then + GraphicsUtil.print("<", self.x - 20, self.y + self.height / 2 - fontSize / 2) + end + if math.abs(self.scrollOffset) < self.maxScrollOffset then + GraphicsUtil.print(">", self.x + self.width + 8, self.y + self.height / 2 - fontSize / 2) + end + end + end end end diff --git a/client/src/ui/TextButton.lua b/client/src/ui/TextButton.lua index 0992ac74..0b369874 100644 --- a/client/src/ui/TextButton.lua +++ b/client/src/ui/TextButton.lua @@ -22,7 +22,4 @@ local TextButton = class(function(self, options) end, Button) TextButton.TYPE = "TextButton" -TextButton.WIDTH_PADDING = 3 -TextButton.HEIGHT_PADDING = 3 - return TextButton diff --git a/client/src/ui/init.lua b/client/src/ui/init.lua index 6d45be17..8b6821dd 100644 --- a/client/src/ui/init.lua +++ b/client/src/ui/init.lua @@ -42,6 +42,8 @@ local ui = { Grid = import("./Grid"), ---@source GridCursor.lua GridCursor = import("./GridCursor"), + ---@source IconTextButton.lua + IconTextButton = import("./IconTextButton"), ---@source ImageButton.lua ImageButton = import("./ImageButton"), ---@source ImageContainer.lua From 6fa92a60c6d8e4a4bb277fb029a733e88ff4baaa Mon Sep 17 00:00:00 2001 From: Endaris Date: Thu, 19 Feb 2026 14:31:30 +0100 Subject: [PATCH 10/18] fix spectate and revert attempt at centered text for room names --- client/src/scenes/Lobby.lua | 4 +++- client/src/ui/IconTextButton.lua | 16 +--------------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/client/src/scenes/Lobby.lua b/client/src/scenes/Lobby.lua index 853b3f90..a3cf7fb5 100644 --- a/client/src/scenes/Lobby.lua +++ b/client/src/scenes/Lobby.lua @@ -156,6 +156,8 @@ function Lobby:requestGameFunction(publicId, gameModeId) end -- requests to spectate the specified room +---@param room LobbyRoomV2 +---@return function function Lobby:requestSpectateFunction(room) return function() GAME.netClient:requestSpectate(room.roomNumber) @@ -256,7 +258,7 @@ function Lobby:createRoomButtons(personalizedLobbyData) iconSize = 16, icon = icon, width = self.lobbyMenuWidth, - onClick = function() self:requestSpectateFunction(room) end + onClick = self:requestSpectateFunction(room) }) button.lobbyType = "room" button.room = room diff --git a/client/src/ui/IconTextButton.lua b/client/src/ui/IconTextButton.lua index ebf6bb7d..b792097d 100644 --- a/client/src/ui/IconTextButton.lua +++ b/client/src/ui/IconTextButton.lua @@ -18,7 +18,7 @@ function(self, options) self.iconSize = options.iconSize self.label = options.label - self.label.hAlign = "center" + self.label.hAlign = "left" self.label.vAlign = "top" --self.label self:addChild(self.label) @@ -38,20 +38,6 @@ function(self, options) end, Button) -function IconTextButton:draw() - if self.isVisible then - self:drawDebugOutline() - self:drawSelf() - -- if DEBUG_ENABLED then - -- GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height, 1, 1, 1, 0.5) - -- end - love.graphics.push("transform") - love.graphics.translate(self.x, self.y) - self:drawChildren() - love.graphics.pop() - end -end - function IconTextButton:drawChildren() local imageWidth, imageHeight = self.icon:getDimensions() local scale = math.min(self.iconSize / imageWidth, self.iconSize / imageHeight) From 7bf65717fb130defb28c8ac8c5898d791f5b250c Mon Sep 17 00:00:00 2001 From: Endaris Date: Thu, 19 Feb 2026 18:06:51 +0100 Subject: [PATCH 11/18] add a room info text to allow players to glean the state of a room without entering --- client/src/scenes/Lobby.lua | 75 +++++++++++++++++++++++++++++++++++++ server/server.lua | 2 +- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/client/src/scenes/Lobby.lua b/client/src/scenes/Lobby.lua index a3cf7fb5..188fbeae 100644 --- a/client/src/scenes/Lobby.lua +++ b/client/src/scenes/Lobby.lua @@ -103,12 +103,39 @@ function Lobby:initLobbyMenu() onClick = exitMenu }) + self.roomPanel = ui.UiElement({ + x = 440, + hAlign = "center", + vAlign = "center", + width = 300, + height = 540, + isVisible = false + }) + + self.roomInfo = ui.Label({ + text = "", + translate = false, + hAlign = "left", + vAlign = "top", + }) + + self.roomTimer = ui.Label({ + text = "00:00", + translate = false, + hAlign = "left", + vAlign = "top", + y = GAME.theme.font.size * 4 + }) + + self.roomPanel:addChild(self.roomInfo) + self.roomPanel:addChild(self.roomTimer) self.lobbyMenuStartingUp = true self.lobbyMenu = ui.ScrollMenu({height = 540, width = 300, hAlign = "center", vAlign = "center"}) self.lobbyMenu.x = self.lobbyMenuXoffsetMap[false] self.uiRoot:addChild(self.lobbyMenu) + self.uiRoot:addChild(self.roomPanel) end ----------------- @@ -472,6 +499,8 @@ function Lobby:onLobbyStateUpdate(lobbyDataV2) self.playerSubMenu.y = y end end + + self:updateRoomPanel(true) end ------------------------------ @@ -481,6 +510,8 @@ local loginStateLabel = ui.Label({text = loc("lb_login"), translate = false, x = function Lobby:updateSelf(dt) self.backgroundImg:update(dt) + self:updateRoomPanel() + if GAME.netClient.state == NetClient.STATES.LOGIN then loginStateLabel:setText(GAME.netClient.loginState or "") else @@ -495,6 +526,50 @@ function Lobby:updateSelf(dt) end end +---@param updateInfo boolean? if the info text should be updated even if the room number did not change +function Lobby:updateRoomPanel(updateInfo) + local selected = self.lobbyMenu.children[self.lobbyMenu.selectedIndex] + if not selected or not selected.room then + if self.roomPanel.isVisible then + self.roomPanel:setVisibility(false) + end + else + if not self.roomPanel.isVisible then + self.roomPanel:setVisibility(true) + end + + local room = selected.room + if room.roomNumber ~= self.roomPanel.roomNumber or updateInfo then + self.roomPanel.roomNumber = room.roomNumber + local text + if #room.players == 2 then + text = string.format("%s %d : %d %s\n%s\n%s %d", GAME.netClient.lobbyDataV2.players[room.players[1]].name, room.wins[1], room.wins[2], GAME.netClient.lobbyDataV2.players[room.players[2]].name, room.state, loc("pl_spectators"), #room.spectators) + elseif #room.players == 1 then + text = string.format("%s\n%s %d", room.state, loc("pl_spectators"), #room.spectators) + end + self.roomInfo:setText(text, nil, false) + end + + local timer = self.roomTimer.text + if room.state == "playing" then + if room.gameStartTime then + local durationInSeconds = os.difftime(to_UTC(os.time()), os.time(room.gameStartTime)) - 3 + if durationInSeconds < 0 then + timer = string.format("-00:%02d", math.abs(durationInSeconds)) + else + timer = string.format("%02d:%02d", math.floor(durationInSeconds / 60), durationInSeconds % 60) + end + end + + if timer ~= self.roomTimer.text then + self.roomTimer:setText(timer, nil, false) + end + elseif timer ~= "00:00" then + self.roomTimer:setText("00:00", nil, false) + end + end +end + function Lobby:draw() self.backgroundImg:draw() self:drawCommunityMessage() diff --git a/server/server.lua b/server/server.lua index ae4f862e..fcde8a2d 100644 --- a/server/server.lua +++ b/server/server.lua @@ -259,7 +259,7 @@ function Server:lobby_state() end ---@alias LobbyPlayerV2 { publicId: PublicPlayerID, name: string, state: string, ratings: table, roomNumber: roomNumber? } ----@alias LobbyRoomV2 { roomNumber: roomNumber, state: string, gameModeId: GameModeID, players: PublicPlayerID[], spectators: PublicPlayerID[], wins: integer[] } +---@alias LobbyRoomV2 { roomNumber: roomNumber, state: string, gameModeId: GameModeID, players: PublicPlayerID[], spectators: PublicPlayerID[], wins: integer[], gameStartTime: integer? } ---@alias LobbyStateV2 { players: table, rooms: table } ---@return LobbyStateV2 From aaf2e7e807c22915fc20cbc3f66ebd88c29add4f Mon Sep 17 00:00:00 2001 From: Endaris Date: Fri, 27 Feb 2026 00:59:14 +0100 Subject: [PATCH 12/18] fix server tests --- client/src/network/NetClient.lua | 1 + server/tests/ServerTesting.lua | 4 +- server/tests/ServerTests.lua | 74 +++++++------------------------- 3 files changed, 18 insertions(+), 61 deletions(-) diff --git a/client/src/network/NetClient.lua b/client/src/network/NetClient.lua index 48c61ed7..7800ba6f 100644 --- a/client/src/network/NetClient.lua +++ b/client/src/network/NetClient.lua @@ -507,6 +507,7 @@ function NetClient:requestLeaderboard() end end +---@deprecated function NetClient:challengePlayer(name) if not self.lobbyData.sentRequests[name] then self.tcpClient:sendRequest(ClientMessages.challengePlayer(config.name, name)) diff --git a/server/tests/ServerTesting.lua b/server/tests/ServerTesting.lua index 5a9ca3bd..10134d1a 100644 --- a/server/tests/ServerTesting.lua +++ b/server/tests/ServerTesting.lua @@ -110,9 +110,9 @@ function ServerTesting.setupRoom(server, player1, player2, alreadyLoggedIn) player2 = ServerTesting.login(server, player2) ServerTesting.clearOutgoingMessages({player1, player2}) end - player1.connection:receiveMessage(json.encode(ClientProtocol.challengePlayer(player1.name, player2.name).messageText)) + player1.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(player1.publicPlayerID, player2.publicPlayerID, "TWO_PLAYER_VS", true).messageText)) server:update() - player2.connection:receiveMessage(json.encode(ClientProtocol.challengePlayer(player2.name, player1.name).messageText)) + player2.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(player2.publicPlayerID, player1.publicPlayerID, "TWO_PLAYER_VS", true).messageText)) server:update() ServerTesting.clearOutgoingMessages({player1, player2}) diff --git a/server/tests/ServerTests.lua b/server/tests/ServerTests.lua index c69f61c6..ade8c3e7 100644 --- a/server/tests/ServerTests.lua +++ b/server/tests/ServerTests.lua @@ -26,9 +26,6 @@ local function testLogin() local message = bob.connection.outgoingMessageQueue:pop() assert(message and message.messageText.type == "loginResponse" and message.messageText.content.approved) message = bob.connection.outgoingMessageQueue:pop().messageText - assert(message and message.type == "lobbyState" and message.content.unpaired and message.content.unpaired[1] == "Bob") - -- during migration we'll have both; remove old lobby state once clients have moved to V2 - message = bob.connection.outgoingMessageQueue:pop().messageText assert(message and message.type == "lobbyStateV2" and message.content.players and message.content.players[4].name == "Bob") end @@ -40,14 +37,14 @@ local function testRoomSetup() -- there are other tests to verify lobby data ServerTesting.clearOutgoingMessages({alice, ben, bob}) - alice.connection:receiveMessage(json.encode(ClientProtocol.challengePlayer("Alice", "Ben").messageText)) + alice.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(alice.publicPlayerID, ben.publicPlayerID, "TWO_PLAYER_VS", true).messageText)) server:update() - assert(server.proposals[alice.publicPlayerID][ben.publicPlayerID] == "TWO_PLAYER_VS") + assert(server.proposals[alice.publicPlayerID][ben.publicPlayerID]["TWO_PLAYER_VS"] == true) local message = ben.connection.outgoingMessageQueue:pop().messageText - assert(message.type == "challenge" and message.content.sender == "Alice" and message.content.receiver == "Ben") + assert(message.type == "challengeUpdate" and message.content.sender == "Alice" and message.content.receiver == "Ben") assert(ben.connection.outgoingMessageQueue:len() == 0) - ben.connection:receiveMessage(json.encode(ClientProtocol.challengePlayer("Ben", "Alice").messageText)) + ben.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(ben.publicPlayerID, alice.publicPlayerID, "TWO_PLAYER_VS", true).messageText)) server:update() assert(server.proposals[alice.publicPlayerID] == nil or next(server.proposals[alice.publicPlayerID]) == nil) assert(server.proposals[ben.publicPlayerID] == nil or next(server.proposals[ben.publicPlayerID]) == nil) @@ -62,8 +59,8 @@ local function testRoomSetup() assert(message.type == "createRoom" and tableUtils.length(message.content.players) == 2) message = bob.connection.outgoingMessageQueue:pop().messageText.content - assert(message.unpaired and #message.unpaired == 1) - assert(message.spectatable and #message.spectatable == 1) + assert(message.players and tableUtils.length(message.players) == 3) + assert(message.rooms and tableUtils.length(message.rooms) == 1) end -- same as the other one, except we're specifying TWO_PLAYER_TIME_ATTACK as the game mode for the challenges @@ -75,14 +72,14 @@ local function testRoomSetup2() -- there are other tests to verify lobby data ServerTesting.clearOutgoingMessages({alice, ben, bob}) - alice.connection:receiveMessage(json.encode(ClientProtocol.challengePlayer("Alice", "Ben", "TWO_PLAYER_TIME_ATTACK").messageText)) + alice.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(alice.publicPlayerID, ben.publicPlayerID, "TWO_PLAYER_TIME_ATTACK", true).messageText)) server:update() - assert(server.proposals[alice.publicPlayerID][ben.publicPlayerID] == "TWO_PLAYER_TIME_ATTACK") + assert(server.proposals[alice.publicPlayerID][ben.publicPlayerID]["TWO_PLAYER_TIME_ATTACK"] == true) local message = ben.connection.outgoingMessageQueue:pop().messageText - assert(message.type == "challenge" and message.content.sender == "Alice" and message.content.receiver == "Ben") + assert(message.type == "challengeUpdate" and message.content.sender == "Alice" and message.content.receiver == "Ben") assert(ben.connection.outgoingMessageQueue:len() == 0) - ben.connection:receiveMessage(json.encode(ClientProtocol.challengePlayer("Ben", "Alice", "TWO_PLAYER_TIME_ATTACK").messageText)) + ben.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(ben.publicPlayerID, alice.publicPlayerID, "TWO_PLAYER_TIME_ATTACK", true).messageText)) server:update() assert(server.proposals[alice.publicPlayerID] == nil or next(server.proposals[alice.publicPlayerID]) == nil) assert(server.proposals[ben.publicPlayerID] == nil or next(server.proposals[ben.publicPlayerID]) == nil) @@ -97,8 +94,8 @@ local function testRoomSetup2() assert(message.type == "createRoom" and tableUtils.length(message.content.players) == 2) message = bob.connection.outgoingMessageQueue:pop().messageText.content - assert(message.unpaired and #message.unpaired == 1) - assert(message.spectatable and #message.spectatable == 1) + assert(message.players and tableUtils.length(message.players) == 3) + assert(message.rooms and tableUtils.length(message.rooms) == 1) end local readyMessage = json.encode({menu_state = {wants_ready = true, loaded = true, ready = true}}) @@ -122,12 +119,6 @@ local function testGameplay() -- and messages that should be sent as the result of room events back to the players -- so just do a cursory check if ONE of the expected things changed is enough to verify the message (probably) ended up where it should message = bob.connection.outgoingMessageQueue:pop().messageText - assert(message.type == "lobbyState") - assert(message.content.unpaired and #message.content.unpaired == 1) - assert(#message.content.spectatable == 1 and message.content.spectatable[1].state == "playing" and message.content.spectatable[1].roomNumber == 1) - - -- during migration we'll have both; remove old lobby state once clients have moved to V2 - message = bob.connection.outgoingMessageQueue:pop().messageText assert(message.type == "lobbyStateV2") assert(message.content.players and tableUtils.length(message.content.players) == 3) assert(tableUtils.length(message.content.rooms) == 1) @@ -210,11 +201,11 @@ local function testGameplay() -- everyone is back to lobby message = alice.connection.outgoingMessageQueue:pop().messageText.content - assert(message.unpaired and #message.unpaired == 3 and #message.spectatable == 0) + assert(message.players and tableUtils.length(message.players) == 3 and tableUtils.length(message.rooms) == 0) message = bob.connection.outgoingMessageQueue:pop().messageText.content - assert(message.unpaired and #message.unpaired == 3 and #message.spectatable == 0) + assert(message.players and tableUtils.length(message.players) == 3 and tableUtils.length(message.rooms) == 0) message = ben.connection.outgoingMessageQueue:pop().messageText.content - assert(message.unpaired and #message.unpaired == 3 and #message.spectatable == 0) + assert(message.players and tableUtils.length(message.players) == 3 and tableUtils.length(message.rooms) == 0) end local function testDisconnect() @@ -242,12 +233,6 @@ local function testDisconnect() -- the people that got kicked out get the new lobby state message = alice.connection.outgoingMessageQueue:pop().messageText - assert(message.type == "lobbyState" and message.content.unpaired and #message.content.unpaired == 2) - message = bob.connection.outgoingMessageQueue:pop().messageText - assert(message.type == "lobbyState" and message.content.unpaired and #message.content.unpaired == 2) - - -- during migration we'll have both; remove old lobby state once clients have moved to V2 - message = alice.connection.outgoingMessageQueue:pop().messageText assert(message and message.type == "lobbyStateV2" and message.content.players and message.content.players[5].name == "Alice" and message.content.players[5].state == "lobby") message = bob.connection.outgoingMessageQueue:pop().messageText assert(message and message.type == "lobbyStateV2" and message.content.players and message.content.players[4].name == "Bob" and message.content.players[4].state == "lobby") @@ -277,27 +262,6 @@ local function testLobbyDataComposition() -- so check what alice can see local message = alice.connection.outgoingMessageQueue:pop().messageText - assert(message.type == "lobbyState") - message = message.content - assert(message.unpaired and #message.unpaired == 2) - assert(message.players) - assert(message.spectatable and #message.spectatable == 1) - for _, unpaired in ipairs(message.unpaired) do - assert(unpaired == "Alice" or unpaired == "Berta") - -- alice and berta both have a rating - assert(message.players[unpaired] and tonumber(message.players[unpaired].rating)) - end - - -- Jerry does not have a rating yet - assert(message.players[message.spectatable[1].a] and not message.players[message.spectatable[1].a].rating) - -- but Ben does - assert(message.players[message.spectatable[1].b] and tonumber(message.players[message.spectatable[1].b].rating)) - - -- bob as a spectator is invisible rip - assert(not message.players["Bob"]) - - -- and now for V2 - message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "lobbyStateV2") message = message.content ---@cast message LobbyStateV2 @@ -331,11 +295,6 @@ local function testSinglePlayer() local message = bob.connection.outgoingMessageQueue:pop().messageText assert(message.type == "createRoom") - message = alice.connection.outgoingMessageQueue:pop().messageText - assert(message.type == "lobbyState") - assert(#message.content.spectatable == 1) - - -- during migration we'll have both; remove old lobby state once clients have moved to V2 message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "lobbyStateV2") assert(tableUtils.length(message.content.rooms) == 1) @@ -368,9 +327,6 @@ local function testSinglePlayer() message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "leaveRoom") message = alice.connection.outgoingMessageQueue:pop().messageText - assert(message.type == "lobbyState") - -- during migration we'll have both; remove old lobby state once clients have moved to V2 - message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "lobbyStateV2") alice.connection:receiveMessage(json.encode(ClientProtocol.requestSpectate("Alice", server.playerToRoom[bob].roomNumber).messageText)) From ef4afb5c4e801bbbc6bef013089d383ff0062fd0 Mon Sep 17 00:00:00 2001 From: Endaris Date: Fri, 27 Feb 2026 18:46:01 +0100 Subject: [PATCH 13/18] bump network version and make server send serverTime on login approval --- common/network/NetworkProtocol.lua | 2 +- common/network/ServerProtocol.lua | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/common/network/NetworkProtocol.lua b/common/network/NetworkProtocol.lua index 8c08e225..d8191eee 100644 --- a/common/network/NetworkProtocol.lua +++ b/common/network/NetworkProtocol.lua @@ -6,7 +6,7 @@ local NetworkProtocol = {} -- Version 002 we supported unicode JSON -- Version 003 we updated login requirements and started sending the network version -- Version 004 server communicates replays in a new standardised format -NetworkProtocol.NETWORK_VERSION = "005" +NetworkProtocol.NETWORK_VERSION = "006" local messageEndMarker = "←J←" diff --git a/common/network/ServerProtocol.lua b/common/network/ServerProtocol.lua index f530128c..980d2f30 100644 --- a/common/network/ServerProtocol.lua +++ b/common/network/ServerProtocol.lua @@ -360,6 +360,7 @@ function ServerProtocol.approveLogin(publicId, notice, newId, newName, oldName) content.newName = newName content.oldName = oldName content.nameChanged = (newName ~= nil) + content.serverTime = os.time() approveLoginMessage.content = content From cfde278b3d2a6b8ef9c44f3ff0ad2ddb7375d0c1 Mon Sep 17 00:00:00 2001 From: Endaris Date: Fri, 27 Feb 2026 20:57:10 +0100 Subject: [PATCH 14/18] remove references to old lobbydata and use enum notation for GameModeIDs everywhere --- client/src/ChallengeMode.lua | 2 +- client/src/network/NetClient.lua | 51 ++----------------- client/src/scenes/EndlessMenu.lua | 2 +- client/src/scenes/Lobby.lua | 28 +++++----- .../scenes/LocalGameModeSelectionScene.lua | 4 +- client/src/scenes/MainMenu.lua | 8 +-- client/src/scenes/TimeAttackMenu.lua | 2 +- client/src/scenes/TrainingMenu.lua | 2 +- client/tests/PlayerSettingsTests.lua | 8 +-- client/tests/StackGraphicsTests.lua | 2 +- common/compatibility/ReplayV2.lua | 9 ++-- common/data/GameModes.lua | 30 +++++++---- common/engine/Puzzle.lua | 2 +- common/network/ClientProtocol.lua | 13 ++--- .../tests/engine/GarbageQueueTestingUtils.lua | 4 +- common/tests/engine/PanelGenTests.lua | 8 +-- .../tests/engine/StackReplayTestingUtils.lua | 2 +- server/ClientMessages.lua | 20 +------- server/tests/LeaderboardTests.lua | 2 +- server/tests/RoomTests.lua | 2 +- server/tests/ServerTesting.lua | 5 +- server/tests/ServerTests.lua | 24 ++++----- serverLauncher.lua | 2 +- 23 files changed, 90 insertions(+), 142 deletions(-) diff --git a/client/src/ChallengeMode.lua b/client/src/ChallengeMode.lua index 846fab5a..ea77c608 100644 --- a/client/src/ChallengeMode.lua +++ b/client/src/ChallengeMode.lua @@ -40,7 +40,7 @@ local ChallengeMode = class( ) function ChallengeMode.create(difficulty, stageIndex) - return ChallengeMode(GameModes.getPreset("ONE_PLAYER_CHALLENGE"), Game1pChallenge, difficulty, stageIndex) + return ChallengeMode(GameModes.getPreset(GameModes.IDs.ONE_PLAYER_CHALLENGE), Game1pChallenge, difficulty, stageIndex) end ChallengeMode.numDifficulties = 8 diff --git a/client/src/network/NetClient.lua b/client/src/network/NetClient.lua index 7800ba6f..6df84780 100644 --- a/client/src/network/NetClient.lua +++ b/client/src/network/NetClient.lua @@ -14,6 +14,7 @@ local GameBase = require("client.src.scenes.GameBase") local LoginRoutine = require("client.src.network.LoginRoutine") local MessageTransition = require("client.src.scenes.Transitions.MessageTransition") local LevelData = require("common.data.LevelData") +local GameModes = require("common.data.GameModes") ---@enum NetClientStates local states = { OFFLINE = 1, LOGIN = 2, ONLINE = 3, ROOM = 4, INGAME = 5 } @@ -22,14 +23,6 @@ local states = { OFFLINE = 1, LOGIN = 2, ONLINE = 3, ROOM = 4, INGAME = 5 } -- that get automatically processed via NetClient:update local function resetLobbyData(self) - self.lobbyData = { - players = {}, - unpairedPlayers = {}, - willingPlayers = {}, - spectatableRooms = {}, - sentRequests = {} - } - ---@class PersonalizedLobbyDataV2 self.lobbyDataV2 = { ---@type table @@ -45,32 +38,6 @@ local function resetLobbyData(self) } end -local function updateLobbyState(self, lobbyState) - if lobbyState.players then - self.lobbyData.players = lobbyState.players - end - - if lobbyState.unpaired then - self.lobbyData.unpairedPlayers = lobbyState.unpaired - -- players who leave the unpaired list no longer have standing invitations to us.\ - -- we also no longer have a standing invitation to them, so we'll remove them from sentRequests - local newWillingPlayers = {} - local newSentRequests = {} - for _, player in ipairs(self.lobbyData.unpairedPlayers) do - newWillingPlayers[player] = self.lobbyData.willingPlayers[player] - newSentRequests[player] = self.lobbyData.sentRequests[player] - end - self.lobbyData.willingPlayers = newWillingPlayers - self.lobbyData.sentRequests = newSentRequests - end - - if lobbyState.spectatable then - self.lobbyData.spectatableRooms = lobbyState.spectatable - end - - self:emitSignal("lobbyStateUpdate", self.lobbyData) -end - ---@param lobbyStateV2Message { content: LobbyStateV2 } local function updateLobbyStateV2(self, lobbyStateV2Message) local lobbyStateV2 = lobbyStateV2Message.content @@ -381,7 +348,6 @@ local function createListeners(self) -- messageListener holds *all* available listeners local messageListeners = {} messageListeners.create_room = createListener(self, "create_room", start2pVsOnlineMatch) - messageListeners.players = createListener(self, "unpaired", updateLobbyState) messageListeners.lobbyStateV2 = createListener(self, "lobbyStateV2", updateLobbyStateV2) messageListeners.challengeUpdate = createListener(self, "challengeUpdate", processChallengeUpdate) messageListeners.menu_state = createListener(self, "menu_state", processMenuStateMessage) @@ -501,18 +467,11 @@ function NetClient:sendInput(input) end end -function NetClient:requestLeaderboard() +---@param gameModeId GameModeID? +function NetClient:requestLeaderboard(gameModeId) if not self.pendingResponses.leaderboardUpdate then - self.pendingResponses.leaderboardUpdate = self.tcpClient:sendRequest(ClientMessages.requestLeaderboard()) - end -end - ----@deprecated -function NetClient:challengePlayer(name) - if not self.lobbyData.sentRequests[name] then - self.tcpClient:sendRequest(ClientMessages.challengePlayer(config.name, name)) - self.lobbyData.sentRequests[name] = true - self:emitSignal("lobbyStateUpdate", self.lobbyData) + gameModeId = gameModeId or GameModes.IDs.TWO_PLAYER_VS + self.pendingResponses.leaderboardUpdate = self.tcpClient:sendRequest(ClientMessages.requestLeaderboard(gameModeId)) end end diff --git a/client/src/scenes/EndlessMenu.lua b/client/src/scenes/EndlessMenu.lua index ea5936ca..a24ee65a 100644 --- a/client/src/scenes/EndlessMenu.lua +++ b/client/src/scenes/EndlessMenu.lua @@ -8,7 +8,7 @@ local ui = require("client.src.ui") ---@class EndlessMenu : CharacterSelect local EndlessMenu = class( function(self, sceneParams) - self.gameMode = GameModes.getPreset("ONE_PLAYER_ENDLESS") + self.gameMode = GameModes.getPreset(GameModes.IDs.ONE_PLAYER_ENDLESS) self.gameScene = "EndlessGame" end, CharacterSelect diff --git a/client/src/scenes/Lobby.lua b/client/src/scenes/Lobby.lua index 188fbeae..f890210a 100644 --- a/client/src/scenes/Lobby.lua +++ b/client/src/scenes/Lobby.lua @@ -64,14 +64,14 @@ function Lobby:initLobbyMenu() label = ui.Label({text = "mm_1_endless"}), width = self.lobbyMenuWidth, onClick = function() - GAME.netClient:requestRoom(GameModes.getPreset("ONE_PLAYER_ENDLESS")) + GAME.netClient:requestRoom(GameModes.getPreset(GameModes.IDs.ONE_PLAYER_ENDLESS)) end }) self.onePlayerTimeAttackButton = ui.TextButton({ label = ui.Label({text = "mm_1_time"}), width = self.lobbyMenuWidth, onClick = function() - GAME.netClient:requestRoom(GameModes.getPreset("ONE_PLAYER_TIME_ATTACK")) + GAME.netClient:requestRoom(GameModes.getPreset(GameModes.IDs.ONE_PLAYER_TIME_ATTACK)) end }) self.onePlayerVsButton = ui.TextButton({ @@ -82,7 +82,7 @@ function Lobby:initLobbyMenu() GAME.localPlayer:setStyle(GameModes.Styles.MODERN) GAME.netClient:sendPlayerSettings(GAME.localPlayer) end - GAME.netClient:requestRoom(GameModes.getPreset("ONE_PLAYER_VS_SELF")) + GAME.netClient:requestRoom(GameModes.getPreset(GameModes.IDs.ONE_PLAYER_VS_SELF)) end }) self.leaderboardToggleLabel = ui.Label({text = "lb_show_board"}) @@ -146,7 +146,7 @@ function Lobby:toggleLeaderboard() GAME.theme:playMoveSfx() if not self.leaderboard.isVisible then self.leaderboardToggleLabel:setText("lb_hide_board") - GAME.netClient:requestLeaderboard() + GAME.netClient:requestLeaderboard(GameModes.IDs.TWO_PLAYER_VS) self.lobbyMenu:setFocus(self.leaderboard, function() self:toggleLeaderboard() end) else self.leaderboardToggleLabel:setText("lb_show_board") @@ -202,7 +202,7 @@ function Lobby.getPlayerNameWithRating(publicId, gameModeId) logger.warn("Tried to get rating for unknown player id " .. publicId) return tostring(publicId) else - gameModeId = gameModeId or "TWO_PLAYER_VS" + gameModeId = gameModeId or GameModes.IDs.TWO_PLAYER_VS if player.ratings[gameModeId] then return player.name .. " (" .. player.ratings[gameModeId] .. ")" else @@ -270,11 +270,11 @@ function Lobby:createRoomButtons(personalizedLobbyData) end local icon - if room.gameModeId == "TWO_PLAYER_VS" or room.gameModeId == "ONE_PLAYER_VS_SELF" then + if room.gameModeId == GameModes.IDs.TWO_PLAYER_VS or room.gameModeId == GameModes.IDs.ONE_PLAYER_VS_SELF then icon = GAME.theme:getFightImage() - elseif room.gameModeId == "TWO_PLAYER_TIME_ATTACK" or room.gameModeId == "ONE_PLAYER_TIME_ATTACK" then + elseif room.gameModeId == GameModes.IDs.TWO_PLAYER_TIME_ATTACK or room.gameModeId == GameModes.IDs.ONE_PLAYER_TIME_ATTACK then icon = GAME.theme:getStopwatchImage() - elseif room.gameModeId == "ONE_PLAYER_ENDLESS" then + elseif room.gameModeId == GameModes.IDs.ONE_PLAYER_ENDLESS then icon = GAME.theme:getEndlessImage() else icon = GAME.theme:chainImage(0) @@ -324,7 +324,7 @@ function Lobby:openPlayerSubMenu(playerId, button) subMenu.playerId = playerId local vsButton = ui.LobbyChallengeButton({ - gameModeId = "TWO_PLAYER_VS", + gameModeId = GameModes.IDs.TWO_PLAYER_VS, iconSize = 16, playerId = playerId, label = ui.Label({text = "vs"}), @@ -337,7 +337,7 @@ function Lobby:openPlayerSubMenu(playerId, button) subMenu:addChild(vsButton) local timeAttackButton = ui.LobbyChallengeButton({ - gameModeId = "TWO_PLAYER_TIME_ATTACK", + gameModeId = GameModes.IDs.TWO_PLAYER_TIME_ATTACK, iconSize = 16, playerId = playerId, label = ui.Label({text = "gm_time_attack"}), @@ -349,19 +349,19 @@ function Lobby:openPlayerSubMenu(playerId, button) subMenu:addChild(timeAttackButton) if lobbyDataV2.outgoingChallenges[playerId] then - if lobbyDataV2.outgoingChallenges[playerId]["TWO_PLAYER_VS"] == true then + if lobbyDataV2.outgoingChallenges[playerId][GameModes.IDs.TWO_PLAYER_VS] == true then vsButton:setState(vsButton.challengeStates.PROPOSING) end - if lobbyDataV2.outgoingChallenges[playerId]["TWO_PLAYER_TIME_ATTACK"] == true then + if lobbyDataV2.outgoingChallenges[playerId][GameModes.IDs.TWO_PLAYER_TIME_ATTACK] == true then timeAttackButton:setState(timeAttackButton.challengeStates.PROPOSING) end end if lobbyDataV2.incomingChallenges[playerId] then - if lobbyDataV2.incomingChallenges[playerId]["TWO_PLAYER_VS"] then + if lobbyDataV2.incomingChallenges[playerId][GameModes.IDs.TWO_PLAYER_VS] then vsButton:setState(vsButton.challengeStates.CHALLENGED) end - if lobbyDataV2.incomingChallenges[playerId]["TWO_PLAYER_TIME_ATTACK"] then + if lobbyDataV2.incomingChallenges[playerId][GameModes.IDs.TWO_PLAYER_TIME_ATTACK] then timeAttackButton:setState(timeAttackButton.challengeStates.CHALLENGED) end end diff --git a/client/src/scenes/LocalGameModeSelectionScene.lua b/client/src/scenes/LocalGameModeSelectionScene.lua index ac90c698..4cab6e0a 100644 --- a/client/src/scenes/LocalGameModeSelectionScene.lua +++ b/client/src/scenes/LocalGameModeSelectionScene.lua @@ -20,14 +20,14 @@ LocalGameModeSelectionScene.name = "LocalGameModeSelectionScene" function LocalGameModeSelectionScene:load(sceneParams) local menuItems = { ui.MenuItem.createButtonMenuItem("rp_browser_info_time_trial", nil, nil, function () - GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("TWO_PLAYER_TIME_ATTACK"), TimeAttackGame) + GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset(GameModes.IDs.TWO_PLAYER_TIME_ATTACK), TimeAttackGame) if GAME.battleRoom then GAME.theme:playValidationSfx() GAME.navigationStack:push(CharacterSelect2p({battleRoom = GAME.battleRoom})) end end), ui.MenuItem.createButtonMenuItem("vs", nil, nil, function () - GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("TWO_PLAYER_VS"), GameBase) + GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset(GameModes.IDs.TWO_PLAYER_VS), GameBase) if GAME.battleRoom then GAME.theme:playValidationSfx() GAME.navigationStack:push(CharacterSelect2p({battleRoom = GAME.battleRoom})) diff --git a/client/src/scenes/MainMenu.lua b/client/src/scenes/MainMenu.lua index 43e6ee87..90e060b7 100644 --- a/client/src/scenes/MainMenu.lua +++ b/client/src/scenes/MainMenu.lua @@ -52,25 +52,25 @@ end function MainMenu:createMainMenu() local menuItems = {ui.MenuItem.createButtonMenuItem("mm_1_endless", nil, nil, function() - GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("ONE_PLAYER_ENDLESS"), EndlessGame) + GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset(GameModes.IDs.ONE_PLAYER_ENDLESS), EndlessGame) if GAME.battleRoom then switchToScene(EndlessMenu({battleRoom = GAME.battleRoom})) end end), ui.MenuItem.createButtonMenuItem("mm_1_puzzle", nil, nil, function() - GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("ONE_PLAYER_PUZZLE"), PuzzleGame) + GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset(GameModes.IDs.ONE_PLAYER_PUZZLE), PuzzleGame) if GAME.battleRoom then switchToScene(PuzzleMenu({battleRoom = GAME.battleRoom})) end end), ui.MenuItem.createButtonMenuItem("mm_1_time", nil, nil, function() - GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("ONE_PLAYER_TIME_ATTACK"), TimeAttackGame) + GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset(GameModes.IDs.ONE_PLAYER_TIME_ATTACK), TimeAttackGame) if GAME.battleRoom then switchToScene(TimeAttackMenu({battleRoom = GAME.battleRoom})) end end), ui.MenuItem.createButtonMenuItem("mm_1_vs", nil, nil, function() - GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("ONE_PLAYER_VS_SELF"), VsSelfGame) + GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset(GameModes.IDs.ONE_PLAYER_VS_SELF), VsSelfGame) if GAME.battleRoom then switchToScene(CharacterSelectVsSelf({battleRoom = GAME.battleRoom})) end diff --git a/client/src/scenes/TimeAttackMenu.lua b/client/src/scenes/TimeAttackMenu.lua index 41fc7338..acec21a8 100644 --- a/client/src/scenes/TimeAttackMenu.lua +++ b/client/src/scenes/TimeAttackMenu.lua @@ -7,7 +7,7 @@ local ui = require("client.src.ui") -- Scene for the time attack game setup menu local TimeAttackMenu = class( function(self, sceneParams) - self.gameMode = GameModes.getPreset("ONE_PLAYER_TIME_ATTACK") + self.gameMode = GameModes.getPreset(GameModes.IDs.ONE_PLAYER_TIME_ATTACK) self.gameScene = "TimeAttackGame" end, CharacterSelect diff --git a/client/src/scenes/TrainingMenu.lua b/client/src/scenes/TrainingMenu.lua index b6445db9..17c88047 100644 --- a/client/src/scenes/TrainingMenu.lua +++ b/client/src/scenes/TrainingMenu.lua @@ -43,7 +43,7 @@ function TrainingMenu:goToCharacterSelect(value, width, height) if value == nil then value = createBasicTrainingMode("", width, height) end - GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("ONE_PLAYER_TRAINING"), GameBase) + GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset(GameModes.IDs.ONE_PLAYER_TRAINING), GameBase) if GAME.battleRoom then GAME.localPlayer:setAttackEngineSettings(value) GAME.navigationStack:push(CharacterSelectVsSelf({battleRoom = GAME.battleRoom})) diff --git a/client/tests/PlayerSettingsTests.lua b/client/tests/PlayerSettingsTests.lua index fc249d3d..d1eda8d6 100644 --- a/client/tests/PlayerSettingsTests.lua +++ b/client/tests/PlayerSettingsTests.lua @@ -8,7 +8,7 @@ local function testDifficultyCarouselShouldNotMutatePlayerOnCreation() local player = Player("TestPlayer", 1, true) - local gameMode = GameModes.getPreset("ONE_PLAYER_ENDLESS") + local gameMode = GameModes.getPreset(GameModes.IDs.ONE_PLAYER_ENDLESS) local battleRoom = BattleRoom(gameMode) battleRoom:addPlayer(player) @@ -35,7 +35,7 @@ testDifficultyCarouselShouldNotMutatePlayerOnCreation() local function testEndlessModeClassicDifficulty1SetsCorrectSettings() - local gameMode = GameModes.getPreset("ONE_PLAYER_ENDLESS") + local gameMode = GameModes.getPreset(GameModes.IDs.ONE_PLAYER_ENDLESS) local battleRoom = BattleRoom.createLocalFromGameMode(gameMode, nil, false) assert(battleRoom ~= nil, "BattleRoom should be created successfully") @@ -60,7 +60,7 @@ testEndlessModeClassicDifficulty1SetsCorrectSettings() local function testVsSelfChangesEndlessClassicSettingsToModern() -- First create an endless battle room with classic difficulty 1 - local gameMode = GameModes.getPreset("ONE_PLAYER_ENDLESS") + local gameMode = GameModes.getPreset(GameModes.IDs.ONE_PLAYER_ENDLESS) local endlessBattleRoom = BattleRoom.createLocalFromGameMode(gameMode, nil, false) assert(endlessBattleRoom ~= nil, "Endless BattleRoom should be created successfully") @@ -79,7 +79,7 @@ local function testVsSelfChangesEndlessClassicSettingsToModern() endlessBattleRoom:shutdown() -- Now create a vs self battle room which should change settings to modern - local vsSelfGameMode = GameModes.getPreset("ONE_PLAYER_VS_SELF") + local vsSelfGameMode = GameModes.getPreset(GameModes.IDs.ONE_PLAYER_VS_SELF) local vsSelfBattleRoom = BattleRoom.createLocalFromGameMode(vsSelfGameMode, nil, false) assert(vsSelfBattleRoom ~= nil, "Vs self BattleRoom should be created successfully") diff --git a/client/tests/StackGraphicsTests.lua b/client/tests/StackGraphicsTests.lua index 09db7e0b..b2230bf4 100644 --- a/client/tests/StackGraphicsTests.lua +++ b/client/tests/StackGraphicsTests.lua @@ -20,7 +20,7 @@ local legacyScoreY = 208 ---@param theme table? ---@return ClientMatch local function createEndlessClientMatch(playerCount, theme) - local endless = GameModes.getPreset("ONE_PLAYER_ENDLESS") + local endless = GameModes.getPreset(GameModes.IDs.ONE_PLAYER_ENDLESS) local players = {} if playerCount == nil then playerCount = 1 diff --git a/common/compatibility/ReplayV2.lua b/common/compatibility/ReplayV2.lua index 8f85cbb8..dc387f17 100644 --- a/common/compatibility/ReplayV2.lua +++ b/common/compatibility/ReplayV2.lua @@ -7,6 +7,7 @@ local LevelPresets = require("common.data.LevelPresets") local LevelData = require("common.data.LevelData") local StackBehaviours = require("common.data.StackBehaviours") local InputCompression = require("common.data.InputCompression") +local GameModes = require("common.data.GameModes") local REPLAY_VERSION = 2 @@ -173,16 +174,16 @@ function ReplayV2.createFromLegacyReplay(legacyReplay, timestamp, winnerIndex) if legacyReplay.vs then mode = "vs" if legacyReplay.vs.P2_char then - gameMode = LegacyGameModes.getPreset("TWO_PLAYER_VS") + gameMode = LegacyGameModes.getPreset(GameModes.IDs.TWO_PLAYER_VS) else - gameMode = LegacyGameModes.getPreset("ONE_PLAYER_VS_SELF") + gameMode = LegacyGameModes.getPreset(GameModes.IDs.ONE_PLAYER_VS_SELF) end elseif legacyReplay.time then mode = "time" - gameMode = LegacyGameModes.getPreset("ONE_PLAYER_TIME_ATTACK") + gameMode = LegacyGameModes.getPreset(GameModes.IDs.ONE_PLAYER_TIME_ATTACK) elseif legacyReplay.endless then mode = "endless" - gameMode = LegacyGameModes.getPreset("ONE_PLAYER_ENDLESS") + gameMode = LegacyGameModes.getPreset(GameModes.IDs.ONE_PLAYER_ENDLESS) end local v1r = legacyReplay[mode] -- doCountdown used to be configurable client side for time attack / endless diff --git a/common/data/GameModes.lua b/common/data/GameModes.lua index 74fadc42..03beee94 100644 --- a/common/data/GameModes.lua +++ b/common/data/GameModes.lua @@ -4,8 +4,6 @@ local TIME_ATTACK_TIME = 120 local GameModes = {} ----@alias GameModeID ("ONE_PLAYER_VS_SELF"|"ONE_PLAYER_TIME_ATTACK"|"ONE_PLAYER_ENDLESS"|"ONE_PLAYER_TRAINING"|"ONE_PLAYER_PUZZLE"|"ONE_PLAYER_CHALLENGE"|"TWO_PLAYER_VS"|"TWO_PLAYER_TIME_ATTACK") Used as identifiers for the type of game that is being played - ---@class GameMode ---@field stackInteraction StackInteractions ---@field matchRules MatchRules @@ -211,16 +209,28 @@ local TwoPlayerTimeAttack = GameMode({ GameModes.Styles = Styles GameModes.StackInteractions = StackInteractions +---@enum (key) GameModeID Used as identifier for the type of game that is being played +GameModes.IDs = { + TWO_PLAYER_VS = "TWO_PLAYER_VS", + ONE_PLAYER_TIME_ATTACK = "ONE_PLAYER_TIME_ATTACK", + ONE_PLAYER_ENDLESS = "ONE_PLAYER_ENDLESS", + ONE_PLAYER_TRAINING = "ONE_PLAYER_TRAINING", + ONE_PLAYER_CHALLENGE = "ONE_PLAYER_CHALLENGE", + ONE_PLAYER_VS_SELF = "ONE_PLAYER_VS_SELF", + ONE_PLAYER_PUZZLE = "ONE_PLAYER_PUZZLE", + TWO_PLAYER_TIME_ATTACK = "TWO_PLAYER_TIME_ATTACK", +} + ---@type table local privateGameModes = {} -privateGameModes.ONE_PLAYER_VS_SELF = OnePlayerVsSelf -privateGameModes.ONE_PLAYER_TIME_ATTACK = OnePlayerTimeAttack -privateGameModes.ONE_PLAYER_ENDLESS = OnePlayerEndless -privateGameModes.ONE_PLAYER_TRAINING = OnePlayerTraining -privateGameModes.ONE_PLAYER_PUZZLE = OnePlayerPuzzle -privateGameModes.ONE_PLAYER_CHALLENGE = OnePlayerChallenge -privateGameModes.TWO_PLAYER_VS = TwoPlayerVersus -privateGameModes.TWO_PLAYER_TIME_ATTACK = TwoPlayerTimeAttack +privateGameModes[GameModes.IDs.ONE_PLAYER_VS_SELF] = OnePlayerVsSelf +privateGameModes[GameModes.IDs.ONE_PLAYER_TIME_ATTACK] = OnePlayerTimeAttack +privateGameModes[GameModes.IDs.ONE_PLAYER_ENDLESS] = OnePlayerEndless +privateGameModes[GameModes.IDs.ONE_PLAYER_TRAINING] = OnePlayerTraining +privateGameModes[GameModes.IDs.ONE_PLAYER_PUZZLE] = OnePlayerPuzzle +privateGameModes[GameModes.IDs.ONE_PLAYER_CHALLENGE] = OnePlayerChallenge +privateGameModes[GameModes.IDs.TWO_PLAYER_VS] = TwoPlayerVersus +privateGameModes[GameModes.IDs.TWO_PLAYER_TIME_ATTACK] = TwoPlayerTimeAttack ---@param mode GameModeID ---@return GameMode diff --git a/common/engine/Puzzle.lua b/common/engine/Puzzle.lua index 65236b97..efd1bb23 100644 --- a/common/engine/Puzzle.lua +++ b/common/engine/Puzzle.lua @@ -397,7 +397,7 @@ end ---@return GameMode function Puzzle:toGameMode() - local mode = GameModes.getPreset("ONE_PLAYER_PUZZLE") + local mode = GameModes.getPreset(GameModes.IDs.ONE_PLAYER_PUZZLE) if not mode.matchRules.stackSetupModifications then mode.matchRules.stackSetupModifications = { behaviours = {}} elseif not mode.matchRules.stackSetupModifications.behaviours then diff --git a/common/network/ClientProtocol.lua b/common/network/ClientProtocol.lua index c6f118b1..e535d400 100644 --- a/common/network/ClientProtocol.lua +++ b/common/network/ClientProtocol.lua @@ -54,14 +54,6 @@ end -- Lobby related requests ------------------------- --- players are challenged by their current name on the server ----@param senderName string ----@param receiverName string ----@param gameModeId GameModeID? nil if the challenged picks the game mode -function ClientMessages.challengePlayer(senderName, receiverName, gameModeId) - error("game_request has been retired") -end - ---@param senderId PublicPlayerID ---@param receiverId PublicPlayerID ---@param gameModeId GameModeID @@ -100,10 +92,11 @@ function ClientMessages.requestSpectate(spectatorName, roomNumber) } end -function ClientMessages.requestLeaderboard() +---@param gameModeId GameModeID +function ClientMessages.requestLeaderboard(gameModeId) local leaderboardRequestMessage = { leaderboard_request = true, - leaderboardType = "TWO_PLAYER_VS", + leaderboardType = gameModeId, } return { diff --git a/common/tests/engine/GarbageQueueTestingUtils.lua b/common/tests/engine/GarbageQueueTestingUtils.lua index 343ccfc1..94492147 100644 --- a/common/tests/engine/GarbageQueueTestingUtils.lua +++ b/common/tests/engine/GarbageQueueTestingUtils.lua @@ -13,9 +13,9 @@ local GarbageQueueTestingUtils = {} function GarbageQueueTestingUtils.createMatch(stackHealth, attackFile) local mode if attackFile then - mode = GameModes.getPreset("ONE_PLAYER_TRAINING") + mode = GameModes.getPreset(GameModes.IDs.ONE_PLAYER_TRAINING) else - mode = GameModes.getPreset("ONE_PLAYER_VS_SELF") + mode = GameModes.getPreset(GameModes.IDs.ONE_PLAYER_VS_SELF) end local levelData = LevelPresets.getModern(1) diff --git a/common/tests/engine/PanelGenTests.lua b/common/tests/engine/PanelGenTests.lua index d2e2e711..5b1dd855 100644 --- a/common/tests/engine/PanelGenTests.lua +++ b/common/tests/engine/PanelGenTests.lua @@ -126,7 +126,7 @@ local function testLegacyStartingBoard1() local difficulty = "easy" -- endless easy deviates by 1 color from time attack easy which is the default easy preset local colorCount = 5 - local stack, panelSource = createStackWithLegacySource(GameModes.getPreset("ONE_PLAYER_ENDLESS"), difficulty, nil, colorCount, seed) + local stack, panelSource = createStackWithLegacySource(GameModes.getPreset(GameModes.IDs.ONE_PLAYER_ENDLESS), difficulty, nil, colorCount, seed) panelSource:setAllowAdjacentColorsOnStartingBoard(true) panelSource.panelBuffer = panelSource:generateStartingBoard(stack) @@ -145,7 +145,7 @@ testLegacyStartingBoard1() local function testLegacyStartingBoard2() local seed = 8 local level = 10 - local stack, panelSource = createStackWithLegacySource(GameModes.getPreset("ONE_PLAYER_VS_SELF"), nil, level, nil, seed) + local stack, panelSource = createStackWithLegacySource(GameModes.getPreset(GameModes.IDs.ONE_PLAYER_VS_SELF), nil, level, nil, seed) panelSource:setAllowAdjacentColorsOnStartingBoard(false) -- expected starting 7 rows (unprocessed): 312132464356316131624643241456614364463521 @@ -174,7 +174,7 @@ local function testLegacyStartingBoard3() -- this seed tests for a certain bug that occured when the first character was a possible metal location for generating the starting board local seed = 351545 local level = 10 - local stack, panelSource = createStackWithLegacySource(GameModes.getPreset("ONE_PLAYER_VS_SELF"), nil, level, nil, seed) + local stack, panelSource = createStackWithLegacySource(GameModes.getPreset(GameModes.IDs.ONE_PLAYER_VS_SELF), nil, level, nil, seed) panelSource:setAllowAdjacentColorsOnStartingBoard(false) panelSource.panelBuffer = panelSource:generateStartingBoard(stack) checkPanels(panelSource.panelBuffer, 6) @@ -187,7 +187,7 @@ local function testLegacyStartingBoard4() -- this seed tests for a certain bug that occured when a starting board row had no shock assignments left: local seed = 4530333 local level = 8 - local stack, panelSource = createStackWithLegacySource(GameModes.getPreset("ONE_PLAYER_VS_SELF"), nil, level, nil, seed) + local stack, panelSource = createStackWithLegacySource(GameModes.getPreset(GameModes.IDs.ONE_PLAYER_VS_SELF), nil, level, nil, seed) panelSource:setAllowAdjacentColorsOnStartingBoard(false) panelSource.panelBuffer = panelSource:generateStartingBoard(stack) diff --git a/common/tests/engine/StackReplayTestingUtils.lua b/common/tests/engine/StackReplayTestingUtils.lua index d6042362..9d405779 100644 --- a/common/tests/engine/StackReplayTestingUtils.lua +++ b/common/tests/engine/StackReplayTestingUtils.lua @@ -18,7 +18,7 @@ end ---@return Match function StackReplayTestingUtils.createEndlessMatch(speed, difficulty, level, playerCount) - local endless = GameModes.getPreset("ONE_PLAYER_ENDLESS") + local endless = GameModes.getPreset(GameModes.IDs.ONE_PLAYER_ENDLESS) if playerCount == nil then playerCount = 1 end diff --git a/server/ClientMessages.lua b/server/ClientMessages.lua index 7b7bbd80..659cc881 100644 --- a/server/ClientMessages.lua +++ b/server/ClientMessages.lua @@ -4,6 +4,7 @@ -- and changes in server code likewise only affect this abstraction layer instead of the ClientProtocol local logger = require("common.lib.logger") local LevelData = require("common.data.LevelData") +local GameModes = require("common.data.GameModes") local ClientMessages = {} @@ -11,8 +12,6 @@ local ClientMessages = {} function ClientMessages.sanitizeMessage(clientMessage) if clientMessage.login_request then return ClientMessages.sanitizeLoginRequest(clientMessage) - elseif clientMessage.game_request then - return ClientMessages.sanitizeGameRequest(clientMessage) elseif clientMessage.challengeUpdate then return ClientMessages.sanitizeChallengeUpdate(clientMessage) elseif clientMessage.menu_state then @@ -110,21 +109,6 @@ function ClientMessages.sanitizeLoginRequest(loginRequest) return sanitized end -function ClientMessages.sanitizeGameRequest(gameRequest) - local sanitized = - { - game_request = - { - sender = gameRequest.game_request.sender, - receiver = gameRequest.game_request.receiver, - -- default value only for slow adaption, remove and sanity check once clients send this properly - gameModeId = gameRequest.game_request.gameModeId or "TWO_PLAYER_VS", - } - } - - return sanitized -end - function ClientMessages.sanitizeChallengeUpdate(message) local sanitized = { @@ -158,7 +142,7 @@ function ClientMessages.sanitizeLeaderboardRequest(leaderboardRequest) { leaderboard_request = leaderboardRequest.leaderboard_request, -- default value only for slow adaption, remove and sanity check later - gameModeId = leaderboardRequest.leaderboardType or "TWO_PLAYER_VS", + gameModeId = leaderboardRequest.leaderboardType or GameModes.IDs.TWO_PLAYER_VS, } return sanitized diff --git a/server/tests/LeaderboardTests.lua b/server/tests/LeaderboardTests.lua index f9dae7b3..d3dd11f7 100644 --- a/server/tests/LeaderboardTests.lua +++ b/server/tests/LeaderboardTests.lua @@ -6,7 +6,7 @@ local GameModes = require("common.data.GameModes") local MockPersistence = require("server.tests.MockPersistence") local ServerTesting = require("server.tests.ServerTesting") -local leaderboard = Leaderboard(GameModes.getPreset("TWO_PLAYER_VS"), MockPersistence) +local leaderboard = Leaderboard(GameModes.getPreset(GameModes.IDs.TWO_PLAYER_VS), MockPersistence) leaderboard.consts.PLACEMENT_MATCH_COUNT_REQUIREMENT = 2 leaderboard.consts.RATING_SPREAD_MODIFIER = 400 leaderboard.consts.ALLOWABLE_RATING_SPREAD_MULTIPLIER = .9 diff --git a/server/tests/RoomTests.lua b/server/tests/RoomTests.lua index 544d3a5d..3019990e 100644 --- a/server/tests/RoomTests.lua +++ b/server/tests/RoomTests.lua @@ -12,7 +12,7 @@ local function getRoom() p2:updateSettings({inputMethod = "controller", level = 10}) -- don't want to deal with I/O for the test p1.save_replays_publicly = "not at all" - local room = Room(1, {p1, p2}, GameModes.getPreset("TWO_PLAYER_VS")) + local room = Room(1, {p1, p2}, GameModes.getPreset(GameModes.IDs.TWO_PLAYER_VS)) -- the game is being cleared from the room when it ends so catch the reference to assert against local gameCatcher = { catch = function(self, game) self.game = game end diff --git a/server/tests/ServerTesting.lua b/server/tests/ServerTesting.lua index 10134d1a..7d9011c6 100644 --- a/server/tests/ServerTesting.lua +++ b/server/tests/ServerTesting.lua @@ -5,6 +5,7 @@ local ClientProtocol = require("common.network.ClientProtocol") local MockConnection = require("server.tests.MockConnection") local Player = require("server.Player") local json = require("common.lib.dkjson") +local GameModes = require("common.data.GameModes") local ServerTesting = {} @@ -110,9 +111,9 @@ function ServerTesting.setupRoom(server, player1, player2, alreadyLoggedIn) player2 = ServerTesting.login(server, player2) ServerTesting.clearOutgoingMessages({player1, player2}) end - player1.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(player1.publicPlayerID, player2.publicPlayerID, "TWO_PLAYER_VS", true).messageText)) + player1.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(player1.publicPlayerID, player2.publicPlayerID, GameModes.IDs.TWO_PLAYER_VS, true).messageText)) server:update() - player2.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(player2.publicPlayerID, player1.publicPlayerID, "TWO_PLAYER_VS", true).messageText)) + player2.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(player2.publicPlayerID, player1.publicPlayerID, GameModes.IDs.TWO_PLAYER_VS, true).messageText)) server:update() ServerTesting.clearOutgoingMessages({player1, player2}) diff --git a/server/tests/ServerTests.lua b/server/tests/ServerTests.lua index ade8c3e7..07c9700c 100644 --- a/server/tests/ServerTests.lua +++ b/server/tests/ServerTests.lua @@ -37,14 +37,14 @@ local function testRoomSetup() -- there are other tests to verify lobby data ServerTesting.clearOutgoingMessages({alice, ben, bob}) - alice.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(alice.publicPlayerID, ben.publicPlayerID, "TWO_PLAYER_VS", true).messageText)) + alice.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(alice.publicPlayerID, ben.publicPlayerID, GameModes.IDs.TWO_PLAYER_VS, true).messageText)) server:update() - assert(server.proposals[alice.publicPlayerID][ben.publicPlayerID]["TWO_PLAYER_VS"] == true) + assert(server.proposals[alice.publicPlayerID][ben.publicPlayerID][GameModes.IDs.TWO_PLAYER_VS] == true) local message = ben.connection.outgoingMessageQueue:pop().messageText assert(message.type == "challengeUpdate" and message.content.sender == "Alice" and message.content.receiver == "Ben") assert(ben.connection.outgoingMessageQueue:len() == 0) - ben.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(ben.publicPlayerID, alice.publicPlayerID, "TWO_PLAYER_VS", true).messageText)) + ben.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(ben.publicPlayerID, alice.publicPlayerID, GameModes.IDs.TWO_PLAYER_VS, true).messageText)) server:update() assert(server.proposals[alice.publicPlayerID] == nil or next(server.proposals[alice.publicPlayerID]) == nil) assert(server.proposals[ben.publicPlayerID] == nil or next(server.proposals[ben.publicPlayerID]) == nil) @@ -52,7 +52,7 @@ local function testRoomSetup() local room = server.playerToRoom[alice] assert(room and room.roomNumber == 1) assert(room == server.playerToRoom[ben]) - assert(room.gameMode.name == GameModes.gameModeIdToName["TWO_PLAYER_VS"]) + assert(room.gameMode.name == GameModes.gameModeIdToName[GameModes.IDs.TWO_PLAYER_VS]) message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "createRoom" and tableUtils.length(message.content.players) == 2) message = ben.connection.outgoingMessageQueue:pop().messageText @@ -72,14 +72,14 @@ local function testRoomSetup2() -- there are other tests to verify lobby data ServerTesting.clearOutgoingMessages({alice, ben, bob}) - alice.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(alice.publicPlayerID, ben.publicPlayerID, "TWO_PLAYER_TIME_ATTACK", true).messageText)) + alice.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(alice.publicPlayerID, ben.publicPlayerID, GameModes.IDs.TWO_PLAYER_TIME_ATTACK, true).messageText)) server:update() - assert(server.proposals[alice.publicPlayerID][ben.publicPlayerID]["TWO_PLAYER_TIME_ATTACK"] == true) + assert(server.proposals[alice.publicPlayerID][ben.publicPlayerID][GameModes.IDs.TWO_PLAYER_TIME_ATTACK] == true) local message = ben.connection.outgoingMessageQueue:pop().messageText assert(message.type == "challengeUpdate" and message.content.sender == "Alice" and message.content.receiver == "Ben") assert(ben.connection.outgoingMessageQueue:len() == 0) - ben.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(ben.publicPlayerID, alice.publicPlayerID, "TWO_PLAYER_TIME_ATTACK", true).messageText)) + ben.connection:receiveMessage(json.encode(ClientProtocol.updateChallengeStatus(ben.publicPlayerID, alice.publicPlayerID, GameModes.IDs.TWO_PLAYER_TIME_ATTACK, true).messageText)) server:update() assert(server.proposals[alice.publicPlayerID] == nil or next(server.proposals[alice.publicPlayerID]) == nil) assert(server.proposals[ben.publicPlayerID] == nil or next(server.proposals[ben.publicPlayerID]) == nil) @@ -87,7 +87,7 @@ local function testRoomSetup2() local room = server.playerToRoom[alice] assert(room and room.roomNumber == 1) assert(room == server.playerToRoom[ben]) - assert(room.gameMode.name == GameModes.gameModeIdToName["TWO_PLAYER_TIME_ATTACK"]) + assert(room.gameMode.name == GameModes.gameModeIdToName[GameModes.IDs.TWO_PLAYER_TIME_ATTACK]) message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "createRoom" and tableUtils.length(message.content.players) == 2) message = ben.connection.outgoingMessageQueue:pop().messageText @@ -241,7 +241,7 @@ end local function testLobbyDataComposition() local server = ServerTesting.getTestServer() - local leaderboard = Leaderboard(GameModes.getPreset("TWO_PLAYER_VS"), MockPersistence) + local leaderboard = Leaderboard(GameModes.getPreset(GameModes.IDs.TWO_PLAYER_VS), MockPersistence) for i, player in ipairs(ServerTesting.players) do ServerTesting.addToLeaderboard(leaderboard, player) end @@ -279,7 +279,7 @@ local function testLobbyDataComposition() assert(#lobbyRoom.players == 2 and #lobbyRoom.spectators == 1) -- in the new lobby data spectators are not invisible anymore! assert(message.players[lobbyRoom.spectators[1]].name == "Bob") - assert(lobbyRoom.gameModeId == "TWO_PLAYER_VS") + assert(lobbyRoom.gameModeId == GameModes.IDs.TWO_PLAYER_VS) end local function testSinglePlayer() @@ -289,7 +289,7 @@ local function testSinglePlayer() ServerTesting.clearOutgoingMessages({bob, alice}) - bob.connection:receiveMessage(json.encode(ClientProtocol.sendRoomRequest(GameModes.getPreset("ONE_PLAYER_VS_SELF")).messageText)) + bob.connection:receiveMessage(json.encode(ClientProtocol.sendRoomRequest(GameModes.getPreset(GameModes.IDs.ONE_PLAYER_VS_SELF)).messageText)) server:update() assert(server.playerToRoom[bob]) local message = bob.connection.outgoingMessageQueue:pop().messageText @@ -307,7 +307,7 @@ local function testSinglePlayer() assert(message.type == "spectatorUpdate") message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "spectateRequestGranted" and message.content.replay == nil) - assert(tableUtils.deep_content_equal(message.content.gameMode, GameModes.getPreset("ONE_PLAYER_VS_SELF"):getGameModeJSONData())) + assert(tableUtils.deep_content_equal(message.content.gameMode, GameModes.getPreset(GameModes.IDs.ONE_PLAYER_VS_SELF):getGameModeJSONData())) message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "spectatorUpdate") diff --git a/serverLauncher.lua b/serverLauncher.lua index 9fc94c3e..4ad5865d 100644 --- a/serverLauncher.lua +++ b/serverLauncher.lua @@ -33,7 +33,7 @@ local Persistence = require("server.Persistence") local server = Server(database, Persistence) server:initializePlayerData("players.txt") -server:initializeLeaderboard(GameModes.getPreset("TWO_PLAYER_VS"), "leaderboard.csv") +server:initializeLeaderboard(GameModes.getPreset(GameModes.IDs.TWO_PLAYER_VS), "leaderboard.csv") local isPlayerTableEmpty = database:getPlayerRecordCount() == 0 if isPlayerTableEmpty then server:importDatabase() From 4eb412a0e2ce2aed2680d6be2379b59401042b2a Mon Sep 17 00:00:00 2001 From: Endaris Date: Fri, 27 Feb 2026 23:37:23 +0100 Subject: [PATCH 15/18] fix logintests failing --- server/tests/MockPersistence.lua | 8 ++++++-- testLauncher.lua | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/server/tests/MockPersistence.lua b/server/tests/MockPersistence.lua index 3fd0a547..ebb6f9b9 100644 --- a/server/tests/MockPersistence.lua +++ b/server/tests/MockPersistence.lua @@ -66,10 +66,14 @@ end ---@param privateUserId privateUserId ---@return DB_Player? function MockPersistence.getPlayerInfo(privateUserId) - --publicPlayerID: integer, privatePlayerID: integer, username: string, lastLoginTime: integer - return {publicPlayerID = testData[tonumber(privateUserId)].publicPlayerID, privatePlayerID = privateUserId, username = testData[tonumber(privateUserId)].name, lastLoginTime = 0} + if testData and testData[tonumber(privateUserId)] then + --publicPlayerID: integer, privatePlayerID: integer, username: string, lastLoginTime: integer + return {publicPlayerID = testData[tonumber(privateUserId)].publicPlayerID, privatePlayerID = privateUserId, username = testData[tonumber(privateUserId)].name, lastLoginTime = 0} + end end +-- set this in case it's important to have pre-existing players for a test with cohesive ids that can be verified against +-- otherwise every new player will be considered "new" on login for the test and may have a new id assigned function MockPersistence.setTestData(playerData) testData = playerData end diff --git a/testLauncher.lua b/testLauncher.lua index ec369f6e..e83e9155 100644 --- a/testLauncher.lua +++ b/testLauncher.lua @@ -77,9 +77,9 @@ local allTests = { "common.tests.network.NetworkProtocolTests", "common.tests.network.TouchDataEncodingTests", "common.tests.data.InputCompressionTests", + "server.tests.LoginTests", "server.tests.LeaderboardTests", "server.tests.RoomTests", - "server.tests.LoginTests", "server.tests.ServerTests", "server.tests.RealSocketPartialSendTest", "client.tests.FileUtilsTests", From 09564014ad23a2d3cb17f119b027613e7b5f8e24 Mon Sep 17 00:00:00 2001 From: Endaris Date: Sat, 28 Feb 2026 02:01:28 +0100 Subject: [PATCH 16/18] add pause to the network protocol and player/room state (single player rooms only for now) --- common/network/ClientProtocol.lua | 45 ++++++++++++++++++++---------- common/network/ServerProtocol.lua | 23 ++++++++++++++++ server/ClientMessages.lua | 14 ++++++++++ server/Player.lua | 2 +- server/Room.lua | 18 +++++++++++- server/server.lua | 15 +++------- server/tests/RoomTests.lua | 46 ++++++++++++++++++++++++++++++- 7 files changed, 134 insertions(+), 29 deletions(-) diff --git a/common/network/ClientProtocol.lua b/common/network/ClientProtocol.lua index e535d400..3a1dddc1 100644 --- a/common/network/ClientProtocol.lua +++ b/common/network/ClientProtocol.lua @@ -2,13 +2,13 @@ local NetworkProtocol = require("common.network.NetworkProtocol") local msgTypes = NetworkProtocol.clientMessageTypes local consts = require("common.engine.consts") -local ClientMessages = {} +local ClientProtocol = {} ------------------------- -- login related requests ------------------------- -function ClientMessages.requestLogin(userId, name, level, inputMethod, panels, bundleCharacter, character, bundleStage, stage, wantsRanked, saveReplaysPublicly) +function ClientProtocol.requestLogin(userId, name, level, inputMethod, panels, bundleCharacter, character, bundleStage, stage, wantsRanked, saveReplaysPublicly) local loginRequestMessage = { login_request = true, @@ -33,7 +33,7 @@ function ClientMessages.requestLogin(userId, name, level, inputMethod, panels, b } end -function ClientMessages.logout() +function ClientProtocol.logout() local logoutMessage = {logout = true} return { @@ -42,7 +42,7 @@ function ClientMessages.logout() } end -function ClientMessages.requestVersionCompatibilityCheck() +function ClientProtocol.requestVersionCompatibilityCheck() return { messageType = msgTypes.versionCheck, messageText = nil, @@ -57,7 +57,7 @@ end ---@param senderId PublicPlayerID ---@param receiverId PublicPlayerID ---@param gameModeId GameModeID -function ClientMessages.updateChallengeStatus(senderId, receiverId, gameModeId, challengeActive) +function ClientProtocol.updateChallengeStatus(senderId, receiverId, gameModeId, challengeActive) local playerChallengeV2Message = { challengeUpdate = @@ -75,7 +75,7 @@ function ClientMessages.updateChallengeStatus(senderId, receiverId, gameModeId, } end -function ClientMessages.requestSpectate(spectatorName, roomNumber) +function ClientProtocol.requestSpectate(spectatorName, roomNumber) local spectateRequestMessage = { spectate_request = @@ -93,7 +93,7 @@ function ClientMessages.requestSpectate(spectatorName, roomNumber) end ---@param gameModeId GameModeID -function ClientMessages.requestLeaderboard(gameModeId) +function ClientProtocol.requestLeaderboard(gameModeId) local leaderboardRequestMessage = { leaderboard_request = true, leaderboardType = gameModeId, @@ -109,7 +109,7 @@ end ------------------------------ -- BattleRoom related requests ------------------------------ -function ClientMessages.leaveRoom() +function ClientProtocol.leaveRoom() local leaveRoomMessage = {leave_room = true} return { messageType = msgTypes.jsonMessage, @@ -117,7 +117,7 @@ function ClientMessages.leaveRoom() } end -function ClientMessages.reportLocalGameResult(outcome) +function ClientProtocol.reportLocalGameResult(outcome) local gameResultMessage = {game_over = true, outcome = outcome} return { messageType = msgTypes.jsonMessage, @@ -125,7 +125,7 @@ function ClientMessages.reportLocalGameResult(outcome) } end -function ClientMessages.sendPlayerSettings(menuState) +function ClientProtocol.sendPlayerSettings(menuState) local menuStateMessage = {menu_state = menuState} return { messageType = msgTypes.jsonMessage, @@ -133,7 +133,7 @@ function ClientMessages.sendPlayerSettings(menuState) } end -function ClientMessages.sendTaunt(direction, index) +function ClientProtocol.sendTaunt(direction, index) local type = "taunt_" .. string.lower(direction) .. "s" local tauntMessage = {taunt = true, type = type, index = index} return { @@ -143,7 +143,7 @@ function ClientMessages.sendTaunt(direction, index) end ---@param gameMode GameMode -function ClientMessages.sendRoomRequest(gameMode) +function ClientProtocol.sendRoomRequest(gameMode) local gameModeData = gameMode:getGameModeJSONData() local roomRequestMessage = { recipient = "server", @@ -156,7 +156,7 @@ function ClientMessages.sendRoomRequest(gameMode) } end -function ClientMessages.sendMatchAbort(roomNumber) +function ClientProtocol.sendMatchAbort(roomNumber) local matchAbortMessage = { recipient = "room", recipientId = roomNumber, @@ -169,11 +169,26 @@ function ClientMessages.sendMatchAbort(roomNumber) } end +---@param pause boolean if the client is paused +function ClientProtocol.sendPauseToggle(roomNumber, pause) + local pauseToggleMessage = { + recipient = "room", + recipientId = roomNumber, + type = "pauseToggle", + content = pause, + } + + return { + messageType = msgTypes.jsonMessage, + messageText = pauseToggleMessage + } +end + ------------------------- -- miscellaneous requests ------------------------- -function ClientMessages.sendErrorReport(errorData) +function ClientProtocol.sendErrorReport(errorData) local errorReportMessage = {error_report = errorData} return { messageType = msgTypes.jsonMessage, @@ -181,4 +196,4 @@ function ClientMessages.sendErrorReport(errorData) } end -return ClientMessages +return ClientProtocol diff --git a/common/network/ServerProtocol.lua b/common/network/ServerProtocol.lua index 980d2f30..8074ae94 100644 --- a/common/network/ServerProtocol.lua +++ b/common/network/ServerProtocol.lua @@ -529,4 +529,27 @@ function ServerProtocol.sendGameAbort(source) } end +local pauseNotificationTemplate = { + sender = "room", + senderId = nil, + type = "pauseNotification", + content = { + source = nil, + paused = nil, + } +} + +---@param source ServerPlayer who paused the game +function ServerProtocol.sendPauseNotification(roomNumber, source, paused) + local pauseNotificationMessage = pauseNotificationTemplate + pauseNotificationMessage.content.source = source.publicPlayerID + pauseNotificationMessage.content.paused = paused + pauseNotificationMessage.senderId = roomNumber + + return { + messageType = msgTypes.jsonMessage, + messageText = pauseNotificationMessage, + } +end + return ServerProtocol \ No newline at end of file diff --git a/server/ClientMessages.lua b/server/ClientMessages.lua index 659cc881..97f9459a 100644 --- a/server/ClientMessages.lua +++ b/server/ClientMessages.lua @@ -32,6 +32,8 @@ function ClientMessages.sanitizeMessage(clientMessage) return ClientMessages.sanitizeRoomRequest(clientMessage) elseif clientMessage.type and clientMessage.type == "matchAbort" then return ClientMessages.sanitizeMatchAbort(clientMessage) + elseif clientMessage.type and clientMessage.type == "pauseToggle" then + return ClientMessages.sanitizePauseToggle(clientMessage) elseif clientMessage.error_report then return clientMessage else @@ -191,10 +193,22 @@ end function ClientMessages.sanitizeMatchAbort(matchAbort) local sanitized = { + roomNumber = matchAbort.recipientId, matchAbort = true } return sanitized end +function sanitizePauseToggle(pauseToggle) + local sanitized = + { + roomNumber = pauseToggle.recipientId, + paused = pauseToggle.content, + type = "pauseToggle", + } + + return sanitized +end + return ClientMessages \ No newline at end of file diff --git a/server/Player.lua b/server/Player.lua index e0fc7981..3ae89ce5 100644 --- a/server/Player.lua +++ b/server/Player.lua @@ -6,7 +6,7 @@ local tableUtils = require("common.lib.tableUtils") local Signal = require("common.lib.signal") local logger = require("common.lib.logger") ----@alias PlayerState ("lobby" | "character select" | "playing" | "spectating") +---@alias PlayerState ("lobby" | "character select" | "playing" | "spectating" | "paused") ---@alias PublicPlayerID integer ---@class ServerPlayer : Signal diff --git a/server/Room.lua b/server/Room.lua index a06686bb..5ffcf94f 100644 --- a/server/Room.lua +++ b/server/Room.lua @@ -83,6 +83,7 @@ function(self, roomNumber, players, gameMode, leaderboard) Signal.turnIntoEmitter(self) self:createSignal("matchStart") self:createSignal("matchEnd") + self:createSignal("pauseToggled") end ) @@ -145,7 +146,7 @@ function Room:prepare_character_select() end end ----@return "character select"|"lobby"|"not_logged_in"|"playing"|"spectating"|"closed" +---@return PlayerState | "closed" function Room:state() if #self.players == 0 then return "closed" @@ -421,4 +422,19 @@ function Room:abortGame(sender) self.game = nil end +function Room:togglePause(sender, paused) + if #self.players == 1 and self.players[1] == sender and paused ~= (self:state() == "paused") then + self:broadcastJson(ServerProtocol.sendPauseNotification(self.roomNumber, sender, paused), sender) + self:emitSignal("pauseToggled") + + for i, player in ipairs(self.players) do + if paused then + player:setState("paused") + else + player:setState("playing") + end + end + end +end + return Room \ No newline at end of file diff --git a/server/server.lua b/server/server.lua index fcde8a2d..26bb6d63 100644 --- a/server/server.lua +++ b/server/server.lua @@ -382,6 +382,7 @@ function Server:create_room(gameMode, ...) local newRoom = Room(self.roomNumberIndex, players, gameMode, leaderboard) newRoom:connectSignal("matchStart", self, self.setLobbyChanged) newRoom:connectSignal("matchEnd", self, self.processGameEnd) + newRoom:connectSignal("pauseToggled", self, self.setLobbyChanged) self.roomNumberIndex = self.roomNumberIndex + 1 self.rooms[newRoom.roomNumber] = newRoom for _, player in ipairs(players) do @@ -410,16 +411,6 @@ function Server:closeRoom(room, reason) self:setLobbyChanged() end ----@param roomNr integer ----@return Room? room -function Server:roomNumberToRoom(roomNr) - for k, v in pairs(self.rooms) do - if self.rooms[k].roomNumber and self.rooms[k].roomNumber == roomNr then - return v - end - end -end - ---@param name string ---@return privateUserId? function Server:createNewUser(name) @@ -687,6 +678,8 @@ function Server:processMessage(message, connection) elseif (player.state == "playing" or player.state == "character select") and message.leave_room then self:handleLeaveRoom(player, player.name .. " left") return true + elseif player.state == "playing" and message.type == "pauseToggle" then + self.rooms[message.roomNumber]:togglePause(player, message.paused) elseif (player.state == "spectating") and message.leave_room then if self.spectatorToRoom[player] and self.spectatorToRoom[player]:remove_spectator(player) then self:setLobbyChanged() @@ -877,7 +870,7 @@ end ---@param message table ---@param player ServerPlayer function Server:handleSpectateRequest(message, player) - local requestedRoom = self:roomNumberToRoom(message.spectate_request.roomNumber) + local requestedRoom = self.rooms[message.spectate_request.roomNumber] if requestedRoom then local roomState = requestedRoom:state() diff --git a/server/tests/RoomTests.lua b/server/tests/RoomTests.lua index 3019990e..4fe7213e 100644 --- a/server/tests/RoomTests.lua +++ b/server/tests/RoomTests.lua @@ -174,7 +174,51 @@ local function abortTest3() assert(message.type == "gameResult" and message.content[1].placement == 0 and message.content[2].placement == 0) end +local function pauseTest() + local p1 = ServerTesting.players[1] + p1:updateSettings({inputMethod = "controller", level = 10}) + -- don't want to deal with I/O for the test + p1.save_replays_publicly = "not at all" + local room = Room(1, {p1}, GameModes.getPreset(GameModes.IDs.ONE_PLAYER_ENDLESS)) + local notificationCatcher = { catchCount = 0, catch = function(self) self.catchCount = self.catchCount + 1 end } + room:connectSignal("pauseToggled", notificationCatcher, notificationCatcher.catch) + local p2 = ServerTesting.players[2] + room:add_spectator(p2) + room:start_match() + for i = 1, 120 do + room:broadcastInput("A", p1) + end + + assert(notificationCatcher.catchCount == 0) + + p2.connection.outgoingMessageQueue:clear() + room:togglePause(p1, true) + + local message = p2.connection.outgoingMessageQueue:pop().messageText + assert(message.type == "pauseNotification" and message.content.source == p1.publicPlayerID and message.content.paused == true) + assert(notificationCatcher.catchCount == 1) + + p1.connection.outgoingMessageQueue:clear() + room:togglePause(p2, false) + -- p2 cannot toggle pause + assert(p1.connection.outgoingMessageQueue:len() == 0) + assert(p2.connection.outgoingMessageQueue:len() == 0) + assert(notificationCatcher.catchCount == 1) + + room:togglePause(p1, true) + -- the game is already paused so nothing should happen) + assert(p1.connection.outgoingMessageQueue:len() == 0) + assert(p2.connection.outgoingMessageQueue:len() == 0) + assert(notificationCatcher.catchCount == 1) + + room:togglePause(p1, false) + assert(notificationCatcher.catchCount == 2) + message = p2.connection.outgoingMessageQueue:pop().messageText + assert(message.type == "pauseNotification" and message.content.source == p1.publicPlayerID and message.content.paused == false) +end + basicTest() abortTest1() abortTest2() -abortTest3() \ No newline at end of file +abortTest3() +pauseTest() \ No newline at end of file From 92d4fa2d349604f938619bcfda3efc07a09fc51a Mon Sep 17 00:00:00 2001 From: Endaris Date: Sat, 28 Feb 2026 02:44:59 +0100 Subject: [PATCH 17/18] resolve delta to server clock for game duration display --- client/src/network/LoginRoutine.lua | 1 + client/src/network/NetClient.lua | 3 +++ client/src/scenes/Lobby.lua | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/client/src/network/LoginRoutine.lua b/client/src/network/LoginRoutine.lua index 1095bbed..cc1662b0 100644 --- a/client/src/network/LoginRoutine.lua +++ b/client/src/network/LoginRoutine.lua @@ -86,6 +86,7 @@ local function login(tcpClient, ip, port) if value.publicId then GAME.localPlayer.publicId = value.publicId end + result.serverTime = value.serverTime return result else --if result.login_denied then diff --git a/client/src/network/NetClient.lua b/client/src/network/NetClient.lua index 6df84780..b29f7009 100644 --- a/client/src/network/NetClient.lua +++ b/client/src/network/NetClient.lua @@ -374,12 +374,14 @@ end ---@field room BattleRoom? ---@field lobbyData table ---@field lobbyDataV2 PersonalizedLobbyDataV2 +---@field serverTimeDelta integer in seconds ---@overload fun(): NetClient local NetClient = class(function(self) self.tcpClient = TcpClient() self.leaderboard = nil self.pendingResponses = {} self.state = states.OFFLINE + self.serverTimeDelta = 0 resetLobbyData(self) @@ -607,6 +609,7 @@ function NetClient:update() self:setState(states.ONLINE) self.loginState = result.message self.loginTime = love.timer.getTime() + self.serverTimeDelta = os.difftime(to_UTC(os.time()), os.time(result.serverTime)) else self.loginState = result.message self:setState(states.OFFLINE) diff --git a/client/src/scenes/Lobby.lua b/client/src/scenes/Lobby.lua index f890210a..f07348a5 100644 --- a/client/src/scenes/Lobby.lua +++ b/client/src/scenes/Lobby.lua @@ -553,7 +553,7 @@ function Lobby:updateRoomPanel(updateInfo) local timer = self.roomTimer.text if room.state == "playing" then if room.gameStartTime then - local durationInSeconds = os.difftime(to_UTC(os.time()), os.time(room.gameStartTime)) - 3 + local durationInSeconds = os.difftime(to_UTC(os.time()), os.time(room.gameStartTime)) - 3 - GAME.netClient.serverTimeDelta if durationInSeconds < 0 then timer = string.format("-00:%02d", math.abs(durationInSeconds)) else From 5dd6a5e73ee77d33f686410ceb20354b8b16ec38 Mon Sep 17 00:00:00 2001 From: Endaris Date: Mon, 2 Mar 2026 20:17:57 +0100 Subject: [PATCH 18/18] remove references to old lobby_state --- client/src/network/ServerMessages.lua | 2 -- common/network/ServerProtocol.lua | 20 ------------ server/server.lua | 45 --------------------------- 3 files changed, 67 deletions(-) diff --git a/client/src/network/ServerMessages.lua b/client/src/network/ServerMessages.lua index d9e86b86..7abac4c2 100644 --- a/client/src/network/ServerMessages.lua +++ b/client/src/network/ServerMessages.lua @@ -131,8 +131,6 @@ function ServerMessages.sanitizeServerMessage(message) ban_duration = message.content.banDuration, } end - elseif message.type == "lobbyState" then - return message.content elseif message.type == "lobbyStateV2" then return { lobbyStateV2 = true, content = message.content } elseif message.type == "leaderboardReport" then diff --git a/common/network/ServerProtocol.lua b/common/network/ServerProtocol.lua index 8074ae94..ffa6ee7e 100644 --- a/common/network/ServerProtocol.lua +++ b/common/network/ServerProtocol.lua @@ -296,26 +296,6 @@ function ServerProtocol.startMatch(roomNumber, replay) } end -local lobbyStateTemplate = { - sender = "server", - type = "lobbyState", - content = { } -} - ----@return {messageType: table, messageText: ServerMessage} -function ServerProtocol.lobbyState(unpaired, rooms, allPlayers) - local lobbyStateMessage = lobbyStateTemplate - - lobbyStateMessage.content.unpaired = unpaired - lobbyStateMessage.content.spectatable = rooms - lobbyStateMessage.content.players = allPlayers - - return { - messageType = msgTypes.jsonMessage, - messageText = lobbyStateMessage, - } -end - ---@class LobbyStateV2Message : ServerMessage ---@field content LobbyStateV2 diff --git a/server/server.lua b/server/server.lua index 26bb6d63..0fd3e392 100644 --- a/server/server.lua +++ b/server/server.lua @@ -212,52 +212,10 @@ function Server:importDatabase() self.database:commitTransaction() -- bulk commit every statement from the start of beginTransaction end -local function addPublicPlayerData(players, player, ratingInfo) - if not players or not ratingInfo then - return - end - - if not players[player.name] then - players[player.name] = { publicId = player.publicPlayerID } - end - - if ratingInfo and ratingInfo.placement_done then - players[player.name].rating = math.round(ratingInfo.rating) - end -end - function Server:setLobbyChanged() self.lobbyChanged = true end -function Server:lobby_state() - local names = {} - local players = {} - for _, connection in pairs(self.connections) do - local player = self.connectionToPlayer[connection] - if player then - logger.debug("Player " .. player.name .. " state is " .. player.state) - end - if player and player.state == "lobby" then - names[#names + 1] = player.name - addPublicPlayerData(players, player, (self.leaderboard and self.leaderboard.players[player.userId] or nil)) - end - end - local spectatableRooms = {} - for _, room in pairs(self.rooms) do - spectatableRooms[#spectatableRooms + 1] = {roomNumber = room.roomNumber, name = room.name, state = room:state()} - for i, player in ipairs(room.players) do - if i == 1 then - spectatableRooms[#spectatableRooms].a = room.players[i].name - else - spectatableRooms[#spectatableRooms].b = room.players[i].name - end - addPublicPlayerData(players, player, (self.leaderboard and self.leaderboard.players[player.userId] or nil)) - end - end - return {unpaired = names, spectatable = spectatableRooms, players = players} -end - ---@alias LobbyPlayerV2 { publicId: PublicPlayerID, name: string, state: string, ratings: table, roomNumber: roomNumber? } ---@alias LobbyRoomV2 { roomNumber: roomNumber, state: string, gameModeId: GameModeID, players: PublicPlayerID[], spectators: PublicPlayerID[], wins: integer[], gameStartTime: integer? } ---@alias LobbyStateV2 { players: table, rooms: table } @@ -714,14 +672,11 @@ end function Server:broadCastLobbyIfChanged() if self.lobbyChanged then - local lobbyState = self:lobby_state() local lobbyStateV2 = self:lobbyStateV2() - local message = ServerProtocol.lobbyState(lobbyState.unpaired, lobbyState.spectatable, lobbyState.players) local messageV2 = ServerProtocol.lobbyStateV2(lobbyStateV2.players, lobbyStateV2.rooms) for _, connection in pairs(self.connections) do local player = self.connectionToPlayer[connection] if player and player.state == "lobby" then - --connection:sendJson(message) connection:sendJson(messageV2) end end