diff --git a/client/assets/localization.csv b/client/assets/localization.csv index 6a8e29c7..aff52b02 100644 --- a/client/assets/localization.csv +++ b/client/assets/localization.csv @@ -663,4 +663,5 @@ Configure suficientes configuraciones de entrada e intente de nuevo.","Es gibt m Bitte konfiguriere genügend Eingabemethoden und versuche es erneut.","Ci sono più giocatori locali che configurazioni di input configurate. Si prega di configurare sufficienti configurazioni di input e riprovare.","มีผู้เล่นท้องถิ่นมากกว่าการกำหนดค่า input โปรดกำหนดค่า input เพียงพอและลองอีกครั้ง" -translation_disclaimer,Disclaimer shown in language selection to inform about the quality / source of translation,"","La traduction initiale est effectuée à l'aide d'outils et peut être de qualité médiocre. La traduction peut être améliorée par la communauté.","A tradução inicial é feita com ferramentas e pode não estar à altura dos padrões. A tradução está aberta a melhorias da comunidade.","初期翻訳はツールによって行われ、水準に達していない可能性があります。翻訳はコミュニティからの改善を受け入れています。","La traducción inicial se realiza con herramientas y puede ser de calidad inferior. La traducción está abierta a mejoras por parte de la comunidad.","Die erste Übersetzung von neuem Text wird mit Tools durchgeführt und kann fehlerhaft sein. Übersetzungen sind offen für Verbesserungsvorschläge aus der Community.","La traduzione iniziale viene eseguita con strumenti e potrebbe non essere di qualità ottimale. La traduzione è aperta a miglioramenti da parte della comunità.", \ No newline at end of file +translation_disclaimer,Disclaimer shown in language selection to inform about the quality / source of translation,"","La traduction initiale est effectuée à l'aide d'outils et peut être de qualité médiocre. La traduction peut être améliorée par la communauté.","A tradução inicial é feita com ferramentas e pode não estar à altura dos padrões. A tradução está aberta a melhorias da comunidade.","初期翻訳はツールによって行われ、水準に達していない可能性があります。翻訳はコミュニティからの改善を受け入れています。","La traducción inicial se realiza con herramientas y puede ser de calidad inferior. La traducción está abierta a mejoras por parte de la comunidad.","Die erste Übersetzung von neuem Text wird mit Tools durchgeführt und kann fehlerhaft sein. Übersetzungen sind offen für Verbesserungsvorschläge aus der Community.","La traduzione iniziale viene eseguita con strumenti e potrebbe non essere di qualità ottimale. La traduzione è aperta a miglioramenti da parte della comunità.", +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 \ No newline at end of file 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 00000000..9d43f84f Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/checkbox_checked@2x.png differ diff --git a/client/assets/themes/Panel Attack Modern/checkbox_unchecked@2x.png b/client/assets/themes/Panel Attack Modern/checkbox_unchecked@2x.png new file mode 100644 index 00000000..7233cd79 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/checkbox_unchecked@2x.png differ 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 00000000..65cebf1c Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/endless@2x.png differ 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 00000000..7b850459 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/fight@2x.png differ 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 00000000..239cee4d Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/stopwatch@2x.png differ 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/mods/Theme.lua b/client/src/mods/Theme.lua index 7eccafb7..d6631e69 100644 --- a/client/src/mods/Theme.lua +++ b/client/src/mods/Theme.lua @@ -448,6 +448,13 @@ 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") + self.images.endless = self:load_theme_img("endless") end function Theme:loadIngameGraphics() @@ -1173,6 +1180,24 @@ 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 + +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/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 4c4a2e78..b29f7009 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,45 +23,62 @@ 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 = { + ---@class PersonalizedLobbyDataV2 + self.lobbyDataV2 = { + ---@type table players = {}, - unpairedPlayers = {}, - willingPlayers = {}, - spectatableRooms = {}, - sentRequests = {} + ---@type LobbyPlayerV2[] + availablePlayers = {}, + ---@type table> + outgoingChallenges = {}, + ---@type table> + incomingChallenges = {}, + ---@type table + rooms = {} } end -local function updateLobbyState(self, lobbyState) - if lobbyState.players then - self.lobbyData.players = lobbyState.players +---@param lobbyStateV2Message { content: LobbyStateV2 } +local function updateLobbyStateV2(self, lobbyStateV2Message) + local lobbyStateV2 = lobbyStateV2Message.content + if lobbyStateV2.players then + self.lobbyDataV2.players = lobbyStateV2.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] + local availablePlayers = {} + for publicId, player in pairs(lobbyStateV2.players) do + if not player.roomNumber then + availablePlayers[#availablePlayers+1] = player end - self.lobbyData.willingPlayers = newWillingPlayers - self.lobbyData.sentRequests = newSentRequests end - if lobbyState.spectatable then - self.lobbyData.spectatableRooms = lobbyState.spectatable + -- 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.lobbyDataV2.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:emitSignal("lobbyStateUpdate", self.lobbyData) + self.lobbyDataV2.rooms = lobbyStateV2.rooms + + self:emitSignal("lobbyStateV2Update", self.lobbyDataV2) 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}) @@ -274,13 +292,14 @@ 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) +---@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 @@ -329,8 +348,8 @@ 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.game_request = createListener(self, "game_request", processGameRequest) + messageListeners.lobbyStateV2 = createListener(self, "lobbyStateV2", updateLobbyStateV2) + 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) @@ -354,12 +373,15 @@ end ---@field messageListeners table ---@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) @@ -368,8 +390,9 @@ 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, + challengeUpdate = messageListeners.challengeUpdate, } -- all listeners running while in a room but not in a match @@ -398,6 +421,7 @@ local NetClient = class(function(self) Signal.turnIntoEmitter(self) self:createSignal("lobbyStateUpdate") + self:createSignal("lobbyStateV2Update") self:createSignal("leaderboardUpdate") -- only fires for unintended disconnects self:createSignal("clientDisconnected") @@ -445,17 +469,29 @@ 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()) + gameModeId = gameModeId or GameModes.IDs.TWO_PLAYER_VS + self.pendingResponses.leaderboardUpdate = self.tcpClient:sendRequest(ClientMessages.requestLeaderboard(gameModeId)) end end -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) +---@param opponentId PublicPlayerID +---@param gameModeId GameModeID +function NetClient:challengePlayerById(opponentId, gameModeId) + 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] = false + self:emitSignal("lobbyStateV2Update", self.lobbyDataV2) end end @@ -573,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/network/ServerMessages.lua b/client/src/network/ServerMessages.lua index 4a1a5dcc..7abac4c2 100644 --- a/client/src/network/ServerMessages.lua +++ b/client/src/network/ServerMessages.lua @@ -131,8 +131,8 @@ 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 return { leaderboard_report = message.content } elseif message.type == "spectateRequestGranted" then @@ -202,13 +202,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/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 a0e303d6..f07348a5 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) @@ -48,7 +49,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) @@ -58,37 +59,83 @@ function Lobby:load(sceneParams) end function Lobby:initLobbyMenu() - local menuItems = { - ui.MenuItem.createMenuItem(self.lobbyMessage), - ui.MenuItem.createButtonMenuItem("mm_1_endless", nil, nil, function() - GAME.netClient:requestRoom(GameModes.getPreset("ONE_PLAYER_ENDLESS")) - end), - ui.MenuItem.createButtonMenuItem("mm_1_time", nil, nil, function() - GAME.netClient:requestRoom(GameModes.getPreset("ONE_PLAYER_TIME_ATTACK")) - end), - ui.MenuItem.createButtonMenuItem("mm_1_vs", 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(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(GameModes.IDs.ONE_PLAYER_TIME_ATTACK)) + 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() + GAME.netClient:requestRoom(GameModes.getPreset(GameModes.IDs.ONE_PLAYER_VS_SELF)) + 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.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.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) + self.uiRoot:addChild(self.roomPanel) end ----------------- @@ -99,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") @@ -121,19 +168,23 @@ 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 -- requests to spectate the specified room +---@param room LobbyRoomV2 +---@return function function Lobby:requestSpectateFunction(room) return function() GAME.netClient:requestSpectate(room.roomNumber) @@ -141,56 +192,315 @@ function Lobby:requestSpectateFunction(room) end end --- rebuilds the UI based on the new lobby information -function Lobby:onLobbyStateUpdate(lobbyState) - 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 +---@param publicId PublicPlayerID +---@param gameModeId GameModeID? +---@return string +function Lobby.getPlayerNameWithRating(publicId, gameModeId) + local player = GAME.netClient.lobbyDataV2.players[publicId] - -- cleanup previous lobby menu - while #self.lobbyMenu.menuItems > 6 do - self.lobbyMenu:removeMenuItemAtIndex(2) + if not player then + logger.warn("Tried to get rating for unknown player id " .. publicId) + return tostring(publicId) + else + gameModeId = gameModeId or GameModes.IDs.TWO_PLAYER_VS + if player.ratings[gameModeId] then + return player.name .. " (" .. player.ratings[gameModeId] .. ")" + else + return player.name + end end - self.lobbyMenu:setSelectedIndex(1) +end - 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") +---@param personalizedLobbyData PersonalizedLobbyDataV2 +function Lobby:createPlayerButtons(personalizedLobbyData) + local playerButtons = {} + + for publicId, player in pairs(personalizedLobbyData.players) do + 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") + elseif personalizedLobbyData.outgoingChallenges[publicId] and next(personalizedLobbyData.outgoingChallenges[publicId]) then + playerName = Lobby.getPlayerNameWithRating(publicId) .. " " .. loc("lb_request") + else + playerName = Lobby.getPlayerNameWithRating(publicId) end - self.lobbyMenu:addMenuItem(2, ui.MenuItem.createButtonMenuItem(unmatchedPlayer, nil, false, self:requestGameFunction(v))) + + 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 - 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))) + + 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.players) 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") .. "\n" .. playerStrings[1] .. "\nvs\n" .. playerStrings[2] .. "\n(" .. room.state .. ")" + end + + local icon + 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 == GameModes.IDs.TWO_PLAYER_TIME_ATTACK or room.gameModeId == GameModes.IDs.ONE_PLAYER_TIME_ATTACK then + icon = GAME.theme:getStopwatchImage() + elseif room.gameModeId == GameModes.IDs.ONE_PLAYER_ENDLESS then + icon = GAME.theme:getEndlessImage() else - local roomName = loc("lb_spectate") .. " " .. room.name .. " (" .. room.state .. ")" - self.lobbyMenu:addMenuItem(2, ui.MenuItem.createButtonMenuItem(roomName, nil, false, self:requestSpectateFunction(room))) + icon = GAME.theme:chainImage(0) + end + + local button = ui.IconTextButton({ + label = ui.Label({text = roomName, translate = false, wrapWidth = self.lobbyMenu.width - 19}), + iconSize = 16, + icon = icon, + width = self.lobbyMenuWidth, + onClick = self:requestSpectateFunction(room) + }) + button.lobbyType = "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 +---@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() + end + + local lobbyDataV2 = GAME.netClient.lobbyDataV2 + + local x, y = button:getScreenPos() + + local subMenu = ui.ScrollMenu({ + x = x + self.lobbyMenu.width + 8, + y = y, + hAlign = "left", + vAlign = "top", + height = 88, + width = 120, + padding = 0, + childGap = 8, + }) + + subMenu.playerId = playerId + + local vsButton = ui.LobbyChallengeButton({ + gameModeId = GameModes.IDs.TWO_PLAYER_VS, + iconSize = 16, + playerId = playerId, + label = ui.Label({text = "vs"}), + acceptImage = GAME.theme:getFightImage(), + proposeImage = GAME.theme:getCheckboxImage(false), + withdrawImage = GAME.theme:getCheckboxImage(true), + width = 120 + }) + + subMenu:addChild(vsButton) + + local timeAttackButton = ui.LobbyChallengeButton({ + gameModeId = GameModes.IDs.TWO_PLAYER_TIME_ATTACK, + iconSize = 16, + playerId = playerId, + label = ui.Label({text = "gm_time_attack"}), + acceptImage = GAME.theme:getFightImage(), + proposeImage = GAME.theme:getCheckboxImage(false), + withdrawImage = GAME.theme:getCheckboxImage(true), + width = 120 + }) + subMenu:addChild(timeAttackButton) + + if lobbyDataV2.outgoingChallenges[playerId] then + if lobbyDataV2.outgoingChallenges[playerId][GameModes.IDs.TWO_PLAYER_VS] == true then + vsButton:setState(vsButton.challengeStates.PROPOSING) + end + 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][GameModes.IDs.TWO_PLAYER_VS] then + vsButton:setState(vsButton.challengeStates.CHALLENGED) + end + if lobbyDataV2.incomingChallenges[playerId][GameModes.IDs.TWO_PLAYER_TIME_ATTACK] then + timeAttackButton:setState(timeAttackButton.challengeStates.CHALLENGED) end end + 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 + + 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 copy = shallowcpy(self.lobbyMenu.children) + local previousIndex = self.lobbyMenu.selectedIndex + + self.lobbyMenu.selectedIndex = nil + + for i = #self.lobbyMenu.children, 1, -1 do + self.lobbyMenu.children[i]:detach() + end + + self.lobbyMenu:addChild(self.lobbyMessage) + + local playerButtons = self:createPlayerButtons(lobbyDataV2) + + for _, button in ipairs(playerButtons) do + self.lobbyMenu:addChild(button) + end + + local roomButtons = self:createRoomButtons(lobbyDataV2) + + for _, button in ipairs(roomButtons) do + 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) + + local previousButton + 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 + previousButton = copy[previousIndex] + if previousButton.lobbyType == "player" then + for i, playerButton in ipairs(playerButtons) do + if previousButton.player.publicId == playerButton.player.publicId then + self.lobbyMenu:select(playerButton) + previousButton = playerButton + break + end + end + elseif previousButton.lobbyType == "room" then + for i, roomButton in ipairs(roomButtons) do + if previousButton.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 + end + + if self.playerSubMenu then + if not lobbyDataV2.players[self.playerSubMenu.playerId] then + self.playerSubMenu:yieldFocus() + else + 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 - self.lobbyMenu:setSelectedIndex(util.bound(2, desiredIndex, #self.lobbyMenu.menuItems)) end + + self:updateRoomPanel(true) end ------------------------------ @@ -200,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 @@ -210,7 +522,51 @@ function Lobby:updateSelf(dt) self.lobbyMessage:setText("lb_select_player", nil, true) end end - self.lobbyMenu:receiveInputs() + self.lobbyMenu:receiveInputs(GAME.input) + 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 - GAME.netClient.serverTimeDelta + 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 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/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..b792097d --- /dev/null +++ b/client/src/ui/IconTextButton.lua @@ -0,0 +1,49 @@ +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 = "left" + 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: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/Line.lua b/client/src/ui/Line.lua new file mode 100644 index 00000000..bae42c3c --- /dev/null +++ b/client/src/ui/Line.lua @@ -0,0 +1,35 @@ +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 + +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 new file mode 100644 index 00000000..d33e16e9 --- /dev/null +++ b/client/src/ui/LobbyChallengeButton.lua @@ -0,0 +1,74 @@ +local import = require("common.lib.import") +local IconTextButton = import("./IconTextButton") +local class = require("common.lib.class") +local GameModes = require("common.data.GameModes") + +---@class LobbyChallengeButtonOptions : IconTextButtonOptions +---@field proposeImage love.Texture +---@field acceptImage love.Texture +---@field withdrawImage love.Texture +---@field gameModeId GameModeID +---@field playerId PublicPlayerID +---@field challengeState ChallengeState? +---@field icon nil + +---@class LobbyChallengeButton : IconTextButton +---@operator call(LobbyChallengeButtonOptions): LobbyChallengeButton +---@field proposeImage love.Texture +---@field acceptImage love.Texture +---@field withdrawImage love.Texture +---@field challengeState ChallengeState +---@field gameModeId GameModeID +---@field playerId PublicPlayerID +---@overload fun(options: LobbyChallengeButtonOptions): LobbyChallengeButton +local LobbyChallengeButton = class( +---@param self LobbyChallengeButton +---@param options LobbyChallengeButtonOptions +function(self, options) + self.proposeImage = options.proposeImage + self.acceptImage = options.acceptImage + self.withdrawImage = options.withdrawImage + self.playerId = options.playerId + self.gameModeId = options.gameModeId + self:setState(options.challengeState or self.challengeStates.NEUTRAL) +end, +IconTextButton, "LobbyChallengeButton") + +LobbyChallengeButton.TYPE = "LobbyChallengeButton" + +---@enum ChallengeState +LobbyChallengeButton.challengeStates = { CHALLENGED = "CHALLENGED", PROPOSING = "PROPOSING", NEUTRAL = "NEUTRAL" } + +---@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() + if self.challengeState == LobbyChallengeButton.challengeStates.PROPOSING then + GAME.netClient:withdrawChallengeForId(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 + +function LobbyChallengeButton:receiveInputs(input) + if input.isDown["MenuSelect"] then + self:onClick() + end +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/ScrollContainer.lua b/client/src/ui/ScrollContainer.lua index fe674170..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) @@ -105,6 +106,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) @@ -136,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/ScrollMenu.lua b/client/src/ui/ScrollMenu.lua new file mode 100644 index 00000000..3fab1ae6 --- /dev/null +++ b/client/src/ui/ScrollMenu.lua @@ -0,0 +1,155 @@ +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 + if not self.scrolling then + self:select(self.touchedChild) + 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 + self:keepVisible(-child.y, child.height) + 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/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/TextButton.lua b/client/src/ui/TextButton.lua index f4c7bb4c..0b369874 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,9 +17,8 @@ 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" 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 2e47094d..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 @@ -56,6 +58,10 @@ 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 Menu = import("./Menu"), ---@source MenuItem.lua @@ -70,6 +76,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/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 2e4efbc7..03beee94 100644 --- a/common/data/GameModes.lua +++ b/common/data/GameModes.lua @@ -15,6 +15,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,26 +209,31 @@ local TwoPlayerTimeAttack = GameMode({ GameModes.Styles = Styles GameModes.StackInteractions = StackInteractions ----@type table +---@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 ----@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]) @@ -256,4 +262,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/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/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/ClientProtocol.lua b/common/network/ClientProtocol.lua index 5f8d72a9..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, @@ -54,24 +54,28 @@ end -- Lobby related requests ------------------------- --- players are challenged by their current name on the server -function ClientMessages.challengePlayer(senderName, receiverName) - local playerChallengeMessage = +---@param senderId PublicPlayerID +---@param receiverId PublicPlayerID +---@param gameModeId GameModeID +function ClientProtocol.updateChallengeStatus(senderId, receiverId, gameModeId, challengeActive) + local playerChallengeV2Message = { - game_request = + challengeUpdate = { - sender = senderName, - receiver = receiverName + senderId = senderId, + receiverId = receiverId, + gameModeId = gameModeId, + challengeActive = challengeActive, } } return { messageType = msgTypes.jsonMessage, - messageText = playerChallengeMessage, + messageText = playerChallengeV2Message, } end -function ClientMessages.requestSpectate(spectatorName, roomNumber) +function ClientProtocol.requestSpectate(spectatorName, roomNumber) local spectateRequestMessage = { spectate_request = @@ -88,8 +92,12 @@ function ClientMessages.requestSpectate(spectatorName, roomNumber) } end -function ClientMessages.requestLeaderboard() - local leaderboardRequestMessage = {leaderboard_request = true} +---@param gameModeId GameModeID +function ClientProtocol.requestLeaderboard(gameModeId) + local leaderboardRequestMessage = { + leaderboard_request = true, + leaderboardType = gameModeId, + } return { messageType = msgTypes.jsonMessage, @@ -101,7 +109,7 @@ end ------------------------------ -- BattleRoom related requests ------------------------------ -function ClientMessages.leaveRoom() +function ClientProtocol.leaveRoom() local leaveRoomMessage = {leave_room = true} return { messageType = msgTypes.jsonMessage, @@ -109,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, @@ -117,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, @@ -125,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 { @@ -135,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", @@ -148,7 +156,7 @@ function ClientMessages.sendRoomRequest(gameMode) } end -function ClientMessages.sendMatchAbort(roomNumber) +function ClientProtocol.sendMatchAbort(roomNumber) local matchAbortMessage = { recipient = "room", recipientId = roomNumber, @@ -161,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, @@ -173,4 +196,4 @@ function ClientMessages.sendErrorReport(errorData) } end -return ClientMessages +return ClientProtocol 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 13e35583..ffa6ee7e 100644 --- a/common/network/ServerProtocol.lua +++ b/common/network/ServerProtocol.lua @@ -296,23 +296,25 @@ function ServerProtocol.startMatch(roomNumber, replay) } end -local lobbyStateTemplate = { +---@class LobbyStateV2Message : ServerMessage +---@field content LobbyStateV2 + +local lobbyState2Template = { sender = "server", - type = "lobbyState", + type = "lobbyStateV2", content = { } } ----@return {messageType: table, messageText: ServerMessage} -function ServerProtocol.lobbyState(unpaired, rooms, allPlayers) - local lobbyStateMessage = lobbyStateTemplate +---@return {messageType: table, messageText: LobbyStateV2Message} +function ServerProtocol.lobbyStateV2(players, rooms) + local lobbyStateV2Message = lobbyState2Template - lobbyStateMessage.content.unpaired = unpaired - lobbyStateMessage.content.spectatable = rooms - lobbyStateMessage.content.players = allPlayers + lobbyStateV2Message.content.players = players + lobbyStateV2Message.content.rooms = rooms return { messageType = msgTypes.jsonMessage, - messageText = lobbyStateMessage, + messageText = lobbyStateV2Message, } end @@ -338,6 +340,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 @@ -460,21 +463,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) - 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, @@ -500,4 +509,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/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 9ae0e96e..97f9459a 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,8 @@ 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 return ClientMessages.sanitizeMenuState(clientMessage.menu_state) elseif clientMessage.spectate_request then @@ -31,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 @@ -108,13 +111,15 @@ function ClientMessages.sanitizeLoginRequest(loginRequest) return sanitized end -function ClientMessages.sanitizeGameRequest(gameRequest) +function ClientMessages.sanitizeChallengeUpdate(message) local sanitized = { - game_request = + challengeUpdate = { - sender = gameRequest.game_request.sender, - receiver = gameRequest.game_request.receiver, + senderId = message.challengeUpdate.senderId, + receiverId = message.challengeUpdate.receiverId, + gameModeId = message.challengeUpdate.gameModeId, + challengeActive = message.challengeUpdate.challengeActive, } } @@ -137,7 +142,9 @@ end function ClientMessages.sanitizeLeaderboardRequest(leaderboardRequest) local sanitized = { - leaderboard_request = leaderboardRequest.leaderboard_request + leaderboard_request = leaderboardRequest.leaderboard_request, + -- default value only for slow adaption, remove and sanity check later + gameModeId = leaderboardRequest.leaderboardType or GameModes.IDs.TWO_PLAYER_VS, } return sanitized @@ -186,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/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/Player.lua b/server/Player.lua index 2d73a954..3ae89ce5 100644 --- a/server/Player.lua +++ b/server/Player.lua @@ -6,12 +6,13 @@ 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 ---@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..5ffcf94f 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 @@ -22,6 +24,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 @@ -80,6 +83,7 @@ function(self, roomNumber, players, gameMode, leaderboard) Signal.turnIntoEmitter(self) self:createSignal("matchStart") self:createSignal("matchEnd") + self:createSignal("pauseToggled") end ) @@ -142,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" @@ -418,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 8ed7b0f6..0fd3e392 100644 --- a/server/server.lua +++ b/server/server.lua @@ -38,11 +38,12 @@ 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 ---@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 = {} @@ -210,85 +212,110 @@ 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 = {} +---@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 } + +---@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 - 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)) + + 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 - local spectatableRooms = {} + for _, room in pairs(self.rooms) do - spectatableRooms[#spectatableRooms + 1] = {roomNumber = room.roomNumber, name = room.name, state = room:state()} + local lobbyRoom = { + roomNumber = room.roomNumber, + state = room:state(), + gameModeId = GameModes.nameToGameModeId[room.gameMode.name], + players = {}, + spectators = {}, + wins = {}, + } + + if room.game then + lobbyRoom.gameStartTime = os.date("*t", to_UTC(room.game.creationTime)) + end + 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)) + 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 + players[spectator.publicPlayerID].roomNumber = room.roomNumber + lobbyRoom.spectators[i] = spectator.publicPlayerID + end + + rooms[lobbyRoom.roomNumber] = lobbyRoom end - return {unpaired = names, spectatable = spectatableRooms, players = players} + + return { players = players, rooms = rooms } end ---@param sender ServerPlayer ---@param receiver ServerPlayer -function Server:proposeGame(sender, receiver) - logger.debug("propose game: " .. sender.name .. " " .. receiver.name) - - local proposals = self.proposals +---@param gameModeId GameModeID +---@param challengeActive boolean +function Server:processChallengeUpdate(sender, receiver, gameModeId, challengeActive) 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) - end + 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 and previouslyProposedGameModes[gameModeId] then + self:create_room(GameModes.getPreset(gameModeId), sender, receiver) else - receiver:sendJson(ServerProtocol.sendChallenge(sender, receiver)) - local prop = {[sender] = true} - proposals[sender][receiver] = prop - proposals[receiver][sender] = prop + -- no existing challenge for this game mode + 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 +---@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] = challengeActive + + self.proposals[sender.publicPlayerID] = senderChallenges +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 + -- 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 end - proposals[player] = nil end end @@ -313,6 +340,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 @@ -341,16 +369,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) @@ -480,11 +498,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 = {} @@ -586,9 +604,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:proposeGame(player, self.nameToPlayer[message.game_request.receiver]) + 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 @@ -617,6 +636,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() @@ -651,12 +672,12 @@ end function Server:broadCastLobbyIfChanged() if self.lobbyChanged then - local lobbyState = self:lobby_state() - local message = ServerProtocol.lobbyState(lobbyState.unpaired, lobbyState.spectatable, lobbyState.players) + local lobbyStateV2 = self:lobbyStateV2() + 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 @@ -729,6 +750,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) @@ -803,7 +825,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() @@ -848,6 +870,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/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/MockPersistence.lua b/server/tests/MockPersistence.lua index 547f1fde..ebb6f9b9 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,18 @@ function MockPersistence.getPlayerData() end ---@param privateUserId privateUserId +---@return DB_Player? function MockPersistence.getPlayerInfo(privateUserId) + 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 return MockPersistence \ No newline at end of file diff --git a/server/tests/RoomTests.lua b/server/tests/RoomTests.lua index 544d3a5d..4fe7213e 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 @@ -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 diff --git a/server/tests/ServerTesting.lua b/server/tests/ServerTesting.lua index e659470e..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 = {} @@ -58,6 +59,7 @@ function ServerTesting.addToLeaderboard(lb, player) end function ServerTesting.getTestServer() + MockPersistence.setTestData(ServerTesting.players) local testServer = Server(false, MockPersistence) testServer:initializePlayerData("", playerData) @@ -109,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.challengePlayer(player1.name, player2.name).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.challengePlayer(player2.name, player1.name).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 17ea93d2..07c9700c 100644 --- a/server/tests/ServerTests.lua +++ b/server/tests/ServerTests.lua @@ -26,7 +26,7 @@ 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") + assert(message and message.type == "lobbyStateV2" and message.content.players and message.content.players[4].name == "Bob") end local function testRoomSetup() @@ -37,30 +37,65 @@ 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, GameModes.IDs.TWO_PLAYER_VS, true).messageText)) server:update() - assert(server.proposals[alice][ben][alice]) - assert(server.proposals[ben][alice][alice]) + assert(server.proposals[alice.publicPlayerID][ben.publicPlayerID][GameModes.IDs.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, GameModes.IDs.TWO_PLAYER_VS, true).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[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 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 +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.updateChallengeStatus(alice.publicPlayerID, ben.publicPlayerID, GameModes.IDs.TWO_PLAYER_TIME_ATTACK, true).messageText)) + server:update() + 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, 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) + 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[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 + assert(message.type == "createRoom" and tableUtils.length(message.content.players) == 2) + + message = bob.connection.outgoingMessageQueue:pop().messageText.content + 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}}) @@ -84,9 +119,12 @@ 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) + 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 @@ -163,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() @@ -195,15 +233,15 @@ 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) + 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.type == "lobbyState" and message.content.unpaired and #message.content.unpaired == 2) + 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 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 @@ -224,24 +262,24 @@ local function testLobbyDataComposition() -- so check what alice can see local message = alice.connection.outgoingMessageQueue:pop().messageText - assert(message.type == "lobbyState") + assert(message.type == "lobbyStateV2") 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)) + ---@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 - -- 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"]) + 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 == GameModes.IDs.TWO_PLAYER_VS) end local function testSinglePlayer() @@ -251,15 +289,15 @@ 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 assert(message.type == "createRoom") message = alice.connection.outgoingMessageQueue:pop().messageText - assert(message.type == "lobbyState") - assert(#message.content.spectatable == 1) + 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() @@ -269,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") @@ -289,7 +327,7 @@ local function testSinglePlayer() message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "leaveRoom") message = alice.connection.outgoingMessageQueue:pop().messageText - assert(message.type == "lobbyState") + assert(message.type == "lobbyStateV2") alice.connection:receiveMessage(json.encode(ClientProtocol.requestSpectate("Alice", server.playerToRoom[bob].roomNumber).messageText)) server:update() @@ -310,6 +348,7 @@ end testLogin() testRoomSetup() +testRoomSetup2() testGameplay() testDisconnect() testLobbyDataComposition() diff --git a/serverLauncher.lua b/serverLauncher.lua index cbe779e2..4ad5865d 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") @@ -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() diff --git a/testLauncher.lua b/testLauncher.lua index ae0e5c91..e83e9155 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.LoginTests", "server.tests.LeaderboardTests", "server.tests.RoomTests", - "server.tests.LoginTests", + "server.tests.ServerTests", "server.tests.RealSocketPartialSendTest", "client.tests.FileUtilsTests", "client.tests.ModControllerTests",